mirror of https://github.com/zulip/zulip.git
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:
parent
92d1b6d6da
commit
001847ac5b
|
@ -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'],
|
||||
|
|
|
@ -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')]),
|
||||
),
|
||||
]
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue