diff --git a/tools/test-js-with-node b/tools/test-js-with-node index 5e5225068f..639e852353 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -196,6 +196,7 @@ EXEMPT_FILES = make_set( "web/src/resize.ts", "web/src/resize_handler.ts", "web/src/rows.ts", + "web/src/saved_snippets_ui.ts", "web/src/scheduled_messages.ts", "web/src/scheduled_messages_feed_ui.ts", "web/src/scheduled_messages_overlay_ui.js", diff --git a/web/src/dropdown_widget.ts b/web/src/dropdown_widget.ts index 8340f85852..5bed73d16f 100644 --- a/web/src/dropdown_widget.ts +++ b/web/src/dropdown_widget.ts @@ -145,7 +145,7 @@ export class DropdownWidget { `${this.widget_selector}, ${this.widget_wrapper_id}`, (e) => { if (e.key === "Enter") { - $(this.widget_selector).trigger("click"); + $(this.widget_selector)[0]?.click(); e.stopPropagation(); e.preventDefault(); } diff --git a/web/src/saved_snippets.ts b/web/src/saved_snippets.ts new file mode 100644 index 0000000000..4306150b1c --- /dev/null +++ b/web/src/saved_snippets.ts @@ -0,0 +1,61 @@ +import * as blueslip from "./blueslip"; +import type {Option} from "./dropdown_widget"; +import {$t} from "./i18n"; +import type {StateData} from "./state_data"; +import * as util from "./util"; + +export type SavedSnippet = { + id: number; + title: string; + content: string; + date_created: number; +}; + +export const ADD_SAVED_SNIPPET_OPTION_ID = -1; +let saved_snippets_dict: Map; + +export function get_saved_snippet_by_id(saved_snippet_id: number): SavedSnippet | undefined { + const saved_snippet = saved_snippets_dict.get(saved_snippet_id); + if (saved_snippet === undefined) { + blueslip.error("Could not find saved snippet", {saved_snippet_id}); + return undefined; + } + return saved_snippet; +} + +export function add_saved_snippet(saved_snippet: SavedSnippet): void { + saved_snippets_dict.set(saved_snippet.id, saved_snippet); +} + +export function remove_saved_snippet(saved_snippet_id: number): void { + saved_snippets_dict.delete(saved_snippet_id); +} + +export function get_options_for_dropdown_widget(): Option[] { + const saved_snippets = [...saved_snippets_dict.values()].sort((a, b) => + util.strcmp(a.title.toLowerCase(), b.title.toLowerCase()), + ); + const options = saved_snippets.map((saved_snippet) => ({ + unique_id: saved_snippet.id, + name: saved_snippet.title, + description: saved_snippet.content, + bold_current_selection: true, + has_delete_icon: true, + })); + + // Option for creating a new saved snippet. + options.unshift({ + unique_id: ADD_SAVED_SNIPPET_OPTION_ID, + name: $t({defaultMessage: "Add a new saved snippet"}), + description: "", + bold_current_selection: true, + has_delete_icon: false, + }); + return options; +} + +export const initialize = (params: StateData["saved_snippets"]): void => { + saved_snippets_dict = new Map( + params.saved_snippets.map((s) => [s.id, s]), + ); +}; diff --git a/web/src/saved_snippets_ui.ts b/web/src/saved_snippets_ui.ts new file mode 100644 index 0000000000..57845f706f --- /dev/null +++ b/web/src/saved_snippets_ui.ts @@ -0,0 +1,131 @@ +import $ from "jquery"; +import assert from "minimalistic-assert"; +import type * as tippy from "tippy.js"; + +import render_add_saved_snippet_modal from "../templates/add_saved_snippet_modal.hbs"; +import render_confirm_delete_saved_snippet from "../templates/confirm_dialog/confirm_delete_saved_snippet.hbs"; + +import * as channel from "./channel"; +import * as compose_ui from "./compose_ui"; +import * as confirm_dialog from "./confirm_dialog"; +import * as dialog_widget from "./dialog_widget"; +import * as dropdown_widget from "./dropdown_widget"; +import {$t_html} from "./i18n"; +import * as popover_menus from "./popover_menus"; +import * as saved_snippets from "./saved_snippets"; +import type {StateData} from "./state_data"; + +let saved_snippet_dropdown_widget: dropdown_widget.DropdownWidget; + +function submit_create_saved_snippet_form(): void { + const title = $("#add-new-saved-snippet-modal .saved-snippet-title") + .val() + ?.trim(); + const content = $("#add-new-saved-snippet-modal .saved-snippet-content") + .val() + ?.trim(); + if (title && content) { + dialog_widget.submit_api_request(channel.post, "/json/saved_snippets", {title, content}); + } +} + +function update_submit_button_state(): void { + const title = $("#add-new-saved-snippet-modal .saved-snippet-title") + .val() + ?.trim(); + const content = $("#add-new-saved-snippet-modal .saved-snippet-content") + .val() + ?.trim(); + const $submit_button = $("#add-new-saved-snippet-modal .dialog_submit_button"); + + $submit_button.prop("disabled", true); + if (title && content) { + $submit_button.prop("disabled", false); + } +} + +function saved_snippet_modal_post_render(): void { + $("#add-new-saved-snippet-modal").on("input", "input,textarea", update_submit_button_state); +} + +export function rerender_dropdown_widget(): void { + const options = saved_snippets.get_options_for_dropdown_widget(); + saved_snippet_dropdown_widget.list_widget?.replace_list_data(options); +} + +function delete_saved_snippet(saved_snippet_id: string): void { + void channel.del({ + url: "/json/saved_snippets/" + saved_snippet_id, + }); +} + +function item_click_callback( + event: JQuery.ClickEvent, + dropdown: tippy.Instance, + widget: dropdown_widget.DropdownWidget, +): void { + event.preventDefault(); + event.stopPropagation(); + + if ( + $(event.target).closest(".saved_snippets-dropdown-list-container .dropdown-list-delete") + .length + ) { + confirm_dialog.launch({ + html_heading: $t_html({defaultMessage: "Delete saved snippet?"}), + html_body: render_confirm_delete_saved_snippet(), + on_click() { + const saved_snippet_id = $(event.currentTarget).attr("data-unique-id"); + assert(saved_snippet_id !== undefined); + delete_saved_snippet(saved_snippet_id); + }, + }); + return; + } + + dropdown.hide(); + // Hide `send_later` popover when a saved snippet is clicked. + popover_menus.hide_current_popover_if_visible(popover_menus.popover_instances.send_later); + const current_value = widget.current_value; + assert(typeof current_value === "number"); + if (current_value === saved_snippets.ADD_SAVED_SNIPPET_OPTION_ID) { + dialog_widget.launch({ + html_heading: $t_html({defaultMessage: "Add a new saved snippet"}), + html_body: render_add_saved_snippet_modal(), + html_submit_button: $t_html({defaultMessage: "Save"}), + id: "add-new-saved-snippet-modal", + form_id: "add-new-saved-snippet-form", + update_submit_disabled_state_on_change: true, + on_click: submit_create_saved_snippet_form, + on_shown: () => $("#add-saved-snippet-title").trigger("focus"), + post_render: saved_snippet_modal_post_render, + }); + } else { + const saved_snippet = saved_snippets.get_saved_snippet_by_id(current_value); + assert(saved_snippet !== undefined); + const content = saved_snippet.content; + const $textarea = $("textarea#compose-textarea"); + compose_ui.insert_syntax_and_focus(content, $textarea); + } +} + +export const initialize = (params: StateData["saved_snippets"]): void => { + saved_snippets.initialize(params); + + saved_snippet_dropdown_widget = new dropdown_widget.DropdownWidget({ + widget_name: "saved_snippets", + get_options: saved_snippets.get_options_for_dropdown_widget, + item_click_callback, + $events_container: $("body"), + unique_id_type: dropdown_widget.DataTypes.NUMBER, + focus_target_on_hidden: false, + prefer_top_start_placement: true, + tippy_props: { + // Using -100 as x offset makes saved snippet icon be in the center + // of the dropdown widget and 5 as y offset is what we use in compose + // recipient dropdown widget. + offset: [-100, 5], + }, + }); + saved_snippet_dropdown_widget.setup(); +}; diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index 13acdb2d09..51e2d78ea2 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -43,6 +43,8 @@ import * as realm_logo from "./realm_logo"; import * as realm_playground from "./realm_playground"; import {realm_user_settings_defaults} from "./realm_user_settings_defaults"; import * as reload from "./reload"; +import * as saved_snippets from "./saved_snippets"; +import * as saved_snippets_ui from "./saved_snippets_ui"; import * as scheduled_messages from "./scheduled_messages"; import * as scheduled_messages_feed_ui from "./scheduled_messages_feed_ui"; import * as scheduled_messages_overlay_ui from "./scheduled_messages_overlay_ui"; @@ -503,6 +505,18 @@ export function dispatch_normal_event(event) { } break; + case "saved_snippets": + switch (event.op) { + case "add": + saved_snippets.add_saved_snippet(event.saved_snippet); + saved_snippets_ui.rerender_dropdown_widget(); + break; + case "remove": + saved_snippets.remove_saved_snippet(event.saved_snippet_id); + saved_snippets_ui.rerender_dropdown_widget(); + break; + } + break; case "scheduled_messages": switch (event.op) { case "add": { diff --git a/web/src/state_data.ts b/web/src/state_data.ts index cae7cc7a10..5853a7b1a1 100644 --- a/web/src/state_data.ts +++ b/web/src/state_data.ts @@ -183,6 +183,13 @@ export const presence_schema = z.object({ idle_timestamp: z.number().optional(), }); +export const saved_snippet_schema = z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + date_created: z.number(), +}); + const one_time_notice_schema = z.object({ name: z.string(), type: z.literal("one_time_notice"), @@ -445,6 +452,11 @@ export const state_data_schema = z }) .transform((presence) => ({presence})), ) + .and( + z + .object({saved_snippets: z.array(saved_snippet_schema)}) + .transform((saved_snippets) => ({saved_snippets})), + ) .and( z .object({starred_messages: z.array(z.number())}) diff --git a/web/src/tippyjs.ts b/web/src/tippyjs.ts index dc6528796e..9088da5df0 100644 --- a/web/src/tippyjs.ts +++ b/web/src/tippyjs.ts @@ -788,4 +788,14 @@ export function initialize(): void { return false; }, }); + + tippy.delegate("body", { + target: ".saved_snippets-dropdown-list-container .dropdown-list-delete", + content: $t({defaultMessage: "Delete snippet"}), + delay: LONG_HOVER_DELAY, + appendTo: () => document.body, + onHidden(instance) { + instance.destroy(); + }, + }); } diff --git a/web/src/ui_init.js b/web/src/ui_init.js index c72ff0c3dd..130fdd06f9 100644 --- a/web/src/ui_init.js +++ b/web/src/ui_init.js @@ -95,6 +95,7 @@ import * as realm_user_settings_defaults from "./realm_user_settings_defaults"; import * as recent_view_ui from "./recent_view_ui"; import * as reload_setup from "./reload_setup"; import * as resize_handler from "./resize_handler"; +import * as saved_snippets_ui from "./saved_snippets_ui"; import * as scheduled_messages from "./scheduled_messages"; import * as scheduled_messages_overlay_ui from "./scheduled_messages_overlay_ui"; import * as scheduled_messages_ui from "./scheduled_messages_ui"; @@ -518,6 +519,7 @@ export function initialize_everything(state_data) { }); inbox_ui.initialize(); alert_words.initialize(state_data.alert_words); + saved_snippets_ui.initialize(state_data.saved_snippets); emojisets.initialize(); scroll_bar.initialize(); message_viewport.initialize(); diff --git a/web/styles/compose.css b/web/styles/compose.css index ebff1d7c4c..1ec95b3081 100644 --- a/web/styles/compose.css +++ b/web/styles/compose.css @@ -1588,6 +1588,51 @@ textarea.new_message_textarea { } } +.saved_snippets-dropdown-list-container { + width: 250px; + + .dropdown-list .dropdown-list-item-common-styles { + padding: 5px 10px; + display: flex; + flex-direction: column; + + .dropdown-list-item-name { + line-height: 15px; + } + + .dropdown-list-item-description { + white-space: nowrap; + font-weight: 400; + font-size: 13px; + opacity: 0.8; + padding: 0; + text-overflow: ellipsis; + overflow: hidden; + } + + .dropdown-list-bold-selected { + font-weight: 500; + max-width: 210px; + display: inline-block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } +} + +#add-new-saved-snippet-modal { + & .saved-snippet-title { + width: 97%; + margin-bottom: 20px; + } + + & .saved-snippet-content { + width: 97%; + resize: vertical; + } +} + #compose.compose-fullscreen, #compose.compose-intermediate { z-index: 99; diff --git a/web/styles/dark_theme.css b/web/styles/dark_theme.css index c67bbea3d3..c3aca89c82 100644 --- a/web/styles/dark_theme.css +++ b/web/styles/dark_theme.css @@ -155,6 +155,19 @@ 0 2px 4px hsl(0deg 0% 0% / 20%); } + .dropdown-list-delete { + /* hsl(7deg 100% 74%) corresponds to var(--red-250) */ + color: color-mix( + in oklch, + hsl(7deg 100% 74%) 70%, + transparent + ) !important; + + &:hover { + color: hsl(7deg 100% 74%) !important; + } + } + #navbar-middle .column-middle-inner, .header, #message_view_header { diff --git a/web/styles/zulip.css b/web/styles/zulip.css index a86438efa9..9b9099b9af 100644 --- a/web/styles/zulip.css +++ b/web/styles/zulip.css @@ -1973,12 +1973,29 @@ body:not(.hide-left-sidebar) { margin-top: 2px; } + .dropdown-list-delete { + visibility: hidden; + float: right; + margin-right: 5px; + cursor: pointer; + /* hsl(359deg 93% 39%) corresponds to var(--red-550) */ + color: color-mix(in oklch, hsl(359deg 93% 39%) 70%, transparent); + + &:hover { + color: hsl(359deg 93% 39%); + } + } + &:focus, &:hover { color: var(--color-dropdown-item); text-decoration: none; background-color: var(--background-color-active-dropdown-item); outline: none; + + .dropdown-list-delete { + visibility: visible; + } } } diff --git a/web/templates/add_saved_snippet_modal.hbs b/web/templates/add_saved_snippet_modal.hbs new file mode 100644 index 0000000000..75e73ef0b5 --- /dev/null +++ b/web/templates/add_saved_snippet_modal.hbs @@ -0,0 +1,8 @@ +
+
+ + +
{{t "Content" }}
+ +
+
diff --git a/web/templates/confirm_dialog/confirm_delete_saved_snippet.hbs b/web/templates/confirm_dialog/confirm_delete_saved_snippet.hbs new file mode 100644 index 0000000000..2a56fb0648 --- /dev/null +++ b/web/templates/confirm_dialog/confirm_delete_saved_snippet.hbs @@ -0,0 +1 @@ +

{{t "This action cannot be undone."}}

diff --git a/web/templates/dropdown_list.hbs b/web/templates/dropdown_list.hbs index 7bf0940360..0f73ea280a 100644 --- a/web/templates/dropdown_list.hbs +++ b/web/templates/dropdown_list.hbs @@ -5,6 +5,11 @@ {{#if bold_current_selection}} {{name}} + {{#if has_delete_icon}} + + + + {{/if}} {{else}} {{name}} {{/if}} diff --git a/web/templates/popovers/send_later_popover.hbs b/web/templates/popovers/send_later_popover.hbs index a9a4228508..542f2a7ead 100644 --- a/web/templates/popovers/send_later_popover.hbs +++ b/web/templates/popovers/send_later_popover.hbs @@ -67,6 +67,13 @@ + + {{#if show_compose_new_message}}