zulip/web/src/drafts.ts
Pratik Chanda 64a9f83473 compose_box: Fix order of group pm recipient pills on focus.
Earlier when compose box was opened or narrowed to pm, the recipient
pills were sorted according to user email strings instead of their
full names which was inconsistent with compose box placeholder text,
recipient row and message header.

This commit fixes the behaviour by introducing a `sort_email`
function to sort emails according to their full names and display
sorted pills.

Fixes: zulip#27375.

Co-authored-by: richardshaju <richardshaju66@gmail.com>
2024-08-27 13:21:16 -07:00

695 lines
22 KiB
TypeScript

import {subDays} from "date-fns";
import $ from "jquery";
import _ from "lodash";
import assert from "minimalistic-assert";
import * as tippy from "tippy.js";
import {z} from "zod";
import render_confirm_delete_all_drafts from "../templates/confirm_dialog/confirm_delete_all_drafts.hbs";
import * as blueslip from "./blueslip";
import * as compose_state from "./compose_state";
import * as confirm_dialog from "./confirm_dialog";
import {$t, $t_html} from "./i18n";
import {localstorage} from "./localstorage";
import * as markdown from "./markdown";
import * as narrow_state from "./narrow_state";
import * as people from "./people";
import * as stream_color from "./stream_color";
import * as stream_data from "./stream_data";
import * as sub_store from "./sub_store";
import * as timerender from "./timerender";
import * as ui_util from "./ui_util";
import * as util from "./util";
export function set_count(count: number): void {
const $drafts_li = $(".top_left_drafts");
ui_util.update_unread_count_in_dom($drafts_li, count);
}
function getTimestamp(): number {
return Date.now();
}
const CURRENT_DRAFT_VERSION = 1;
const draft_schema = z.intersection(
z.object({
content: z.string(),
updatedAt: z.number(),
is_sending_saving: z.boolean().default(false),
// `drafts_version` is 0 for drafts that aren't auto-restored
// and 1 for drafts created since that change, to avoid a flood
// of old drafts showing up when this feature was introduced.
drafts_version: z.number().default(0),
}),
z.discriminatedUnion("type", [
z.object({
type: z.literal("stream"),
topic: z.string(),
stream_id: z.number().optional(),
}),
z.object({
type: z.literal("private"),
reply_to: z.string(),
private_message_recipient: z.string(),
}),
]),
);
type LocalStorageDraft = z.infer<typeof draft_schema>;
// The id is added to the draft in format_drafts in drafts_overlay_ui.
// We should probably just include it in the draft object itself always?
type LocalStorageDraftWithId = LocalStorageDraft & {id: string};
const possibly_buggy_draft_schema = z.intersection(
z.object({
content: z.string(),
updatedAt: z.number(),
is_sending_saving: z.boolean().default(false),
drafts_version: z.number().default(0),
}),
z.discriminatedUnion("type", [
z.object({
type: z.literal("stream"),
topic: z.string().optional(),
stream_id: z.number().optional(),
stream: z.string().optional(),
}),
z.object({
type: z.literal("private"),
reply_to: z.string(),
private_message_recipient: z.string(),
}),
]),
);
const drafts_schema = z.record(z.string(), draft_schema);
const possibly_buggy_drafts_schema = z.record(z.string(), possibly_buggy_draft_schema);
export const draft_model = (function () {
// the key that the drafts are stored under.
const KEY = "drafts";
const ls = localstorage();
let fixed_buggy_drafts = false;
function get(): Record<string, LocalStorageDraft> {
let drafts = ls.get(KEY);
if (drafts === undefined) {
return {};
}
if (!fixed_buggy_drafts) {
fix_buggy_drafts();
drafts = ls.get(KEY);
}
return drafts_schema.parse(drafts);
}
function fix_buggy_drafts(): void {
const drafts = ls.get(KEY);
const parsed_drafts = possibly_buggy_drafts_schema.parse(drafts);
const valid_drafts: Record<string, LocalStorageDraft> = {};
for (const [draft_id, draft] of Object.entries(parsed_drafts)) {
if (draft.type !== "stream") {
valid_drafts[draft_id] = draft;
continue;
}
// draft.stream is deprecated but might still exist on old drafts
if (draft.stream !== undefined) {
const sub = stream_data.get_sub(draft.stream);
if (sub) {
draft.stream_id = sub.stream_id;
}
delete draft.stream;
}
// A one-time fix for buggy drafts that had their topics renamed to
// `undefined` when the topic was moved to another stream without
// changing the topic. The bug was introduced in
// 4c8079c49a81b08b29871f9f1625c6149f48b579 and fixed in
// aebdf6af8c6675fbd2792888d701d582c4a1110a; but servers running
// intermediate versions may have generated some bugged drafts with
// this invalid topic value.
//
// TODO/compatibility: This can be deleted once servers can no longer
// directly upgrade from Zulip 6.0beta1 and earlier development branch where the bug was present,
// since we expect bugged drafts will have either been run through
// this code or else been deleted after 30 (DRAFT_LIFETIME) days.
if (draft.topic === undefined) {
draft.topic = "";
}
valid_drafts[draft_id] = {
...draft,
topic: draft.topic,
};
}
ls.set(KEY, valid_drafts);
set_count(Object.keys(valid_drafts).length);
fixed_buggy_drafts = true;
}
function getDraft(id: string): LocalStorageDraft | false {
return get()[id] ?? false;
}
function getDraftCount(): number {
const drafts = get();
return Object.keys(drafts).length;
}
function save(drafts: Record<string, LocalStorageDraft>, update_count = true): void {
ls.set(KEY, drafts);
if (update_count) {
set_count(Object.keys(drafts).length);
update_compose_draft_count();
}
}
function addDraft(draft: LocalStorageDraft, update_count = true): string {
const drafts = get();
// use the base16 of the current time + a random string to reduce
// collisions to essentially zero.
const id = getTimestamp().toString(16) + "-" + Math.random().toString(16).split(/\./).pop();
drafts[id] = draft;
save(drafts, update_count);
return id;
}
function editDraft(id: string, draft: LocalStorageDraft): boolean {
const drafts = get();
let changed = false;
function check_if_equal(draft_a: LocalStorageDraft, draft_b: LocalStorageDraft): boolean {
return _.isEqual(_.omit(draft_a, ["updatedAt"]), _.omit(draft_b, ["updatedAt"]));
}
const old_draft = drafts[id];
if (old_draft !== undefined) {
changed = !check_if_equal(old_draft, draft);
drafts[id] = draft;
save(drafts);
}
return changed;
}
function deleteDraft(id: string): void {
const drafts = get();
// TODO(typescript) rework this to store the draft data in a map.
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete drafts[id];
save(drafts);
}
return {
get,
getDraft,
getDraftCount,
addDraft,
editDraft,
deleteDraft,
};
})();
export function update_compose_draft_count(): void {
const $count_container = $(".compose-drafts-count-container");
const $count_ele = $count_container.find(".compose-drafts-count");
if (!compose_state.has_full_recipient()) {
$count_ele.text("");
$count_container.hide();
return;
}
const compose_draft_count = Object.keys(filter_drafts_by_compose_box_and_recipient()).length;
if (compose_draft_count > 0) {
$count_ele.text(compose_draft_count > 99 ? "99+" : compose_draft_count);
$count_container.show();
} else {
$count_ele.text("");
$count_container.hide();
}
}
export function sync_count(): void {
const drafts = draft_model.get();
set_count(Object.keys(drafts).length);
}
export function delete_all_drafts(): void {
const drafts = draft_model.get();
for (const [id] of Object.entries(drafts)) {
draft_model.deleteDraft(id);
}
}
export function confirm_delete_all_drafts(): void {
const html_body = render_confirm_delete_all_drafts();
confirm_dialog.launch({
html_heading: $t_html({defaultMessage: "Delete all drafts"}),
html_body,
on_click: delete_all_drafts,
});
}
export function rename_stream_recipient(
old_stream_id: number,
old_topic: string,
new_stream_id: number,
new_topic: string,
): void {
for (const [draft_id, draft] of Object.entries(draft_model.get())) {
if (draft.type !== "stream" || draft.stream_id === undefined) {
continue;
}
if (
util.same_stream_and_topic(
{stream_id: draft.stream_id, topic: draft.topic},
{stream_id: old_stream_id, topic: old_topic},
)
) {
// If new_stream_id is undefined, that means the stream wasn't updated.
if (new_stream_id !== undefined) {
draft.stream_id = new_stream_id;
}
// If new_topic is undefined, that means the topic wasn't updated.
if (new_topic !== undefined) {
draft.topic = new_topic;
}
draft_model.editDraft(draft_id, draft);
}
}
}
export function snapshot_message(): LocalStorageDraft | undefined {
if (!compose_state.composing() || !compose_state.has_savable_message_content()) {
// If you aren't in the middle of composing the body of a
// message or the message is shorter than 2 characters long, don't try to snapshot.
return undefined;
}
// Save what we can.
const message = {
type: compose_state.get_message_type(),
content: compose_state.message_content(),
updatedAt: getTimestamp(),
};
if (message.type === "private") {
const recipient = compose_state.private_message_recipient();
return {
...message,
type: "private",
reply_to: recipient,
private_message_recipient: recipient,
is_sending_saving: false,
drafts_version: CURRENT_DRAFT_VERSION,
};
}
assert(message.type === "stream");
return {
...message,
type: "stream",
stream_id: compose_state.stream_id(),
topic: compose_state.topic(),
is_sending_saving: false,
drafts_version: CURRENT_DRAFT_VERSION,
};
}
type ComposeArguments =
| {
type: "stream";
stream_id: number | undefined;
topic: string;
content: string;
}
| {
type: "private";
private_message_recipient: string;
content: string;
};
export function restore_message(draft: LocalStorageDraft): ComposeArguments {
// This is kinda the inverse of snapshot_message, and
// we are essentially making a deep copy of the draft,
// being explicit about which fields we send to the compose
// system.
if (draft.type === "stream") {
return {
type: "stream",
stream_id: draft.stream_id,
topic: draft.topic,
content: draft.content,
};
}
const recipient_emails = draft.private_message_recipient
.split(",")
.filter((email) => people.is_valid_email_for_compose(email));
const sorted_recipient_emails = people.sort_emails_by_username(recipient_emails);
return {
type: "private",
private_message_recipient: sorted_recipient_emails.join(","),
content: draft.content,
};
}
function draft_notify(): void {
// Display a tooltip to notify the user about the saved draft.
const instance = util.the(
tippy.default(".top_left_drafts .unread_count", {
content: $t({defaultMessage: "Saved as draft"}),
arrow: true,
placement: "right",
}),
);
instance.show();
function remove_instance(): void {
instance.destroy();
}
setTimeout(remove_instance, 3000);
}
function maybe_notify(no_notify: boolean): void {
if (!no_notify) {
draft_notify();
}
}
export let compose_draft_id: string | undefined;
export function set_compose_draft_id(draft_id: string | undefined): void {
compose_draft_id = draft_id;
}
type UpdateDraftOptions = {
no_notify?: boolean;
update_count?: boolean;
is_sending_saving?: boolean;
};
export function update_draft(opts: UpdateDraftOptions = {}): string | undefined {
const draft_id = compose_draft_id;
const old_draft = draft_id === undefined ? undefined : draft_model.getDraft(draft_id);
const no_notify = opts.no_notify ?? false;
const draft = snapshot_message();
if (draft === undefined) {
// The user cleared the compose box, which means
// there is nothing to save here but delete the
// draft if exists.
if (draft_id) {
draft_model.deleteDraft(draft_id);
}
return undefined;
}
if (opts.is_sending_saving !== undefined) {
draft.is_sending_saving = opts.is_sending_saving;
} else {
draft.is_sending_saving = old_draft ? old_draft.is_sending_saving : false;
}
// Now that it's been updated, we consider it to be the most recent version.
draft.drafts_version = CURRENT_DRAFT_VERSION;
if (draft_id !== undefined) {
// We don't save multiple drafts of the same message;
// just update the existing draft.
const changed = draft_model.editDraft(draft_id, draft);
if (changed) {
maybe_notify(no_notify);
}
return draft_id;
}
// We have never saved a draft for this message, so add one.
const update_count = opts.update_count ?? true;
const new_draft_id = draft_model.addDraft(draft, update_count);
compose_draft_id = new_draft_id;
maybe_notify(no_notify);
return new_draft_id;
}
export const DRAFT_LIFETIME = 30;
export function current_recipient_data(): {
stream_name: string | undefined;
topic: string | undefined;
private_recipients: string | undefined;
} {
// Prioritize recipients from the compose box first. If the compose
// box isn't open, just return data from the current narrow.
if (!compose_state.composing()) {
const stream_name = narrow_state.stream_name();
return {
stream_name,
topic: narrow_state.topic(),
private_recipients: narrow_state.pm_emails_string(),
};
}
if (compose_state.get_message_type() === "stream") {
const stream_name = compose_state.stream_name();
return {
stream_name,
topic: compose_state.topic(),
private_recipients: undefined,
};
} else if (compose_state.get_message_type() === "private") {
return {
stream_name: undefined,
topic: undefined,
private_recipients: compose_state.private_message_recipient(),
};
}
return {
stream_name: undefined,
topic: undefined,
private_recipients: undefined,
};
}
export function filter_drafts_by_compose_box_and_recipient(
drafts = draft_model.get(),
): Record<string, LocalStorageDraft> {
const {stream_name, topic, private_recipients} = current_recipient_data();
const stream_id = stream_name ? stream_data.get_stream_id(stream_name) : undefined;
const narrow_drafts_ids = [];
for (const [id, draft] of Object.entries(drafts)) {
// Match by stream and topic.
if (
stream_id &&
topic &&
draft.type === "stream" &&
draft.topic &&
draft.stream_id !== undefined &&
util.same_recipient(
{type: "stream", stream_id: draft.stream_id, topic: draft.topic},
{type: "stream", stream_id, topic},
)
) {
narrow_drafts_ids.push(id);
}
// Match by only stream.
else if (draft.type === "stream" && stream_id && !topic && draft.stream_id === stream_id) {
narrow_drafts_ids.push(id);
}
// Match by direct message recipient.
else if (
draft.type === "private" &&
private_recipients &&
_.isEqual(
draft.private_message_recipient
.split(",")
.map((s) => s.trim())
.sort(),
private_recipients
.split(",")
.map((s) => s.trim())
.sort(),
)
) {
narrow_drafts_ids.push(id);
}
}
return _.pick(drafts, narrow_drafts_ids);
}
export function get_last_restorable_draft_based_on_compose_state():
| LocalStorageDraftWithId
| undefined {
const current_drafts = draft_model.get();
const drafts_map_for_compose_state = filter_drafts_by_compose_box_and_recipient(current_drafts);
const drafts_for_compose_state = Object.entries(drafts_map_for_compose_state).map(
([draft_id, draft]) => ({
...draft,
id: draft_id,
}),
);
return drafts_for_compose_state
.sort((draft_a, draft_b) => draft_a.updatedAt - draft_b.updatedAt)
.findLast((draft) => !draft.is_sending_saving && draft.drafts_version >= 1);
}
export function remove_old_drafts(): void {
const old_date = subDays(new Date(), DRAFT_LIFETIME).getTime();
const drafts = draft_model.get();
for (const [id, draft] of Object.entries(drafts)) {
if (draft.updatedAt !== undefined && draft.updatedAt < old_date) {
draft_model.deleteDraft(id);
}
}
}
type FormattedDraft =
| {
is_stream: true;
draft_id: string;
stream_name?: string | undefined;
recipient_bar_color: string;
stream_privacy_icon_color: string;
topic: string;
raw_content: string;
stream_id: number | undefined;
time_stamp: string;
invite_only: boolean;
is_web_public: boolean;
}
| {
is_stream: false;
draft_id: string;
recipients: string;
raw_content: string;
time_stamp: string;
};
export function format_draft(draft: LocalStorageDraftWithId): FormattedDraft | undefined {
const id = draft.id;
const time = new Date(draft.updatedAt);
let invite_only = false;
let is_web_public = false;
let time_stamp = timerender.render_now(time).time_str;
if (time_stamp === $t({defaultMessage: "Today"})) {
time_stamp = timerender.stringify_time(time);
}
let markdown_data;
try {
markdown_data = markdown.render(draft.content);
} catch (error) {
// In the unlikely event that there is syntax in the
// draft content which our Markdown processor is
// unable to process, we delete the draft, so that the
// drafts overlay can be opened without any errors.
// We also report the exception to the server so that
// the bug can be fixed.
draft_model.deleteDraft(id);
blueslip.error(
"Error in rendering draft.",
{
draft_content: draft.content,
},
error,
);
return undefined;
}
if (draft.type === "stream") {
let stream_name;
let sub;
if (draft.stream_id) {
sub = sub_store.get(draft.stream_id);
}
if (sub) {
stream_name = sub.name;
invite_only = sub.invite_only;
is_web_public = sub.is_web_public;
}
const draft_topic = draft.topic || compose_state.empty_topic_placeholder();
const draft_stream_color = stream_data.get_color(draft.stream_id);
return {
draft_id: draft.id,
is_stream: true,
stream_name,
recipient_bar_color: stream_color.get_recipient_bar_color(draft_stream_color),
stream_privacy_icon_color:
stream_color.get_stream_privacy_icon_color(draft_stream_color),
topic: draft_topic,
raw_content: draft.content,
stream_id: draft.stream_id,
time_stamp,
invite_only,
is_web_public,
...markdown_data,
};
}
const emails = util.extract_pm_recipients(draft.private_message_recipient);
const recipients = people.emails_to_full_names_string(emails);
return {
draft_id: draft.id,
is_stream: false,
recipients,
raw_content: draft.content,
time_stamp,
...markdown_data,
};
}
export function initialize(): void {
remove_old_drafts();
// It's possible that drafts will get still have
// `is_sending_saving` set to true if the page was
// refreshed in the middle of sending a message. We
// reset the field on page reload to ensure that drafts
// don't get stuck in that state.
for (const [draft_id, draft] of Object.entries(draft_model.get())) {
if (draft.is_sending_saving) {
draft.is_sending_saving = false;
draft_model.editDraft(draft_id, draft);
}
}
window.addEventListener("beforeunload", () => {
update_draft();
});
// Show exact time when draft was saved in UTC format.
tippy.delegate("body", {
target: ".drafts-list .recipient_row_date",
appendTo: () => document.body,
delay: [750, 20], // LONG_HOVER_DELAY
onShow(instance) {
const $time_elem = $(instance.reference);
const $row = $time_elem.closest(".overlay-message-row");
const draft_id = $row.attr("data-draft-id");
assert(typeof draft_id === "string");
const draft = draft_model.getDraft(draft_id);
if (draft) {
const time = new Date(draft.updatedAt);
instance.setContent(timerender.get_full_datetime_clarification(time));
}
},
onHidden(instance) {
instance.destroy();
},
});
}
export function initialize_ui(): void {
set_count(draft_model.getDraftCount());
}