Add backend support for emoji reactions.

This commit adds the following:

1. A reaction model that consists of a user, a message and an emoji that
are unique together (a user cannot react to a particular message more
than once with the same emoji)
2. A reaction event that looks like:
    {
        'type': 'reaction',
	'op': 'add',
	'message_id': 3,
	'emoji_name': 'doge',
	'user': {
	    'user_id': 1,
            'email': 'hamlet@zulip.com',
            'full_name': 'King Hamlet'
	}
    }
3. A new API endpoint, /reactions, that accepts POST requests to add a
reaction to a message
4. A migration to add the new model to the database
5. Tests that check that
   (a) Invalid requests cannot be made
   (b) The reaction event body contains all the info
   (c) The reaction event is sent to the appropriate users
   (d) Reacting more than once fails

It is still missing important features like removing emoji and
fetching them alongside messages.
This commit is contained in:
Arpith Siromoney 2016-11-03 10:49:00 -07:00 committed by Tim Abbott
parent 92d1b6d6da
commit 001847ac5b
6 changed files with 287 additions and 1 deletions

View File

@ -35,7 +35,8 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity,
UserActivityInterval, get_active_user_dicts_in_realm, get_active_streams, \
realm_filters_for_domain, RealmFilter, receives_offline_notifications, \
ScheduledJob, realm_filters_for_domain, get_owned_bot_dicts, \
get_old_unclaimed_attachments, get_cross_realm_emails, receives_online_notifications
get_old_unclaimed_attachments, get_cross_realm_emails, receives_online_notifications, \
Reaction
from zerver.lib.alert_words import alert_words_in_realm
from zerver.lib.avatar import get_avatar_url, avatar_url
@ -906,6 +907,30 @@ def do_send_messages(messages):
# intermingle sending zephyr messages with other messages.
return already_sent_ids + [message['message'].id for message in messages]
def do_add_reaction(user_profile, message, emoji_name):
# type: (UserProfile, Message, text_type) -> None
reaction = Reaction(user_profile=user_profile, message=message, emoji_name=emoji_name)
reaction.save()
user_dict = {'user_id': user_profile.id,
'email': user_profile.email,
'full_name': user_profile.full_name}
event = {'type': 'reaction',
'op': 'add',
'user': user_dict,
'message_id': message.id,
'emoji_name': emoji_name} # type: Dict[str, Any]
# Recipients for message update events, including reactions, are
# everyone who got the original message. This means reactions
# won't live-update in preview narrows, but it's the right
# performance tradeoff, since otherwise we'd need to send all
# reactions to public stream messages to every browser for every
# client in the organization, which doesn't scale.
ums = UserMessage.objects.filter(message=message.id)
send_event(event, [um.user_profile.id for um in ums])
def do_send_typing_notification(notification):
# type: (Dict[str, Any]) -> None
recipient_user_profiles = get_recipient_user_profiles(notification['recipient'],

View File

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import zerver.lib.str_utils
class Migration(migrations.Migration):
dependencies = [
('zerver', '0043_realm_filter_validators'),
]
operations = [
migrations.CreateModel(
name='Reaction',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('user_profile', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
('message', models.ForeignKey(to='zerver.Message')),
('emoji_name', models.TextField()),
],
bases=(zerver.lib.str_utils.ModelReprMixin, models.Model),
),
migrations.AlterUniqueTogether(
name='reaction',
unique_together=set([('user_profile', 'message', 'emoji_name')]),
),
]

View File

@ -1010,6 +1010,14 @@ def get_context_for_message(message):
post_save.connect(flush_message, sender=Message)
class Reaction(ModelReprMixin, models.Model):
user_profile = models.ForeignKey(UserProfile) # type: UserProfile
message = models.ForeignKey(Message) # type: Message
emoji_name = models.TextField() # type: text_type
class Meta(object):
unique_together = ("user_profile", "message", "emoji_name")
# Whenever a message is sent, for each user current subscribed to the
# corresponding Recipient object, we add a row to the UserMessage
# table, which has has columns (id, user profile id, message id,

View File

@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
import ujson
from typing import Any, Dict, List
from six import string_types
from zerver.lib.test_helpers import tornado_redirected_to_list, get_display_recipient
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import get_user_profile_by_email
class ReactionEmojiTest(ZulipTestCase):
def test_missing_emoji(self):
# type: () -> None
"""
Sending reaction without emoji fails
"""
sender = 'hamlet@zulip.com'
result = self.client_post('/api/v1/reactions', {'message_id': 1},
**self.api_auth(sender))
self.assert_json_error(result, "Missing 'emoji' argument")
def test_empty_emoji(self):
# type: () -> None
"""
Sending empty emoji fails
"""
sender = 'hamlet@zulip.com'
result = self.client_post('/api/v1/reactions', {'message_id': 1, 'emoji': ''},
**self.api_auth(sender))
self.assert_json_error(result, "Emoji '' does not exist")
def test_invalid_emoji(self):
# type: () -> None
"""
Sending invalid emoji fails
"""
sender = 'hamlet@zulip.com'
result = self.client_post('/api/v1/reactions', {'message_id': 1, 'emoji': 'foo'},
**self.api_auth(sender))
self.assert_json_error(result, "Emoji 'foo' does not exist")
def test_valid_emoji(self):
# type: () -> None
"""
Reacting with valid emoji succeeds
"""
sender = 'hamlet@zulip.com'
result = self.client_post('/api/v1/reactions', {'message_id': 1, 'emoji': 'smile'},
**self.api_auth(sender))
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
def test_valid_realm_emoji(self):
# type: () -> None
"""
Reacting with valid realm emoji succeeds
"""
sender = 'hamlet@zulip.com'
emoji_name = 'my_emoji'
emoji_data = {'name': emoji_name, 'url': 'https://example.com/my_emoji'}
result = self.client_put('/json/realm/emoji', info=emoji_data,
**self.api_auth(sender))
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
result = self.client_get("/json/realm/emoji", **self.api_auth(sender))
content = ujson.loads(result.content)
self.assert_json_success(result)
self.assertTrue(emoji_name in content["emoji"])
result = self.client_post('/api/v1/reactions', {'message_id': 1, 'emoji': emoji_name},
**self.api_auth(sender))
self.assert_json_success(result)
class ReactionMessageIDTest(ZulipTestCase):
def test_missing_message_id(self):
# type: () -> None
"""
Reacting without a message_id fails
"""
sender = 'hamlet@zulip.com'
result = self.client_post('/api/v1/reactions', {'emoji': 'smile'},
**self.api_auth(sender))
self.assert_json_error(result, "Missing 'message_id' argument")
def test_invalid_message_id(self):
# type: () -> None
"""
Reacting to an invalid message id fails
"""
sender = 'hamlet@zulip.com'
message_id = -1
result = self.client_post('/api/v1/reactions', {'message_id': message_id, 'emoji': 'smile'},
**self.api_auth(sender))
self.assert_json_error(result, "Bad value for 'message_id': " + str(message_id))
def test_inaccessible_message_id(self):
# type: () -> None
"""
Reacting to a inaccessible (for instance, private) message fails
"""
pm_sender = 'hamlet@zulip.com'
pm_recipient = 'othello@zulip.com'
reaction_sender = 'iago@zulip.com'
result = self.client_post("/api/v1/messages", {"type": "private",
"content": "Test message",
"to": pm_recipient},
**self.api_auth(pm_sender))
self.assert_json_success(result)
content = ujson.loads(result.content)
pm_id = content['id']
result = self.client_post('/api/v1/reactions', {'message_id': pm_id, 'emoji': 'smile'},
**self.api_auth(reaction_sender))
self.assert_json_error(result, "Invalid message(s)")
class ReactionTest(ZulipTestCase):
def test_existing_reaction(self):
# type: () -> None
"""
Creating the same reaction twice fails
"""
pm_sender = 'hamlet@zulip.com'
pm_recipient = 'othello@zulip.com'
reaction_sender = pm_recipient
pm = self.client_post("/api/v1/messages", {"type": "private",
"content": "Test message",
"to": pm_recipient},
**self.api_auth(pm_sender))
self.assert_json_success(pm)
content = ujson.loads(pm.content)
pm_id = content['id']
first = self.client_post('/api/v1/reactions', {'message_id': pm_id,
'emoji': 'smile'},
**self.api_auth(reaction_sender))
self.assert_json_success(first)
second = self.client_post('/api/v1/reactions', {'message_id': pm_id,
'emoji': 'smile'},
**self.api_auth(reaction_sender))
self.assert_json_error(second, "Reaction already exists")
class ReactionEventTest(ZulipTestCase):
def test_event(self):
# type: () -> None
"""
Recipients of the message receive the reaction event
and event contains relevant data
"""
pm_sender = 'hamlet@zulip.com'
pm_recipient = 'othello@zulip.com'
reaction_sender = pm_recipient
result = self.client_post("/api/v1/messages", {"type": "private",
"content": "Test message",
"to": pm_recipient},
**self.api_auth(pm_sender))
self.assert_json_success(result)
content = ujson.loads(result.content)
pm_id = content['id']
expected_recipient_emails = set([pm_sender, pm_recipient])
expected_recipient_ids = set([get_user_profile_by_email(email).id for email in expected_recipient_emails])
events = [] # type: List[Dict[str, Any]]
with tornado_redirected_to_list(events):
result = self.client_post('/api/v1/reactions', {'message_id': pm_id,
'emoji': 'smile'},
**self.api_auth(reaction_sender))
self.assert_json_success(result)
self.assertEqual(len(events), 1)
event = events[0]['event']
event_user_ids = set(events[0]['users'])
self.assertEqual(expected_recipient_ids, event_user_ids)
self.assertEqual(event['user']['email'], reaction_sender)
self.assertEqual(event['type'], 'reaction')
self.assertEqual(event['op'], 'add')
self.assertEqual(event['emoji_name'], 'smile')
self.assertEqual(event['message_id'], pm_id)

36
zerver/views/reactions.py Normal file
View File

@ -0,0 +1,36 @@
from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _
from six import text_type
from zerver.decorator import authenticated_json_post_view,\
has_request_variables, REQ, to_non_negative_int
from zerver.lib.actions import do_add_reaction
from zerver.lib.bugdown import emoji_list
from zerver.lib.message import access_message
from zerver.lib.request import JsonableError
from zerver.lib.response import json_success
from zerver.models import Reaction, UserProfile
@has_request_variables
def add_reaction_backend(request, user_profile, emoji_name=REQ('emoji'),
message_id = REQ('message_id', converter=to_non_negative_int)):
# type: (HttpRequest, UserProfile, text_type, int) -> HttpResponse
# access_message will throw a JsonableError exception if the user
# cannot see the message (e.g. for messages to private streams).
message = access_message(user_profile, message_id)[0]
existing_emojis = set(message.sender.realm.get_emoji().keys()) or set(emoji_list)
if emoji_name not in existing_emojis:
raise JsonableError(_("Emoji '%s' does not exist" % (emoji_name,)))
# We could probably just make this check be a try/except for the
# IntegrityError from it already existing, but this is a bit cleaner.
if Reaction.objects.filter(user_profile=user_profile,
message=message,
emoji_name=emoji_name).exists():
raise JsonableError(_("Reaction already exists"))
do_add_reaction(user_profile, message, emoji_name)
return json_success()

View File

@ -198,6 +198,11 @@ v1_api_and_json_patterns = [
url(r'^messages/flags$', rest_dispatch,
{'POST': 'zerver.views.messages.update_message_flags'}),
# reactions -> zerver.view.reactions
# POST adds a reaction to a message
url(r'^reactions$', rest_dispatch,
{'POST': 'zerver.views.reactions.add_reaction_backend'}),
# typing -> zerver.views.typing
# POST sends a typing notification event to recipients
url(r'^typing$', rest_dispatch,