compose: Transform stream/topic urls on paste.

Transforming valid stream/topic urls  to the #**stream>topic**
syntax.
- A valid url contains a stream and optionally a topic
 but nothing else, and in that order.
 It must belong to the same origin as the Zulip server.
 The stream id present in the pasted url should
  correspond to an actual stream in the current
  server.
- `near` links are not transformed.
- Use-mention distinction is respected by
  not transforming a valid url if pasted using
  `Ctrl+Shift+V`.
- No transformation occurs inside a code block.
-  On pressing `Ctrl+Z` after pasting,
  the actual pasted link is restored.
- No transformation occurs if the url is pasted over an
 existing url in a markdown link syntax.
- No transformation occurs if the stream or topic name
contained in the pasted url is known to produce broken
 stream/topic syntax links (as per #30071).

Fixes #29136
This commit is contained in:
Kislay Verma 2024-07-12 10:28:32 +05:30 committed by Tim Abbott
parent 759c066d05
commit ffd49ac35b
3 changed files with 149 additions and 8 deletions

View File

@ -2,11 +2,14 @@ import isUrl from "is-url";
import $ from "jquery";
import _ from "lodash";
import assert from "minimalistic-assert";
import {insertTextIntoField} from "text-field-edit";
import TurndownService from "turndown";
import * as compose_ui from "./compose_ui";
import * as hash_util from "./hash_util";
import * as message_lists from "./message_lists";
import * as rows from "./rows";
import * as topic_link_util from "./topic_link_util";
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@ -640,6 +643,41 @@ export function maybe_transform_html(html: string, text: string): string {
return html;
}
function add_text_and_select(text: string, $textarea: JQuery<HTMLTextAreaElement>): void {
const textarea = $textarea.get(0);
assert(textarea instanceof HTMLTextAreaElement);
const init_cursor_pos = textarea.selectionStart;
insertTextIntoField(textarea, text);
const new_cursor_pos = textarea.selectionStart;
textarea.setSelectionRange(init_cursor_pos, new_cursor_pos);
}
export function try_stream_topic_syntax_text(text: string): string | null {
const stream_topic = hash_util.decode_stream_topic_from_url(text);
if (!stream_topic) {
return null;
}
if (topic_link_util.will_produce_broken_stream_topic_link(stream_topic.stream_name)) {
return null;
}
if (
stream_topic.topic_name !== undefined &&
topic_link_util.will_produce_broken_stream_topic_link(stream_topic.topic_name)
) {
return null;
}
let syntax_text = "#**" + stream_topic.stream_name;
if (stream_topic.topic_name) {
syntax_text += ">" + stream_topic.topic_name;
}
syntax_text += "**";
return syntax_text;
}
export function paste_handler(this: HTMLTextAreaElement, event: JQuery.TriggeredEvent): void {
assert(event.originalEvent instanceof ClipboardEvent);
const clipboardData = event.originalEvent.clipboardData;
@ -661,15 +699,36 @@ export function paste_handler(this: HTMLTextAreaElement, event: JQuery.Triggered
// Only intervene to generate formatted links when dealing
// with a URL and a URL-safe range selection.
if (isUrl(trimmed_paste_text) && is_safe_url_paste_target($textarea)) {
event.preventDefault();
event.stopPropagation();
const url = trimmed_paste_text;
compose_ui.format_text($textarea, "linked", url);
return;
}
if (isUrl(trimmed_paste_text)) {
if (is_safe_url_paste_target($textarea)) {
event.preventDefault();
event.stopPropagation();
const url = trimmed_paste_text;
compose_ui.format_text($textarea, "linked", url);
return;
}
// We do not paste formatted markdoown when inside a code block.
if (
!compose_ui.cursor_inside_code_block($textarea) &&
!cursor_at_markdown_link_marker($textarea) &&
!compose_ui.shift_pressed
) {
// Try to transform the url to #**stream>topic** syntax
// if it is a valid url.
const syntax_text = try_stream_topic_syntax_text(trimmed_paste_text);
if (syntax_text) {
event.preventDefault();
event.stopPropagation();
// To ensure you can get the actual pasted URL back via the browser
// undo feature, we first paste the URL in, then select it, and then
// replace it with the nicer markdown syntax.
add_text_and_select(trimmed_paste_text, $textarea);
compose_ui.insert_and_scroll_into_view(syntax_text + " ", $textarea);
}
return;
}
}
// We do not paste formatted markdown when inside a code block.
// Unlike Chrome, Firefox doesn't automatically paste plainly on using Ctrl+Shift+V,
// hence we need to handle it ourselves, by checking if shift key is pressed, and only
// if not, we proceed with the default formatted paste.

View File

@ -285,3 +285,37 @@ export function validate_group_settings_hash(hash: string): string {
}
return hash;
}
export function decode_stream_topic_from_url(
url_str: string,
): {stream_name: string; topic_name?: string} | null {
try {
const url = new URL(url_str);
if (url.origin !== window.location.origin || !url.hash.startsWith("#narrow")) {
return null;
}
const terms = parse_narrow(url.hash.split(/\//));
if (terms === undefined) {
return null;
}
if (terms.length > 2) {
// The link should only contain stream and topic,
// near/ links are not transformed.
return null;
}
// This check is important as a malformed url
// may have `stream`, `topic` or `near:` in a wrong order
if (terms[0]?.operator !== "stream") {
return null;
}
if (terms.length === 1) {
return {stream_name: terms[0].operand};
}
if (terms[1]?.operator !== "topic") {
return null;
}
return {stream_name: terms[0].operand, topic_name: terms[1].operand};
} catch {
return null;
}
}

View File

@ -6,6 +6,54 @@ const {zrequire} = require("./lib/namespace");
const {run_test} = require("./lib/test");
const copy_and_paste = zrequire("copy_and_paste");
const stream_data = zrequire("stream_data");
stream_data.add_sub({
stream_id: 4,
name: "Rome",
});
run_test("try_stream_topic_syntax_text", () => {
const test_cases = [
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/old.20FAILED.20EXPORT",
"#**Rome>old FAILED EXPORT**",
],
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20profits",
"#**Rome>100% profits**",
],
[
"http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/old.20API.20wasn't.20compiling.20erratically",
"#**Rome>old API wasn't compiling erratically**",
],
["http://different.origin.com/#narrow/stream/4-Rome/topic/old.20FAILED.20EXPORT"],
// malformed urls
["http://zulip.zulipdev.com/narrow/stream/4-Rome/topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#not_narrow/stream/4-Rome/topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#narrow/not_stream/4-Rome/topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/not_topic/old.20FAILED.20EXPORT"],
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/old.20FAILED.20EXPORT/near/100"],
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/", "#**Rome**"],
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic"],
["http://zulip.zulipdev.com/#narrow/topic/cheese"],
["http://zulip.zulipdev.com/#narrow/topic/pizza/stream/Rome"],
// characters which are known to produce broken #**stream>topic** urls.
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20profits.60"],
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/100.25.20*profits"],
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/.24.24 100.25.20profits"],
["http://zulip.zulipdev.com/#narrow/stream/4-Rome/topic/>100.25.20profits"],
];
for (const test_case of test_cases) {
const result = copy_and_paste.try_stream_topic_syntax_text(test_case[0]);
const expected = test_case[1] ?? null;
assert.equal(result, expected, "Failed for url: " + test_case[0]);
}
});
run_test("maybe_transform_html", () => {
// Copied HTML from VS Code