From 6f061beb46f6b63f15af2783cd83a8bcb9c36b67 Mon Sep 17 00:00:00 2001 From: Amy Liu Date: Mon, 23 Jan 2017 16:48:35 -0800 Subject: [PATCH] hotspots: Add backend support for tutorial hotspots. This commit adds the backend support for a new style of tutorial which allows for highlighting of multiple areas of the page with hotspots that disappear when clicked by the user. --- .eslintrc.json | 3 +- static/js/hotspots.js | 60 +++++++++++++++++++++++++++ static/js/server_events.js | 5 +++ static/js/ui_init.js | 1 + zerver/lib/actions.py | 9 +++- zerver/lib/events.py | 6 +++ zerver/lib/hotspots.py | 12 ++++++ zerver/migrations/0070_userhotspot.py | 31 ++++++++++++++ zerver/models.py | 8 ++++ zerver/tests/test_events.py | 11 +++++ zerver/tests/test_home.py | 1 + zerver/tests/test_hotspots.py | 52 +++++++++++++++++++++++ zerver/views/home.py | 1 + zerver/views/hotspots.py | 17 ++++++++ zproject/settings.py | 1 + zproject/urls.py | 4 ++ 16 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 static/js/hotspots.js create mode 100644 zerver/lib/hotspots.py create mode 100644 zerver/migrations/0070_userhotspot.py create mode 100644 zerver/tests/test_hotspots.py create mode 100644 zerver/views/hotspots.py diff --git a/.eslintrc.json b/.eslintrc.json index c0dfa499ea..616726be06 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -119,7 +119,8 @@ "emoji_codes": false, "drafts": false, "katex": false, - "Clipboard": false + "Clipboard": false, + "hotspots": false }, "rules": { "no-restricted-syntax": 0, diff --git a/static/js/hotspots.js b/static/js/hotspots.js new file mode 100644 index 0000000000..7d7a416b43 --- /dev/null +++ b/static/js/hotspots.js @@ -0,0 +1,60 @@ +var hotspots = (function () { + +var exports = {}; + +exports.show = function (hotspot_list) { + $('.hotspot').hide(); + for (var i = 0; i < hotspot_list.length; i += 1) { + $("#hotspot_".concat(hotspot_list[i])).show(); + } +}; + +exports.initialize = function () { + exports.show(page_params.hotspots); +}; + +function mark_hotspot_as_read(hotspot) { + channel.post({ + url: '/json/users/me/hotspots', + data: {hotspot: JSON.stringify(hotspot)}, + }); +} + +$(function () { + $("#hotspot_welcome").on('click', function (e) { + mark_hotspot_as_read("welcome"); + e.preventDefault(); + e.stopPropagation(); + }); + $("#hotspot_streams").on('click', function (e) { + mark_hotspot_as_read("streams"); + e.preventDefault(); + e.stopPropagation(); + }); + $("#hotspot_topics").on('click', function (e) { + mark_hotspot_as_read("topics"); + e.preventDefault(); + e.stopPropagation(); + }); + $("#hotspot_narrowing").on('click', function (e) { + mark_hotspot_as_read("narrowing"); + e.preventDefault(); + e.stopPropagation(); + }); + $("#hotspot_replying").on('click', function (e) { + mark_hotspot_as_read("replying"); + e.preventDefault(); + e.stopPropagation(); + }); + $("#hotspot_get_started").on('click', function (e) { + mark_hotspot_as_read("get_started"); + e.preventDefault(); + e.stopPropagation(); + }); +}); + +return exports; +}()); +if (typeof module !== 'undefined') { + module.exports = hotspots; +} diff --git a/static/js/server_events.js b/static/js/server_events.js index 54f311f3a2..f9c73e115b 100644 --- a/static/js/server_events.js +++ b/static/js/server_events.js @@ -23,6 +23,11 @@ function dispatch_normal_event(event) { admin.update_default_streams_table(); break; + case 'hotspots': + hotspots.show(event.hotspots); + page_params.hotspots = event.hotspots; + break; + case 'muted_topics': muting_ui.handle_updates(event.muted_topics); break; diff --git a/static/js/ui_init.js b/static/js/ui_init.js index de3fcc2e0a..090a7621d8 100644 --- a/static/js/ui_init.js +++ b/static/js/ui_init.js @@ -256,6 +256,7 @@ $(function () { unread_ui.initialize(); activity.initialize(); emoji.initialize(); + hotspots.initialize(); }); diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 173391ecb6..90efb48fb5 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -20,6 +20,7 @@ from zerver.lib.cache import ( to_dict_cache_key_id, ) from zerver.lib.context_managers import lockfile +from zerver.lib.hotspots import get_next_hotspots from zerver.lib.message import ( access_message, MessageDict, @@ -28,7 +29,7 @@ from zerver.lib.message import ( ) from zerver.lib.realm_icon import realm_icon_url from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, RealmAlias, \ - Subscription, Recipient, Message, Attachment, UserMessage, RealmAuditLog, \ + Subscription, Recipient, Message, Attachment, UserMessage, RealmAuditLog, UserHotspot, \ Client, DefaultStream, UserPresence, Referral, PushDeviceToken, MAX_SUBJECT_LENGTH, \ MAX_MESSAGE_LENGTH, get_client, get_stream, get_recipient, get_huddle, \ get_user_profile_by_id, PreregistrationUser, get_display_recipient, \ @@ -3174,6 +3175,12 @@ def do_update_muted_topic(user_profile, stream, topic, op): event = dict(type="muted_topics", muted_topics=muted_topics) send_event(event, [user_profile.id]) +def do_mark_hotspot_as_read(user, hotspot): + # type: (UserProfile, str) -> None + UserHotspot.objects.get_or_create(user=user, hotspot=hotspot) + event = dict(type="hotspots", hotspots=get_next_hotspots(user)) + send_event(event, [user.id]) + def notify_realm_filters(realm): # type: (Realm) -> None realm_filters = realm_filters_for_realm(realm.id) diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 37c36ec8da..70bcc78c9e 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -20,6 +20,7 @@ session_engine = import_module(settings.SESSION_ENGINE) from zerver.lib.alert_words import user_alert_words from zerver.lib.attachments import user_attachments from zerver.lib.avatar import get_avatar_url +from zerver.lib.hotspots import get_next_hotspots from zerver.lib.narrow import check_supported_events_narrow_filter from zerver.lib.realm_icon import realm_icon_url from zerver.lib.request import JsonableError @@ -72,6 +73,9 @@ def fetch_initial_state_data(user_profile, event_types, queue_id, if want('attachments'): state['attachments'] = user_attachments(user_profile) + if want('hotspots'): + state['hotspots'] = get_next_hotspots(user_profile) + if want('message'): # The client should use get_messages() to fetch messages # starting with the max_message_id. They will get messages @@ -181,6 +185,8 @@ def apply_event(state, event, user_profile, include_subscribers): # type: (Dict[str, Any], Dict[str, Any], UserProfile, bool) -> None if event['type'] == "message": state['max_message_id'] = max(state['max_message_id'], event['message']['id']) + elif event['type'] == "hotspots": + state['hotspots'] = event['hotspots'] elif event['type'] == "pointer": state['pointer'] = max(state['pointer'], event['pointer']) elif event['type'] == "realm_user": diff --git a/zerver/lib/hotspots.py b/zerver/lib/hotspots.py new file mode 100644 index 0000000000..74ad557cf3 --- /dev/null +++ b/zerver/lib/hotspots.py @@ -0,0 +1,12 @@ +from zerver.models import UserProfile, UserHotspot + +from typing import List, Text + +ALL_HOTSPOTS = ['welcome', 'streams', 'topics', 'narrowing', 'replying', 'get_started'] +def get_next_hotspots(user): + # type: (UserProfile) -> List[Text] + seen_hotspots = frozenset(UserHotspot.objects.filter(user=user).values_list('hotspot', flat=True)) + for hotspot in ALL_HOTSPOTS: + if hotspot not in seen_hotspots: + return [hotspot] + return [] diff --git a/zerver/migrations/0070_userhotspot.py b/zerver/migrations/0070_userhotspot.py new file mode 100644 index 0000000000..fe832bc74b --- /dev/null +++ b/zerver/migrations/0070_userhotspot.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-03-28 00:22 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('zerver', '0069_realmauditlog_extra_data'), + ] + + operations = [ + migrations.CreateModel( + name='UserHotspot', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hotspot', models.CharField(max_length=30)), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='userhotspot', + unique_together=set([('user', 'hotspot')]), + ), + ] diff --git a/zerver/models.py b/zerver/models.py index 5f3f658401..a9a6314240 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -1553,3 +1553,11 @@ class RealmAuditLog(models.Model): event_time = models.DateTimeField() # type: datetime.datetime backfilled = models.BooleanField(default=False) # type: bool extra_data = models.TextField(null=True) # type: Text + +class UserHotspot(models.Model): + user = models.ForeignKey(UserProfile) # type: UserProfile + hotspot = models.CharField(max_length=30) # type: Text + timestamp = models.DateTimeField(default=timezone.now) # type: datetime.datetime + + class Meta(object): + unique_together = ("user", "hotspot") diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index f4407462e6..f713bfe95e 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -38,6 +38,7 @@ from zerver.lib.actions import ( do_create_user, do_deactivate_stream, do_deactivate_user, + do_mark_hotspot_as_read, do_reactivate_user, do_refer_friend, do_regenerate_api_key, @@ -1451,6 +1452,16 @@ class EventsRegisterTest(ZulipTestCase): error = bot_reactivate_checker('events[1]', events[1]) self.assert_on_error(error) + def test_do_mark_hotspot_as_read(self): + # type: () -> None + schema_checker = check_dict([ + ('type', equals('hotspots')), + ('hotspots', check_list(check_string)), + ]) + events = self.do_test(lambda: do_mark_hotspot_as_read(self.user_profile, 'welcome')) + error = schema_checker('events[0]', events[0]) + self.assert_on_error(error) + def test_rename_stream(self): # type: () -> None stream = self.make_stream('old_name') diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index be81f2e747..13ffe059f0 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -68,6 +68,7 @@ class HomeTest(ZulipTestCase): "furthest_read_time", "has_mobile_devices", "have_initial_messages", + "hotspots", "initial_pointer", "initial_presences", "initial_servertime", diff --git a/zerver/tests/test_hotspots.py b/zerver/tests/test_hotspots.py new file mode 100644 index 0000000000..52de17b49b --- /dev/null +++ b/zerver/tests/test_hotspots.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from zerver.lib.actions import do_mark_hotspot_as_read +from zerver.lib.hotspots import ALL_HOTSPOTS, get_next_hotspots +from zerver.lib.test_classes import ZulipTestCase +from zerver.models import UserProfile, UserHotspot +from zerver.views.hotspots import mark_hotspot_as_read + +from typing import Any, Dict +import ujson + +# Splitting this out, since I imagine this will eventually have most of the +# complicated hotspots logic. +class TestGetNextHotspots(ZulipTestCase): + def test_first_hotspot(self): + # type: () -> None + user = UserProfile.objects.get(email='hamlet@zulip.com') + self.assertEqual(get_next_hotspots(user), ['welcome']) + + def test_some_done_some_not(self): + # type: () -> None + user = UserProfile.objects.get(email='hamlet@zulip.com') + do_mark_hotspot_as_read(user, 'welcome') + do_mark_hotspot_as_read(user, 'topics') + self.assertEqual(get_next_hotspots(user), ['streams']) + + def test_all_done(self): + # type: () -> None + user = UserProfile.objects.get(email='hamlet@zulip.com') + for hotspot in ALL_HOTSPOTS: + do_mark_hotspot_as_read(user, hotspot) + self.assertEqual(get_next_hotspots(user), []) + +class TestHotspots(ZulipTestCase): + def test_do_mark_hotspot_as_read(self): + # type: () -> None + user = UserProfile.objects.get(email='hamlet@zulip.com') + do_mark_hotspot_as_read(user, 'streams') + self.assertEqual(list(UserHotspot.objects.filter(user=user) + .values_list('hotspot', flat=True)), ['streams']) + + def test_hotspots_url_endpoint(self): + # type: () -> None + email = 'hamlet@zulip.com' + user = UserProfile.objects.get(email=email) + self.login(email) + result = self.client_post('/json/users/me/hotspots', + {'hotspot': ujson.dumps('welcome')}) + self.assert_json_success(result) + self.assertEqual(list(UserHotspot.objects.filter(user=user) + .values_list('hotspot', flat=True)), ['welcome']) diff --git a/zerver/views/home.py b/zerver/views/home.py index 1e769edcef..95147bfedb 100644 --- a/zerver/views/home.py +++ b/zerver/views/home.py @@ -283,6 +283,7 @@ def home_real(request): 'attachments', 'default_language', 'emoji_alt_code', + 'hotspots', 'last_event_id', 'left_side_userlist', 'max_icon_file_size', diff --git a/zerver/views/hotspots.py b/zerver/views/hotspots.py new file mode 100644 index 0000000000..dd1bcf8584 --- /dev/null +++ b/zerver/views/hotspots.py @@ -0,0 +1,17 @@ +from django.http import HttpRequest, HttpResponse +from django.utils.translation import ugettext as _ + +from zerver.decorator import has_request_variables, REQ +from zerver.lib.actions import do_mark_hotspot_as_read +from zerver.lib.hotspots import ALL_HOTSPOTS +from zerver.lib.response import json_error, json_success +from zerver.lib.validator import check_string +from zerver.models import UserProfile + +@has_request_variables +def mark_hotspot_as_read(request, user, hotspot=REQ(validator=check_string)): + # type: (HttpRequest, UserProfile, str) -> HttpResponse + if hotspot not in ALL_HOTSPOTS: + return json_error(_('Unknown hotspot: %s') % (hotspot,)) + do_mark_hotspot_as_read(user, hotspot) + return json_success() diff --git a/zproject/settings.py b/zproject/settings.py index d6c9a91af2..5054232d00 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -895,6 +895,7 @@ JS_SPECS = { 'js/colorspace.js', 'js/timerender.js', 'js/tutorial.js', + 'js/hotspots.js', 'js/templates.js', 'js/upload_widget.js', 'js/avatar.js', diff --git a/zproject/urls.py b/zproject/urls.py index b2e580401e..30edc235ca 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -298,6 +298,10 @@ v1_api_and_json_patterns = [ {'PUT': 'zerver.views.user_settings.set_avatar_backend', 'DELETE': 'zerver.views.user_settings.delete_avatar_backend'}), + # users/me/hotspots -> zerver.views.hotspots + url(r'^users/me/hotspots$', rest_dispatch, + {'POST': 'zerver.views.hotspots.mark_hotspot_as_read'}), + # settings -> zerver.views.user_settings url(r'^settings/display$', rest_dispatch, {'PATCH': 'zerver.views.user_settings.update_display_settings_backend'}),