diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 13ce92b53f..c027038a22 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 346** + +* [Markdown message formatting](/api/message-formatting#links-to-channels-topics-and-messages): + Added support for empty string as a valid topic name in syntaxes + for linking to topics and messages. + **Feature level 345** * `POST /remotes/server/register/transfer`, diff --git a/api_docs/message-formatting.md b/api_docs/message-formatting.md index 150c945370..226de60a4c 100644 --- a/api_docs/message-formatting.md +++ b/api_docs/message-formatting.md @@ -55,7 +55,31 @@ the channel had been renamed. That field is **deprecated**, because displaying an updated value for the most common forms of this syntax requires parsing the URL to get the topic to use anyway. -**Changes**: In Zulip 10.0 (feature level 319), added Markdown syntax +When a topic is an empty string, it is replaced with +`realm_empty_topic_display_name` found in the [`POST /register`](/api/register-queue) +response and wrapped with the `` tag. + +Sample HTML formats with `"realm_empty_topic_display_name": "general chat"` +are as follows: +```html + + + #announce > general chat + + + + + #announce > general chat @ 💬 + +``` + +**Changes**: Before Zulip 10.0 (feature level 346), empty string +was not a valid topic name in syntaxes for linking to topics and +messages. + +In Zulip 10.0 (feature level 319), added Markdown syntax for linking to a specific message in a conversation. Declared the `data-stream-id` field to be deprecated as detailed above. diff --git a/version.py b/version.py index fd77efa08a..4f738b312a 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 345 # Last bumped for push notifications transfer. +API_FEATURE_LEVEL = 346 # Last bumped for topic="" support in link to topics/messages. # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/web/src/markdown.ts b/web/src/markdown.ts index 673d95a118..d7c4a79e07 100644 --- a/web/src/markdown.ts +++ b/web/src/markdown.ts @@ -673,13 +673,14 @@ function handleStreamTopicMessage({ stream_topic_hash: (stream_id: number, topic: string) => string; }): string | undefined { const stream = get_stream_by_name(stream_name); - if (stream === undefined || !topic) { + if (stream === undefined) { return undefined; } const href = stream_topic_hash(stream.stream_id, topic) + "/near/" + message_id; return render_channel_message_link({ channel_name: stream.name, - topic_display_name: topic, + topic_display_name: util.get_final_topic_display_name(topic), + is_empty_string_topic: topic === "", href, }); } diff --git a/web/src/rendered_markdown.ts b/web/src/rendered_markdown.ts index 1ba35ecf92..acb1f989b0 100644 --- a/web/src/rendered_markdown.ts +++ b/web/src/rendered_markdown.ts @@ -3,6 +3,7 @@ import {isValid, parseISO} from "date-fns"; import $ from "jquery"; import assert from "minimalistic-assert"; +import render_channel_message_link from "../templates/channel_message_link.hbs"; import code_buttons_container from "../templates/code_buttons_container.hbs"; import render_markdown_timestamp from "../templates/markdown_timestamp.hbs"; import render_mention_content_wrapper from "../templates/mention_content_wrapper.hbs"; @@ -235,7 +236,7 @@ export const update_elements = ($content: JQuery): void => { } }); - $content.find("a.stream-topic").each(function (): void { + $content.find("a.stream-topic, a.message-link").each(function (): void { const narrow_url = $(this).attr("href"); assert(narrow_url !== undefined); const channel_topic = hash_util.decode_stream_topic_from_url( @@ -251,14 +252,22 @@ export const update_elements = ($content: JQuery): void => { const topic_name = channel_topic.topic_name; assert(topic_name !== undefined); const topic_display_name = util.get_final_topic_display_name(topic_name); - const topic_link_html = render_topic_link({ - channel_id: channel_topic.stream_id, + const context = { channel_name, topic_display_name, is_empty_string_topic: topic_name === "", href: narrow_url, - }); - $(this).replaceWith($(topic_link_html)); + }; + if ($(this).hasClass("stream-topic")) { + const topic_link_html = render_topic_link({ + channel_id: channel_topic.stream_id, + ...context, + }); + $(this).replaceWith($(topic_link_html)); + } else { + const message_link_html = render_channel_message_link(context); + $(this).replaceWith($(message_link_html)); + } } }); diff --git a/web/templates/channel_message_link.hbs b/web/templates/channel_message_link.hbs index e4c7ba753f..d7ea2162ee 100644 --- a/web/templates/channel_message_link.hbs +++ b/web/templates/channel_message_link.hbs @@ -1,2 +1,13 @@ -#{{channel_name}} > {{topic_display_name}} @ 💬 -{{~!-- squash whitespace --~}} +{{#if is_empty_string_topic}} + + {{~!-- squash whitespace --~}} + #{{channel_name}} > {{topic_display_name}} @ 💬 + {{~!-- squash whitespace --~}} + +{{~else}} + + {{~!-- squash whitespace --~}} + #{{channel_name}} > {{topic_display_name}} @ 💬 + {{~!-- squash whitespace --~}} + +{{~/if}} diff --git a/web/tests/markdown.test.cjs b/web/tests/markdown.test.cjs index 7dbacf239a..f34e01cdce 100644 --- a/web/tests/markdown.test.cjs +++ b/web/tests/markdown.test.cjs @@ -426,6 +426,10 @@ test("marked", ({override}) => { expected: '

Look at #Denmark > message_link @ 💬

', }, + { + input: "Look at #**Denmark>@100**", + expected: `

Look at #Denmark > translated: ${REALM_EMPTY_TOPIC_DISPLAY_NAME} @ 💬

`, + }, { input: "Look at #**Unknown>message_link@100**", expected: "

Look at #**Unknown>message_link@100**

", diff --git a/web/tests/rendered_markdown.test.cjs b/web/tests/rendered_markdown.test.cjs index 787f989fd8..96535fb533 100644 --- a/web/tests/rendered_markdown.test.cjs +++ b/web/tests/rendered_markdown.test.cjs @@ -143,7 +143,7 @@ const get_content_element = () => { $content.set_find_results(".topic-mention", $array([])); $content.set_find_results(".user-group-mention", $array([])); $content.set_find_results("a.stream", $array([])); - $content.set_find_results("a.stream-topic", $array([])); + $content.set_find_results("a.stream-topic, a.message-link", $array([])); $content.set_find_results("time", $array([])); $content.set_find_results("span.timestamp-error", $array([])); $content.set_find_results(".emoji", $array([])); @@ -422,10 +422,11 @@ run_test("stream-links", ({mock_template}) => { `/#narrow/channel/${stream.stream_id}-random/topic/topic.20name.20.3E.20still.20the.20topic.20name`, ); $stream_topic.replaceWith = noop; + $stream_topic.hasClass = (class_name) => class_name === "stream-topic"; $stream_topic.text("#random > topic name > still the topic name"); $content.set_find_results("a.stream", $array([$stream])); - $content.set_find_results("a.stream-topic", $array([$stream_topic])); + $content.set_find_results("a.stream-topic, a.message-link", $array([$stream_topic])); let topic_link_context; let topic_link_rendered_html; @@ -460,8 +461,9 @@ run_test("topic-link (empty string topic)", ({mock_template}) => { $channel_topic.set_find_results(".highlight", false); $channel_topic.attr("href", `/#narrow/channel/${stream.stream_id}-random/topic/`); $channel_topic.replaceWith = noop; + $channel_topic.hasClass = (class_name) => class_name === "stream-topic"; $channel_topic.html(`#random > ${REALM_EMPTY_TOPIC_DISPLAY_NAME}`); - $content.set_find_results("a.stream-topic", $array([$channel_topic])); + $content.set_find_results("a.stream-topic, a.message-link", $array([$channel_topic])); let topic_link_context; let topic_link_rendered_html; @@ -487,6 +489,45 @@ run_test("topic-link (empty string topic)", ({mock_template}) => { assert.ok(topic_link_rendered_html.includes("empty-topic-display")); }); +run_test("message-links", ({mock_template}) => { + // Setup + const $content = get_content_element(); + const $channel_topic_message = $.create("a.message-link"); + $channel_topic_message.set_find_results(".highlight", false); + $channel_topic_message.attr( + "href", + `/#narrow/channel/${stream.stream_id}-${stream.name}/topic//near/123`, + ); + $channel_topic_message.replaceWith = noop; + $channel_topic_message.hasClass = (class_name) => class_name === "message-link"; + $channel_topic_message.html( + `#${stream.name} > ${REALM_EMPTY_TOPIC_DISPLAY_NAME} @ 💬`, + ); + $content.set_find_results("a.stream-topic, a.message-link", $array([$channel_topic_message])); + + let channel_message_link_context; + let channel_message_link_rendered_html; + mock_template("channel_message_link.hbs", true, (data, html) => { + channel_message_link_context = data; + channel_message_link_rendered_html = html; + return html; + }); + + // Initial assert + assert.equal($channel_topic_message.html(), "#test > general chat @ 💬"); + + rm.update_elements($content); + + // Final asserts + assert.deepEqual(channel_message_link_context, { + channel_name: stream.name, + topic_display_name: `translated: ${REALM_EMPTY_TOPIC_DISPLAY_NAME}`, + is_empty_string_topic: true, + href: `/#narrow/channel/${stream.stream_id}-test/topic//near/123`, + }); + assert.ok(channel_message_link_rendered_html.includes("empty-topic-display")); +}); + run_test("timestamp without time", () => { const $content = get_content_element(); const $timestamp = $.create("timestamp without actual time"); diff --git a/web/third/marked/lib/marked.cjs b/web/third/marked/lib/marked.cjs index 38e579fc02..8e57319821 100644 --- a/web/third/marked/lib/marked.cjs +++ b/web/third/marked/lib/marked.cjs @@ -545,7 +545,7 @@ inline.zulip = merge({}, inline.breaks, { unicodeemoji: possible_emoji_regex, usermention: /^@(_?)(?:\*\*([^\*]+)\*\*)/, // Match potentially multi-word string between @** ** groupmention: /^@(_?)(?:\*([^\*]+)\*)/, // Match multi-word string between @* * - stream_topic_message: /^#\*\*([^\*>]+)>([^\*]+)@(\d+)\*\*/, + stream_topic_message: /^#\*\*([^\*>]+)>([^\*]*)@(\d+)\*\*/, stream_topic: /^#\*\*([^\*>]+)>([^\*]*)\*\*/, stream: /^#\*\*([^\*]+)\*\*/, tex: /^(\$\$([^\n_$](\\\$|[^\n$])*)\$\$(?!\$))\B/, diff --git a/zerver/lib/markdown/__init__.py b/zerver/lib/markdown/__init__.py index fd13d5705e..cd89cb4e94 100644 --- a/zerver/lib/markdown/__init__.py +++ b/zerver/lib/markdown/__init__.py @@ -200,7 +200,7 @@ STREAM_TOPIC_MESSAGE_LINK_REGEX = rf""" \#\*\* # and after hash sign followed by double asterisks (?P[^\*>]+) # stream name can contain anything except > > # > acts as separator - (?P[^\*]+) # topic name can contain anything + (?P[^\*]*) # topic name can be an empty string or contain anything @ (?P\d+) # message id \*\* # ends by double asterisks @@ -2097,8 +2097,17 @@ class StreamTopicMessagePattern(StreamTopicMessageProcessor): topic_url = hash_util_encode(topic_name) link = f"/#narrow/channel/{stream_url}/topic/{topic_url}/near/{message_id}" el.set("href", link) - text = f"#{stream_name} > {topic_name} @ 💬" - el.text = markdown.util.AtomicString(text) + + if topic_name == "": + topic_el = Element("em") + topic_el.text = Message.EMPTY_TOPIC_FALLBACK_NAME + el.text = markdown.util.AtomicString(f"#{stream_name} > ") + el.append(topic_el) + topic_el.tail = markdown.util.AtomicString(" @ 💬") + else: + text = f"#{stream_name} > {topic_name} @ 💬" + el.text = markdown.util.AtomicString(text) + return el, m.start(), m.end() diff --git a/zerver/tests/test_markdown.py b/zerver/tests/test_markdown.py index 2dc7c24f58..0daccc9beb 100644 --- a/zerver/tests/test_markdown.py +++ b/zerver/tests/test_markdown.py @@ -3171,6 +3171,20 @@ class MarkdownStreamMentionTests(ZulipTestCase): ".

", ) + def test_empty_string_topic_message_link(self) -> None: + denmark = get_stream("Denmark", get_realm("zulip")) + sender = self.example_user("othello") + msg = Message( + sender=sender, + sending_client=get_client("test"), + realm=sender.realm, + ) + content = "#**Denmark>@123**" + self.assertEqual( + render_message_markdown(msg, content).rendered_content, + f'

#{denmark.name} > {Message.EMPTY_TOPIC_FALLBACK_NAME} @ 💬

', + ) + def test_possible_stream_names(self) -> None: content = """#**test here** This mentions #**Denmark** too.