user_status: Add backend changes to support status emoji.

In this commit:

* We update the `UserStatus` model to accept
`AbstractReaction` as a base class so, we can get all the
fields related to store status emoji.

* We update the user status endpoint
(`users/me/status`) to accept status emoji fields.

* We update the user status event to add status emoji
fields.

Co-authored-by: Yash Rathore <33805964+YashRE42@users.noreply.github.com>
This commit is contained in:
Riken Shah 2021-06-22 16:42:31 +00:00 committed by Tim Abbott
parent ed01ffadba
commit 9fadd43830
10 changed files with 330 additions and 48 deletions

View File

@ -11,6 +11,15 @@ below features are supported.
## Changes in Zulip 5.0 ## 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** **Feature level 85**
* [`POST /register`](/api/register-queue), `PATCH /realm`: Replaced `add_emoji_by_admins_only` * [`POST /register`](/api/register-queue), `PATCH /realm`: Replaced `add_emoji_by_admins_only`

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md, as well as # new level means in templates/zerver/api/changelog.md, as well as
# "**Changes**" entries in the endpoint's documentation in `zulip.yaml`. # "**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 # 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 # only when going from an old version of the code to a newer version. Bump

View File

@ -5401,7 +5401,13 @@ def update_user_presence(
def do_update_user_status( 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: ) -> None:
if away is None: if away is None:
status = None status = None
@ -5417,6 +5423,9 @@ def do_update_user_status(
status=status, status=status,
status_text=status_text, status_text=status_text,
client_id=client_id, client_id=client_id,
emoji_name=emoji_name,
emoji_code=emoji_code,
reaction_type=reaction_type,
) )
event = dict( event = dict(
@ -5430,6 +5439,10 @@ def do_update_user_status(
if status_text is not None: if status_text is not None:
event["status_text"] = status_text 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)) send_event(realm, event, active_user_ids(realm.id))

View File

@ -1653,6 +1653,9 @@ user_status_event = event_dict_type(
# force vertical # force vertical
("away", bool), ("away", bool),
("status_text", str), ("status_text", str),
("emoji_name", str),
("emoji_code", str),
("reaction_type", str),
], ],
) )
_check_user_status = make_checker(user_status_event) _check_user_status = make_checker(user_status_event)

View File

