drafts: Send events to clients when drafts change.

With this, the core of the new drafts system is complete.
This commit is contained in:
Hemanth V. Alluri 2021-07-24 10:26:56 +05:30 committed by Tim Abbott
parent c00089ac28
commit 6fee946a43
7 changed files with 300 additions and 5 deletions

View File

@ -24,6 +24,7 @@ from zerver.lib.validator import (
check_union, check_union,
) )
from zerver.models import Draft, UserProfile from zerver.models import Draft, UserProfile
from zerver.tornado.django_api import send_event
VALID_DRAFT_TYPES: Set[str] = {"", "private", "stream"} VALID_DRAFT_TYPES: Set[str] = {"", "private", "stream"}
@ -117,6 +118,14 @@ def do_create_drafts(draft_dicts: List[Dict[str, Any]], user_profile: UserProfil
) )
created_draft_objects = Draft.objects.bulk_create(draft_objects) created_draft_objects = Draft.objects.bulk_create(draft_objects)
event = {
"type": "drafts",
"op": "add",
"drafts": [draft.to_dict() for draft in created_draft_objects],
}
send_event(user_profile.realm, event, [user_profile.id])
return created_draft_objects return created_draft_objects
@ -135,6 +144,9 @@ def do_edit_draft(draft_id: int, draft_dict: Dict[str, Any], user_profile: UserP
draft_object.last_edit_time = valid_draft_dict["last_edit_time"] draft_object.last_edit_time = valid_draft_dict["last_edit_time"]
draft_object.save() draft_object.save()
event = {"type": "drafts", "op": "update", "draft": draft_object.to_dict()}
send_event(user_profile.realm, event, [user_profile.id])
def do_delete_draft(draft_id: int, user_profile: UserProfile) -> None: def do_delete_draft(draft_id: int, user_profile: UserProfile) -> None:
"""Delete a draft belonging to a particular user.""" """Delete a draft belonging to a particular user."""
@ -142,4 +154,9 @@ def do_delete_draft(draft_id: int, user_profile: UserProfile) -> None:
draft_object = Draft.objects.get(id=draft_id, user_profile=user_profile) draft_object = Draft.objects.get(id=draft_id, user_profile=user_profile)
except Draft.DoesNotExist: except Draft.DoesNotExist:
raise ResourceNotFoundError(_("Draft does not exist")) raise ResourceNotFoundError(_("Draft does not exist"))
draft_id = draft_object.id
draft_object.delete() draft_object.delete()
event = {"type": "drafts", "op": "remove", "draft_id": draft_id}
send_event(user_profile.realm, event, [user_profile.id])

View File

@ -54,6 +54,7 @@ from zerver.models import (
MAX_TOPIC_NAME_LENGTH, MAX_TOPIC_NAME_LENGTH,
Client, Client,
CustomProfileField, CustomProfileField,
Draft,
Message, Message,
Realm, Realm,
Stream, Stream,
@ -169,6 +170,17 @@ def fetch_initial_state_data(
else: else:
state["max_message_id"] = -1 state["max_message_id"] = -1
if want("drafts"):
# Note: if a user ever disables synching drafts then all of
# their old drafts stored on the server will be deleted and
# simply retained in local storage. In which case user_drafts
# would just be an empty queryset.
user_draft_objects = Draft.objects.filter(user_profile=user_profile).order_by(
"-last_edit_time"
)[: settings.MAX_DRAFTS_IN_REGISTER_RESPONSE]
user_draft_dicts = [draft.to_dict() for draft in user_draft_objects]
state["drafts"] = user_draft_dicts
if want("muted_topics"): if want("muted_topics"):
state["muted_topics"] = [] if user_profile is None else get_topic_mutes(user_profile) state["muted_topics"] = [] if user_profile is None else get_topic_mutes(user_profile)
@ -618,6 +630,34 @@ def apply_event(
# It may be impossible for a heartbeat event to actually reach # It may be impossible for a heartbeat event to actually reach
# this code path. But in any case, they're noops. # this code path. But in any case, they're noops.
pass pass
elif event["type"] == "drafts":
if event["op"] == "add":
state["drafts"].extend(event["drafts"])
else:
if event["op"] == "update":
event_draft_idx = event["draft"]["id"]
def _draft_update_action(i: int) -> None:
state["drafts"][i] = event["draft"]
elif event["op"] == "remove":
event_draft_idx = event["draft_id"]
def _draft_update_action(i: int) -> None:
del state["drafts"][i]
# We have to perform a linear search for the draft that
# was either edited or removed since we have a list
# ordered by the last edited timestamp and not id.
state_draft_idx = None
for idx, draft in enumerate(state["drafts"]):
if draft["id"] == event_draft_idx:
state_draft_idx = idx
break
assert state_draft_idx is not None
_draft_update_action(state_draft_idx)
elif event["type"] == "hotspots": elif event["type"] == "hotspots":
state["hotspots"] = event["hotspots"] state["hotspots"] = event["hotspots"]
elif event["type"] == "custom_profile_fields": elif event["type"] == "custom_profile_fields":

View File

@ -3818,6 +3818,102 @@ paths:
}, },
"id": 0, "id": 0,
} }
- type: object
additionalProperties: false
description: |
Event containing details of newly created drafts.
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- "drafts"
op:
type: string
enum:
- "add"
drafts:
type: array
description: |
An array containing objects for the newly created drafts.
items:
$ref: "#/components/schemas/Draft"
example:
{
"type": "drafts",
"op": "add",
"drafts":
[
{
"id": 17,
"type": "private",
"to": [6],
"topic": "",
"content": "Hello there!",
"timestamp": 15954790200,
},
],
}
- type: object
additionalProperties: false
description: |
Event containing details for an edited draft.
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- "drafts"
op:
type: string
enum:
- "update"
draft:
$ref: "#/components/schemas/Draft"
example:
{
"type": "drafts",
"op": "update",
"draft":
{
"id": 17,
"type": "private",
"to": [6, 7, 8, 9, 10],
"topic": "",
"content": "Hello everyone!",
"timestamp": 15954790200,
},
}
- type: object
additionalProperties: false
description: |
Event containing the id of a deleted draft.
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- "drafts"
op:
type: string
enum:
- "remove"
draft_id:
type: integer
description: |
The ID of the draft that was just deleted.
example:
{
"type": "drafts",
"op": "update",
"draft_id": 17,
}
queue_id: queue_id:
type: string type: string
description: | description: |
@ -7980,6 +8076,14 @@ paths:
type: string type: string
description: | description: |
The name of the custom profile field type. The name of the custom profile field type.
drafts:
type: array
description: |
An array containing draft objects for the user. These drafts are being
stored on the backend for the purpose of syncing across devices. This
array will be empty if `enable_drafts_synchronization` is set to `false`.
items:
$ref: "#/components/schemas/Draft"
hotspots: hotspots:
type: array type: array
description: | description: |
@ -8835,7 +8939,8 @@ paths:
description: | description: |
Present if `update_display_settings` is present in `fetch_event_types`. Present if `update_display_settings` is present in `fetch_event_types`.
Whether drafts synchronization is enabled for the user. Whether drafts synchronization is enabled for the user. If disabled,
clients will receive an error when trying to use the `drafts` endpoints.
See [PATCH /settings](/api/update-settings) for details on See [PATCH /settings](/api/update-settings) for details on
the meaning of this setting. the meaning of this setting.
@ -12800,6 +12905,59 @@ components:
description: | description: |
Whether the client is capable of showing mobile/push notifications Whether the client is capable of showing mobile/push notifications
to the user. to the user.
Draft:
type: object
description: |
A dictionary for representing a message draft.
properties:
id:
type: integer
description: |
The unique ID of the draft. It will only used whenever the drafts are
fetched. This field should not be specified when the draft is being
created or edited.
type:
type: string
description: |
The type of the draft. Either unaddressed (empty string), "stream",
or "private" (for PMs and private group messages).
enum:
- ""
- "stream"
- "private"
to:
type: array
description: |
An array of the tentative target audience IDs. For "stream"
messages, this should contain exactly 1 ID, the ID of the
target stream. For private messages, this should be an array
of target user IDs. For unaddressed drafts this is ignored
so it's best to leave it as an empty array.
items:
type: integer
topic:
type: string
description: |
For stream message drafts, the tentative topic name. For private
or unaddressed messages this will be ignored and should ideally
be an empty string. Should not contain null bytes.
content:
type: string
description: |
The body of the draft. Should not contain null bytes.
timestamp:
type: number
description: |
A Unix timestamp (seconds only) representing when the draft was
last edited. When creating a draft, this key need not be present
and it will be filled in automatically by the server.
example: 1595479019
additionalProperties: false
required:
- type
- to
- topic
- content
User: User:
allOf: allOf:
- $ref: "#/components/schemas/UserBase" - $ref: "#/components/schemas/UserBase"

