From 2a15da47d99091b203094c15e2496c42b53703cd Mon Sep 17 00:00:00 2001 From: opmkumar Date: Fri, 7 Feb 2025 00:54:43 +0530 Subject: [PATCH] message_edit: Show typing indicator for message editing. This commit adds typing indicators for message editing in stream as well as in dm, if the send typing notification for corresponding is enabled. Based on earlier work in #28585. Co-authored-by: Rohan Gudimetla Fixes #25719. --- api_docs/changelog.md | 9 + api_docs/include/rest-endpoints.md | 1 + tools/check-schemas | 7 + version.py | 2 +- web/shared/src/typing_status.ts | 158 +++++++++++++- web/src/message_edit.ts | 2 + web/src/message_list_view.ts | 11 + web/src/server_events_dispatch.js | 19 ++ web/src/typing.ts | 91 +++++++- web/src/typing_data.ts | 19 ++ web/src/typing_events.ts | 64 ++++++ web/styles/message_row.css | 54 +++++ web/templates/edited_notice.hbs | 1 + web/templates/editing_notifications.hbs | 5 + web/tests/dispatch.test.cjs | 66 ++++++ web/tests/lib/events.cjs | 46 ++++ web/tests/typing_data.test.cjs | 18 ++ web/tests/typing_status.test.cjs | 270 ++++++++++++++++++++++-- zerver/actions/typing.py | 73 +++++++ zerver/lib/event_schema.py | 8 + zerver/lib/event_types.py | 41 ++++ zerver/lib/events.py | 3 + zerver/openapi/python_examples.py | 40 ++++ zerver/openapi/zulip.yaml | 196 +++++++++++++++++ zerver/tests/test_events.py | 49 ++++- zerver/tests/test_typing.py | 208 +++++++++++++++++- zerver/views/typing.py | 50 ++++- zproject/urls.py | 4 +- 28 files changed, 1477 insertions(+), 38 deletions(-) create mode 100644 web/templates/editing_notifications.hbs diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 8305fa0da5..72aa3deaec 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,15 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 351** + +* [`POST /message_edit_typing`](/api/set-typing-status-for-message-edit): + Added a new endpoint for sending typing notification when a message is + being edited both in streams and direct messages. + +* [`GET /events`](/api/get-events): The new `typing_edit_message` event + is sent when a user starts editing a message. + **Feature level 350** * [`POST /register`](/api/register-queue): Added diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index eb9f006102..85516b39dd 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -73,6 +73,7 @@ * [Get a user's status](/api/get-user-status) * [Update your status](/api/update-status) * [Set "typing" status](/api/set-typing-status) +* [Set "typing" status for message editing](/api/set-typing-status-for-message-edit) * [Get a user's presence](/api/get-user-presence) * [Get presence of all users](/api/get-presence) * [Update your presence](/api/update-presence) diff --git a/tools/check-schemas b/tools/check-schemas index 277af854ac..c6c249eff8 100755 --- a/tools/check-schemas +++ b/tools/check-schemas @@ -61,6 +61,13 @@ def get_event_checker(event: dict[str, Any]) -> Callable[[str, dict[str, Any]], # Start by grabbing the event type. name = event["type"] + # This is a temporary workaround until a proper fix is implemented. + if name == "typing_edit_message": + if event["recipient"]["type"] == "channel": + name = "typing_edit_channel_message" + else: + name = "typing_edit_direct_message" + # Handle things like AttachmentRemoveEvent if "op" in event: name += "_" + event["op"].title() diff --git a/version.py b/version.py index 411597c32f..640e94ee65 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 350 # Last bumped for AI settings. +API_FEATURE_LEVEL = 351 # Last bumped for adding typing indicator for message editing. # 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/web/shared/src/typing_status.ts b/web/shared/src/typing_status.ts index 941c6d6f60..d1ae74c6ed 100644 --- a/web/shared/src/typing_status.ts +++ b/web/shared/src/typing_status.ts @@ -14,13 +14,24 @@ export type Recipient = | (StreamTopic & { message_type: "stream"; notification_event_type: "typing"; - }); + }) + | { + notification_event_type: "typing_message_edit"; + message_id: number; + }; + type TypingStatusWorker = { get_current_time: () => number; notify_server_start: (recipient: Recipient) => void; notify_server_stop: (recipient: Recipient) => void; }; +export type EditingStatusWorker = { + get_current_time: () => number; + notify_server_editing_start: (recipient: Recipient) => void; + notify_server_editing_stop: (recipient: Recipient) => void; +}; + type TypingStatusState = { current_recipient: Recipient; next_send_start_time: number; @@ -41,19 +52,21 @@ function same_recipient(a: Recipient | null, b: Recipient | null): boolean { return false; } - if (a.message_type === "direct" && b.message_type === "direct") { - // direct message recipients - return _.isEqual(a.ids, b.ids); - } else if (a.message_type === "stream" && b.message_type === "stream") { - // stream recipients - return same_stream_and_topic(a, b); + if (a.notification_event_type === "typing" && b.notification_event_type === "typing") { + if (a.message_type === "direct" && b.message_type === "direct") { + // direct message recipients + return _.isEqual(a.ids, b.ids); + } else if (a.message_type === "stream" && b.message_type === "stream") { + // stream recipients + return same_stream_and_topic(a, b); + } } - return false; } /** Exported only for tests. */ export let state: TypingStatusState | null = null; +export const editing_state = new Map(); export function rewire_state(value: typeof state): void { state = value; @@ -67,6 +80,18 @@ export let stop_last_notification = (worker: TypingStatusWorker): void => { state = null; }; +export function stop_notification_for_message_edit( + worker: EditingStatusWorker, + message_id: number, +): void { + const state = editing_state.get(message_id); + if (state !== undefined) { + clearTimeout(state.idle_timer); + worker.notify_server_editing_stop(state.current_recipient); + editing_state.delete(message_id); + } +} + export function rewire_stop_last_notification(value: typeof stop_last_notification): void { stop_last_notification = value; } @@ -90,6 +115,26 @@ export let start_or_extend_idle_timer = ( return setTimeout(on_idle_timeout, typing_stopped_wait_period); }; +function start_or_extend_idle_timer_for_message_edit( + worker: EditingStatusWorker, + message_id: number, + typing_stopped_wait_period: number, +): ReturnType { + function on_idle_timeout(): void { + // We don't do any real error checking here, because + // if we've been idle, we need to tell folks, and if + // our current recipients has changed, previous code will + // have stopped the timer. + stop_notification_for_message_edit(worker, message_id); + } + const state = editing_state.get(message_id); + if (state?.idle_timer) { + clearTimeout(state.idle_timer); + } + + return setTimeout(on_idle_timeout, typing_stopped_wait_period); +} + export function rewire_start_or_extend_idle_timer(value: typeof start_or_extend_idle_timer): void { start_or_extend_idle_timer = value; } @@ -99,6 +144,17 @@ function set_next_start_time(current_time: number, typing_started_wait_period: n state.next_send_start_time = current_time + typing_started_wait_period; } +function set_next_start_time_for_message_edit( + current_time: number, + typing_started_wait_period: number, + message_id: number, +): void { + const state = editing_state.get(message_id); + assert(state !== undefined); + state.next_send_start_time = current_time + typing_started_wait_period; + editing_state.set(message_id, state); +} + // Exported for tests export let actually_ping_server = ( worker: TypingStatusWorker, @@ -110,6 +166,21 @@ export let actually_ping_server = ( set_next_start_time(current_time, typing_started_wait_period); }; +function actually_ping_server_for_message_edit( + worker: EditingStatusWorker, + recipient: Recipient, + current_time: number, + typing_started_wait_period: number, +): void { + assert(recipient.notification_event_type === "typing_message_edit"); + worker.notify_server_editing_start(recipient); + set_next_start_time_for_message_edit( + current_time, + typing_started_wait_period, + recipient.message_id, + ); +} + export function rewire_actually_ping_server(value: typeof actually_ping_server): void { actually_ping_server = value; } @@ -127,6 +198,24 @@ export let maybe_ping_server = ( } }; +export function maybe_ping_server_for_message_edit( + worker: EditingStatusWorker, + recipient: Recipient, + typing_started_wait_period: number, +): void { + assert(recipient.notification_event_type === "typing_message_edit"); + const state = editing_state.get(recipient.message_id); + assert(state !== undefined); + const current_time = worker.get_current_time(); + if (current_time > state.next_send_start_time) { + actually_ping_server_for_message_edit( + worker, + recipient, + current_time, + typing_started_wait_period, + ); + } +} export function rewire_maybe_ping_server(value: typeof maybe_ping_server): void { maybe_ping_server = value; } @@ -195,3 +284,56 @@ export function update( const current_time = worker.get_current_time(); actually_ping_server(worker, new_recipient, current_time, typing_started_wait_period); } + +export function update_editing_status( + edit_box_worker: EditingStatusWorker, + new_recipient: Recipient, + new_status: "start" | "stop", + typing_started_wait_period: number, + typing_stopped_wait_period: number, +): void { + assert(new_recipient.notification_event_type === "typing_message_edit"); + const message_id = new_recipient.message_id; + + if (new_status === "stop") { + stop_notification_for_message_edit(edit_box_worker, message_id); + return; + } + + if (editing_state.has(message_id)) { + // Nothing has really changed, except we may need to extend out our idle time. + const state = editing_state.get(message_id)!; + state.idle_timer = start_or_extend_idle_timer_for_message_edit( + edit_box_worker, + message_id, + typing_stopped_wait_period, + ); + + // We may need to send a ping to the server too. + maybe_ping_server_for_message_edit( + edit_box_worker, + new_recipient, + typing_started_wait_period, + ); + return; + } + + const edit_state: TypingStatusState = { + current_recipient: new_recipient, + next_send_start_time: 0, + idle_timer: start_or_extend_idle_timer_for_message_edit( + edit_box_worker, + message_id, + typing_stopped_wait_period, + ), + }; + + editing_state.set(message_id, edit_state); + const current_time = edit_box_worker.get_current_time(); + actually_ping_server_for_message_edit( + edit_box_worker, + new_recipient, + current_time, + typing_started_wait_period, + ); +} diff --git a/web/src/message_edit.ts b/web/src/message_edit.ts index 8b3a03cf3c..6c3a6423bf 100644 --- a/web/src/message_edit.ts +++ b/web/src/message_edit.ts @@ -55,6 +55,7 @@ import * as stream_data from "./stream_data.ts"; import * as stream_topic_history from "./stream_topic_history.ts"; import * as sub_store from "./sub_store.ts"; import * as timerender from "./timerender.ts"; +import * as typing from "./typing.ts"; import * as ui_report from "./ui_report.ts"; import * as upload from "./upload.ts"; import {the} from "./util.ts"; @@ -942,6 +943,7 @@ export function end_message_row_edit($row: JQuery): void { const message = message_lists.current.get(row_id); if (message !== undefined && currently_editing_messages.has(message.id)) { + typing.stop_message_edit_notifications(message.id); currently_editing_messages.delete(message.id); message_lists.current.hide_edit_message($row); compose_call.abort_video_callbacks(message.id.toString()); diff --git a/web/src/message_list_view.ts b/web/src/message_list_view.ts index b2aa4e2d39..f0eff86a9a 100644 --- a/web/src/message_list_view.ts +++ b/web/src/message_list_view.ts @@ -42,6 +42,8 @@ import * as submessage from "./submessage.ts"; import {is_same_day} from "./time_zone_util.ts"; import * as timerender from "./timerender.ts"; import type {TopicLink} from "./types.ts"; +import * as typing_data from "./typing_data.ts"; +import * as typing_events from "./typing_events.ts"; import * as user_topics from "./user_topics.ts"; import type {AllVisibilityPolicies} from "./user_topics.ts"; import * as util from "./util.ts"; @@ -669,6 +671,15 @@ export class MessageListView { moved: boolean; modified: boolean; } { + const is_typing = typing_data.is_message_editing(message.id); + if (is_typing) { + // Ensure the typing animation is rendered when a user switches + // to a view where someone is editing a message. + setTimeout(() => { + typing_events.render_message_editing_typing(message.id, true); + }, 0); + } + /* If the message needs to be hidden because the sender was muted, we do a few things: diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index d7a0c294bd..f824df1a40 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -742,6 +742,25 @@ export function dispatch_normal_event(event) { } break; + case "typing_edit_message": + if (event.sender_id === current_user.user_id) { + // typing edit message notifications are sent to the user who is typing + // as well as recipients; we ignore such self-generated events. + return; + } + switch (event.op) { + case "start": + typing_events.display_message_edit_notification(event); + break; + case "stop": + typing_events.hide_message_edit_notification(event); + break; + default: + blueslip.error("Unexpected event type typing_edit_message/" + event.op); + break; + } + break; + case "user_settings": { const notification_name = event.property; if (settings_config.all_notification_settings.includes(notification_name)) { diff --git a/web/src/typing.ts b/web/src/typing.ts index eec388234f..e68c2afbe7 100644 --- a/web/src/typing.ts +++ b/web/src/typing.ts @@ -1,17 +1,22 @@ import $ from "jquery"; +import assert from "minimalistic-assert"; import * as typing_status from "../shared/src/typing_status.ts"; -import type {Recipient} from "../shared/src/typing_status.ts"; +import type {EditingStatusWorker, Recipient} from "../shared/src/typing_status.ts"; import * as blueslip from "./blueslip.ts"; import * as channel from "./channel.ts"; import * as compose_pm_pill from "./compose_pm_pill.ts"; import * as compose_state from "./compose_state.ts"; +import * as message_store from "./message_store.ts"; import * as people from "./people.ts"; +import * as rows from "./rows.ts"; import {realm} from "./state_data.ts"; import * as stream_data from "./stream_data.ts"; import {user_settings} from "./user_settings.ts"; +let edit_box_worker: EditingStatusWorker; + type TypingAPIRequest = {op: "start" | "stop"} & ( | { type: string; @@ -41,6 +46,25 @@ function send_typing_notification_ajax(data: TypingAPIRequest): void { }); } +function send_message_edit_typing_notification_ajax( + message_id: number, + operation: "start" | "stop", +): void { + const data = { + message_id: JSON.stringify(message_id), + op: operation, + }; + void channel.post({ + url: "/json/message_edit_typing", + data, + error(xhr) { + if (xhr.readyState !== 0) { + blueslip.warn("Failed to send message edit typing event: " + xhr.responseText); + } + }, + }); +} + function send_direct_message_typing_notification( user_ids_array: number[], operation: "start" | "stop", @@ -71,6 +95,7 @@ function send_typing_notification_based_on_message_type( to: Recipient, operation: "start" | "stop", ): void { + assert(to.notification_event_type === "typing"); if (to.message_type === "direct" && user_settings.send_private_typing_notifications) { send_direct_message_typing_notification(to.ids, operation); } else if (to.message_type === "stream" && user_settings.send_stream_typing_notifications) { @@ -78,6 +103,24 @@ function send_typing_notification_based_on_message_type( } } +function message_edit_typing_notifications_enabled(message_id: number): boolean { + const message = message_store.get(message_id); + assert(message !== undefined); + if (message.type === "stream") { + return user_settings.send_stream_typing_notifications; + } + return user_settings.send_private_typing_notifications; +} + +function send_typing_notifications_for_message_edit( + message_id: number, + operation: "start" | "stop", +): void { + if (message_edit_typing_notifications_enabled(message_id)) { + send_message_edit_typing_notification_ajax(message_id, operation); + } +} + function get_user_ids_array(): number[] | null { const user_ids_string = compose_pm_pill.get_user_ids_string(); if (user_ids_string === "") { @@ -108,6 +151,16 @@ function notify_server_stop(to: Recipient): void { send_typing_notification_based_on_message_type(to, "stop"); } +function notify_server_editing_start(to: Recipient): void { + assert(to.notification_event_type === "typing_message_edit"); + send_typing_notifications_for_message_edit(to.message_id, "start"); +} + +function notify_server_editing_stop(to: Recipient): void { + assert(to.notification_event_type === "typing_message_edit"); + send_typing_notifications_for_message_edit(to.message_id, "stop"); +} + export function get_recipient(): Recipient | null { const message_type = compose_state.get_message_type(); if (message_type === "private") { @@ -144,6 +197,23 @@ export function get_recipient(): Recipient | null { return null; } +function get_message_edit_recipient(message_id: number): Recipient { + return { + notification_event_type: "typing_message_edit", + message_id, + }; +} +export function stop_message_edit_notifications(message_id: number): void { + const recipient = get_message_edit_recipient(message_id); + typing_status.update_editing_status( + edit_box_worker, + recipient, + "stop", + realm.server_typing_started_wait_period_milliseconds, + realm.server_typing_stopped_wait_period_milliseconds, + ); +} + export function initialize(): void { const worker = { get_current_time, @@ -151,6 +221,12 @@ export function initialize(): void { notify_server_stop, }; + edit_box_worker = { + get_current_time, + notify_server_editing_start, + notify_server_editing_stop, + }; + $(document).on("input", "#compose-textarea", () => { // If our previous state was no typing notification, send a // start-typing notice immediately. @@ -163,6 +239,19 @@ export function initialize(): void { ); }); + $("body").on("input", ".message_edit_content", function (this: HTMLElement) { + const $message_row = $(this).closest(".message_row"); + const message_id = rows.id($message_row); + const new_recipient = get_message_edit_recipient(message_id); + typing_status.update_editing_status( + edit_box_worker, + new_recipient, + "start", + realm.server_typing_started_wait_period_milliseconds, + realm.server_typing_stopped_wait_period_milliseconds, + ); + }); + // We send a stop-typing notification immediately when compose is // closed/cancelled $(document).on("compose_canceled.zulip compose_finished.zulip", () => { diff --git a/web/src/typing_data.ts b/web/src/typing_data.ts index e0a065cc26..ea4dd5b626 100644 --- a/web/src/typing_data.ts +++ b/web/src/typing_data.ts @@ -4,6 +4,7 @@ import * as util from "./util.ts"; // See docs/subsystems/typing-indicators.md for details on typing indicators. const typists_dict = new Map(); +const edit_message_typing_ids = new Set(); const inbound_timer_dict = new Map | undefined>(); export function clear_for_testing(): void { @@ -72,6 +73,24 @@ export function clear_typing_data(): void { typists_dict.clear(); } +export function add_edit_message_typing_id(message_id: number): void { + if (!edit_message_typing_ids.has(message_id)) { + edit_message_typing_ids.add(message_id); + } +} + +export function remove_edit_message_typing_id(message_id: number): boolean { + if (!edit_message_typing_ids.has(message_id)) { + return false; + } + edit_message_typing_ids.delete(message_id); + return true; +} + +export function is_message_editing(message_id: number): boolean { + return edit_message_typing_ids.has(message_id); +} + // The next functions aren't pure data, but it is easy // enough to mock the setTimeout/clearTimeout functions. export function clear_inbound_timer(key: string): void { diff --git a/web/src/typing_events.ts b/web/src/typing_events.ts index ae871a7286..fe041b51e9 100644 --- a/web/src/typing_events.ts +++ b/web/src/typing_events.ts @@ -2,8 +2,10 @@ import $ from "jquery"; import assert from "minimalistic-assert"; import {z} from "zod"; +import render_editing_notifications from "../templates/editing_notifications.hbs"; import render_typing_notifications from "../templates/typing_notifications.hbs"; +import * as message_lists from "./message_lists.ts"; import * as narrow_state from "./narrow_state.ts"; import * as people from "./people.ts"; import {current_user, realm} from "./state_data.ts"; @@ -52,6 +54,26 @@ export const typing_event_schema = z ); type TypingEvent = z.output; +export const typing_edit_message_event_schema = z.object({ + message_id: z.number(), + op: z.enum(["start", "stop"]), + type: z.literal("typing_edit_message"), + sender_id: z.number(), + recipient: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("channel"), + channel_id: z.number(), + topic: z.string(), + }), + z.object({ + type: z.literal("direct"), + user_ids: z.array(z.number()), + }), + ]), +}); + +type TypingMessageEditEvent = z.output; + function get_users_typing_for_narrow(): number[] { if (narrow_state.narrowed_by_topic_reply()) { const current_stream_id = narrow_state.stream_id(); @@ -113,6 +135,24 @@ export function render_notifications_for_narrow(): void { } } +function apply_message_edit_notifications($row: JQuery, is_typing: boolean): void { + const $editing_notifications = $row.find(".edit-notifications"); + if (is_typing) { + $row.find(".message_edit_notice").addClass("hide"); + $editing_notifications.html(render_editing_notifications()); + } else { + $row.find(".message_edit_notice").removeClass("hide"); + $editing_notifications.html(""); + } +} + +export function render_message_editing_typing(message_id: number, is_typing: boolean): void { + const $row = message_lists.current?.get_row(message_id); + if ($row !== undefined) { + apply_message_edit_notifications($row, is_typing); + } +} + function get_key(event: TypingEvent): string { if (event.message_type === "stream") { return typing_data.get_topic_key(event.stream_id, event.topic); @@ -136,6 +176,16 @@ export function hide_notification(event: TypingEvent): void { } } +export function hide_message_edit_notification(event: TypingMessageEditEvent): void { + const message_id = event.message_id; + const key = JSON.stringify(message_id); + typing_data.clear_inbound_timer(key); + const removed = typing_data.remove_edit_message_typing_id(message_id); + if (removed) { + render_message_editing_typing(message_id, false); + } +} + export function display_notification(event: TypingEvent): void { const sender_id = event.sender.user_id; @@ -153,6 +203,20 @@ export function display_notification(event: TypingEvent): void { ); } +export function display_message_edit_notification(event: TypingMessageEditEvent): void { + const message_id = event.message_id; + const key = JSON.stringify(message_id); + typing_data.add_edit_message_typing_id(message_id); + render_message_editing_typing(message_id, true); + typing_data.kickstart_inbound_timer( + key, + realm.server_typing_started_expiry_period_milliseconds, + () => { + hide_message_edit_notification(event); + }, + ); +} + export function disable_typing_notification(): void { typing_data.clear_typing_data(); render_notifications_for_narrow(); diff --git a/web/styles/message_row.css b/web/styles/message_row.css index e395bbe6f0..8cdc584da1 100644 --- a/web/styles/message_row.css +++ b/web/styles/message_row.css @@ -130,6 +130,10 @@ grid-area: edited; } + .edit-notifications { + grid-area: edited; + } + .slow-send-spinner { display: none; justify-self: end; @@ -370,6 +374,10 @@ margin-left: 4px; /* Unset the padding used on edited notices under the avatar. */ padding-right: 0; + + &.hide { + display: none; + } } .message_sender { @@ -758,6 +766,52 @@ of the base style defined for a read-only textarea in dark mode. */ opacity: 1; } +.message-editing-animation { + display: inline-flex; + align-items: baseline; + margin-left: 3px; + width: 40px; + height: 5px; + + .y-animated-dot { + width: 4px; + height: 4px; + background-color: hsl(0deg 0% 53%); + border-radius: 50%; + margin-left: 3px; + opacity: 0; + + &:nth-child(1) { + animation: typing 1s infinite 0.2s; + } + + &:nth-child(2) { + animation: typing 1s infinite 0.4s; + } + + &:nth-child(3) { + animation: typing 1s infinite 0.6s; + } + } +} + +@keyframes typing { + 0% { + opacity: 0; + transform: translateY(0); + } + + 50% { + opacity: 1; + transform: translateY(-3px); + } + + 100% { + opacity: 0; + transform: translateY(0); + } +} + .message_edit_countdown_timer { text-align: right; display: inline; diff --git a/web/templates/edited_notice.hbs b/web/templates/edited_notice.hbs index a4931cfccc..7a7412e4d9 100644 --- a/web/templates/edited_notice.hbs +++ b/web/templates/edited_notice.hbs @@ -1,3 +1,4 @@ +
{{#if modified}} {{#if msg/local_edit_timestamp}}
diff --git a/web/templates/editing_notifications.hbs b/web/templates/editing_notifications.hbs new file mode 100644 index 0000000000..81e305e415 --- /dev/null +++ b/web/templates/editing_notifications.hbs @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/web/tests/dispatch.test.cjs b/web/tests/dispatch.test.cjs index 066647bc28..693ae86805 100644 --- a/web/tests/dispatch.test.cjs +++ b/web/tests/dispatch.test.cjs @@ -911,6 +911,66 @@ run_test("stream_typing", ({override}) => { } }); +run_test("message_edit_typing", ({override}) => { + override(current_user, "user_id", typing_person1.user_id + 1); + + let event = event_fixtures.message_edit_typing__start; + { + const stub = make_stub(); + override(typing_events, "display_message_edit_notification", stub.f); + dispatch(event); + assert.equal(stub.num_calls, 1); + const args = stub.get_args("event"); + assert_same(args.event.sender_id, typing_person1.user_id); + assert_same(args.event.message_id, event.message_id); + } + + event = event_fixtures.message_edit_typing__stop; + { + const stub = make_stub(); + override(typing_events, "hide_message_edit_notification", stub.f); + dispatch(event); + assert.equal(stub.num_calls, 1); + const args = stub.get_args("event"); + assert_same(args.event.sender_id, typing_person1.user_id); + assert_same(args.event.message_id, event.message_id); + } + + // Get line coverage--we ignore our own typing events. + override(current_user, "user_id", typing_person1.user_id); + event = event_fixtures.message_edit_typing__start; + dispatch(event); + override(current_user, "user_id", undefined); +}); + +run_test("stream_typing_message_edit", ({override}) => { + const stream_typing_in_id = events.stream_typing_in_id; + + let event = event_fixtures.channel_typing_edit_message__start; + { + const stub = make_stub(); + override(typing_events, "display_message_edit_notification", stub.f); + dispatch(event); + assert.equal(stub.num_calls, 1); + const args = stub.get_args("event"); + assert_same(args.event.sender_id, typing_person1.user_id); + assert_same(args.event.recipient.type, "channel"); + assert_same(args.event.recipient.channel_id, stream_typing_in_id); + } + + event = event_fixtures.channel_typing_edit_message__stop; + { + const stub = make_stub(); + override(typing_events, "hide_message_edit_notification", stub.f); + dispatch(event); + assert.equal(stub.num_calls, 1); + const args = stub.get_args("event"); + assert_same(args.event.sender_id, typing_person1.user_id); + assert_same(args.event.recipient.type, "channel"); + assert_same(args.event.recipient.channel_id, stream_typing_in_id); + } +}); + run_test("user_settings", ({override}) => { settings_preferences.set_default_language_name = () => {}; let event = event_fixtures.user_settings__default_language; @@ -1362,6 +1422,12 @@ run_test("server_event_dispatch_op_errors", () => { sender: {user_id: 5}, op: "other", }); + blueslip.expect("error", "Unexpected event type typing_edit_message/other"); + server_events_dispatch.dispatch_normal_event({ + type: "typing_edit_message", + sender_id: 5, + op: "other", + }); blueslip.expect("error", "Unexpected event type user_group/other"); server_events_dispatch.dispatch_normal_event({type: "user_group", op: "other"}); }); diff --git a/web/tests/lib/events.cjs b/web/tests/lib/events.cjs index 3ef1c41e69..c14884149f 100644 --- a/web/tests/lib/events.cjs +++ b/web/tests/lib/events.cjs @@ -137,6 +137,30 @@ exports.fixtures = { upload_space_used: 90000, }, + channel_typing_edit_message__start: { + type: "typing_edit_message", + op: "start", + sender_id: typing_person1.user_id, + message_id: 128, + recipient: { + type: "channel", + channel_id: this.stream_typing_in_id, + topic: this.topic_typing_in, + }, + }, + + channel_typing_edit_message__stop: { + type: "typing_edit_message", + op: "stop", + sender_id: typing_person1.user_id, + message_id: 128, + recipient: { + type: "channel", + channel_id: this.stream_typing_in_id, + topic: this.topic_typing_in, + }, + }, + custom_profile_fields: { type: "custom_profile_fields", fields: [ @@ -187,6 +211,28 @@ exports.fixtures = { type: "invites_changed", }, + message_edit_typing__start: { + type: "typing_edit_message", + op: "start", + sender_id: typing_person1.user_id, + message_id: 128, + recipient: { + type: "direct", + user_ids: [typing_person2.user_id], + }, + }, + + message_edit_typing__stop: { + type: "typing_edit_message", + op: "stop", + sender_id: typing_person1.user_id, + message_id: 128, + recipient: { + type: "direct", + user_ids: [typing_person2.user_id], + }, + }, + muted_users: { type: "muted_users", muted_users: [ diff --git a/web/tests/typing_data.test.cjs b/web/tests/typing_data.test.cjs index 365ba4fd10..9b0928770a 100644 --- a/web/tests/typing_data.test.cjs +++ b/web/tests/typing_data.test.cjs @@ -24,6 +24,7 @@ test("basics", () => { const stream_id = 1; const topic = "typing notifications"; const topic_typing_key = typing_data.get_topic_key(stream_id, topic); + let status; typing_data.add_typist(typing_data.get_direct_message_conversation_key([5, 10, 15]), 15); assert.deepEqual(typing_data.get_group_typists([15, 10, 5]), [15]); @@ -31,6 +32,23 @@ test("basics", () => { typing_data.add_typist(topic_typing_key, 12); assert.deepEqual(typing_data.get_topic_typists(stream_id, topic), [12]); + // test that you can add a message_id to messages editing state + typing_data.add_edit_message_typing_id(3); + assert.deepEqual(typing_data.is_message_editing(3), true); + + typing_data.add_edit_message_typing_id(7); + assert.deepEqual(typing_data.is_message_editing(7), true); + + // test removing a message from editing state + status = typing_data.remove_edit_message_typing_id(3); + assert.deepEqual(status, true); + assert.deepEqual(typing_data.is_message_editing(3), false); + + // test removing message_id that doesn't exist from editing + assert.deepEqual(typing_data.is_message_editing(3), false); + status = typing_data.remove_edit_message_typing_id(3); + assert.deepEqual(status, false); + // test that you can add twice typing_data.add_typist(typing_data.get_direct_message_conversation_key([5, 10, 15]), 15); diff --git a/web/tests/typing_status.test.cjs b/web/tests/typing_status.test.cjs index d906e64af3..e3831b46d3 100644 --- a/web/tests/typing_status.test.cjs +++ b/web/tests/typing_status.test.cjs @@ -64,12 +64,20 @@ run_test("basics", ({override, override_rewire}) => { set_global("clearTimeout", clear_timeout); function notify_server_start(recipient) { - assert.deepStrictEqual(recipient, {message_type: "direct", ids: [1, 2]}); + assert.deepStrictEqual(recipient, { + message_type: "direct", + notification_event_type: "typing", + ids: [1, 2], + }); events.started = true; } function notify_server_stop(recipient) { - assert.deepStrictEqual(recipient, {message_type: "direct", ids: [1, 2]}); + assert.deepStrictEqual(recipient, { + message_type: "direct", + notification_event_type: "typing", + ids: [1, 2], + }); events.stopped = true; } @@ -97,11 +105,11 @@ run_test("basics", ({override, override_rewire}) => { }; // Start talking to users having ids - 1, 2. - call_handler({message_type: "direct", ids: [1, 2]}); + call_handler({message_type: "direct", notification_event_type: "typing", ids: [1, 2]}); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(5 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "direct", ids: [1, 2]}, + current_recipient: {message_type: "direct", notification_event_type: "typing", ids: [1, 2]}, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -113,11 +121,11 @@ run_test("basics", ({override, override_rewire}) => { // type again 3 seconds later worker.get_current_time = returns_time(8); - call_handler({message_type: "direct", ids: [1, 2]}); + call_handler({message_type: "direct", notification_event_type: "typing", ids: [1, 2]}); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(5 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "direct", ids: [1, 2]}, + current_recipient: {message_type: "direct", notification_event_type: "typing", ids: [1, 2]}, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -130,11 +138,11 @@ run_test("basics", ({override, override_rewire}) => { // type after 15 secs, so that we can notify the server // again worker.get_current_time = returns_time(18); - call_handler({message_type: "direct", ids: [1, 2]}); + call_handler({message_type: "direct", notification_event_type: "typing", ids: [1, 2]}); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(18 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "direct", ids: [1, 2]}, + current_recipient: {message_type: "direct", notification_event_type: "typing", ids: [1, 2]}, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -167,11 +175,11 @@ run_test("basics", ({override, override_rewire}) => { // Start talking to users again. worker.get_current_time = returns_time(50); - call_handler({message_type: "direct", ids: [1, 2]}); + call_handler({message_type: "direct", notification_event_type: "typing", ids: [1, 2]}); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(50 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "direct", ids: [1, 2]}, + current_recipient: {message_type: "direct", notification_event_type: "typing", ids: [1, 2]}, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -193,11 +201,11 @@ run_test("basics", ({override, override_rewire}) => { // Start talking to users again. worker.get_current_time = returns_time(80); - call_handler({message_type: "direct", ids: [1, 2]}); + call_handler({message_type: "direct", notification_event_type: "typing", ids: [1, 2]}); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(80 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "direct", ids: [1, 2]}, + current_recipient: {message_type: "direct", notification_event_type: "typing", ids: [1, 2]}, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -229,11 +237,11 @@ run_test("basics", ({override, override_rewire}) => { // Start talking to users again. worker.get_current_time = returns_time(170); - call_handler({message_type: "direct", ids: [1, 2]}); + call_handler({message_type: "direct", notification_event_type: "typing", ids: [1, 2]}); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(170 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "direct", ids: [1, 2]}, + current_recipient: {message_type: "direct", notification_event_type: "typing", ids: [1, 2]}, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -247,15 +255,19 @@ run_test("basics", ({override, override_rewire}) => { worker.get_current_time = returns_time(171); worker.notify_server_start = (recipient) => { - assert.deepStrictEqual(recipient, {message_type: "direct", ids: [3, 4]}); + assert.deepStrictEqual(recipient, { + message_type: "direct", + notification_event_type: "typing", + ids: [3, 4], + }); events.started = true; }; - call_handler({message_type: "direct", ids: [3, 4]}); + call_handler({message_type: "direct", notification_event_type: "typing", ids: [3, 4]}); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(171 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "direct", ids: [3, 4]}, + current_recipient: {message_type: "direct", notification_event_type: "typing", ids: [3, 4]}, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -367,12 +379,22 @@ run_test("stream_messages", ({override, override_rewire}) => { set_global("clearTimeout", clear_timeout); function notify_server_start(recipient) { - assert.deepStrictEqual(recipient, {message_type: "stream", stream_id: 3, topic: "test"}); + assert.deepStrictEqual(recipient, { + message_type: "stream", + notification_event_type: "typing", + stream_id: 3, + topic: "test", + }); events.started = true; } function notify_server_stop(recipient) { - assert.deepStrictEqual(recipient, {message_type: "stream", stream_id: 3, topic: "test"}); + assert.deepStrictEqual(recipient, { + message_type: "stream", + notification_event_type: "typing", + stream_id: 3, + topic: "test", + }); events.stopped = true; } @@ -400,11 +422,21 @@ run_test("stream_messages", ({override, override_rewire}) => { }; // Start typing stream message - call_handler({message_type: "stream", stream_id: 3, topic: "test"}); + call_handler({ + message_type: "stream", + notification_event_type: "typing", + stream_id: 3, + topic: "test", + }); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(5 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "stream", stream_id: 3, topic: "test"}, + current_recipient: { + message_type: "stream", + notification_event_type: "typing", + stream_id: 3, + topic: "test", + }, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -416,11 +448,21 @@ run_test("stream_messages", ({override, override_rewire}) => { // type again 3 seconds later. Covers 'same_stream_and_topic' codepath. worker.get_current_time = returns_time(8); - call_handler({message_type: "stream", stream_id: 3, topic: "test"}); + call_handler({ + message_type: "stream", + notification_event_type: "typing", + stream_id: 3, + topic: "test", + }); assert.deepEqual(typing_status.state, { next_send_start_time: make_time(5 + 10), idle_timer: "idle_timer_stub", - current_recipient: {message_type: "stream", stream_id: 3, topic: "test"}, + current_recipient: { + message_type: "stream", + notification_event_type: "typing", + stream_id: 3, + topic: "test", + }, }); assert.deepEqual(events, { idle_callback: events.idle_callback, @@ -440,3 +482,185 @@ run_test("stream_messages", ({override, override_rewire}) => { timer_cleared: true, }); }); + +run_test("edit_messages", ({override_rewire}) => { + override_rewire(typing_status, "state", null); + + let worker = {}; + const events = {}; + const message_id = 7; + + function set_timeout(f, delay) { + assert.equal(delay, 5000); + events.idle_callback = f; + return "idle_timer_stub"; + } + + function clear_timeout() { + events.timer_cleared = true; + } + + set_global("setTimeout", set_timeout); + set_global("clearTimeout", clear_timeout); + + function notify_server_editing_start(recipient) { + assert.deepStrictEqual(recipient, { + notification_event_type: "typing_message_edit", + message_id, + }); + events.started = true; + } + + function notify_server_editing_stop(recipient) { + assert.deepStrictEqual(recipient, { + notification_event_type: "typing_message_edit", + message_id, + }); + events.stopped = true; + } + + function clear_events() { + events.idle_callback = undefined; + events.started = false; + events.stopped = false; + events.timer_cleared = false; + } + + function call_handler_start(new_recipient) { + clear_events(); + typing_status.update_editing_status( + worker, + new_recipient, + "start", + TYPING_STARTED_WAIT_PERIOD, + TYPING_STOPPED_WAIT_PERIOD, + ); + } + + function call_handler_stop(new_recipient) { + clear_events(); + typing_status.update_editing_status( + worker, + new_recipient, + "stop", + TYPING_STARTED_WAIT_PERIOD, + TYPING_STOPPED_WAIT_PERIOD, + ); + } + + worker = { + get_current_time: returns_time(5), + notify_server_editing_start, + notify_server_editing_stop, + }; + + // Start typing stream message + call_handler_start({ + notification_event_type: "typing_message_edit", + message_id, + }); + assert.deepEqual(typing_status.editing_state.get(message_id), { + next_send_start_time: make_time(5 + 10), + idle_timer: "idle_timer_stub", + current_recipient: { + notification_event_type: "typing_message_edit", + message_id, + }, + }); + assert.deepEqual(events, { + idle_callback: events.idle_callback, + started: true, + stopped: false, + timer_cleared: false, + }); + assert.ok(events.idle_callback); + + worker.get_current_time = returns_time(8); + call_handler_start({ + notification_event_type: "typing_message_edit", + message_id, + }); + assert.deepEqual(typing_status.editing_state.get(message_id), { + next_send_start_time: make_time(5 + 10), + idle_timer: "idle_timer_stub", + current_recipient: { + notification_event_type: "typing_message_edit", + message_id, + }, + }); + assert.deepEqual(events, { + idle_callback: events.idle_callback, + started: false, + stopped: false, + timer_cleared: true, + }); + assert.ok(events.idle_callback); + + worker.get_current_time = returns_time(18); + call_handler_start({ + notification_event_type: "typing_message_edit", + message_id, + }); + assert.deepEqual(typing_status.editing_state.get(message_id), { + next_send_start_time: make_time(18 + 10), + idle_timer: "idle_timer_stub", + current_recipient: { + notification_event_type: "typing_message_edit", + message_id, + }, + }); + assert.deepEqual(events, { + idle_callback: events.idle_callback, + started: true, + stopped: false, + timer_cleared: true, + }); + assert.ok(events.idle_callback); + + // Now call recipients idle callback that we captured earlier. + const callback = events.idle_callback; + clear_events(); + callback(); + assert.deepEqual(typing_status.editing_state.get(message_id), undefined); + assert.deepEqual(events, { + idle_callback: undefined, + started: false, + stopped: true, + timer_cleared: true, + }); + + // Start editing message again. + worker.get_current_time = returns_time(50); + call_handler_start({ + notification_event_type: "typing_message_edit", + message_id, + }); + assert.deepEqual(typing_status.editing_state.get(message_id), { + next_send_start_time: make_time(50 + 10), + idle_timer: "idle_timer_stub", + current_recipient: { + notification_event_type: "typing_message_edit", + message_id, + }, + }); + assert.deepEqual(events, { + idle_callback: events.idle_callback, + started: true, + stopped: false, + timer_cleared: false, + }); + assert.ok(events.idle_callback); + + // Explicitly stop. + call_handler_stop({ + notification_event_type: "typing_message_edit", + message_id, + }); + assert.deepEqual(typing_status.editing_state.get(message_id), undefined); + assert.deepEqual(events, { + idle_callback: undefined, + started: false, + stopped: true, + timer_cleared: true, + }); +}); diff --git a/zerver/actions/typing.py b/zerver/actions/typing.py index 1f2d649436..45fc14e685 100644 --- a/zerver/actions/typing.py +++ b/zerver/actions/typing.py @@ -1,3 +1,5 @@ +from typing import Literal + from django.conf import settings from django.utils.translation import gettext as _ @@ -98,3 +100,74 @@ def do_send_stream_typing_notification( ) send_event_rollback_unsafe(sender.realm, event, user_ids_to_notify) + + +def do_send_stream_message_edit_typing_notification( + sender: UserProfile, + channel_id: int, + message_id: int, + operator: Literal["start", "stop"], + topic_name: str, +) -> None: + event = dict( + type="typing_edit_message", + op=operator, + sender_id=sender.id, + message_id=message_id, + recipient=dict( + type="channel", + channel_id=channel_id, + topic=topic_name, + ), + ) + + subscriptions_query = get_active_subscriptions_for_stream_id( + channel_id, include_deactivated_users=False + ) + + total_subscriptions = subscriptions_query.count() + if total_subscriptions > settings.MAX_STREAM_SIZE_FOR_TYPING_NOTIFICATIONS: + # TODO: Stream typing notifications are disabled in streams + # with too many subscribers for performance reasons. + return + + # We don't notify long_term_idle subscribers. + user_ids_to_notify = set( + subscriptions_query.exclude(user_profile__long_term_idle=True) + .exclude(user_profile__receives_typing_notifications=False) + .values_list("user_profile_id", flat=True) + ) + + send_event_rollback_unsafe(sender.realm, event, user_ids_to_notify) + + +def do_send_direct_message_edit_typing_notification( + sender: UserProfile, + user_ids: list[int], + message_id: int, + operator: Literal["start", "stop"], +) -> None: + recipient_user_profiles = [] + for user_id in user_ids: + user_profile = get_user_by_id_in_realm_including_cross_realm(user_id, sender.realm) + recipient_user_profiles.append(user_profile) + + # Only deliver the notification to active user recipients + user_ids_to_notify = [ + user.id + for user in recipient_user_profiles + if user.is_active and user.receives_typing_notifications + ] + + event = dict( + type="typing_edit_message", + op=operator, + sender_id=sender.id, + message_id=message_id, + recipient=dict( + type="direct", + user_ids=user_ids_to_notify, + ), + ) + + send_event_rollback_unsafe(sender.realm, event, user_ids_to_notify) diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 917720c06c..bb71d4b4ad 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -72,6 +72,10 @@ from zerver.lib.event_types import ( EventSubscriptionPeerRemove, EventSubscriptionRemove, EventSubscriptionUpdate, + EventTypingEditChannelMessageStart, + EventTypingEditChannelMessageStop, + EventTypingEditDirectMessageStart, + EventTypingEditDirectMessageStop, EventTypingStart, EventTypingStop, EventUpdateDisplaySettings, @@ -194,6 +198,10 @@ check_subscription_peer_remove = make_checker(EventSubscriptionPeerRemove) check_subscription_remove = make_checker(EventSubscriptionRemove) check_typing_start = make_checker(EventTypingStart) check_typing_stop = make_checker(EventTypingStop) +check_typing_edit_channel_message_start = make_checker(EventTypingEditChannelMessageStart) +check_typing_edit_direct_message_start = make_checker(EventTypingEditDirectMessageStart) +check_typing_edit_channel_message_stop = make_checker(EventTypingEditChannelMessageStop) +check_typing_edit_direct_message_stop = make_checker(EventTypingEditDirectMessageStop) check_update_message_flags_add = make_checker(EventUpdateMessageFlagsAdd) check_update_message_flags_remove = make_checker(EventUpdateMessageFlagsRemove) check_user_group_add = make_checker(EventUserGroupAdd) diff --git a/zerver/lib/event_types.py b/zerver/lib/event_types.py index 182b41c551..f4c580f259 100644 --- a/zerver/lib/event_types.py +++ b/zerver/lib/event_types.py @@ -908,6 +908,47 @@ class EventTypingStop(EventTypingStopCore): topic: str | None = None +class RecipientFieldForTypingEditChannelMessage(BaseModel): + type: Literal["channel"] + channel_id: int | None = None + topic: str | None = None + + +class RecipientFieldForTypingEditDirectMessage(BaseModel): + type: Literal["direct"] + user_ids: list[int] | None = None + + +class EventTypingEditMessageStartCore(BaseEvent): + type: Literal["typing_edit_message"] + op: Literal["start"] + sender_id: int + message_id: int + + +class EventTypingEditChannelMessageStart(EventTypingEditMessageStartCore): + recipient: RecipientFieldForTypingEditChannelMessage + + +class EventTypingEditDirectMessageStart(EventTypingEditMessageStartCore): + recipient: RecipientFieldForTypingEditDirectMessage + + +class EventTypingEditMessageStopCore(BaseEvent): + type: Literal["typing_edit_message"] + op: Literal["stop"] + sender_id: int + message_id: int + + +class EventTypingEditChannelMessageStop(EventTypingEditMessageStopCore): + recipient: RecipientFieldForTypingEditChannelMessage + + +class EventTypingEditDirectMessageStop(EventTypingEditMessageStopCore): + recipient: RecipientFieldForTypingEditDirectMessage + + class EventUpdateDisplaySettingsCore(BaseEvent): type: Literal["update_display_settings"] setting_name: str diff --git a/zerver/lib/events.py b/zerver/lib/events.py index edd33e0684..6682fedce2 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -1535,6 +1535,9 @@ def apply_event( elif event["type"] == "typing": # Typing notification events are transient and thus ignored pass + elif event["type"] == "typing_edit_message": + # Typing message edit notification events are transient and thus ignored + pass elif event["type"] == "attachment": # Attachment events are just for updating the "uploads" UI; # they are not sent directly. diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index 0f87cdfec0..712b1c585f 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -1614,6 +1614,45 @@ def set_typing_status(client: Client) -> None: validate_against_openapi_schema(result, "/typing", "post", "200") +@openapi_test_function("/message_edit_typing:post") +def set_message_edit_typing_status(client: Client) -> None: + message = {"type": "stream", "to": "Verona", "topic": "test_topic", "content": "test content"} + response = client.send_message(message) + message_id = response["id"] + # {code_example|start} + # The user has started typing while editing a message + request = { + "op": "start", + "message_id": message_id, + } + result = client.call_endpoint( + "message_edit_typing", + method="POST", + request=request, + ) + # {code_example|end} + assert_success_response(result) + validate_against_openapi_schema(result, "/message_edit_typing", "post", "200") + + message = {"type": "stream", "to": "Verona", "topic": "test_topic", "content": "test content"} + response = client.send_message(message) + message_id = response["id"] + # {code_example|start} + # The user has stopped typing while editing a message. + request = { + "op": "stop", + "message_id": message_id, + } + result = client.call_endpoint( + "message_edit_typing", + method="POST", + request=request, + ) + # {code_example|end} + assert_success_response(result) + validate_against_openapi_schema(result, "/message_edit_typing", "post", "200") + + @openapi_test_function("/realm/emoji/{emoji_name}:post") def upload_custom_emoji(client: Client) -> None: emoji_path = os.path.join(ZULIP_DIR, "zerver", "tests", "images", "img.jpg") @@ -1761,6 +1800,7 @@ def test_invalid_stream_error(client: Client) -> None: def test_messages(client: Client, nonadmin_client: Client) -> None: render_message(client) message_id = send_message(client) + set_message_edit_typing_status(client) add_reaction(client, message_id) remove_reaction(client, message_id) update_message(client, message_id) diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 8ce80c3c07..78b38076c1 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -2942,6 +2942,163 @@ paths: ], "id": 0, } + - type: object + additionalProperties: false + description: | + Event sent when a user starts editing a message. + Event sent when a user starts typing in a textarea to edit the + content of a message. See the [edit message typing notifications + endpoint](/api/set-typing-status-for-message-edit). + + Clients requesting `typing_edit_message` event type that have + `receives_typing_notifications` enabled will receive this event if + they would have been notified if the message's content edit were to + be saved (E.g., because they were a direct message recipient or + are a subscribe to the channel). + + **Changes**: New in Zulip 10.0 (feature level 351). Previously, + typing notifications were not available when editing messages. + properties: + id: + $ref: "#/components/schemas/EventIdSchema" + type: + allOf: + - $ref: "#/components/schemas/EventTypeSchema" + - enum: + - typing_edit_message + op: + type: string + enum: + - start + sender_id: + type: integer + description: | + The ID of the user who sent the message. + message_id: + type: integer + description: | + Indicates the message id of the message that is being edited. + recipient: + type: object + description: | + Object containing details about recipients of message edit typing notification. + additionalProperties: false + properties: + type: + type: string + description: | + Type of message being composed. Must be `"channel"` or `"direct"`. + enum: + - direct + - channel + channel_id: + type: integer + description: | + Only present if `type` is `"channel"`. + + The unique ID of the channel to which message is being edited. + topic: + type: string + description: | + Only present if `type` is `"channel"`. + + Topic within the channel where the message is being edited. + user_ids: + type: array + items: + type: integer + description: | + Present only if `type` is `direct`. + + The user IDs of every recipient of this direct message. + example: + { + "type": "typing_edit_message", + "op": "start", + "sender_id": 10, + "recipient": + {"type": "direct", "user_ids": [8, 10]}, + "message_id": 7, + "id": 0, + } + - type: object + additionalProperties: false + description: | + Event sent when a user stops typing in a textarea to edit the + content of a message. See the [edit message typing notifications + endpoint](/api/set-typing-status-for-message-edit). + + Clients requesting `typing_edit_message` event type that have + `receives_typing_notifications` enabled will receive this event if + they would have been notified if the message's content edit were to + be saved (E.g., because they were a direct message recipient or + are a subscribe to the channel). + + **Changes**: New in Zulip 10.0 (feature level 351). Previously, + typing notifications were not available when editing messages. + properties: + id: + $ref: "#/components/schemas/EventIdSchema" + type: + allOf: + - $ref: "#/components/schemas/EventTypeSchema" + - enum: + - typing_edit_message + op: + type: string + enum: + - stop + sender_id: + type: integer + description: | + The ID of the user who sent the message. + message_id: + type: integer + description: | + Indicates the message id of the message that is being edited. + recipient: + type: object + description: | + Object containing details about recipients of message edit typing notification. + additionalProperties: false + properties: + type: + type: string + description: | + Type of message being composed. Must be `"channel"` or `"direct"`. + enum: + - direct + - channel + channel_id: + type: integer + description: | + Only present if `type` is `"channel"`. + + The unique ID of the channel to which message is being edited. + topic: + type: string + description: | + Only present if `type` is `"channel"`. + + Topic within the channel where the message is being edited. + user_ids: + type: array + items: + type: integer + description: | + Present only if `type` is `direct`. + + The user IDs of every recipient of this direct message. + example: + { + "type": "typing_edit_message", + "op": "stop", + "sender_id": 10, + "message_id": 31, + "recipient": + {"type": "direct", "user_ids": [8, 10]}, + "id": 0, + } - type: object additionalProperties: false description: | @@ -21002,6 +21159,45 @@ paths: description: | An example JSON error response when the user composes a channel message and `stream_id` is not specified: + /message_edit_typing: + post: + operationId: set-typing-status-for-message-edit + summary: Set "typing" status for message editing + tags: ["users"] + description: | + Notify other users whether the current user is editing a message. + + Typing notifications for editing messages follow the same protocol as + [set-typing-status](/api/set-typing-status), see that endpoint for details. + + **Changes**: New in Zulip 10.0 (feature level 351). Previously, + typing notifications were not available when editing messages. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + op: + description: | + Whether the user has started (`"start"`) or stopped (`"stop"`) editing. + type: string + enum: + - start + - stop + example: start + message_id: + description: | + Describes the message id of the message being edited. + type: integer + example: 47 + required: + - op + - message_id + responses: + "200": + $ref: "#/components/responses/SimpleSuccess" /user_groups/create: post: operationId: create-user-group diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index b143916e37..d8eb8e34ef 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -108,7 +108,12 @@ from zerver.actions.streams import ( do_rename_stream, ) from zerver.actions.submessage import do_add_submessage -from zerver.actions.typing import check_send_typing_notification, do_send_stream_typing_notification +from zerver.actions.typing import ( + check_send_typing_notification, + do_send_direct_message_edit_typing_notification, + do_send_stream_message_edit_typing_notification, + do_send_stream_typing_notification, +) from zerver.actions.user_groups import ( add_subgroups_to_user_group, bulk_add_members_to_user_groups, @@ -192,6 +197,10 @@ from zerver.lib.event_schema import ( check_subscription_peer_remove, check_subscription_remove, check_subscription_update, + check_typing_edit_channel_message_start, + check_typing_edit_channel_message_stop, + check_typing_edit_direct_message_start, + check_typing_edit_direct_message_stop, check_typing_start, check_typing_stop, check_update_display_settings, @@ -1444,6 +1453,44 @@ class NormalActionsTest(BaseAction): ) self.assertEqual(events, []) + def test_edit_direct_message_typing_events(self) -> None: + msg_id = self.send_personal_message(self.user_profile, self.example_user("cordelia")) + with self.verify_action(state_change_expected=False) as events: + do_send_direct_message_edit_typing_notification( + self.user_profile, + [self.example_user("cordelia").id, self.user_profile.id], + msg_id, + "start", + ) + check_typing_edit_direct_message_start("events[0]", events[0]) + + with self.verify_action(state_change_expected=False) as events: + do_send_direct_message_edit_typing_notification( + self.user_profile, + [self.example_user("cordelia").id, self.user_profile.id], + msg_id, + "stop", + ) + check_typing_edit_direct_message_stop("events[0]", events[0]) + + def test_stream_edit_message_typing_events(self) -> None: + channel = get_stream("Denmark", self.user_profile.realm) + msg_id = self.send_stream_message( + self.user_profile, channel.name, topic_name="editing", content="before edit" + ) + topic_name = "editing" + with self.verify_action(state_change_expected=False) as events: + do_send_stream_message_edit_typing_notification( + self.user_profile, channel.id, msg_id, "start", topic_name + ) + check_typing_edit_channel_message_start("events[0]", events[0]) + + with self.verify_action(state_change_expected=False) as events: + do_send_stream_message_edit_typing_notification( + self.user_profile, channel.id, msg_id, "stop", topic_name + ) + check_typing_edit_channel_message_stop("events[0]", events[0]) + 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 b7cae1ecde..0007389fe5 100644 --- a/zerver/tests/test_typing.py +++ b/zerver/tests/test_typing.py @@ -37,6 +37,16 @@ class TypingValidateOperatorTest(ZulipTestCase): ) self.assert_json_error(result, "Invalid op") + def test_invalid_parameter_message_edit(self) -> None: + sender = self.example_user("hamlet") + + params = dict( + op="foo", + message_id=7, + ) + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + self.assert_json_error(result, "Invalid op") + class TypingMessagetypeTest(ZulipTestCase): def test_invalid_type(self) -> None: @@ -93,8 +103,23 @@ class TypingValidateToArgumentsTest(ZulipTestCase): result = self.api_post(sender, "/api/v1/typing", {"op": "start", "to": invalid}) self.assert_json_error(result, "Invalid user ID 9999999") + def test_message_edit_typing_notifications_for_other_user_message(self) -> None: + sender = self.example_user("hamlet") + msg_id = self.send_stream_message( + self.example_user("iago"), "Denmark", topic_name="editing", content="before edit" + ) + result = self.api_post( + sender, + "/api/v1/message_edit_typing", + { + "op": "start", + "message_id": str(msg_id), + }, + ) + self.assert_json_error(result, "You don't have permission to edit this message") -class TypingValidateStreamIdTopicArgumentsTest(ZulipTestCase): + +class TypingValidateStreamIdTopicMessageIdArgumentsTest(ZulipTestCase): def test_missing_stream_id(self) -> None: """ Sending stream typing notifications without 'stream_id' fails. @@ -486,6 +511,31 @@ class TypingHappyPathTestStreams(ZulipTestCase): self.assert_json_success(result) self.assert_length(events, 0) + def test_max_stream_size_for_typing_notifications_setting_message_edit(self) -> None: + sender = self.example_user("hamlet") + channel_name = self.get_streams(sender)[0] + msg_id = self.send_stream_message( + self.example_user("hamlet"), channel_name, topic_name="editing", content="before edit" + ) + + for name in ["aaron", "iago", "cordelia", "prospero", "othello", "polonius"]: + user = self.example_user(name) + self.subscribe(user, channel_name) + + params = dict( + op="start", + message_id=msg_id, + ) + + with self.settings(MAX_STREAM_SIZE_FOR_TYPING_NOTIFICATIONS=5): + with ( + self.assert_database_query_count(5), + self.capture_send_event_calls(expected_num_events=0) as events, + ): + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + self.assert_json_success(result) + self.assert_length(events, 0) + def test_notify_not_long_term_idle_subscribers_only(self) -> None: sender = self.example_user("hamlet") stream_name = self.get_streams(sender)[0] @@ -636,3 +686,159 @@ class TestSendTypingNotificationsSettings(ZulipTestCase): # notifications. self.assertNotIn(aaron.id, event_user_ids) self.assertIn(iago.id, event_user_ids) + + def test_send_stream_message_edit_typing_notifications_setting(self) -> None: + sender = self.example_user("hamlet") + aaron = self.example_user("aaron") + iago = self.example_user("iago") + channel_name = self.get_streams(sender)[0] + + for user in [aaron, iago]: + self.subscribe(user, channel_name) + + msg_id = self.send_stream_message( + sender, channel_name, topic_name="editing", content="before edit" + ) + expected_user_ids = self.not_long_term_idle_subscriber_ids(channel_name, sender.realm) + + params = dict(op="start", message_id=msg_id) + + # Test typing events sent when `send_stream_typing_notifications` set to `True`. + self.assertTrue(sender.send_stream_typing_notifications) + + with self.capture_send_event_calls(expected_num_events=1) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + self.assert_json_success(result) + self.assert_length(events, 1) + self.assertEqual(orjson.loads(result.content)["msg"], "") + event_user_ids = set(events[0]["users"]) + self.assertEqual(expected_user_ids, event_user_ids) + + sender.send_stream_typing_notifications = False + sender.save() + + # No events should be sent now + with self.capture_send_event_calls(expected_num_events=0) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + self.assert_json_error( + result, "User has disabled typing notifications for channel messages" + ) + self.assertEqual(events, []) + + sender.send_stream_typing_notifications = True + sender.save() + + aaron.receives_typing_notifications = False + aaron.save() + + with self.capture_send_event_calls(expected_num_events=1) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + self.assert_json_success(result) + self.assert_length(events, 1) + + event_user_ids = set(events[0]["users"]) + + # Only users who have typing notifications enabled would receive + # notifications. + self.assertNotIn(aaron.id, event_user_ids) + self.assertIn(iago.id, event_user_ids) + + def test_send_direct_message_edit_typing_notifications_setting(self) -> None: + sender = self.example_user("hamlet") + recipient_user = self.example_user("othello") + msg_id = self.send_personal_message(sender, recipient_user) + expected_recipient_ids = {sender.id, recipient_user.id} + + params = dict( + op="start", + message_id=msg_id, + ) + + # Test typing events sent when `send_private_typing_notifications` set to `True`. + self.assertTrue(sender.send_private_typing_notifications) + + with self.capture_send_event_calls(expected_num_events=1) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + + self.assert_json_success(result) + self.assert_length(events, 1) + event_user_ids = set(events[0]["users"]) + self.assertEqual(expected_recipient_ids, event_user_ids) + self.assertEqual(orjson.loads(result.content)["msg"], "") + + sender.send_private_typing_notifications = False + sender.save() + + # No events should be sent now + with self.capture_send_event_calls(expected_num_events=0) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + + self.assert_json_error(result, "User has disabled typing notifications for direct messages") + self.assertEqual(events, []) + + sender.send_private_typing_notifications = True + sender.save() + + recipient_user.receives_typing_notifications = False + recipient_user.save() + + with self.capture_send_event_calls(expected_num_events=1) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + self.assert_json_success(result) + self.assert_length(events, 1) + + event_user_ids = set(events[0]["users"]) + + # Only users who have typing notifications enabled would receive + # notifications. + self.assertNotIn(recipient_user.id, event_user_ids) + + def test_group_personal_message_edit_typing_notifications_setting(self) -> None: + hamlet = self.example_user("hamlet") + cordelia = self.example_user("cordelia") + iago = self.example_user("iago") + users = [hamlet, cordelia, iago] + sender = users[0] + msg_id = self.send_group_direct_message(sender, users, "test content") + expected_recipient_ids = {user.id for user in users} + + params = dict( + op="start", + message_id=str(msg_id), + ) + # Test typing events sent when `send_private_typing_notifications` set to `True`. + self.assertTrue(sender.send_private_typing_notifications) + with self.capture_send_event_calls(expected_num_events=1) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + + self.assert_json_success(result) + self.assert_length(events, 1) + event_user_ids = set(events[0]["users"]) + self.assertEqual(expected_recipient_ids, event_user_ids) + self.assertEqual(orjson.loads(result.content)["msg"], "") + + sender.send_private_typing_notifications = False + sender.save() + + # No events should be sent now + with self.capture_send_event_calls(expected_num_events=0) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + + self.assert_json_error(result, "User has disabled typing notifications for direct messages") + self.assertEqual(events, []) + + sender.send_private_typing_notifications = True + sender.save() + + iago.receives_typing_notifications = False + iago.save() + # Only users who have typing notifications enabled should receive + # notifications. + expected_recipient_ids = {hamlet.id, cordelia.id} + + with self.capture_send_event_calls(expected_num_events=1) as events: + result = self.api_post(sender, "/api/v1/message_edit_typing", params) + self.assert_json_success(result) + self.assert_length(events, 1) + event_user_ids = set(events[0]["users"]) + self.assertEqual(expected_recipient_ids, event_user_ids) diff --git a/zerver/views/typing.py b/zerver/views/typing.py index 94f59bf245..f46ebeb556 100644 --- a/zerver/views/typing.py +++ b/zerver/views/typing.py @@ -4,13 +4,21 @@ from django.http import HttpRequest, HttpResponse from django.utils.translation import gettext as _ from pydantic import Json -from zerver.actions.typing import check_send_typing_notification, do_send_stream_typing_notification +from zerver.actions.message_edit import validate_user_can_edit_message +from zerver.actions.typing import ( + check_send_typing_notification, + do_send_direct_message_edit_typing_notification, + do_send_stream_message_edit_typing_notification, + do_send_stream_typing_notification, +) from zerver.lib.exceptions import JsonableError +from zerver.lib.message import access_message from zerver.lib.response import json_success from zerver.lib.streams import access_stream_by_id_for_message, access_stream_for_send_message from zerver.lib.topic import maybe_rename_general_chat_to_empty_topic from zerver.lib.typed_endpoint import ApiParamConfig, OptionalTopic, typed_endpoint -from zerver.models import UserProfile +from zerver.models import Recipient, UserProfile +from zerver.models.recipients import get_direct_message_group_user_ids @typed_endpoint @@ -62,3 +70,41 @@ def send_notification_backend( check_send_typing_notification(user_profile, user_ids, operator) return json_success(request) + + +@typed_endpoint +def send_message_edit_notification_backend( + request: HttpRequest, + user_profile: UserProfile, + *, + operator: Annotated[Literal["start", "stop"], ApiParamConfig("op")], + message_id: Json[int], +) -> HttpResponse: + message = access_message(user_profile, message_id) + validate_user_can_edit_message(user_profile, message, edit_limit_buffer=0) + recipient = message.recipient + + if recipient.type == Recipient.STREAM: + if not user_profile.send_stream_typing_notifications: + raise JsonableError(_("User has disabled typing notifications for channel messages")) + + channel_id = recipient.type_id + topic = message.topic_name() + do_send_stream_message_edit_typing_notification( + user_profile, channel_id, message_id, operator, topic + ) + + else: + if not user_profile.send_private_typing_notifications: + raise JsonableError(_("User has disabled typing notifications for direct messages")) + + if recipient.type == Recipient.PERSONAL: + recipient_ids = [user_profile.id, recipient.type_id] + else: + recipient_ids = list(get_direct_message_group_user_ids(recipient)) + + do_send_direct_message_edit_typing_notification( + user_profile, recipient_ids, message_id, operator + ) + + return json_success(request) diff --git a/zproject/urls.py b/zproject/urls.py index a9d1b08c41..e80d79caf9 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -189,7 +189,7 @@ from zerver.views.streams import ( from zerver.views.submessage import process_submessage from zerver.views.thumbnail import backend_serve_thumbnail from zerver.views.tusd import handle_tusd_hook -from zerver.views.typing import send_notification_backend +from zerver.views.typing import send_message_edit_notification_backend, send_notification_backend from zerver.views.unsubscribe import email_unsubscribe from zerver.views.upload import ( serve_file_backend, @@ -398,6 +398,8 @@ v1_api_and_json_patterns = [ # typing -> zerver.views.typing # POST sends a typing notification event to recipients rest_path("typing", POST=send_notification_backend), + # POST sends a message edit typing notification + rest_path("message_edit_typing", POST=send_message_edit_notification_backend), # user_uploads -> zerver.views.upload rest_path("user_uploads", POST=upload_file_backend), rest_path(