mirror of
https://github.com/zulip/zulip.git
synced 2025-10-24 16:43:57 +00:00
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 <rohan.gudimetla07@gmail.com> Fixes #25719.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +52,7 @@ function same_recipient(a: Recipient | null, b: Recipient | null): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -48,12 +60,13 @@ function same_recipient(a: Recipient | null, b: Recipient | null): boolean {
|
||||
// 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<number, TypingStatusState>();
|
||||
|
||||
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<typeof setTimeout> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, number[]>();
|
||||
const edit_message_typing_ids = new Set<number>();
|
||||
const inbound_timer_dict = new Map<string, ReturnType<typeof setInterval> | 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 {
|
||||
|
||||
@@ -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<typeof typing_event_schema>;
|
||||
|
||||
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<typeof typing_edit_message_event_schema>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<div class="edit-notifications"></div>
|
||||
{{#if modified}}
|
||||
{{#if msg/local_edit_timestamp}}
|
||||
<div class="message_edit_notice">
|
||||
|
||||
5
web/templates/editing_notifications.hbs
Normal file
5
web/templates/editing_notifications.hbs
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="message-editing-animation">
|
||||
<span class="y-animated-dot"></span>
|
||||
<span class="y-animated-dot"></span>
|
||||
<span class="y-animated-dot"></span>
|
||||
</div>
|
||||
@@ -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"});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user