diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index ee42df2aad..10d86202df 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -1380,6 +1380,23 @@ def do_remove_reaction_legacy(user_profile, message, emoji_name): reaction.delete() notify_reaction_update(user_profile, message, reaction, "remove") +def do_add_reaction(user_profile, message, emoji_name, emoji_code, reaction_type): + # type: (UserProfile, Message, Text, Text, Text) -> None + reaction = Reaction(user_profile=user_profile, message=message, + emoji_name=emoji_name, emoji_code=emoji_code, + reaction_type=reaction_type) + reaction.save() + notify_reaction_update(user_profile, message, reaction, "add") + +def do_remove_reaction(user_profile, message, emoji_code, reaction_type): + # type: (UserProfile, Message, Text, Text) -> None + reaction = Reaction.objects.filter(user_profile=user_profile, + message=message, + emoji_code=emoji_code, + reaction_type=reaction_type).get() + reaction.delete() + notify_reaction_update(user_profile, message, reaction, "remove") + def do_send_typing_notification(notification): # type: (Dict[str, Any]) -> None recipient_user_profiles = get_typing_user_profiles(notification['recipient'], diff --git a/zerver/lib/emoji.py b/zerver/lib/emoji.py index 84f1081deb..415ce87501 100644 --- a/zerver/lib/emoji.py +++ b/zerver/lib/emoji.py @@ -12,9 +12,14 @@ from zerver.lib.upload import upload_backend from zerver.models import Reaction, Realm, RealmEmoji, UserProfile NAME_TO_CODEPOINT_PATH = os.path.join(settings.STATIC_ROOT, "generated", "emoji", "name_to_codepoint.json") +CODEPOINT_TO_NAME_PATH = os.path.join(settings.STATIC_ROOT, "generated", "emoji", "codepoint_to_name.json") + with open(NAME_TO_CODEPOINT_PATH) as fp: name_to_codepoint = ujson.load(fp) +with open(CODEPOINT_TO_NAME_PATH) as fp: + codepoint_to_name = ujson.load(fp) + def emoji_name_to_emoji_code(realm, emoji_name): # type: (Realm, Text) -> Tuple[Text, Text] realm_emojis = realm.get_emoji() @@ -30,6 +35,45 @@ def check_valid_emoji(realm, emoji_name): # type: (Realm, Text) -> None emoji_name_to_emoji_code(realm, emoji_name) +def emoji_code_is_valid(realm: Realm, emoji_code: str, emoji_type: str) -> bool: + # For a given realm and emoji type, determines whether an emoji + # code is valid for new reactions, or not. + if emoji_type == "realm_emoji": + realm_emojis = realm.get_emoji() + if emoji_code not in realm_emojis: + return False + if realm_emojis[emoji_code]["deactivated"]: + return False + return True + elif emoji_type == "zulip_extra_emoji": + return emoji_code == "zulip" + elif emoji_type == "unicode_emoji": + return emoji_code in codepoint_to_name + + # The above are the only valid emoji types + return False + +def emoji_name_is_valid(emoji_name: str, emoji_code: str, emoji_type: str) -> bool: + # Given a realm, emoji code and emoji type, determines whether the + # passed emoji name is a valid name for that emoji. It is assumed + # here that the emoji code has been checked for validity before + # calling this function. + if emoji_type == "realm_emoji": + return emoji_code == emoji_name + elif emoji_type == "zulip_extra_emoji": + return emoji_name == "zulip" + elif emoji_type == "unicode_emoji": + return name_to_codepoint.get(emoji_name) == emoji_code + raise AssertionError("Emoji type should have been checked previously") + +def check_emoji_name_consistency(emoji_name: str, emoji_code: str, emoji_type: str) -> None: + if not emoji_name_is_valid(emoji_name, emoji_code, emoji_type): + raise JsonableError(_("Invalid emoji name.")) + +def check_emoji_code_consistency(realm: Realm, emoji_code: str, emoji_type: str) -> None: + if not emoji_code_is_valid(realm, emoji_code, emoji_type): + raise JsonableError(_("Emoji for this emoji code not found.")) + def check_emoji_admin(user_profile, emoji_name=None): # type: (UserProfile, Optional[Text]) -> None """Raises an exception if the user cannot administer the target realm diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 3e8acbf1c7..983927e5ee 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -25,6 +25,7 @@ from zerver.lib.actions import ( check_send_typing_notification, do_add_alert_words, do_add_default_stream, + do_add_reaction, do_add_reaction_legacy, do_add_realm_domain, do_add_realm_filter, @@ -55,6 +56,7 @@ from zerver.lib.actions import ( do_remove_alert_words, do_remove_default_stream, do_remove_default_stream_group, + do_remove_reaction, do_remove_reaction_legacy, do_remove_realm_domain, do_remove_realm_emoji, @@ -861,6 +863,59 @@ class EventsRegisterTest(ZulipTestCase): error = schema_checker('events[0]', events[0]) self.assert_on_error(error) + def test_add_reaction(self): + # type: () -> None + schema_checker = self.check_events_dict([ + ('type', equals('reaction')), + ('op', equals('add')), + ('message_id', check_int), + ('emoji_name', check_string), + ('emoji_code', check_string), + ('reaction_type', check_string), + ('user', check_dict_only([ + ('email', check_string), + ('full_name', check_string), + ('user_id', check_int) + ])), + ]) + + message_id = self.send_stream_message(self.example_email("hamlet"), "Verona", "hello") + message = Message.objects.get(id=message_id) + events = self.do_test( + lambda: do_add_reaction( + self.user_profile, message, "tada", "1f389", "unicode_emoji"), + state_change_expected=False, + ) + error = schema_checker('events[0]', events[0]) + self.assert_on_error(error) + + def test_remove_reaction(self): + # type: () -> None + schema_checker = self.check_events_dict([ + ('type', equals('reaction')), + ('op', equals('remove')), + ('message_id', check_int), + ('emoji_name', check_string), + ('emoji_code', check_string), + ('reaction_type', check_string), + ('user', check_dict_only([ + ('email', check_string), + ('full_name', check_string), + ('user_id', check_int) + ])), + ]) + + message_id = self.send_stream_message(self.example_email("hamlet"), "Verona", "hello") + message = Message.objects.get(id=message_id) + do_add_reaction(self.user_profile, message, "tada", "1f389", "unicode_emoji") + events = self.do_test( + lambda: do_remove_reaction( + self.user_profile, message, "1f389", "unicode_emoji"), + state_change_expected=False, + ) + error = schema_checker('events[0]', events[0]) + self.assert_on_error(error) + def test_typing_events(self): # type: () -> None schema_checker = self.check_events_dict([ diff --git a/zerver/tests/test_reactions.py b/zerver/tests/test_reactions.py index e291508c65..44cb51c19b 100644 --- a/zerver/tests/test_reactions.py +++ b/zerver/tests/test_reactions.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- import ujson -from typing import Any, Mapping, List +from django.http import HttpResponse +from typing import Any, Dict, List, Mapping, Text from unittest import mock from zerver.lib.emoji import emoji_name_to_emoji_code @@ -9,7 +10,7 @@ from zerver.lib.request import JsonableError from zerver.lib.test_helpers import tornado_redirected_to_list, get_display_recipient, \ get_test_image_file from zerver.lib.test_classes import ZulipTestCase -from zerver.models import get_realm, RealmEmoji, Recipient, UserMessage +from zerver.models import get_realm, Message, Reaction, RealmEmoji, Recipient, UserMessage class ReactionEmojiTest(ZulipTestCase): def test_missing_emoji(self): @@ -367,3 +368,473 @@ class ReactionEventTest(ZulipTestCase): self.assertEqual(event['op'], 'remove') self.assertEqual(event['emoji_name'], 'smile') self.assertEqual(event['message_id'], pm_id) + +class DefaultEmojiReactionTests(ZulipTestCase): + def post_reaction(self, reaction_info, message_id=1, sender='hamlet'): + # type: (Dict[str, str], int, str) -> HttpResponse + reaction_info['reaction_type'] = 'unicode_emoji' + sender = self.example_email(sender) + result = self.client_post('/api/v1/messages/%s/reactions' % (message_id,), + reaction_info, + **self.api_auth(sender)) + return result + + def delete_reaction(self, reaction_info, message_id=1, sender='hamlet'): + # type: (Dict[str, str], int, str) -> HttpResponse + reaction_info['reaction_type'] = 'unicode_emoji' + sender = self.example_email(sender) + result = self.client_delete('/api/v1/messages/%s/reactions' % (message_id,), + reaction_info, + **self.api_auth(sender)) + return result + + def get_message_reactions(self, message_id, emoji_code, reaction_type): + # type: (int, Text, Text) -> List[Reaction] + message = Message.objects.get(id=message_id) + reactions = Reaction.objects.filter(message=message, + emoji_code=emoji_code, + reaction_type=reaction_type) + return list(reactions) + + def setUp(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'hamburger', + 'emoji_code': '1f354', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + def test_add_default_emoji_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'thumbs_up', + 'emoji_code': '1f44d', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + def test_add_default_emoji_invalid_code(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'hamburger', + 'emoji_code': 'TBD', + } + result = self.post_reaction(reaction_info) + self.assert_json_error(result, 'Emoji for this emoji code not found.') + + def test_add_default_emoji_invalid_name(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'non-existent', + 'emoji_code': '1f44d', + } + result = self.post_reaction(reaction_info) + self.assert_json_error(result, 'Invalid emoji name.') + + def test_add_to_existing_renamed_default_emoji_reaction(self): + # type: () -> None + hamlet = self.example_user('hamlet') + message = Message.objects.get(id=1) + reaction = Reaction.objects.create(user_profile=hamlet, + message=message, + emoji_name='old_name', + emoji_code='1f603', + reaction_type='unicode_emoji', + ) + reaction_info = { + 'emoji_name': 'smiley', + 'emoji_code': '1f603', + } + result = self.post_reaction(reaction_info, sender='AARON') + self.assert_json_success(result) + + reactions = self.get_message_reactions(1, '1f603', 'unicode_emoji') + for reaction in reactions: + self.assertEqual(reaction.emoji_name, 'old_name') + + def test_add_duplicate_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'non-existent', + 'emoji_code': '1f354', + } + result = self.post_reaction(reaction_info) + self.assert_json_error(result, 'Reaction already exists.') + + def test_preserve_non_canonical_name(self): + # type: () -> None + reaction_info = { + 'emoji_name': '+1', + 'emoji_code': '1f44d', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + reactions = self.get_message_reactions(1, '1f44d', 'unicode_emoji') + for reaction in reactions: + self.assertEqual(reaction.emoji_name, '+1') + + def test_reaction_name_collapse(self): + # type: () -> None + reaction_info = { + 'emoji_name': '+1', + 'emoji_code': '1f44d', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + reaction_info['emoji_name'] = 'thumbs_up' + result = self.post_reaction(reaction_info, sender='AARON') + self.assert_json_success(result) + + reactions = self.get_message_reactions(1, '1f44d', 'unicode_emoji') + for reaction in reactions: + self.assertEqual(reaction.emoji_name, '+1') + + def test_delete_default_emoji_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'hamburger', + 'emoji_code': '1f354', + } + result = self.delete_reaction(reaction_info) + self.assert_json_success(result) + + def test_delete_non_existing_emoji_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'thumbs_up', + 'emoji_code': '1f44d', + } + result = self.delete_reaction(reaction_info) + self.assert_json_error(result, "Reaction doesn't exist.") + + def test_delete_renamed_default_emoji(self): + # type: () -> None + hamlet = self.example_user('hamlet') + message = Message.objects.get(id=1) + Reaction.objects.create(user_profile=hamlet, + message=message, + emoji_name='old_name', + emoji_code='1f44f', + reaction_type='unicode_emoji', + ) + + reaction_info = { + 'emoji_name': 'new_name', + 'emoji_code': '1f44f', + } + result = self.delete_reaction(reaction_info) + self.assert_json_success(result) + + def test_react_historical(self): + # type: () -> None + """ + Reacting with valid emoji on a historical message succeeds. + """ + stream_name = "Saxony" + self.subscribe(self.example_user("cordelia"), stream_name) + message_id = self.send_stream_message(self.example_email("cordelia"), stream_name) + + user_profile = self.example_user('hamlet') + + # Verify that hamlet did not receive the message. + self.assertFalse(UserMessage.objects.filter(user_profile=user_profile, + message_id=message_id).exists()) + + # Have hamlet react to the message + reaction_info = { + 'emoji_name': 'hamburger', + 'emoji_code': '1f354', + } + result = self.post_reaction(reaction_info, message_id=message_id) + self.assert_json_success(result) + + # Fetch the now-created UserMessage object to confirm it exists and is historical + user_message = UserMessage.objects.get(user_profile=user_profile, message_id=message_id) + self.assertTrue(user_message.flags.historical) + self.assertTrue(user_message.flags.read) + self.assertFalse(user_message.flags.starred) + +class ZulipExtraEmojiReactionTest(ZulipTestCase): + def post_zulip_reaction(self, message_id=1, sender='hamlet'): + # type: (int, Text) -> HttpResponse + sender = self.example_email(sender) + reaction_info = { + 'emoji_name': 'zulip', + 'emoji_code': 'zulip', + 'reaction_type': 'zulip_extra_emoji', + } + result = self.client_post('/api/v1/messages/%s/reactions' % (message_id,), + reaction_info, + **self.api_auth(sender)) + return result + + def test_add_zulip_emoji_reaction(self): + # type: () -> None + result = self.post_zulip_reaction() + self.assert_json_success(result) + + def test_add_duplicate_zulip_reaction(self): + # type: () -> None + result = self.post_zulip_reaction() + self.assert_json_success(result) + + result = self.post_zulip_reaction() + self.assert_json_error(result, 'Reaction already exists.') + + def test_delete_zulip_emoji(self): + # type: () -> None + result = self.post_zulip_reaction() + self.assert_json_success(result) + + sender = self.example_email('hamlet') + reaction_info = { + 'emoji_name': 'zulip', + 'emoji_code': 'zulip', + 'reaction_type': 'zulip_extra_emoji', + } + result = self.client_delete('/api/v1/messages/1/reactions', + reaction_info, + **self.api_auth(sender)) + self.assert_json_success(result) + + def test_delete_non_existent_zulip_reaction(self): + # type: () -> None + sender = self.example_email('hamlet') + reaction_info = { + 'emoji_name': 'zulip', + 'emoji_code': 'zulip', + 'reaction_type': 'zulip_extra_emoji', + } + result = self.client_delete('/api/v1/messages/1/reactions', + reaction_info, + **self.api_auth(sender)) + self.assert_json_error(result, "Reaction doesn't exist.") + +class RealmEmojiReactionTests(ZulipTestCase): + def post_reaction(self, reaction_info, message_id=1, sender='hamlet'): + # type: (Dict[str, str], int, str) -> HttpResponse + reaction_info['reaction_type'] = 'realm_emoji' + sender = self.example_email(sender) + result = self.client_post('/api/v1/messages/%s/reactions' % (message_id,), + reaction_info, + **self.api_auth(sender)) + return result + + def delete_reaction(self, reaction_info, message_id=1, sender='hamlet'): + # type: (Dict[str, str], int, str) -> HttpResponse + reaction_info['reaction_type'] = 'realm_emoji' + sender = self.example_email(sender) + result = self.client_delete('/api/v1/messages/%s/reactions' % (message_id,), + reaction_info, + **self.api_auth(sender)) + return result + + def get_message_reactions(self, message_id, emoji_code, reaction_type): + # type: (int, Text, Text) -> List[Reaction] + message = Message.objects.get(id=message_id) + reactions = Reaction.objects.filter(message=message, + emoji_code=emoji_code, + reaction_type=reaction_type) + return list(reactions) + + def test_add_realm_emoji(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'green_tick', + 'emoji_code': 'green_tick', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + def test_add_realm_emoji_invalid_code(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'green_tick', + 'emoji_code': 'non_existent', + } + result = self.post_reaction(reaction_info) + self.assert_json_error(result, 'Emoji for this emoji code not found.') + + def test_add_deactivated_realm_emoji(self): + # type: () -> None + emoji = RealmEmoji.objects.get(name="green_tick") + emoji.deactivated = True + emoji.save(update_fields=['deactivated']) + + reaction_info = { + 'emoji_name': 'green_tick', + 'emoji_code': 'green_tick', + } + result = self.post_reaction(reaction_info) + self.assert_json_error(result, 'Emoji for this emoji code not found.') + + def test_add_to_existing_deactivated_realm_emoji_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'green_tick', + 'emoji_code': 'green_tick', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + emoji = RealmEmoji.objects.get(name="green_tick") + emoji.deactivated = True + emoji.save(update_fields=['deactivated']) + + result = self.post_reaction(reaction_info, sender='AARON') + self.assert_json_success(result) + + reactions = self.get_message_reactions(1, 'green_tick', 'realm_emoji') + self.assertEqual(len(reactions), 2) + + def test_remove_realm_emoji_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'green_tick', + 'emoji_code': 'green_tick', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + result = self.delete_reaction(reaction_info) + self.assert_json_success(result) + + def test_remove_deactivated_realm_emoji_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'green_tick', + 'emoji_code': 'green_tick', + } + result = self.post_reaction(reaction_info) + self.assert_json_success(result) + + emoji = RealmEmoji.objects.get(name="green_tick") + emoji.deactivated = True + emoji.save(update_fields=['deactivated']) + + result = self.delete_reaction(reaction_info) + self.assert_json_success(result) + + def test_remove_non_existent_realm_emoji_reaction(self): + # type: () -> None + reaction_info = { + 'emoji_name': 'non_existent', + 'emoji_code': 'TBD', + } + result = self.delete_reaction(reaction_info) + self.assert_json_error(result, "Reaction doesn't exist.") + + def test_invalid_reaction_type(self) -> None: + reaction_info = { + 'emoji_name': 'zulip', + 'emoji_code': 'zulip', + 'reaction_type': 'nonexistent_emoji_type', + } + sender = self.example_email("hamlet") + message_id = 1 + result = self.client_post('/api/v1/messages/%s/reactions' % (message_id,), + reaction_info, + **self.api_auth(sender)) + self.assert_json_error(result, "Emoji for this emoji code not found.") + +class ReactionAPIEventTest(ZulipTestCase): + def test_add_event(self): + # type: () -> None + """ + Recipients of the message receive the reaction event + and event contains relevant data + """ + pm_sender = self.example_user('hamlet') + pm_recipient = self.example_user('othello') + reaction_sender = pm_recipient + + result = self.client_post("/api/v1/messages", {"type": "private", + "content": "Test message", + "to": pm_recipient.email}, + **self.api_auth(pm_sender.email)) + self.assert_json_success(result) + pm_id = result.json()['id'] + + expected_recipient_ids = set([pm_sender.id, pm_recipient.id]) + + reaction_info = { + 'emoji_name': 'hamburger', + 'emoji_code': '1f354', + 'reaction_type': 'unicode_emoji', + } + events = [] # type: List[Mapping[str, Any]] + with tornado_redirected_to_list(events): + result = self.client_post('/api/v1/messages/%s/reactions' % (pm_id,), + reaction_info, + **self.api_auth(reaction_sender.email)) + 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']['user_id'], reaction_sender.id) + self.assertEqual(event['user']['email'], reaction_sender.email) + self.assertEqual(event['user']['full_name'], reaction_sender.full_name) + self.assertEqual(event['type'], 'reaction') + self.assertEqual(event['op'], 'add') + self.assertEqual(event['message_id'], pm_id) + self.assertEqual(event['emoji_name'], reaction_info['emoji_name']) + self.assertEqual(event['emoji_code'], reaction_info['emoji_code']) + self.assertEqual(event['reaction_type'], reaction_info['reaction_type']) + + def test_remove_event(self): + # type: () -> None + """ + Recipients of the message receive the reaction event + and event contains relevant data + """ + pm_sender = self.example_user('hamlet') + pm_recipient = self.example_user('othello') + reaction_sender = pm_recipient + + result = self.client_post("/api/v1/messages", {"type": "private", + "content": "Test message", + "to": pm_recipient.email}, + **self.api_auth(pm_sender.email)) + self.assert_json_success(result) + content = result.json() + pm_id = content['id'] + + expected_recipient_ids = set([pm_sender.id, pm_recipient.id]) + + reaction_info = { + 'emoji_name': 'hamburger', + 'emoji_code': '1f354', + 'reaction_type': 'unicode_emoji', + } + add = self.client_post('/api/v1/messages/%s/reactions' % (pm_id,), + reaction_info, + **self.api_auth(reaction_sender.email)) + self.assert_json_success(add) + + events = [] # type: List[Mapping[str, Any]] + with tornado_redirected_to_list(events): + result = self.client_delete('/api/v1/messages/%s/reactions' % (pm_id,), + reaction_info, + **self.api_auth(reaction_sender.email)) + 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']['user_id'], reaction_sender.id) + self.assertEqual(event['user']['email'], reaction_sender.email) + self.assertEqual(event['user']['full_name'], reaction_sender.full_name) + self.assertEqual(event['type'], 'reaction') + self.assertEqual(event['op'], 'remove') + self.assertEqual(event['message_id'], pm_id) + self.assertEqual(event['emoji_name'], reaction_info['emoji_name']) + self.assertEqual(event['emoji_code'], reaction_info['emoji_code']) + self.assertEqual(event['reaction_type'], reaction_info['reaction_type']) diff --git a/zerver/views/reactions.py b/zerver/views/reactions.py index 1ab5fee15d..1f35b59aa1 100644 --- a/zerver/views/reactions.py +++ b/zerver/views/reactions.py @@ -5,9 +5,10 @@ from typing import Text from zerver.decorator import \ has_request_variables, REQ, to_non_negative_int -from zerver.lib.actions import do_add_reaction_legacy,\ - do_remove_reaction_legacy -from zerver.lib.emoji import check_valid_emoji +from zerver.lib.actions import do_add_reaction, do_add_reaction_legacy,\ + do_remove_reaction, do_remove_reaction_legacy +from zerver.lib.emoji import check_emoji_code_consistency,\ + check_emoji_name_consistency, check_valid_emoji from zerver.lib.message import access_message from zerver.lib.request import JsonableError from zerver.lib.response import json_success @@ -66,3 +67,76 @@ def remove_reaction_backend(request, user_profile, message_id, emoji_name): do_remove_reaction_legacy(user_profile, message, emoji_name) return json_success() + +@has_request_variables +def add_reaction(request: HttpRequest, user_profile: UserProfile, message_id: int, + emoji_name: str=REQ(), + emoji_code: str=REQ(), + reaction_type: str=REQ(default="unicode_emoji")) -> HttpResponse: + message, user_message = access_message(user_profile, message_id) + + if Reaction.objects.filter(user_profile=user_profile, + message=message, + emoji_code=emoji_code, + reaction_type=reaction_type).exists(): + raise JsonableError(_("Reaction already exists.")) + + query = Reaction.objects.filter(message=message, + emoji_code=emoji_code, + reaction_type=reaction_type) + if query.exists(): + # If another user has already reacted to this message with + # same emoji code, we treat the new reaction as a vote for the + # existing reaction. So the emoji name used by that earlier + # reaction takes precendence over whatever was passed in this + # request. This is necessary to avoid a message having 2 + # "different" emoji reactions with the same emoji code (and + # thus same image) on the same message, which looks ugly. + # + # In this "voting for an existing reaction" case, we shouldn't + # check whether the emoji code and emoji name match, since + # it's possible that the (emoji_type, emoji_name, emoji_code) + # triple for this existing rection xmay not pass validation + # now (e.g. because it is for a realm emoji that has been + # since deactivated). We still want to allow users to add a + # vote any old reaction they see in the UI even if that is a + # deactivated custom emoji, so we just use the emoji name from + # the existing reaction with no further validation. + emoji_name = query.first().emoji_name + else: + # Otherwise, use the name provided in this request, but verify + # it is valid in the user's realm (e.g. not a deactivated + # realm emoji). + check_emoji_code_consistency(message.sender.realm, emoji_code, reaction_type) + check_emoji_name_consistency(emoji_name, emoji_code, reaction_type) + + if user_message is None: + create_historical_message(user_profile, message) + + do_add_reaction(user_profile, message, emoji_name, emoji_code, reaction_type) + + return json_success() + +@has_request_variables +def remove_reaction(request: HttpRequest, user_profile: UserProfile, message_id: int, + emoji_code: str=REQ(), + reaction_type: str=REQ(default="unicode_emoji")) -> HttpResponse: + message, user_message = access_message(user_profile, message_id) + + if not Reaction.objects.filter(user_profile=user_profile, + message=message, + emoji_code=emoji_code, + reaction_type=reaction_type).exists(): + raise JsonableError(_("Reaction doesn't exist.")) + + # Unlike adding reactions, while deleting a reaction, we don't + # check whether the provided (emoji_type, emoji_code) pair is + # valid in this realm. Since there's a row in the database, we + # know it was valid when the user added their reaction in the + # first place, so it is safe to just remove the reaction if it + # exists. And the (reaction_type, emoji_code) pair may no longer be + # valid in legitimate situations (e.g. if a realm emoji was + # deactivated by an administrator in the meantime). + do_remove_reaction(user_profile, message, emoji_code, reaction_type) + + return json_success() diff --git a/zproject/urls.py b/zproject/urls.py index e4f94c38f7..ab11e8c2bb 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -171,6 +171,12 @@ v1_api_and_json_patterns = [ url(r'users/me/subscriptions/(?P\d+)$', rest_dispatch, {'PATCH': 'zerver.views.streams.update_subscriptions_property'}), + # New endpoint for handling reactions. + url(r'^messages/(?P[0-9]+)/reactions$', + rest_dispatch, + {'POST': 'zerver.views.reactions.add_reaction', + 'DELETE': 'zerver.views.reactions.remove_reaction'}), + # reactions -> zerver.view.reactions # PUT adds a reaction to a message # DELETE removes a reaction from a message