2017-01-07 21:19:52 +01:00
|
|
|
from __future__ import absolute_import
|
|
|
|
from typing import Any, List, Dict, Optional, Text
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
from django.core.urlresolvers import reverse
|
|
|
|
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
|
2017-03-16 14:14:31 +01:00
|
|
|
from django.shortcuts import redirect, render
|
2017-01-07 21:19:52 +01:00
|
|
|
from django.utils import translation
|
|
|
|
from django.utils.cache import patch_cache_control
|
|
|
|
from six.moves import zip_longest, zip, range
|
|
|
|
|
2017-02-17 23:38:31 +01:00
|
|
|
from zerver.decorator import zulip_login_required, process_client
|
2017-01-07 21:19:52 +01:00
|
|
|
from zerver.forms import ToSForm
|
2017-02-21 03:41:20 +01:00
|
|
|
from zerver.lib.realm_icon import realm_icon_url
|
2017-01-07 21:19:52 +01:00
|
|
|
from zerver.models import Message, UserProfile, Stream, Subscription, Huddle, \
|
2017-03-31 16:20:07 +02:00
|
|
|
Recipient, Realm, UserMessage, DefaultStream, RealmEmoji, RealmDomain, \
|
2017-02-18 00:35:38 +01:00
|
|
|
RealmFilter, PreregistrationUser, UserActivity, \
|
2017-01-30 03:12:50 +01:00
|
|
|
UserPresence, get_recipient, name_changes_disabled, email_to_username, \
|
2017-04-29 06:06:57 +02:00
|
|
|
get_realm_domains
|
2017-02-10 23:04:46 +01:00
|
|
|
from zerver.lib.events import do_events_register
|
2017-01-07 21:19:52 +01:00
|
|
|
from zerver.lib.actions import update_user_presence, do_change_tos_version, \
|
2017-02-10 23:04:46 +01:00
|
|
|
do_update_pointer, get_cross_realm_dicts, realm_user_count
|
2017-01-07 21:19:52 +01:00
|
|
|
from zerver.lib.avatar import avatar_url
|
|
|
|
from zerver.lib.i18n import get_language_list, get_language_name, \
|
|
|
|
get_language_list_for_templates
|
|
|
|
from zerver.lib.push_notifications import num_push_devices_for_user
|
2017-01-30 03:11:00 +01:00
|
|
|
from zerver.lib.streams import access_stream_by_name
|
2017-01-07 21:19:52 +01:00
|
|
|
from zerver.lib.utils import statsd, get_subdomain
|
|
|
|
|
|
|
|
import calendar
|
|
|
|
import datetime
|
|
|
|
import logging
|
2017-02-28 05:42:19 +01:00
|
|
|
import os
|
2017-01-07 21:19:52 +01:00
|
|
|
import re
|
|
|
|
import simplejson
|
|
|
|
import time
|
|
|
|
|
|
|
|
@zulip_login_required
|
|
|
|
def accounts_accept_terms(request):
|
|
|
|
# type: (HttpRequest) -> HttpResponse
|
|
|
|
if request.method == "POST":
|
|
|
|
form = ToSForm(request.POST)
|
|
|
|
if form.is_valid():
|
|
|
|
do_change_tos_version(request.user, settings.TOS_VERSION)
|
|
|
|
return redirect(home)
|
|
|
|
else:
|
|
|
|
form = ToSForm()
|
|
|
|
|
|
|
|
email = request.user.email
|
|
|
|
special_message_template = None
|
|
|
|
if request.user.tos_version is None and settings.FIRST_TIME_TOS_TEMPLATE is not None:
|
|
|
|
special_message_template = 'zerver/' + settings.FIRST_TIME_TOS_TEMPLATE
|
2017-03-16 14:14:31 +01:00
|
|
|
return render(
|
|
|
|
request,
|
2017-01-07 21:19:52 +01:00
|
|
|
'zerver/accounts_accept_terms.html',
|
2017-03-16 14:14:31 +01:00
|
|
|
context={'form': form,
|
|
|
|
'email': email,
|
|
|
|
'special_message_template': special_message_template},
|
|
|
|
)
|
2017-01-07 21:19:52 +01:00
|
|
|
|
|
|
|
def approximate_unread_count(user_profile):
|
|
|
|
# type: (UserProfile) -> int
|
|
|
|
not_in_home_view_recipients = [sub.recipient.id for sub in
|
|
|
|
Subscription.objects.filter(
|
2017-01-24 07:06:13 +01:00
|
|
|
user_profile=user_profile, in_home_view=False)]
|
2017-01-07 21:19:52 +01:00
|
|
|
|
|
|
|
# TODO: We may want to exclude muted messages from this count.
|
|
|
|
# It was attempted in the past, but the original attempt
|
|
|
|
# was broken. When we re-architect muting, we may
|
|
|
|
# want to to revisit this (see git issue #1019).
|
|
|
|
return UserMessage.objects.filter(
|
|
|
|
user_profile=user_profile, message_id__gt=user_profile.pointer).exclude(
|
|
|
|
message__recipient__type=Recipient.STREAM,
|
|
|
|
message__recipient__id__in=not_in_home_view_recipients).exclude(
|
|
|
|
flags=UserMessage.flags.read).count()
|
|
|
|
|
|
|
|
def sent_time_in_epoch_seconds(user_message):
|
2017-02-11 05:45:39 +01:00
|
|
|
# type: (UserMessage) -> Optional[float]
|
2017-01-07 21:19:52 +01:00
|
|
|
# user_message is a UserMessage object.
|
|
|
|
if not user_message:
|
|
|
|
return None
|
|
|
|
# We have USE_TZ = True, so our datetime objects are timezone-aware.
|
|
|
|
# Return the epoch seconds in UTC.
|
|
|
|
return calendar.timegm(user_message.message.pub_date.utctimetuple())
|
|
|
|
|
|
|
|
def home(request):
|
|
|
|
# type: (HttpRequest) -> HttpResponse
|
2017-02-28 05:42:19 +01:00
|
|
|
if settings.DEVELOPMENT and os.path.exists('var/handlebars-templates/compile.error'):
|
2017-03-16 14:14:31 +01:00
|
|
|
response = render(request, 'zerver/handlebars_compilation_failed.html')
|
2017-02-28 05:42:19 +01:00
|
|
|
response.status_code = 500
|
|
|
|
return response
|
2017-01-07 21:19:52 +01:00
|
|
|
if not settings.SUBDOMAINS_HOMEPAGE:
|
|
|
|
return home_real(request)
|
|
|
|
|
|
|
|
# If settings.SUBDOMAINS_HOMEPAGE, sends the user the landing
|
|
|
|
# page, not the login form, on the root domain
|
|
|
|
|
|
|
|
subdomain = get_subdomain(request)
|
|
|
|
if subdomain != "":
|
|
|
|
return home_real(request)
|
|
|
|
|
2017-03-16 14:14:31 +01:00
|
|
|
return render(request, 'zerver/hello.html')
|
2017-01-07 21:19:52 +01:00
|
|
|
|
|
|
|
@zulip_login_required
|
|
|
|
def home_real(request):
|
|
|
|
# type: (HttpRequest) -> HttpResponse
|
|
|
|
# We need to modify the session object every two weeks or it will expire.
|
|
|
|
# This line makes reloading the page a sufficient action to keep the
|
|
|
|
# session alive.
|
|
|
|
request.session.modified = True
|
|
|
|
|
|
|
|
user_profile = request.user
|
|
|
|
|
|
|
|
# If a user hasn't signed the current Terms of Service, send them there
|
|
|
|
if settings.TERMS_OF_SERVICE is not None and settings.TOS_VERSION is not None and \
|
|
|
|
int(settings.TOS_VERSION.split('.')[0]) > user_profile.major_tos_version():
|
|
|
|
return accounts_accept_terms(request)
|
|
|
|
|
|
|
|
narrow = [] # type: List[List[Text]]
|
|
|
|
narrow_stream = None
|
|
|
|
narrow_topic = request.GET.get("topic")
|
|
|
|
if request.GET.get("stream"):
|
|
|
|
try:
|
2017-01-30 03:11:00 +01:00
|
|
|
narrow_stream_name = request.GET.get("stream")
|
|
|
|
(narrow_stream, ignored_rec, ignored_sub) = access_stream_by_name(
|
|
|
|
user_profile, narrow_stream_name)
|
2017-01-07 21:19:52 +01:00
|
|
|
narrow = [["stream", narrow_stream.name]]
|
|
|
|
except Exception:
|
|
|
|
logging.exception("Narrow parsing")
|
2017-01-30 03:12:50 +01:00
|
|
|
if narrow_stream is not None and narrow_topic is not None:
|
2017-01-07 21:19:52 +01:00
|
|
|
narrow.append(["topic", narrow_topic])
|
|
|
|
|
|
|
|
register_ret = do_events_register(user_profile, request.client,
|
|
|
|
apply_markdown=True, narrow=narrow)
|
|
|
|
user_has_messages = (register_ret['max_message_id'] != -1)
|
|
|
|
|
|
|
|
# Reset our don't-spam-users-with-email counter since the
|
|
|
|
# user has since logged in
|
2017-01-24 06:07:45 +01:00
|
|
|
if user_profile.last_reminder is not None:
|
2017-01-07 21:19:52 +01:00
|
|
|
user_profile.last_reminder = None
|
|
|
|
user_profile.save(update_fields=["last_reminder"])
|
|
|
|
|
|
|
|
# Brand new users get the tutorial
|
|
|
|
needs_tutorial = settings.TUTORIAL_ENABLED and \
|
|
|
|
user_profile.tutorial_status != UserProfile.TUTORIAL_FINISHED
|
|
|
|
|
|
|
|
first_in_realm = realm_user_count(user_profile.realm) == 1
|
|
|
|
# If you are the only person in the realm and you didn't invite
|
|
|
|
# anyone, we'll continue to encourage you to do so on the frontend.
|
|
|
|
prompt_for_invites = first_in_realm and \
|
|
|
|
not PreregistrationUser.objects.filter(referred_by=user_profile).count()
|
|
|
|
|
|
|
|
if user_profile.pointer == -1 and user_has_messages:
|
|
|
|
# Put the new user's pointer at the bottom
|
|
|
|
#
|
|
|
|
# This improves performance, because we limit backfilling of messages
|
|
|
|
# before the pointer. It's also likely that someone joining an
|
|
|
|
# organization is interested in recent messages more than the very
|
|
|
|
# first messages on the system.
|
|
|
|
|
|
|
|
register_ret['pointer'] = register_ret['max_message_id']
|
|
|
|
user_profile.last_pointer_updater = request.session.session_key
|
|
|
|
|
|
|
|
if user_profile.pointer == -1:
|
|
|
|
latest_read = None
|
|
|
|
else:
|
|
|
|
try:
|
|
|
|
latest_read = UserMessage.objects.get(user_profile=user_profile,
|
|
|
|
message__id=user_profile.pointer)
|
|
|
|
except UserMessage.DoesNotExist:
|
|
|
|
# Don't completely fail if your saved pointer ID is invalid
|
|
|
|
logging.warning("%s has invalid pointer %s" % (user_profile.email, user_profile.pointer))
|
|
|
|
latest_read = None
|
|
|
|
|
|
|
|
desktop_notifications_enabled = user_profile.enable_desktop_notifications
|
|
|
|
if narrow_stream is not None:
|
|
|
|
desktop_notifications_enabled = False
|
|
|
|
|
|
|
|
if user_profile.realm.notifications_stream:
|
|
|
|
notifications_stream = user_profile.realm.notifications_stream.name
|
|
|
|
else:
|
|
|
|
notifications_stream = ""
|
|
|
|
|
|
|
|
# Set default language and make it persist
|
|
|
|
default_language = register_ret['default_language']
|
|
|
|
url_lang = '/{}'.format(request.LANGUAGE_CODE)
|
|
|
|
if not request.path.startswith(url_lang):
|
|
|
|
translation.activate(default_language)
|
|
|
|
|
|
|
|
request.session[translation.LANGUAGE_SESSION_KEY] = default_language
|
|
|
|
|
|
|
|
# Pass parameters to the client-side JavaScript code.
|
|
|
|
# These end up in a global JavaScript Object named 'page_params'.
|
|
|
|
page_params = dict(
|
2017-02-28 23:41:41 +01:00
|
|
|
# Server settings.
|
2017-01-07 21:19:52 +01:00
|
|
|
share_the_love = settings.SHARE_THE_LOVE,
|
|
|
|
development_environment = settings.DEVELOPMENT,
|
|
|
|
debug_mode = settings.DEBUG,
|
|
|
|
test_suite = settings.TEST_SUITE,
|
|
|
|
poll_timeout = settings.POLL_TIMEOUT,
|
|
|
|
login_page = settings.HOME_NOT_LOGGED_IN,
|
|
|
|
server_uri = settings.SERVER_URI,
|
|
|
|
maxfilesize = settings.MAX_FILE_UPLOAD_SIZE,
|
2017-03-06 06:22:28 +01:00
|
|
|
max_avatar_file_size = settings.MAX_AVATAR_FILE_SIZE,
|
2017-01-07 21:19:52 +01:00
|
|
|
server_generation = settings.SERVER_GENERATION,
|
2017-02-28 23:41:41 +01:00
|
|
|
use_websockets = settings.USE_WEBSOCKETS,
|
|
|
|
save_stacktraces = settings.SAVE_FRONTEND_STACKTRACES,
|
2017-03-13 14:42:03 +01:00
|
|
|
server_inline_image_preview = settings.INLINE_IMAGE_PREVIEW,
|
|
|
|
server_inline_url_embed_preview = settings.INLINE_URL_EMBED_PREVIEW,
|
2017-02-28 23:41:41 +01:00
|
|
|
|
|
|
|
# user_profile data.
|
|
|
|
# TODO: Move all of these data to register_ret and pull from there
|
|
|
|
fullname = user_profile.full_name,
|
|
|
|
email = user_profile.email,
|
2017-01-07 21:19:52 +01:00
|
|
|
enter_sends = user_profile.enter_sends,
|
|
|
|
user_id = user_profile.id,
|
2017-02-28 23:41:41 +01:00
|
|
|
is_admin = user_profile.is_realm_admin,
|
|
|
|
can_create_streams = user_profile.can_create_streams(),
|
|
|
|
autoscroll_forever = user_profile.autoscroll_forever,
|
|
|
|
default_desktop_notifications = user_profile.default_desktop_notifications,
|
|
|
|
avatar_url = avatar_url(user_profile),
|
|
|
|
avatar_url_medium = avatar_url(user_profile, medium=True),
|
|
|
|
avatar_source = user_profile.avatar_source,
|
2017-01-07 21:19:52 +01:00
|
|
|
|
|
|
|
# Stream message notification settings:
|
|
|
|
stream_desktop_notifications_enabled = user_profile.enable_stream_desktop_notifications,
|
|
|
|
stream_sounds_enabled = user_profile.enable_stream_sounds,
|
|
|
|
|
|
|
|
# Private message and @-mention notification settings:
|
|
|
|
desktop_notifications_enabled = desktop_notifications_enabled,
|
|
|
|
sounds_enabled = user_profile.enable_sounds,
|
|
|
|
enable_offline_email_notifications = user_profile.enable_offline_email_notifications,
|
|
|
|
pm_content_in_desktop_notifications = user_profile.pm_content_in_desktop_notifications,
|
|
|
|
enable_online_push_notifications = user_profile.enable_online_push_notifications,
|
2017-02-28 23:41:41 +01:00
|
|
|
|
|
|
|
# Realm foreign key data from register_ret.
|
|
|
|
# TODO: Rename these to match register_ret values.
|
|
|
|
initial_pointer = register_ret['pointer'],
|
|
|
|
|
|
|
|
# Misc. extra data.
|
|
|
|
have_initial_messages = user_has_messages,
|
|
|
|
initial_servertime = time.time(), # Used for calculating relative presence age
|
|
|
|
default_language_name = get_language_name(register_ret['default_language']),
|
|
|
|
language_list_dbl_col = get_language_list_for_templates(register_ret['default_language']),
|
|
|
|
language_list = get_language_list(),
|
|
|
|
needs_tutorial = needs_tutorial,
|
|
|
|
first_in_realm = first_in_realm,
|
|
|
|
prompt_for_invites = prompt_for_invites,
|
|
|
|
notifications_stream = notifications_stream,
|
|
|
|
cross_realm_bots = list(get_cross_realm_dicts()),
|
2017-01-07 21:19:52 +01:00
|
|
|
unread_count = approximate_unread_count(user_profile),
|
|
|
|
furthest_read_time = sent_time_in_epoch_seconds(latest_read),
|
|
|
|
has_mobile_devices = num_push_devices_for_user(user_profile) > 0,
|
|
|
|
)
|
|
|
|
|
2017-02-28 23:31:10 +01:00
|
|
|
# These fields will be automatically copied from register_ret into
|
|
|
|
# page_params. It is a goal to move more of the page_params list
|
|
|
|
# into this sort of cleaner structure.
|
|
|
|
page_params_core_fields = [
|
|
|
|
'alert_words',
|
|
|
|
'attachments',
|
|
|
|
'default_language',
|
2017-03-02 08:30:53 +01:00
|
|
|
'emoji_alt_code',
|
2017-04-26 23:54:23 +02:00
|
|
|
'emojiset',
|
2017-04-26 23:49:40 +02:00
|
|
|
'emojiset_choices',
|
2017-04-29 06:50:12 +02:00
|
|
|
'enable_digest_emails',
|
2017-04-29 06:50:41 +02:00
|
|
|
'enable_offline_push_notifications',
|
2017-01-24 01:48:35 +01:00
|
|
|
'hotspots',
|
2017-02-28 23:31:10 +01:00
|
|
|
'last_event_id',
|
|
|
|
'left_side_userlist',
|
2017-03-06 06:22:28 +01:00
|
|
|
'max_icon_file_size',
|
2017-02-28 23:31:10 +01:00
|
|
|
'max_message_id',
|
|
|
|
'muted_topics',
|
2017-04-21 07:53:21 +02:00
|
|
|
'never_subscribed',
|
2017-04-24 21:23:50 +02:00
|
|
|
'presences',
|
2017-04-24 21:40:16 +02:00
|
|
|
'queue_id',
|
2017-02-28 23:31:10 +01:00
|
|
|
'realm_add_emoji_by_admins_only',
|
|
|
|
'realm_allow_message_editing',
|
|
|
|
'realm_authentication_methods',
|
2017-03-05 04:17:12 +01:00
|
|
|
'realm_bot_domain',
|
2017-04-21 08:24:30 +02:00
|
|
|
'realm_bots',
|
2017-02-28 23:31:10 +01:00
|
|
|
'realm_create_stream_by_admins_only',
|
|
|
|
'realm_default_language',
|
|
|
|
'realm_default_streams',
|
2017-04-20 07:30:51 +02:00
|
|
|
'realm_domains',
|
2017-03-13 18:41:27 +01:00
|
|
|
'realm_email_changes_disabled',
|
2017-02-28 23:31:10 +01:00
|
|
|
'realm_emoji',
|
2017-03-13 18:33:49 +01:00
|
|
|
'realm_filters',
|
2017-03-08 07:08:58 +01:00
|
|
|
'realm_icon_source',
|
|
|
|
'realm_icon_url',
|
2017-02-28 23:31:10 +01:00
|
|
|
'realm_invite_by_admins_only',
|
2017-03-13 14:42:03 +01:00
|
|
|
'realm_inline_image_preview',
|
|
|
|
'realm_inline_url_embed_preview',
|
2017-02-28 23:31:10 +01:00
|
|
|
'realm_invite_required',
|
2017-04-20 08:03:44 +02:00
|
|
|
'realm_is_zephyr_mirror_realm',
|
2017-04-20 07:50:34 +02:00
|
|
|
'realm_mandatory_topics',
|
2017-03-13 18:33:49 +01:00
|
|
|
'realm_message_content_edit_limit_seconds',
|
2016-11-30 10:42:58 +01:00
|
|
|
'realm_message_retention_days',
|
2017-03-13 18:33:49 +01:00
|
|
|
'realm_name',
|
2017-03-18 20:19:44 +01:00
|
|
|
'realm_description',
|
2017-03-13 18:33:49 +01:00
|
|
|
'realm_name_changes_disabled',
|
2017-04-20 08:21:31 +02:00
|
|
|
'realm_password_auth_enabled',
|
2017-04-20 07:35:53 +02:00
|
|
|
'realm_presence_disabled',
|
2017-02-28 23:31:10 +01:00
|
|
|
'realm_restricted_to_domain',
|
2017-04-20 07:59:03 +02:00
|
|
|
'realm_show_digest_email',
|
2017-04-19 06:38:28 +02:00
|
|
|
'realm_uri',
|
2017-04-24 21:59:07 +02:00
|
|
|
'realm_users',
|
2017-02-28 23:31:10 +01:00
|
|
|
'realm_waiting_period_threshold',
|
|
|
|
'referrals',
|
2017-04-21 07:43:51 +02:00
|
|
|
'subscriptions',
|
2017-04-21 07:49:41 +02:00
|
|
|
'unsubscribed',
|
2017-04-26 23:57:15 +02:00
|
|
|
'timezone',
|
2017-02-28 23:31:10 +01:00
|
|
|
'twenty_four_hour_time',
|
|
|
|
'zulip_version',
|
|
|
|
]
|
|
|
|
|
|
|
|
for field_name in page_params_core_fields:
|
|
|
|
page_params[field_name] = register_ret[field_name]
|
|
|
|
|
2017-01-07 21:19:52 +01:00
|
|
|
if narrow_stream is not None:
|
|
|
|
# In narrow_stream context, initial pointer is just latest message
|
|
|
|
recipient = get_recipient(Recipient.STREAM, narrow_stream.id)
|
|
|
|
try:
|
|
|
|
initial_pointer = Message.objects.filter(recipient=recipient).order_by('id').reverse()[0].id
|
|
|
|
except IndexError:
|
|
|
|
initial_pointer = -1
|
|
|
|
page_params["narrow_stream"] = narrow_stream.name
|
|
|
|
if narrow_topic is not None:
|
|
|
|
page_params["narrow_topic"] = narrow_topic
|
|
|
|
page_params["narrow"] = [dict(operator=term[0], operand=term[1]) for term in narrow]
|
|
|
|
page_params["max_message_id"] = initial_pointer
|
|
|
|
page_params["initial_pointer"] = initial_pointer
|
|
|
|
page_params["have_initial_messages"] = (initial_pointer != -1)
|
|
|
|
|
|
|
|
statsd.incr('views.home')
|
|
|
|
show_invites = True
|
|
|
|
|
|
|
|
# Some realms only allow admins to invite users
|
|
|
|
if user_profile.realm.invite_by_admins_only and not user_profile.is_realm_admin:
|
|
|
|
show_invites = False
|
|
|
|
|
|
|
|
request._log_data['extra'] = "[%s]" % (register_ret["queue_id"],)
|
2017-03-16 14:14:31 +01:00
|
|
|
response = render(request, 'zerver/index.html',
|
|
|
|
context={'user_profile': user_profile,
|
|
|
|
'page_params': simplejson.encoder.JSONEncoderForHTML().encode(page_params),
|
|
|
|
'nofontface': is_buggy_ua(request.META.get("HTTP_USER_AGENT", "Unspecified")),
|
|
|
|
'avatar_url': avatar_url(user_profile),
|
|
|
|
'show_debug':
|
|
|
|
settings.DEBUG and ('show_debug' in request.GET),
|
|
|
|
'pipeline': settings.PIPELINE_ENABLED,
|
|
|
|
'show_invites': show_invites,
|
|
|
|
'is_admin': user_profile.is_realm_admin,
|
|
|
|
'show_webathena': user_profile.realm.webathena_enabled,
|
|
|
|
'enable_feedback': settings.ENABLE_FEEDBACK,
|
|
|
|
'embedded': narrow_stream is not None,
|
|
|
|
},)
|
2017-01-07 21:19:52 +01:00
|
|
|
patch_cache_control(response, no_cache=True, no_store=True, must_revalidate=True)
|
|
|
|
return response
|
|
|
|
|
|
|
|
@zulip_login_required
|
|
|
|
def desktop_home(request):
|
|
|
|
# type: (HttpRequest) -> HttpResponse
|
|
|
|
return HttpResponseRedirect(reverse('zerver.views.home.home'))
|
|
|
|
|
|
|
|
def is_buggy_ua(agent):
|
|
|
|
# type: (str) -> bool
|
|
|
|
"""Discrimiate CSS served to clients based on User Agent
|
|
|
|
|
|
|
|
Due to QTBUG-3467, @font-face is not supported in QtWebKit.
|
|
|
|
This may get fixed in the future, but for right now we can
|
|
|
|
just serve the more conservative CSS to all our desktop apps.
|
|
|
|
"""
|
|
|
|
return ("Humbug Desktop/" in agent or "Zulip Desktop/" in agent or "ZulipDesktop/" in agent) and \
|
|
|
|
"Mac" not in agent
|