View File

@ -980,13 +980,14 @@ class FetchQueriesTest(ZulipTestCase):
with mock.patch("zerver.lib.events.always_want") as want_mock: with mock.patch("zerver.lib.events.always_want") as want_mock:
fetch_initial_state_data(user) fetch_initial_state_data(user)
self.assert_length(queries, 33) self.assert_length(queries, 34)
expected_counts = dict( expected_counts = dict(
alert_words=1, alert_words=1,
custom_profile_fields=1, custom_profile_fields=1,
default_streams=1, default_streams=1,
default_stream_groups=1, default_stream_groups=1,
drafts=1,
hotspots=0, hotspots=0,
message=1, message=1,
muted_topics=1, muted_topics=1,

View File

@ -101,6 +101,7 @@ from zerver.lib.actions import (
try_add_realm_custom_profile_field, try_add_realm_custom_profile_field,
try_update_realm_custom_profile_field, try_update_realm_custom_profile_field,
) )
from zerver.lib.drafts import do_create_drafts, do_delete_draft, do_edit_draft
from zerver.lib.event_schema import ( from zerver.lib.event_schema import (
check_alert_words, check_alert_words,
check_attachment_add, check_attachment_add,
@ -2308,6 +2309,45 @@ class DraftActionTest(BaseAction):
def do_disable_drafts_synchronization(self, user_profile: UserProfile) -> None: def do_disable_drafts_synchronization(self, user_profile: UserProfile) -> None:
do_set_user_display_setting(user_profile, "enable_drafts_synchronization", False) do_set_user_display_setting(user_profile, "enable_drafts_synchronization", False)
def test_draft_create_event(self) -> None:
self.do_enable_drafts_synchronization(self.user_profile)
dummy_draft = {
"type": "draft",
"to": "",
"topic": "",
"content": "Sample draft content",
"timestamp": 1596820995,
}
action = lambda: do_create_drafts([dummy_draft], self.user_profile)
self.verify_action(action)
def test_draft_edit_event(self) -> None:
self.do_enable_drafts_synchronization(self.user_profile)
dummy_draft = {
"type": "draft",
"to": "",
"topic": "",
"content": "Sample draft content",
"timestamp": 1596820995,
}
draft_id = do_create_drafts([dummy_draft], self.user_profile)[0].id
dummy_draft["content"] = "Some more sample draft content"
action = lambda: do_edit_draft(draft_id, dummy_draft, self.user_profile)
self.verify_action(action)
def test_draft_delete_event(self) -> None:
self.do_enable_drafts_synchronization(self.user_profile)
dummy_draft = {
"type": "draft",
"to": "",
"topic": "",
"content": "Sample draft content",
"timestamp": 1596820995,
}
draft_id = do_create_drafts([dummy_draft], self.user_profile)[0].id
action = lambda: do_delete_draft(draft_id, self.user_profile)
self.verify_action(action)
def test_enable_syncing_drafts(self) -> None: def test_enable_syncing_drafts(self) -> None:
self.do_disable_drafts_synchronization(self.user_profile) self.do_disable_drafts_synchronization(self.user_profile)
action = lambda: self.do_enable_drafts_synchronization(self.user_profile) action = lambda: self.do_enable_drafts_synchronization(self.user_profile)

View File

@ -24,6 +24,7 @@ from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import get_user_messages, queries_captured from zerver.lib.test_helpers import get_user_messages, queries_captured
from zerver.models import ( from zerver.models import (
DefaultStream, DefaultStream,
Draft,
Realm, Realm,
UserActivity, UserActivity,
UserProfile, UserProfile,
@ -64,6 +65,7 @@ class HomeTest(ZulipTestCase):
"dense_mode", "dense_mode",
"desktop_icon_count_display", "desktop_icon_count_display",
"development_environment", "development_environment",
"drafts",
"email", "email",
"email_notifications_batching_period_seconds", "email_notifications_batching_period_seconds",
"emojiset", "emojiset",
@ -271,7 +273,7 @@ class HomeTest(ZulipTestCase):
set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"} set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"}
) )
self.assert_length(queries, 42) self.assert_length(queries, 43)
self.assert_length(cache_mock.call_args_list, 5) self.assert_length(cache_mock.call_args_list, 5)
html = result.content.decode("utf-8") html = result.content.decode("utf-8")
@ -351,7 +353,7 @@ class HomeTest(ZulipTestCase):
result = self._get_home_page() result = self._get_home_page()
self.check_rendered_logged_in_app(result) self.check_rendered_logged_in_app(result)
self.assert_length(cache_mock.call_args_list, 6) self.assert_length(cache_mock.call_args_list, 6)
self.assert_length(queries, 39) self.assert_length(queries, 40)
def test_num_queries_with_streams(self) -> None: def test_num_queries_with_streams(self) -> None:
main_user = self.example_user("hamlet") main_user = self.example_user("hamlet")
@ -382,7 +384,7 @@ class HomeTest(ZulipTestCase):
with queries_captured() as queries2: with queries_captured() as queries2:
result = self._get_home_page() result = self._get_home_page()
self.assert_length(queries2, 37) self.assert_length(queries2, 38)
# Do a sanity check that our new streams were in the payload. # Do a sanity check that our new streams were in the payload.
html = result.content.decode("utf-8") html = result.content.decode("utf-8")
@ -1041,3 +1043,35 @@ class HomeTest(ZulipTestCase):
page_params = self._get_page_params(result) page_params = self._get_page_params(result)
self.assertEqual(page_params["default_language"], "es") self.assertEqual(page_params["default_language"], "es")
@override_settings(MAX_DRAFTS_IN_REGISTER_RESPONSE=5)
def test_limit_drafts(self) -> None:
draft_objects = []
hamlet = self.example_user("hamlet")
base_time = timezone_now()
step_value = timedelta(seconds=1)
# Create 11 drafts.
for i in range(0, settings.MAX_DRAFTS_IN_REGISTER_RESPONSE + 1):
draft_objects.append(
Draft(
user_profile=hamlet,
recipient=None,
topic="",
content="sample draft",
last_edit_time=base_time + i * step_value,
)
)
Draft.objects.bulk_create(draft_objects)
# Now fetch the drafts part of the initial state and make sure
# that we only got back settings.MAX_DRAFTS_IN_REGISTER_RESPONSE.
# No more. Also make sure that the drafts returned are the most
# recently edited ones.
self.login("hamlet")
page_params = self._get_page_params(self._get_home_page())
self.assertEqual(page_params["enable_drafts_synchronization"], True)
self.assert_length(page_params["drafts"], settings.MAX_DRAFTS_IN_REGISTER_RESPONSE)
self.assertEqual(Draft.objects.count(), settings.MAX_DRAFTS_IN_REGISTER_RESPONSE + 1)
for draft in page_params["drafts"]:
self.assertNotEqual(draft["timestamp"], base_time)

View File

@ -455,3 +455,8 @@ OUTGOING_WEBHOOK_TIMEOUT_SECONDS = 10
# Any message content exceeding this limit will be truncated. # Any message content exceeding this limit will be truncated.
# See: `_internal_prep_message` function in zerver/lib/actions.py. # See: `_internal_prep_message` function in zerver/lib/actions.py.
MAX_MESSAGE_LENGTH = 10000 MAX_MESSAGE_LENGTH = 10000
# The maximum number of drafts to send in the response to /register.
# More drafts, should they exist for some crazy reason, could be
# fetched in a separate request.
MAX_DRAFTS_IN_REGISTER_RESPONSE = 1000