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);
+});