diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index d6e82a4b9a..b4da1600e7 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -11,6 +11,15 @@ below features are supported. ## Changes in Zulip 5.0 +**Feature level 86** + +* [`GET /events`](/api/get-events): Added `emoji_name`, + `emoji_code`, and `reaction_type` fields to `user_status` objects. +* [`POST /register`](/api/register-queue): Added `emoji_name`, + `emoji_code`, and `reaction_type` fields to `user_status` objects. +* `POST /users/me/status`: Added support for new `emoji_name`, + `emoji_code`, and `reaction_type` parameters. + **Feature level 85** * [`POST /register`](/api/register-queue), `PATCH /realm`: Replaced `add_emoji_by_admins_only` diff --git a/version.py b/version.py index 91980c443c..991226cb94 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3" # Changes should be accompanied by documentation explaining what the # new level means in templates/zerver/api/changelog.md, as well as # "**Changes**" entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 85 +API_FEATURE_LEVEL = 86 # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 2b5caee771..5fe653aa00 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -5401,7 +5401,13 @@ def update_user_presence( def do_update_user_status( - user_profile: UserProfile, away: Optional[bool], status_text: Optional[str], client_id: int + user_profile: UserProfile, + away: Optional[bool], + status_text: Optional[str], + client_id: int, + emoji_name: Optional[str], + emoji_code: Optional[str], + reaction_type: Optional[str], ) -> None: if away is None: status = None @@ -5417,6 +5423,9 @@ def do_update_user_status( status=status, status_text=status_text, client_id=client_id, + emoji_name=emoji_name, + emoji_code=emoji_code, + reaction_type=reaction_type, ) event = dict( @@ -5430,6 +5439,10 @@ def do_update_user_status( if status_text is not None: event["status_text"] = status_text + if emoji_name is not None: + event["emoji_name"] = emoji_name + event["emoji_code"] = emoji_code + event["reaction_type"] = reaction_type send_event(realm, event, active_user_ids(realm.id)) diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index d47afd05ce..0ef8d48fc5 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -1653,6 +1653,9 @@ user_status_event = event_dict_type( # force vertical ("away", bool), ("status_text", str), + ("emoji_name", str), + ("emoji_code", str), + ("reaction_type", str), ], ) _check_user_status = make_checker(user_status_event) diff --git a/zerver/lib/events.py b/zerver/lib/events.py index c7c73b8364..47d74006dc 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -59,6 +59,7 @@ from zerver.models import ( Stream, UserMessage, UserProfile, + UserStatus, custom_profile_fields_for_realm, get_default_stream_groups, get_realm_domains, @@ -1092,6 +1093,9 @@ def apply_event( user_status = state["user_status"] away = event.get("away") status_text = event.get("status_text") + emoji_name = event.get("emoji_name") + emoji_code = event.get("emoji_code") + reaction_type = event.get("reaction_type") if user_id_str not in user_status: user_status[user_id_str] = {} @@ -1108,6 +1112,24 @@ def apply_event( else: user_status[user_id_str]["status_text"] = status_text + if emoji_name is not None: + if emoji_name == "": + user_status[user_id_str].pop("emoji_name", None) + else: + user_status[user_id_str]["emoji_name"] = emoji_name + + if emoji_code is not None: + if emoji_code == "": + user_status[user_id_str].pop("emoji_code", None) + else: + user_status[user_id_str]["emoji_code"] = emoji_code + + if reaction_type is not None: + if reaction_type == UserStatus.UNICODE_EMOJI and emoji_name == "": + user_status[user_id_str].pop("reaction_type", None) + else: + user_status[user_id_str]["reaction_type"] = reaction_type + if not user_status[user_id_str]: user_status.pop(user_id_str, None) diff --git a/zerver/lib/user_status.py b/zerver/lib/user_status.py index 89cae8e7df..efc82bff16 100644 --- a/zerver/lib/user_status.py +++ b/zerver/lib/user_status.py @@ -13,12 +13,19 @@ def get_user_info_dict(realm_id: int) -> Dict[str, Dict[str, Any]]: user_profile__is_active=True, ) .exclude( - Q(status=UserStatus.NORMAL) & Q(status_text=""), + Q(status=UserStatus.NORMAL) + & Q(status_text="") + & Q(emoji_name="") + & Q(emoji_code="") + & Q(reaction_type=UserStatus.UNICODE_EMOJI), ) .values( "user_profile_id", "status", "status_text", + "emoji_name", + "emoji_code", + "reaction_type", ) ) @@ -27,12 +34,19 @@ def get_user_info_dict(realm_id: int) -> Dict[str, Dict[str, Any]]: away = row["status"] == UserStatus.AWAY status_text = row["status_text"] user_id = row["user_profile_id"] + emoji_name = row["emoji_name"] + emoji_code = row["emoji_code"] + reaction_type = row["reaction_type"] dct = {} if away: dct["away"] = away if status_text: dct["status_text"] = status_text + if emoji_name: + dct["emoji_name"] = emoji_name + dct["emoji_code"] = emoji_code + dct["reaction_type"] = reaction_type user_dict[str(user_id)] = dct @@ -40,7 +54,13 @@ def get_user_info_dict(realm_id: int) -> Dict[str, Dict[str, Any]]: def update_user_status( - user_profile_id: int, status: Optional[int], status_text: Optional[str], client_id: int + user_profile_id: int, + status: Optional[int], + status_text: Optional[str], + client_id: int, + emoji_name: Optional[str], + emoji_code: Optional[str], + reaction_type: Optional[str], ) -> None: timestamp = timezone_now() @@ -56,6 +76,15 @@ def update_user_status( if status_text is not None: defaults["status_text"] = status_text + if emoji_name is not None: + defaults["emoji_name"] = emoji_name + + if emoji_code is not None: + defaults["emoji_code"] = emoji_code + + if reaction_type is not None: + defaults["reaction_type"] = reaction_type + UserStatus.objects.update_or_create( user_profile_id=user_profile_id, defaults=defaults, diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 2ba1e3ccd8..07bc490e54 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -1430,6 +1430,24 @@ paths: type: string description: | The text content of the status message. + emoji_name: + type: string + description: | + The [emoji name](/api/add-reaction#parameters) for the emoji associated with the new status. + + **Changes**; New in Zulip 5.0 (feature level 86). + emoji_code: + type: string + description: | + The [emoji code](/api/add-reaction#parameters) for the emoji associated with the new status. + + **Changes**; New in Zulip 5.0 (feature level 86). + reaction_type: + type: string + description: | + The [emoji type](/api/add-reaction#parameters) for the emoji associated with the new status. + + **Changes**; New in Zulip 5.0 (feature level 86). user_id: type: integer description: | @@ -1440,6 +1458,9 @@ paths: "user_id": 10, "away": true, "status_text": "out to lunch", + "emoji_name": "car", + "emoji_code": "1f697", + "reaction_type": "unicode_emoji", "id": 0, } - type: object @@ -8360,12 +8381,19 @@ paths: error messages when a search returns limited results because a stop word in the query was ignored. user_status: - type: object + allOf: + - $ref: "#/components/schemas/EmojiBase" description: | Present if `user_status` is present in `fetch_event_types`. A dictionary which contains the [status](/help/status-and-availability) of all users in the Zulip organization who have set a status. + + **Changes**: The emoji parameters are new in Zulip 5.0 (feature level 86). + Previously, Zulip did not support emoji associated with statuses. + + A status that does not have an emoji associated with it is encoded + with `emoji_name=""`. additionalProperties: description: | `{user_id}`: Object containing the status details of a user @@ -12241,7 +12269,7 @@ components: reaction_type: {} user_id: {} user: {} - EmojiReactionBase: + EmojiBase: type: object properties: emoji_code: @@ -12267,42 +12295,47 @@ components: (`emoji_code` will be its ID). * `zulip_extra_emoji`: Special emoji included with Zulip. Exists to namespace the `zulip` emoji. - user_id: - type: integer - description: | - The ID of the user who added the reaction. - - **Changes**: New in Zulip 3.0 (feature level 2). The `user` - object is deprecated and will be removed in the future. - user: - type: object - additionalProperties: false - deprecated: true - description: | - Dictionary with data on the user who added the reaction, including - the user ID as the `id` field. **Note**: In the [events - API](/api/get-events), this `user` dictionary - confusing had the user ID in a field called `user_id` - instead. We recommend ignoring fields other than the user - ID. **Deprecated** and to be removed in a future release - once core clients have migrated to use the `user_id` field. - properties: - id: + EmojiReactionBase: + allOf: + - $ref: "#/components/schemas/EmojiBase" + - properties: + user_id: type: integer description: | - ID of the user. - email: - type: string - description: | - Email of the user. - full_name: - type: string - description: | - Full name of the user. - is_mirror_dummy: - type: boolean + The ID of the user who added the reaction. + + **Changes**: New in Zulip 3.0 (feature level 2). The `user` + object is deprecated and will be removed in the future. + user: + type: object + additionalProperties: false + deprecated: true description: | Whether the user is a mirror dummy. + Dictionary with data on the user who added the reaction, including + the user ID as the `id` field. **Note**: In the [events + API](/api/get-events), this `user` dictionary + confusing had the user ID in a field called `user_id` + instead. We recommend ignoring fields other than the user + ID. **Deprecated** and to be removed in a future release + once core clients have migrated to use the `user_id` field. + properties: + id: + type: integer + description: | + ID of the user. + email: + type: string + description: | + Email of the user. + full_name: + type: string + description: | + Full name of the user. + is_mirror_dummy: + type: boolean + description: | + Whether the user is a mirror dummy. Messages: allOf: - $ref: "#/components/schemas/MessagesBase" diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 453d8a3fc1..bc6248c05d 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -196,6 +196,7 @@ from zerver.models import ( UserMessage, UserPresence, UserProfile, + UserStatus, get_client, get_stream, get_user_by_delivery_email, @@ -954,23 +955,45 @@ class NormalActionsTest(BaseAction): user_profile=self.user_profile, away=True, status_text="out to lunch", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, client_id=client.id, ) ) - check_user_status("events[0]", events[0], {"away", "status_text"}) - + check_user_status( + "events[0]", + events[0], + {"away", "status_text", "emoji_name", "emoji_code", "reaction_type"}, + ) events = self.verify_action( lambda: do_update_user_status( - user_profile=self.user_profile, away=False, status_text="", client_id=client.id + user_profile=self.user_profile, + away=False, + status_text="", + emoji_name="", + emoji_code="", + reaction_type=UserStatus.UNICODE_EMOJI, + client_id=client.id, ) ) - check_user_status("events[0]", events[0], {"away", "status_text"}) + check_user_status( + "events[0]", + events[0], + {"away", "status_text", "emoji_name", "emoji_code", "reaction_type"}, + ) events = self.verify_action( lambda: do_update_user_status( - user_profile=self.user_profile, away=True, status_text=None, client_id=client.id + user_profile=self.user_profile, + away=True, + status_text=None, + emoji_name=None, + emoji_code=None, + reaction_type=None, + client_id=client.id, ) ) @@ -981,6 +1004,9 @@ class NormalActionsTest(BaseAction): user_profile=self.user_profile, away=None, status_text="at the beach", + emoji_name=None, + emoji_code=None, + reaction_type=None, client_id=client.id, ) ) diff --git a/zerver/tests/test_user_status.py b/zerver/tests/test_user_status.py index f4bd2691ff..eb1c3691d2 100644 --- a/zerver/tests/test_user_status.py +++ b/zerver/tests/test_user_status.py @@ -36,6 +36,9 @@ class UserStatusTest(ZulipTestCase): user_profile_id=hamlet.id, status=UserStatus.AWAY, status_text=None, + emoji_name=None, + emoji_code=None, + reaction_type=None, client_id=client1.id, ) @@ -52,12 +55,21 @@ class UserStatusTest(ZulipTestCase): user_profile_id=hamlet.id, status=UserStatus.AWAY, status_text="out to lunch", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, client_id=client2.id, ) self.assertEqual( user_info(hamlet), - dict(away=True, status_text="out to lunch"), + dict( + away=True, + status_text="out to lunch", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, + ), ) away_user_ids = get_away_user_ids(realm_id=realm_id) @@ -66,24 +78,35 @@ class UserStatusTest(ZulipTestCase): rec_count = UserStatus.objects.filter(user_profile_id=hamlet.id).count() self.assertEqual(rec_count, 1) - # Setting status_text to None causes it be ignored. + # Setting status_text and emoji_info to None causes it be ignored. update_user_status( user_profile_id=hamlet.id, status=UserStatus.NORMAL, status_text=None, + emoji_name=None, + emoji_code=None, + reaction_type=None, client_id=client2.id, ) self.assertEqual( user_info(hamlet), - dict(status_text="out to lunch"), + dict( + status_text="out to lunch", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, + ), ) - # Clear the status_text now. + # Clear the status_text and emoji_info now. update_user_status( user_profile_id=hamlet.id, status=None, status_text="", + emoji_name="", + emoji_code="", + reaction_type=UserStatus.UNICODE_EMOJI, client_id=client2.id, ) @@ -101,18 +124,27 @@ class UserStatusTest(ZulipTestCase): user_profile_id=hamlet.id, status=UserStatus.AWAY, status_text=None, + emoji_name=None, + emoji_code=None, + reaction_type=None, client_id=client1.id, ) update_user_status( user_profile_id=cordelia.id, status=UserStatus.AWAY, status_text=None, + emoji_name=None, + emoji_code=None, + reaction_type=None, client_id=client2.id, ) update_user_status( user_profile_id=king_lear.id, status=UserStatus.AWAY, status_text=None, + emoji_name=None, + emoji_code=None, + reaction_type=None, client_id=client2.id, ) @@ -127,6 +159,9 @@ class UserStatusTest(ZulipTestCase): user_profile_id=hamlet.id, status=UserStatus.NORMAL, status_text="in a meeting", + emoji_name=None, + emoji_code=None, + reaction_type=None, client_id=client2.id, ) @@ -158,6 +193,31 @@ class UserStatusTest(ZulipTestCase): result = self.client_post("/json/users/me/status", payload) self.assert_json_error(result, "Client did not pass any new values.") + # Try to omit emoji_name parameter but passing emoji_code --this should be an error. + payload = {"status_text": "In a meeting", "emoji_code": "1f4bb"} + result = self.client_post("/json/users/me/status", payload) + self.assert_json_error( + result, "Client must pass emoji_name if they pass either emoji_code or reaction_type." + ) + + # Invalid emoji requests fail + payload = {"status_text": "In a meeting", "emoji_code": "1f4bb", "emoji_name": "invalid"} + result = self.client_post("/json/users/me/status", payload) + self.assert_json_error(result, "Emoji 'invalid' does not exist") + + payload = {"status_text": "In a meeting", "emoji_code": "1f4bb", "emoji_name": "car"} + result = self.client_post("/json/users/me/status", payload) + self.assert_json_error(result, "Invalid emoji name.") + + payload = { + "status_text": "In a meeting", + "emoji_code": "1f4bb", + "emoji_name": "car", + "reaction_type": "realm_emoji", + } + result = self.client_post("/json/users/me/status", payload) + self.assert_json_error(result, "Invalid custom emoji.") + # Try a long message. long_text = "x" * 61 payload = dict(status_text=long_text) @@ -178,6 +238,52 @@ class UserStatusTest(ZulipTestCase): dict(away=True, status_text="on vacation"), ) + # Server should fill emoji_code and reaction_type by emoji_name. + self.update_status_and_assert_event( + payload=dict( + away=orjson.dumps(True).decode(), + emoji_name="car", + ), + expected_event=dict( + type="user_status", + user_id=hamlet.id, + away=True, + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, + ), + ) + self.assertEqual( + user_info(hamlet), + dict( + away=True, + status_text="on vacation", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, + ), + ) + + # Server should remove emoji_code and reaction_type if emoji_name is empty. + self.update_status_and_assert_event( + payload=dict( + away=orjson.dumps(True).decode(), + emoji_name="", + ), + expected_event=dict( + type="user_status", + user_id=hamlet.id, + away=True, + emoji_name="", + emoji_code="", + reaction_type=UserStatus.UNICODE_EMOJI, + ), + ) + self.assertEqual( + user_info(hamlet), + dict(away=True, status_text="on vacation"), + ) + # Now revoke "away" status. self.update_status_and_assert_event( payload=dict(away=orjson.dumps(False).decode()), diff --git a/zerver/views/presence.py b/zerver/views/presence.py index 9ef9d048a1..dcb74c9c8d 100644 --- a/zerver/views/presence.py +++ b/zerver/views/presence.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext as _ from zerver.decorator import human_users_only from zerver.lib.actions import do_update_user_status, update_user_presence +from zerver.lib.emoji import check_emoji_request, emoji_name_to_emoji_code from zerver.lib.exceptions import JsonableError from zerver.lib.presence import get_presence_for_user, get_presence_response from zerver.lib.request import REQ, get_request_notes, has_request_variables @@ -18,6 +19,7 @@ from zerver.models import ( UserActivity, UserPresence, UserProfile, + UserStatus, get_active_user, get_active_user_profile_by_id_in_realm, ) @@ -68,14 +70,50 @@ def update_user_status_backend( user_profile: UserProfile, away: Optional[bool] = REQ(json_validator=check_bool, default=None), status_text: Optional[str] = REQ(str_validator=check_capped_string(60), default=None), + emoji_name: Optional[str] = REQ(default=None), + emoji_code: Optional[str] = REQ(default=None), + # TODO: emoji_type is the more appropriate name for this parameter, but changing + # that requires nontrivial work on the API documentation, since it's not clear + # that the reactions endpoint would prefer such a change. + emoji_type: Optional[str] = REQ("reaction_type", default=None), ) -> HttpResponse: if status_text is not None: status_text = status_text.strip() - if (away is None) and (status_text is None): + if (away is None) and (status_text is None) and (emoji_name is None): raise JsonableError(_("Client did not pass any new values.")) + if emoji_name == "": + # Reset the emoji_code and reaction_type if emoji_name is empty. + # This should clear the user's configured emoji. + emoji_code = "" + emoji_type = UserStatus.UNICODE_EMOJI + + elif emoji_name is not None: + if emoji_code is None: + # The emoji_code argument is only required for rare corner + # cases discussed in the long block comment below. For simple + # API clients, we allow specifying just the name, and just + # look up the code using the current name->code mapping. + emoji_code = emoji_name_to_emoji_code(user_profile.realm, emoji_name)[0] + + if emoji_type is None: + emoji_type = emoji_name_to_emoji_code(user_profile.realm, emoji_name)[1] + + elif emoji_type or emoji_code: + raise JsonableError( + _("Client must pass emoji_name if they pass either emoji_code or reaction_type.") + ) + + # If we're asking to set an emoji (not clear it ("") or not adjust + # it (None)), we need to verify the emoji is valid. + if emoji_name not in ["", None]: + assert emoji_name is not None + assert emoji_code is not None + assert emoji_type is not None + check_emoji_request(user_profile.realm, emoji_name, emoji_code, emoji_type) + client = get_request_notes(request).client assert client is not None do_update_user_status( @@ -83,6 +121,9 @@ def update_user_status_backend( away=away, status_text=status_text, client_id=client.id, + emoji_name=emoji_name, + emoji_code=emoji_code, + reaction_type=emoji_type, ) return json_success()