@ -59,6 +59,7 @@ from zerver.models import (
Stream, Stream,
UserMessage, UserMessage,
UserProfile, UserProfile,
UserStatus,
custom_profile_fields_for_realm, custom_profile_fields_for_realm,
get_default_stream_groups, get_default_stream_groups,
get_realm_domains, get_realm_domains,
@ -1092,6 +1093,9 @@ def apply_event(
user_status = state["user_status"] user_status = state["user_status"]
away = event.get("away") away = event.get("away")
status_text = event.get("status_text") 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: if user_id_str not in user_status:
user_status[user_id_str] = {} user_status[user_id_str] = {}
@ -1108,6 +1112,24 @@ def apply_event(
else: else:
user_status[user_id_str]["status_text"] = status_text 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]: if not user_status[user_id_str]:
user_status.pop(user_id_str, None) user_status.pop(user_id_str, None)

View File

@ -13,12 +13,19 @@ def get_user_info_dict(realm_id: int) -> Dict[str, Dict[str, Any]]:
user_profile__is_active=True, user_profile__is_active=True,
) )
.exclude( .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( .values(
"user_profile_id", "user_profile_id",
"status", "status",
"status_text", "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 away = row["status"] == UserStatus.AWAY
status_text = row["status_text"] status_text = row["status_text"]
user_id = row["user_profile_id"] user_id = row["user_profile_id"]
emoji_name = row["emoji_name"]
emoji_code = row["emoji_code"]
reaction_type = row["reaction_type"]
dct = {} dct = {}
if away: if away:
dct["away"] = away dct["away"] = away
if status_text: if status_text:
dct["status_text"] = 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 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( 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: ) -> None:
timestamp = timezone_now() timestamp = timezone_now()
@ -56,6 +76,15 @@ def update_user_status(
if status_text is not None: if status_text is not None:
defaults["status_text"] = status_text 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( UserStatus.objects.update_or_create(
user_profile_id=user_profile_id, user_profile_id=user_profile_id,
defaults=defaults, defaults=defaults,

View File

@ -1430,6 +1430,24 @@ paths:
type: string type: string
description: | description: |
The text content of the status message. 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: user_id:
type: integer type: integer
description: | description: |
@ -1440,6 +1458,9 @@ paths:
"user_id": 10, "user_id": 10,
"away": true, "away": true,
"status_text": "out to lunch", "status_text": "out to lunch",
"emoji_name": "car",
"emoji_code": "1f697",
"reaction_type": "unicode_emoji",
"id": 0, "id": 0,
} }
- type: object - type: object
@ -8360,12 +8381,19 @@ paths:
error messages when a search returns limited results because error messages when a search returns limited results because
a stop word in the query was ignored. a stop word in the query was ignored.
user_status: user_status:
type: object allOf:
- $ref: "#/components/schemas/EmojiBase"
description: | description: |
Present if `user_status` is present in `fetch_event_types`. Present if `user_status` is present in `fetch_event_types`.
A dictionary which contains the [status](/help/status-and-availability) A dictionary which contains the [status](/help/status-and-availability)
of all users in the Zulip organization who have set a status. 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: additionalProperties:
description: | description: |
`{user_id}`: Object containing the status details of a user `{user_id}`: Object containing the status details of a user
@ -12241,7 +12269,7 @@ components:
reaction_type: {} reaction_type: {}
user_id: {} user_id: {}
user: {} user: {}
EmojiReactionBase: EmojiBase:
type: object type: object
properties: properties:
emoji_code: emoji_code:
@ -12267,6 +12295,10 @@ components:
(`emoji_code` will be its ID). (`emoji_code` will be its ID).
* `zulip_extra_emoji`: Special emoji included with Zulip. Exists to * `zulip_extra_emoji`: Special emoji included with Zulip. Exists to
namespace the `zulip` emoji. namespace the `zulip` emoji.
EmojiReactionBase:
allOf:
- $ref: "#/components/schemas/EmojiBase"
- properties:
user_id: user_id:
type: integer type: integer
description: | description: |
@ -12279,6 +12311,7 @@ components:
additionalProperties: false additionalProperties: false
deprecated: true deprecated: true
description: | description: |
Whether the user is a mirror dummy.
Dictionary with data on the user who added the reaction, including Dictionary with data on the user who added the reaction, including
the user ID as the `id` field. **Note**: In the [events the user ID as the `id` field. **Note**: In the [events
API](/api/get-events), this `user` dictionary API](/api/get-events), this `user` dictionary

View File

@ -196,6 +196,7 @@ from zerver.models import (
UserMessage, UserMessage,
UserPresence, UserPresence,
UserProfile, UserProfile,
UserStatus,
get_client, get_client,
get_stream, get_stream,
get_user_by_delivery_email, get_user_by_delivery_email,
@ -954,23 +955,45 @@ class NormalActionsTest(BaseAction):
user_profile=self.user_profile, user_profile=self.user_profile,
away=True, away=True,
status_text="out to lunch", status_text="out to lunch",
emoji_name="car",
emoji_code="1f697",
reaction_type=UserStatus.UNICODE_EMOJI,
client_id=client.id, 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( events = self.verify_action(
lambda: do_update_user_status( 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( events = self.verify_action(
lambda: do_update_user_status( 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, user_profile=self.user_profile,
away=None, away=None,
status_text="at the beach", status_text="at the beach",
emoji_name=None,
emoji_code=None,
reaction_type=None,
client_id=client.id, client_id=client.id,
) )
) )

View File

@ -36,6 +36,9 @@ class UserStatusTest(ZulipTestCase):
user_profile_id=hamlet.id, user_profile_id=hamlet.id,
status=UserStatus.AWAY, status=UserStatus.AWAY,
status_text=None, status_text=None,
emoji_name=None,
emoji_code=None,
reaction_type=None,
client_id=client1.id, client_id=client1.id,
) )
@ -52,12 +55,21 @@ class UserStatusTest(ZulipTestCase):
user_profile_id=hamlet.id, user_profile_id=hamlet.id,
status=UserStatus.AWAY, status=UserStatus.AWAY,
status_text="out to lunch", status_text="out to lunch",
emoji_name="car",
emoji_code="1f697",
reaction_type=UserStatus.UNICODE_EMOJI,
client_id=client2.id, client_id=client2.id,
) )
self.assertEqual( self.assertEqual(
user_info(hamlet), 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) 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() rec_count = UserStatus.objects.filter(user_profile_id=hamlet.id).count()
self.assertEqual(rec_count, 1) 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( update_user_status(
user_profile_id=hamlet.id, user_profile_id=hamlet.id,
status=UserStatus.NORMAL, status=UserStatus.NORMAL,
status_text=None, status_text=None,
emoji_name=None,
emoji_code=None,
reaction_type=None,
client_id=client2.id, client_id=client2.id,
) )
self.assertEqual( self.assertEqual(
user_info(hamlet), 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( update_user_status(
user_profile_id=hamlet.id, user_profile_id=hamlet.id,
status=None, status=None,
status_text="", status_text="",
emoji_name="",
emoji_code="",
reaction_type=UserStatus.UNICODE_EMOJI,
client_id=client2.id, client_id=client2.id,
) )
@ -101,18 +124,27 @@ class UserStatusTest(ZulipTestCase):
user_profile_id=hamlet.id, user_profile_id=hamlet.id,
status=UserStatus.AWAY, status=UserStatus.AWAY,
status_text=None, status_text=None,
emoji_name=None,
emoji_code=None,
reaction_type=None,
client_id=client1.id, client_id=client1.id,
) )
update_user_status( update_user_status(
user_profile_id=cordelia.id, user_profile_id=cordelia.id,
status=UserStatus.AWAY, status=UserStatus.AWAY,
status_text=None, status_text=None,
emoji_name=None,
emoji_code=None,
reaction_type=None,
client_id=client2.id, client_id=client2.id,
) )
update_user_status( update_user_status(
user_profile_id=king_lear.id, user_profile_id=king_lear.id,
status=UserStatus.AWAY, status=UserStatus.AWAY,
status_text=None, status_text=None,
emoji_name=None,
emoji_code=None,
reaction_type=None,
client_id=client2.id, client_id=client2.id,
) )
@ -127,6 +159,9 @@ class UserStatusTest(ZulipTestCase):
user_profile_id=hamlet.id, user_profile_id=hamlet.id,
status=UserStatus.NORMAL, status=UserStatus.NORMAL,
status_text="in a meeting", status_text="in a meeting",
emoji_name=None,
emoji_code=None,
reaction_type=None,
client_id=client2.id, client_id=client2.id,
) )
@ -158,6 +193,31 @@ class UserStatusTest(ZulipTestCase):
result = self.client_post("/json/users/me/status", payload) result = self.client_post("/json/users/me/status", payload)
self.assert_json_error(result, "Client did not pass any new values.") 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. # Try a long message.
long_text = "x" * 61 long_text = "x" * 61
payload = dict(status_text=long_text) payload = dict(status_text=long_text)
@ -178,6 +238,52 @@ class UserStatusTest(ZulipTestCase):
dict(away=True, status_text="on vacation"), 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. # Now revoke "away" status.
self.update_status_and_assert_event( self.update_status_and_assert_event(
payload=dict(away=orjson.dumps(False).decode()), payload=dict(away=orjson.dumps(False).decode()),

View File

@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
from zerver.decorator import human_users_only from zerver.decorator import human_users_only
from zerver.lib.actions import do_update_user_status, update_user_presence 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.exceptions import JsonableError
from zerver.lib.presence import get_presence_for_user, get_presence_response from zerver.lib.presence import get_presence_for_user, get_presence_response
from zerver.lib.request import REQ, get_request_notes, has_request_variables from zerver.lib.request import REQ, get_request_notes, has_request_variables
@ -18,6 +19,7 @@ from zerver.models import (
UserActivity, UserActivity,
UserPresence, UserPresence,
UserProfile, UserProfile,
UserStatus,
get_active_user, get_active_user,
get_active_user_profile_by_id_in_realm, get_active_user_profile_by_id_in_realm,
) )
@ -68,14 +70,50 @@ def update_user_status_backend(
user_profile: UserProfile, user_profile: UserProfile,
away: Optional[bool] = REQ(json_validator=check_bool, default=None), away: Optional[bool] = REQ(json_validator=check_bool, default=None),
status_text: Optional[str] = REQ(str_validator=check_capped_string(60), 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: ) -> HttpResponse:
if status_text is not None: if status_text is not None:
status_text = status_text.strip() 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.")) 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 client = get_request_notes(request).client
assert client is not None assert client is not None
do_update_user_status( do_update_user_status(
@ -83,6 +121,9 @@ def update_user_status_backend(
away=away, away=away,
status_text=status_text, status_text=status_text,
client_id=client.id, client_id=client.id,
emoji_name=emoji_name,
emoji_code=emoji_code,
reaction_type=emoji_type,
) )
return json_success() return json_success()