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.