typing: Support sending stream/topic typing status.

This extends the /json/typing endpoint to also accept
stream_id and topic. With this change, the requests
sent to /json/typing should have these:
* `to`: a list set to
    - recipients for a PM
    - stream_id for a stream message
* `topic`, in case of stream message
along with `op`(start or stop).

On receiving a request with stream_id and topic, we send
typing events to clients with stream_typing_notifications set
to True for all users subscribed to that stream.
This commit is contained in:
Dinesh 2020-12-25 01:30:20 +05:30 committed by Tim Abbott
parent 734d935d4a
commit 27e4f5da92
12 changed files with 427 additions and 31 deletions

View File

@ -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],
},

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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")

View File

@ -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:

View File

@ -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

View File

@ -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"])

View File

@ -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

View File

@ -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()