diff --git a/web/src/clipboard_handler.ts b/web/src/clipboard_handler.ts new file mode 100644 index 0000000000..ce947719b2 --- /dev/null +++ b/web/src/clipboard_handler.ts @@ -0,0 +1,42 @@ +import * as hash_util from "./hash_util"; +import * as stream_data from "./stream_data"; + +export function generate_formatted_link(link_text: string): string { + const stream_topic = hash_util.decode_stream_topic_from_url(link_text); + + if (!stream_topic) { + return "Invalid stream"; + } + + const stream = stream_data.get_sub_by_id(stream_topic.stream_id); + + // had to replace <> characters from stream and topic name + // with HTML entity because sites supporting html won't + // render that name and treats that name as tag. + + const topic_name = stream_topic.topic_name?.replace(//g, ">"); + const stream_name = stream?.name?.replace(//g, ">"); + + if (topic_name !== undefined) { + return `#${stream_name}>${topic_name}`; + } + + return `#${stream_name}`; +} + +export function copy_to_clipboard(link_text: string, after_copy_cb: () => void): void { + const formatted_url = generate_formatted_link(link_text); + + if (formatted_url === "Invalid stream") { + return; + } + + const clipboardItem = new ClipboardItem({ + "text/plain": new Blob([link_text], { + type: "text/plain", + }), + "text/html": new Blob([formatted_url], {type: "text/html"}), + }); + + void navigator.clipboard.write([clipboardItem]).then(after_copy_cb); +} diff --git a/web/src/stream_popover.js b/web/src/stream_popover.js index e7cda5742a..102e8004f1 100644 --- a/web/src/stream_popover.js +++ b/web/src/stream_popover.js @@ -1,4 +1,3 @@ -import ClipboardJS from "clipboard"; import $ from "jquery"; import assert from "minimalistic-assert"; @@ -8,6 +7,7 @@ import render_left_sidebar_stream_actions_popover from "../templates/popovers/le import * as blueslip from "./blueslip.ts"; import * as browser_history from "./browser_history.ts"; +import * as clipboard_handler from "./clipboard_handler"; import * as composebox_typeahead from "./composebox_typeahead.ts"; import * as dialog_widget from "./dialog_widget.ts"; import * as dropdown_widget from "./dropdown_widget.ts"; @@ -212,9 +212,11 @@ function build_stream_popover(opts) { $(e.currentTarget).hide(); e.stopPropagation(); }); - - new ClipboardJS($popper.find(".copy_stream_link")[0]).on("success", () => { - popover_menus.hide_current_popover_if_visible(instance); + $popper.on("click", ".copy_stream_link", () => { + clipboard_handler.copy_to_clipboard( + $(".copy_stream_link").data("clipboard-text"), + () => popover_menus.hide_current_popover_if_visible(instance), + ); }); }, onHidden() { diff --git a/web/src/topic_popover.js b/web/src/topic_popover.js index 805d6135e9..d4bd9f7d82 100644 --- a/web/src/topic_popover.js +++ b/web/src/topic_popover.js @@ -1,9 +1,9 @@ -import ClipboardJS from "clipboard"; import $ from "jquery"; import render_delete_topic_modal from "../templates/confirm_dialog/confirm_delete_topic.hbs"; import render_left_sidebar_topic_actions_popover from "../templates/popovers/left_sidebar/left_sidebar_topic_actions_popover.hbs"; +import * as clipboard_handler from "./clipboard_handler"; import * as confirm_dialog from "./confirm_dialog.ts"; import {$t_html} from "./i18n.ts"; import * as message_edit from "./message_edit.ts"; @@ -157,13 +157,12 @@ export function initialize() { stream_popover.build_move_topic_to_stream_popover(stream_id, topic_name, true); popover_menus.hide_current_popover_if_visible(instance); }); - - new ClipboardJS($popper.find(".sidebar-popover-copy-link-to-topic")[0]).on( - "success", - () => { - popover_menus.hide_current_popover_if_visible(instance); - }, - ); + $popper.on("click", ".sidebar-popover-copy-link-to-topic", () => { + clipboard_handler.copy_to_clipboard( + $(".sidebar-popover-copy-link-to-topic").data("clipboard-text"), + () => popover_menus.hide_current_popover_if_visible(instance), + ); + }); }, onHidden(instance) { instance.destroy(); diff --git a/web/tests/clipboard_handler.test.js b/web/tests/clipboard_handler.test.js new file mode 100644 index 0000000000..0b8779c8e6 --- /dev/null +++ b/web/tests/clipboard_handler.test.js @@ -0,0 +1,102 @@ +"use strict"; + +const assert = require("node:assert/strict"); + +const {zrequire} = require("./lib/namespace"); +const {make_stub} = require("./lib/stub"); +const {run_test} = require("./lib/test"); + +const clipboard_handler = zrequire("clipboard_handler"); +const stream_data = zrequire("stream_data"); + +const stream = { + name: "Stream", + description: "Color and Lights", + stream_id: 1, + subscribed: true, + type: "stream", +}; + +const markdown_stream = { + name: "", + description: "Colors and lights", + stream_id: 2, + subscribe: true, + type: "stream", +}; + +stream_data.add_sub(stream); +stream_data.add_sub(markdown_stream); + +const normal_stream_with_topic = + "http://zulip.zulipdev.com/#narrow/stream/1-Stream/topic/normal.20topic"; +const markdown_stream_with_normal_topic = + "http://zulip.zulipdev.com/#narrow/channel/2-.3CStream*.24.60.26.3E/topic/normal.20topic"; +const normal_stream_with_markdown_topic = + "http://zulip.zulipdev.com/#narrow/stream/1-Stream/topic/.3C.24topic.60*.26.3E"; +const markdown_stream_with_markdown_topic = + "http://zulip.zulipdev.com/#narrow/channel/2-.3CStream*.24.60.26.3E/topic/.3C.24topic.60*.26.3E"; +const invalid_stream = "http://zulip.zulipdev.com/#narrow/stream/99-Stream"; +const normal_stream_no_topic = "http://zulip.zulipdev.com/#narrow/stream/1-Stream"; +const markdown_stream_no_topic = + "http://zulip.zulipdev.com/#narrow/channel/2-.3CStream*.24.60.26.3E"; + +run_test("generate_formatted_url", () => { + assert.equal( + clipboard_handler.generate_formatted_link(normal_stream_with_topic), + `#Stream>normal topic`, + ); + + assert.equal( + clipboard_handler.generate_formatted_link(markdown_stream_with_normal_topic), + `#<Stream*$\`&>>normal topic`, + ); + + assert.equal( + clipboard_handler.generate_formatted_link(normal_stream_with_markdown_topic), + `#Stream><$topic\`*&>`, + ); + + assert.equal( + clipboard_handler.generate_formatted_link(markdown_stream_with_markdown_topic), + `#<Stream*$\`&>><$topic\`*&>`, + ); + + assert.equal(clipboard_handler.generate_formatted_link(invalid_stream), "Invalid stream"); + + assert.equal( + clipboard_handler.generate_formatted_link(normal_stream_no_topic), + `#Stream`, + ); + + assert.equal( + clipboard_handler.generate_formatted_link(markdown_stream_no_topic), + `#<Stream*$\`&>`, + ); +}); + +global.ClipboardItem = class { + constructor(items) { + this.items = items; + } +}; + +global.navigator = { + clipboard: {}, +}; + +run_test("copy_to_clipboard", async ({override}) => { + const after_copy_cb = make_stub(); + + // replacing navigator.clipboard.write with custom function + override(global.navigator.clipboard, "write", () => Promise.resolve()); + + // ensuring that function returns early when link in invalid + await clipboard_handler.copy_to_clipboard(invalid_stream, after_copy_cb.f); + assert.deepEqual(after_copy_cb.num_calls, 0); + + // ensuring function is beign called and after_copy_cb is triggered when link is correct + await clipboard_handler.copy_to_clipboard(normal_stream_with_topic, after_copy_cb.f); + assert.deepEqual(global.navigator.clipboard.write(), Promise.resolve()); + assert.deepEqual(after_copy_cb.num_calls, 1); +});