Files
zulip/web/src/message_store.ts
Evy Kassirer d3ef72f1d6 message: Remove unused recipient_id.
I confirmed this isn't used in any .hbs templates, so
it doesn't seem to be being used anywhere.
2025-09-09 09:20:40 -07:00

356 lines
12 KiB
TypeScript

import _ from "lodash";
import * as z from "zod/mini";
import * as blueslip from "./blueslip.ts";
import * as people from "./people.ts";
import {topic_link_schema} from "./types.ts";
import type {UserStatusEmojiInfo} from "./user_status.ts";
import * as util from "./util.ts";
const stored_messages = new Map<number, Message>();
const matched_message_schema = z.object({
match_content: z.optional(z.string()),
match_subject: z.optional(z.string()),
});
export type MatchedMessage = z.infer<typeof matched_message_schema>;
const message_reaction_type_schema = z.enum(["unicode_emoji", "realm_emoji", "zulip_extra_emoji"]);
export type MessageReactionType = z.infer<typeof message_reaction_type_schema>;
const display_recipient_user_schema = z.object({
email: z.string(),
full_name: z.string(),
id: z.number(),
});
export type DisplayRecipientUser = z.infer<typeof display_recipient_user_schema>;
const display_recipient_schema = z.union([z.string(), z.array(display_recipient_user_schema)]);
export type DisplayRecipient = z.infer<typeof display_recipient_schema>;
const message_edit_history_entry_schema = z.object({
user_id: z.nullable(z.number()),
timestamp: z.number(),
prev_content: z.optional(z.string()),
prev_rendered_content: z.optional(z.string()),
prev_stream: z.optional(z.number()),
prev_topic: z.optional(z.string()),
stream: z.optional(z.number()),
topic: z.optional(z.string()),
});
export type MessageEditHistoryEntry = z.infer<typeof message_edit_history_entry_schema>;
const message_reaction_schema = z.object({
emoji_name: z.string(),
emoji_code: z.string(),
reaction_type: message_reaction_type_schema,
user_id: z.number(),
});
export type MessageReaction = z.infer<typeof message_reaction_schema>;
export const submessage_schema = z.object({
id: z.number(),
sender_id: z.number(),
message_id: z.number(),
content: z.string(),
msg_type: z.string(),
});
export const raw_message_schema = z.intersection(
z.intersection(
z.object({
avatar_url: z.nullable(z.string()),
client: z.string(),
content: z.string(),
content_type: z.literal("text/html"),
display_recipient: display_recipient_schema,
edit_history: z.optional(z.array(message_edit_history_entry_schema)),
id: z.number(),
is_me_message: z.boolean(),
last_edit_timestamp: z.optional(z.number()),
last_moved_timestamp: z.optional(z.number()),
reactions: z.array(message_reaction_schema),
sender_email: z.string(),
sender_full_name: z.string(),
sender_id: z.number(),
sender_realm_str: z.string(),
submessages: z.array(submessage_schema),
timestamp: z.number(),
flags: z.array(z.string()),
}),
z.discriminatedUnion("type", [
z.object({
type: z.literal("private"),
topic_links: z.optional(z.array(z.undefined())),
}),
z.object({
type: z.literal("stream"),
stream_id: z.number(),
// Messages that come from the server use `subject`.
// Messages that come from `send_message` use `topic`.
subject: z.optional(z.string()),
topic: z.optional(z.string()),
topic_links: z.array(topic_link_schema),
}),
]),
),
matched_message_schema,
);
export type RawMessage = z.infer<typeof raw_message_schema>;
// We add these boolean properties to Raw message in
// `message_store.convert_raw_message_to_message_with_booleans` method.
export type MessageWithBooleans = (
| Omit<RawMessage & {type: "private"}, "flags">
| Omit<RawMessage & {type: "stream"}, "flags">
) & {
unread: boolean;
historical: boolean;
starred: boolean;
mentioned: boolean;
mentioned_me_directly: boolean;
stream_wildcard_mentioned: boolean;
topic_wildcard_mentioned: boolean;
collapsed: boolean;
condensed?: boolean;
alerted: boolean;
};
export type MessageCleanReaction = {
class: string;
count: number;
emoji_alt_code: boolean;
emoji_code: string;
emoji_name: string;
is_realm_emoji: boolean;
label: string;
local_id: string;
reaction_type: "zulip_extra_emoji" | "realm_emoji" | "unicode_emoji";
user_ids: number[];
vote_text: string;
};
export type Message = (
| Omit<MessageWithBooleans & {type: "private"}, "reactions">
| Omit<MessageWithBooleans & {type: "stream"}, "reactions" | "subject">
) & {
clean_reactions: Map<string, MessageCleanReaction>;
// Local echo state cluster of fields.
locally_echoed?: boolean;
failed_request?: boolean;
show_slow_send_spinner?: boolean;
resend?: boolean;
local_id?: string;
// The original markup for the message, which we'll have if we
// sent it or if we fetched it (usually, because the current user
// tried to edit the message).
raw_content?: string | undefined;
// Added in `message_helper.process_new_message`.
sent_by_me: boolean;
reply_to: string;
// These properties are set and used in `message_list_view.ts`.
// TODO: It would be nice if we could not store these on the message
// object and only reference them within `message_list_view`.
message_reactions?: MessageCleanReaction[];
url?: string;
// Used in `markdown.js`, `server_events.js`, and
// `convert_raw_message_to_message_with_booleans`
flags?: string[];
small_avatar_url?: string | null; // Used in `message_avatar.hbs`
status_emoji_info?: UserStatusEmojiInfo | undefined; // Used in `message_body.hbs`
local_edit_timestamp?: number; // Used for edited messages
notification_sent?: boolean; // Used in message_notifications
} & (
| {
type: "private";
is_private: true;
is_stream: false;
pm_with_url: string;
to_user_ids: string;
display_reply_to: string;
}
| {
type: "stream";
is_private: false;
is_stream: true;
stream: string;
topic: string;
display_reply_to: undefined;
}
);
export function update_message_cache(message: Message): void {
// You should only call this from message_helper (or in tests).
stored_messages.set(message.id, message);
}
export function get_cached_message(message_id: number): Message | undefined {
// You should only call this from message_helper.
// Use the get() wrapper below for most other use cases.
return stored_messages.get(message_id);
}
export function clear_for_testing(): void {
stored_messages.clear();
}
export function get(message_id: number): Message | undefined {
return stored_messages.get(message_id);
}
export function get_pm_emails(message: Message | MessageWithBooleans): string {
const user_ids = people.pm_with_user_ids(message) ?? [];
const emails = user_ids
.map((user_id) => {
const person = people.maybe_get_user_by_id(user_id);
if (!person) {
blueslip.error("Unknown user id", {user_id});
return "?";
}
return person.email;
})
.sort();
return emails.join(", ");
}
export function get_pm_full_names(user_ids: number[]): string {
user_ids = people.sorted_other_user_ids(user_ids);
const names = people.get_display_full_names(user_ids);
const sorted_names = names.sort(util.make_strcmp());
return sorted_names.join(", ");
}
export function convert_raw_message_to_message_with_booleans(
message: RawMessage,
): MessageWithBooleans {
const flags = message.flags ?? [];
function convert_flag(flag_name: string): boolean {
return flags.includes(flag_name);
}
const converted_flags = {
unread: !convert_flag("read"),
historical: convert_flag("historical"),
starred: convert_flag("starred"),
mentioned:
convert_flag("mentioned") ||
convert_flag("stream_wildcard_mentioned") ||
convert_flag("topic_wildcard_mentioned"),
mentioned_me_directly: convert_flag("mentioned"),
stream_wildcard_mentioned: convert_flag("stream_wildcard_mentioned"),
topic_wildcard_mentioned: convert_flag("topic_wildcard_mentioned"),
collapsed: convert_flag("collapsed"),
alerted: convert_flag("has_alert_word"),
};
// Once we have set boolean flags here, the `flags` attribute is
// just a distraction, so we delete it. (All the downstream code
// uses booleans.)
// We have to return these separately because of how the `MessageWithBooleans`
// type is set up.
if (message.type === "private") {
return {
..._.omit(message, "flags"),
...converted_flags,
};
}
return {
..._.omit(message, "flags"),
...converted_flags,
};
}
export function update_booleans(message: Message, flags: string[]): void {
// When we get server flags for local echo or message edits,
// we are vulnerable to race conditions, so only update flags
// that are driven by message content.
function convert_flag(flag_name: string): boolean {
return flags.includes(flag_name);
}
message.mentioned =
convert_flag("mentioned") ||
convert_flag("stream_wildcard_mentioned") ||
convert_flag("topic_wildcard_mentioned");
message.mentioned_me_directly = convert_flag("mentioned");
message.stream_wildcard_mentioned = convert_flag("stream_wildcard_mentioned");
message.topic_wildcard_mentioned = convert_flag("topic_wildcard_mentioned");
message.alerted = convert_flag("has_alert_word");
}
export function update_sender_full_name(user_id: number, new_name: string): void {
for (const msg of stored_messages.values()) {
if (msg.sender_id && msg.sender_id === user_id) {
msg.sender_full_name = new_name;
}
}
}
export function update_small_avatar_url(user_id: number, new_url: string | null): void {
for (const msg of stored_messages.values()) {
if (msg.sender_id && msg.sender_id === user_id) {
msg.small_avatar_url = new_url;
}
}
}
export function update_stream_name(stream_id: number, new_name: string): void {
for (const msg of stored_messages.values()) {
if (msg.type === "stream" && msg.stream_id === stream_id) {
msg.display_recipient = new_name;
}
}
}
export function update_status_emoji_info(
user_id: number,
new_info: UserStatusEmojiInfo | undefined,
): void {
for (const msg of stored_messages.values()) {
if (msg.sender_id && msg.sender_id === user_id) {
msg.status_emoji_info = new_info;
}
}
}
export function reify_message_id({old_id, new_id}: {old_id: number; new_id: number}): void {
const message = stored_messages.get(old_id);
if (message !== undefined) {
message.id = new_id;
message.locally_echoed = false;
stored_messages.set(new_id, message);
stored_messages.delete(old_id);
}
}
export function remove(message_ids: number[]): void {
for (const message_id of message_ids) {
stored_messages.delete(message_id);
}
}
export function get_message_ids_in_stream(stream_id: number): number[] {
return [...stored_messages.values()]
.filter((message) => message.type === "stream" && message.stream_id === stream_id)
.map((message) => message.id);
}