diff --git a/frontend_tests/node_tests/lib/events.js b/frontend_tests/node_tests/lib/events.js index 88a25ec322..d029372d73 100644 --- a/frontend_tests/node_tests/lib/events.js +++ b/frontend_tests/node_tests/lib/events.js @@ -610,6 +610,7 @@ exports.fixtures = { typing__start: { type: "typing", op: "start", + message_type: "private", sender: typing_person1, recipients: [typing_person2], }, @@ -617,6 +618,7 @@ exports.fixtures = { typing__stop: { type: "typing", op: "stop", + message_type: "private", sender: typing_person1, recipients: [typing_person2], }, diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index d8fd7eb5b3..75fdff5b02 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -10,6 +10,18 @@ below features are supported. ## Changes in Zulip 4.0 +**Feature level 58** + +* [`POST /register`](/api/register-queue): Added the new + `stream_typing_notifications` property to supported + `client_capabilities`. +* [`GET /events`](/api/get-events): Extended format for `typing` + events to support typing notifications in stream messages. These new + events are only sent to clients with `client_capabilities` + showing support for `stream_typing_notifications`. +* [`POST /set-typing-status`](/api/set-typing-status): Added support + for sending typing notifications for stream messages. + **Feature level 57** * [`PATCH /realm/filters/{filter_id}`](/api/update-linkifier): New diff --git a/templates/zerver/api/set-typing-status.md b/templates/zerver/api/set-typing-status.md index b4278e3fac..f211966715 100644 --- a/templates/zerver/api/set-typing-status.md +++ b/templates/zerver/api/set-typing-status.md @@ -17,7 +17,7 @@ More examples and documentation can be found [here](https://github.com/zulip/zul {tab|curl} -{generate_code_example(curl)|/typing:post|example} +{generate_code_example(curl, exclude=["topic"])|/typing:post|example} {end_tabs} diff --git a/version.py b/version.py index 52e282d02a..ef3a9f759e 100644 --- a/version.py +++ b/version.py @@ -30,7 +30,7 @@ DESKTOP_WARNING_VERSION = "5.2.0" # # Changes should be accompanied by documentation explaining what the # new level means in templates/zerver/api/changelog.md. -API_FEATURE_LEVEL = 57 +API_FEATURE_LEVEL = 58 # 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 03945750a5..5a357b36d2 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -132,6 +132,7 @@ from zerver.lib.stream_subscription import ( get_stream_subscriptions_for_user, get_stream_subscriptions_for_users, get_subscribed_stream_ids_for_user, + get_user_ids_for_streams, num_subscribers_for_stream_id, subscriber_ids_with_stream_history_access, ) @@ -2227,6 +2228,7 @@ def do_send_typing_notification( ] event = dict( type="typing", + message_type="private", op=operator, sender=sender_dict, recipients=recipient_dicts, @@ -2270,6 +2272,26 @@ def check_send_typing_notification(sender: UserProfile, user_ids: List[int], ope ) +def do_send_stream_typing_notification( + sender: UserProfile, operator: str, stream: Stream, topic: str +) -> None: + + sender_dict = {"user_id": sender.id, "email": sender.email} + + event = dict( + type="typing", + message_type="stream", + op=operator, + sender=sender_dict, + stream_id=stream.id, + topic=topic, + ) + + user_ids_to_notify = get_user_ids_for_streams({stream.id})[stream.id] + + send_event(sender.realm, event, user_ids_to_notify) + + def ensure_stream( realm: Realm, stream_name: str, diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 30227dbede..b1ccc82971 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -1319,9 +1319,14 @@ typing_start_event = event_dict_type( required_keys=[ ("type", Equals("typing")), ("op", Equals("start")), + ("message_type", str), ("sender", typing_person_type), + ], + optional_keys=[ ("recipients", ListType(typing_person_type)), - ] + ("stream_id", int), + ("topic", str), + ], ) check_typing_start = make_checker(typing_start_event) @@ -1329,9 +1334,14 @@ typing_stop_event = event_dict_type( required_keys=[ ("type", Equals("typing")), ("op", Equals("stop")), + ("message_type", str), ("sender", typing_person_type), + ], + optional_keys=[ ("recipients", ListType(typing_person_type)), - ] + ("stream_id", int), + ("topic", str), + ], ) check_typing_stop = make_checker(typing_stop_event) diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index 9e42f450af..9f25fb37ea 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -1186,6 +1186,7 @@ def set_typing_status(client: Client) -> None: "to": [user_id1, user_id2], } result = client.set_typing_status(request) + # {code_example|end} validate_against_openapi_schema(result, "/typing", "post", "200") @@ -1200,6 +1201,41 @@ def set_typing_status(client: Client) -> None: "to": [user_id1, user_id2], } result = client.set_typing_status(request) + + # {code_example|end} + + validate_against_openapi_schema(result, "/typing", "post", "200") + + # {code_example|start} + # The user has started to type in topic "typing status" of stream "Denmark" + stream_id = client.get_stream_id("Denmark")["stream_id"] + topic = "typing status" + + request = { + "type": "stream", + "op": "start", + "to": [stream_id], + "topic": topic, + } + result = client.set_typing_status(request) + + # {code_example|end} + + validate_against_openapi_schema(result, "/typing", "post", "200") + + # {code_example|start} + # The user has finished typing in topic "typing status" of stream "Denmark" + stream_id = client.get_stream_id("Denmark")["stream_id"] + topic = "typing status" + + request = { + "type": "stream", + "op": "stop", + "to": [stream_id], + "topic": topic, + } + result = client.set_typing_status(request) + # {code_example|end} validate_against_openapi_schema(result, "/typing", "post", "200") diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index e4ce908d9a..6db2274fc1 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -1934,8 +1934,16 @@ paths: - type: object additionalProperties: false description: | - Event sent when a user starts typing a private or group private message. Sent - to all clients for users who would receive the message being typed. + Event sent when a user starts typing a message. + + Sent to all clients for users who would receive the + message being typed, with the additional rule that typing + notifications for stream messages are only sent to clients + that included `stream_typing_notifications` in their + `client_capabilities` when registering the event queue. + + **Changes**: Typing notifications for stream messages are new in + Zulip 4.0 (feature level 58). See the [typing endpoint docs](/api/set-typing-status) for more details. properties: @@ -1950,6 +1958,17 @@ paths: type: string enum: - start + message_type: + type: string + description: | + Type of message being composed. Must be "stream" or "private", + as with sending a message. + + **Changes**: New in Zulip 4.0 (feature level 58). Previously, + all typing notifications were implicitly private `private`. + enum: + - private + - stream sender: additionalProperties: false type: object @@ -1967,6 +1986,8 @@ paths: recipients: type: array description: | + Only present if `message_type` is `private`. + Array of dictionaries describing the set of users who would be recipients of the message being typed. Each dictionary contains details on one one of the recipients users; the sending user is guaranteed to appear @@ -1985,6 +2006,24 @@ paths: type: string description: | The Zulip display email address for the user. + stream_id: + type: integer + description: | + Only present if `message_type` is `stream`. + + The unique ID of the stream to which message is being typed. + + **Changes**: New in Zulip 4.0 (feature level 58). Previously, + typing notifications were only for private messages. + topic: + type: string + description: | + Only present if `message_type` is `stream`. + + Topic within the stream where the message is being typed. + + **Changes**: New in Zulip 4.0 (feature level 58). Previously, + typing notifications were only for private messages. example: { "type": "typing", @@ -2010,9 +2049,16 @@ paths: - type: object additionalProperties: false description: | - Event sent when a user stops typing a private or group private message. Sent - to all clients for users who would receive the message that was - previously being typed. + Event sent when a user stops typing a message. + + Sent to all clients for users who would receive the message + that was previously being typed, with the additional rule + that typing notifications for stream messages are only sent to + clients that included `stream_typing_notifications` in their + `client_capabilities` when registering the event queue. + + **Changes**: Typing notifications for stream messages are new in + Zulip 4.0 (feature level 58). See the [typing endpoint docs](/api/set-typing-status) for more details. properties: @@ -2027,6 +2073,17 @@ paths: type: string enum: - stop + message_type: + type: string + description: | + Type of message being composed. Must be "stream" or "private", + as with sending a message. + + **Changes**: New in Zulip 4.0 (feature level 58). Previously, + all typing notifications were implicitly private `private`. + enum: + - private + - stream sender: additionalProperties: false type: object @@ -2045,6 +2102,8 @@ paths: recipients: type: array description: | + Only present for typing notifications for (group) private messages. + Array of dictionaries describing the set of users who would be recipients of the message that stopped being typed. Each dictionary contains details on one one of the recipients users; the sending user is @@ -2063,6 +2122,24 @@ paths: type: string description: | The Zulip display email address for the user. + stream_id: + type: integer + description: | + Only present if `message_type` is `stream`. + + The unique ID of the stream to which message is being typed. + + **Changes**: New in Zulip 4.0 (feature level 58). Previously, + typing notifications were only for private messages. + topic: + type: string + description: | + Only present if `message_type` is `stream`. + + Topic within the stream where the message is being typed. + + **Changes**: New in Zulip 4.0 (feature level 58). Previously, + typing notifications were only for private messages. example: { "type": "typing", @@ -6888,6 +6965,10 @@ paths: * `stream_typing_notifications`: Boolean for whether the client supports stream typing notifications. + + New in Zulip 4.0 (feature level 58). This capability is + for backwards-compatibility; it will be required in a + future server release. content: application/json: schema: @@ -9743,11 +9824,10 @@ paths: Clients implementing Zulip's typing notifications protocol should work as follows: - * Send a request to this endpoint with `op="start"` when a user starts typing - a private message or group private message, and also every - `TYPING_STARTED_WAIT_PERIOD=10` seconds that the user continues to actively type - or otherwise interact with the compose UI (E.g. interacting with the compose box - emoji picker). + * Send a request to this endpoint with `op="start"` when a user starts typing a message, + and also every `TYPING_STARTED_WAIT_PERIOD=10` seconds that the user continues to + actively type or otherwise interact with the compose UI (E.g. interacting with the + compose box emoji picker). * Send a request to this endpoint with `op="stop"` when a user pauses using the compose UI for at least `TYPING_STOPPED_WAIT_PERIOD=5` seconds or cancels the compose action (if it had previously sent a "start" operation for that @@ -9757,6 +9837,9 @@ paths: * Continue displaying "Sender is typing" until they receive an `op="stop"` event from the [events API](/api/get-events) or `TYPING_STARTED_EXPIRY_PERIOD=15` seconds have passed without a new `op="start"` event for that conversation. + * Clients that support displaying stream typing notifications (new in Zulip 4.0) + should indicate they support processing stream typing events via the + `stream_typing_notifications` in the `client_capabilities` parameter to `/register`. This protocol is designed to allow the server-side typing notifications implementation to be stateless while being resilient; network failures cannot result in a user being @@ -9774,6 +9857,7 @@ paths: type: string enum: - private + - stream default: private example: private - name: op @@ -9790,11 +9874,13 @@ paths: - name: to in: query description: | - The user_ids of the recipients of the message being typed. Typing - notifications are only supported for private messages. Send a - JSON-encoded list of user_ids. (Use a list even if there is only one + For 'private' type it is the user_ids of the recipients of the message being typed. + Send a JSON-encoded list of user_ids. (Use a list even if there is only one recipient.) + For 'stream' type it is a single element list containing ID of stream in + which the message being typed. + **Changes**: Before Zulip 2.0, this parameter accepted only a JSON-encoded list of email addresses. Support for the email address-based format was removed in Zulip 3.0 (feature level 11). @@ -9805,7 +9891,14 @@ paths: items: type: integer example: [9, 10] - required: true + - name: topic + in: query + description: | + Topic to which message is being typed. Required for the 'stream' type. + Ignored in case of 'private' type. + schema: + type: string + example: typing notifications responses: "200": description: Success. @@ -9813,6 +9906,19 @@ paths: application/json: schema: $ref: "#/components/schemas/JsonSuccess" + "400": + description: Bad request. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonError" + - example: + { + "code": "BAD_REQUEST", + "msg": "Cannot send to multiple streams", + "result": "error", + } /user_groups/create: post: diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 78c87b48a5..c89a4cd7e0 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -76,6 +76,7 @@ from zerver.lib.actions import ( do_rename_stream, do_revoke_multi_use_invite, do_revoke_user_invite, + do_send_stream_typing_notification, do_set_realm_authentication_methods, do_set_realm_message_editing, do_set_realm_notifications_stream, @@ -708,6 +709,60 @@ class NormalActionsTest(BaseAction): ) check_typing_stop("events[0]", events[0]) + def test_stream_typing_events(self) -> None: + stream = get_stream("Denmark", self.user_profile.realm) + topic = "streams typing" + + events = self.verify_action( + lambda: do_send_stream_typing_notification( + self.user_profile, + "start", + stream, + topic, + ), + state_change_expected=False, + ) + check_typing_start("events[0]", events[0]) + + events = self.verify_action( + lambda: do_send_stream_typing_notification( + self.user_profile, + "stop", + stream, + topic, + ), + state_change_expected=False, + ) + check_typing_stop("events[0]", events[0]) + + # Having client_capability `stream_typing_notification=False` + # shouldn't produce any events. + events = self.verify_action( + lambda: do_send_stream_typing_notification( + self.user_profile, + "start", + stream, + topic, + ), + state_change_expected=False, + stream_typing_notifications=False, + num_events=0, + ) + self.assertEqual(events, []) + + events = self.verify_action( + lambda: do_send_stream_typing_notification( + self.user_profile, + "stop", + stream, + topic, + ), + state_change_expected=False, + stream_typing_notifications=False, + num_events=0, + ) + self.assertEqual(events, []) + def test_custom_profile_fields_events(self) -> None: realm = self.user_profile.realm diff --git a/zerver/tests/test_typing.py b/zerver/tests/test_typing.py index ab3fce57a1..45ab835f80 100644 --- a/zerver/tests/test_typing.py +++ b/zerver/tests/test_typing.py @@ -19,7 +19,7 @@ class TypingValidateOperatorTest(ZulipTestCase): result = self.api_post(sender, "/api/v1/typing", params) self.assert_json_error(result, "Missing 'op' argument") - def test_invalid_parameter(self) -> None: + def test_invalid_parameter_pm(self) -> None: """ Sending typing notification with invalid value for op parameter fails """ @@ -31,6 +31,14 @@ class TypingValidateOperatorTest(ZulipTestCase): result = self.api_post(sender, "/api/v1/typing", params) self.assert_json_error(result, "Invalid op") + def test_invalid_parameter_stream(self) -> None: + sender = self.example_user("hamlet") + + result = self.api_post( + sender, "/api/v1/typing", {"op": "foo", "stream_id": 1, "topic": "topic"} + ) + self.assert_json_error(result, "Invalid op") + class TypingMessagetypeTest(ZulipTestCase): def test_invalid_type(self) -> None: @@ -44,14 +52,24 @@ class TypingMessagetypeTest(ZulipTestCase): self.assert_json_error(result, "Invalid type") -class TypingValidateUsersTest(ZulipTestCase): - def test_empty_array(self) -> None: +class TypingValidateToArgumentsTest(ZulipTestCase): + def test_empty_to_array_pms(self) -> None: """ - Sending typing notification without recipient fails + Sending pms typing notification without recipient fails """ sender = self.example_user("hamlet") result = self.api_post(sender, "/api/v1/typing", {"op": "start", "to": "[]"}) - self.assert_json_error(result, "Missing parameter: 'to' (recipient)") + self.assert_json_error(result, "Empty 'to' list") + + def test_empty_to_array_stream(self) -> None: + """ + Sending stream typing notification without recipient fails + """ + sender = self.example_user("hamlet") + result = self.api_post( + sender, "/api/v1/typing", {"type": "stream", "op": "start", "to": "[]"} + ) + self.assert_json_error(result, "Empty 'to' list") def test_missing_recipient(self) -> None: """ @@ -79,8 +97,44 @@ class TypingValidateUsersTest(ZulipTestCase): result = self.api_post(sender, "/api/v1/typing", {"op": "start", "to": invalid}) self.assert_json_error(result, "Invalid user ID 9999999") + def test_send_multiple_stream_ids(self) -> None: + sender = self.example_user("hamlet") -class TypingHappyPathTest(ZulipTestCase): + result = self.api_post( + sender, "/api/v1/typing", {"type": "stream", "op": "stop", "to": "[1, 2, 3]"} + ) + self.assert_json_error(result, "Cannot send to multiple streams") + + def test_includes_stream_id_but_not_topic(self) -> None: + sender = self.example_user("hamlet") + stream_id = self.get_stream_id("general") + + result = self.api_post( + sender, + "/api/v1/typing", + {"type": "stream", "op": "start", "to": orjson.dumps([stream_id]).decode()}, + ) + self.assert_json_error(result, "Missing topic") + + def test_stream_doesnt_exist(self) -> None: + sender = self.example_user("hamlet") + stream_id = self.INVALID_STREAM_ID + topic = "some topic" + + result = self.api_post( + sender, + "/api/v1/typing", + { + "type": "stream", + "op": "start", + "to": orjson.dumps([stream_id]).decode(), + "topic": topic, + }, + ) + self.assert_json_error(result, "Invalid stream id") + + +class TypingHappyPathTestPMs(ZulipTestCase): def test_start_to_single_recipient(self) -> None: sender = self.example_user("hamlet") recipient_user = self.example_user("othello") @@ -290,3 +344,77 @@ class TypingHappyPathTest(ZulipTestCase): self.assertEqual(event["sender"]["email"], sender.email) self.assertEqual(event["type"], "typing") self.assertEqual(event["op"], "stop") + + +class TypingHappyPathTestStreams(ZulipTestCase): + def test_start(self) -> None: + sender = self.example_user("hamlet") + stream_name = self.get_streams(sender)[0] + stream_id = self.get_stream_id(stream_name) + topic = "Some topic" + + expected_user_ids = { + user_profile.id + for user_profile in self.users_subscribed_to_stream(stream_name, sender.realm) + } + + params = dict( + type="stream", + op="start", + to=orjson.dumps([stream_id]).decode(), + topic=topic, + ) + + events: List[Mapping[str, Any]] = [] + with queries_captured() as queries: + with tornado_redirected_to_list(events): + result = self.api_post(sender, "/api/v1/typing", params) + self.assert_json_success(result) + self.assertEqual(len(events), 1) + self.assertEqual(len(queries), 5) + + event = events[0]["event"] + event_user_ids = set(events[0]["users"]) + + self.assertEqual(expected_user_ids, event_user_ids) + self.assertEqual(sender.email, event["sender"]["email"]) + self.assertEqual(stream_id, event["stream_id"]) + self.assertEqual(topic, event["topic"]) + self.assertEqual("typing", event["type"]) + self.assertEqual("start", event["op"]) + + def test_stop(self) -> None: + sender = self.example_user("hamlet") + stream_name = self.get_streams(sender)[0] + stream_id = self.get_stream_id(stream_name) + topic = "Some topic" + + expected_user_ids = { + user_profile.id + for user_profile in self.users_subscribed_to_stream(stream_name, sender.realm) + } + + params = dict( + type="stream", + op="stop", + to=orjson.dumps([stream_id]).decode(), + topic=topic, + ) + + events: List[Mapping[str, Any]] = [] + with queries_captured() as queries: + with tornado_redirected_to_list(events): + result = self.api_post(sender, "/api/v1/typing", params) + self.assert_json_success(result) + self.assertEqual(len(events), 1) + self.assertEqual(len(queries), 5) + + event = events[0]["event"] + event_user_ids = set(events[0]["users"]) + + self.assertEqual(expected_user_ids, event_user_ids) + self.assertEqual(sender.email, event["sender"]["email"]) + self.assertEqual(stream_id, event["stream_id"]) + self.assertEqual(topic, event["topic"]) + self.assertEqual("typing", event["type"]) + self.assertEqual("stop", event["op"]) diff --git a/zerver/tornado/event_queue.py b/zerver/tornado/event_queue.py index 0fbf9701a3..555e20e0b6 100644 --- a/zerver/tornado/event_queue.py +++ b/zerver/tornado/event_queue.py @@ -204,6 +204,11 @@ class ClientDescriptor: return False if event["type"] == "message": return self.narrow_filter(event) + if event["type"] == "typing" and "stream_id" in event: + # Typing notifications for stream messages are only + # delivered if the stream_typing_notifications + # client_capability is enabled, for backwards compatibility. + return self.stream_typing_notifications return True # TODO: Refactor so we don't need this function diff --git a/zerver/views/typing.py b/zerver/views/typing.py index c09de7a46d..1bd9929c1f 100644 --- a/zerver/views/typing.py +++ b/zerver/views/typing.py @@ -1,16 +1,17 @@ -from typing import List +from typing import List, Optional from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ from zerver.decorator import REQ, has_request_variables -from zerver.lib.actions import check_send_typing_notification +from zerver.lib.actions import check_send_typing_notification, do_send_stream_typing_notification from zerver.lib.response import json_error, json_success +from zerver.lib.streams import access_stream_by_id, access_stream_for_send_message from zerver.lib.validator import check_int, check_list, check_string_in from zerver.models import UserProfile VALID_OPERATOR_TYPES = ["start", "stop"] -VALID_MESSAGE_TYPES = ["private"] +VALID_MESSAGE_TYPES = ["private", "stream"] @has_request_variables @@ -21,10 +22,29 @@ def send_notification_backend( "type", str_validator=check_string_in(VALID_MESSAGE_TYPES), default="private" ), operator: str = REQ("op", str_validator=check_string_in(VALID_OPERATOR_TYPES)), - user_ids: List[int] = REQ("to", json_validator=check_list(check_int)), + notification_to: List[int] = REQ("to", json_validator=check_list(check_int)), + topic: Optional[str] = REQ("topic", default=None), ) -> HttpResponse: - if len(user_ids) == 0: - return json_error(_("Missing parameter: 'to' (recipient)")) + to_length = len(notification_to) + + if to_length == 0: + return json_error(_("Empty 'to' list")) + + if message_type == "stream": + if to_length > 1: + return json_error(_("Cannot send to multiple streams")) + + if topic is None: + return json_error(_("Missing topic")) + + stream_id = notification_to[0] + # Verify that the user has access to the stream and has + # permission to send messages to it. + stream = access_stream_by_id(user_profile, stream_id)[0] + access_stream_for_send_message(user_profile, stream, forwarder_user_profile=None) + do_send_stream_typing_notification(user_profile, operator, stream, topic) + else: + user_ids = notification_to + check_send_typing_notification(user_profile, user_ids, operator) - check_send_typing_notification(user_profile, user_ids, operator) return json_success()