message_link: Add support for empty string topic in syntax.

This commit adds support for empty string as a valid topic name
in syntax for linking to channel messages.

The server stores it after empty string is replaced with
`realm_empty_topic_display_name` and wrapped with an <em> tag.

The web client parses the rendered_content and updates
the topic_name part in the HTML with topic_name in user's language
+ wraps it in a <span> tag with 'empty-topic-display' css class.
This commit is contained in:
Prakhar Pratyush
2025-01-28 22:01:25 +05:30
committed by Tim Abbott
parent 53dc5198c6
commit 0788942a68
11 changed files with 137 additions and 18 deletions

View File

@@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0 ## 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** **Feature level 345**
* `POST /remotes/server/register/transfer`, * `POST /remotes/server/register/transfer`,

View File

@@ -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 displaying an updated value for the most common forms of this syntax
requires parsing the URL to get the topic to use anyway. 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 `<em>` tag.
Sample HTML formats with `"realm_empty_topic_display_name": "general chat"`
are as follows:
```html
<!-- Syntax: #**announce>** -->
<a class="stream-topic" data-stream-id="9"
href="/#narrow/channel/9-announce/topic/">
#announce &gt; <em>general chat</em>
</a>
<!-- Syntax: #**announce>@214** -->
<a class="message-link"
href="/#narrow/channel/9-announce/topic//near/214">
#announce &gt; <em>general chat</em> @ 💬
</a>
```
**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 for linking to a specific message in a conversation. Declared the
`data-stream-id` field to be deprecated as detailed above. `data-stream-id` field to be deprecated as detailed above.

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # 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 # 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 # only when going from an old version of the code to a newer version. Bump

View File

@@ -673,13 +673,14 @@ function handleStreamTopicMessage({
stream_topic_hash: (stream_id: number, topic: string) => string; stream_topic_hash: (stream_id: number, topic: string) => string;
}): string | undefined { }): string | undefined {
const stream = get_stream_by_name(stream_name); const stream = get_stream_by_name(stream_name);
if (stream === undefined || !topic) { if (stream === undefined) {
return undefined; return undefined;
} }
const href = stream_topic_hash(stream.stream_id, topic) + "/near/" + message_id; const href = stream_topic_hash(stream.stream_id, topic) + "/near/" + message_id;
return render_channel_message_link({ return render_channel_message_link({
channel_name: stream.name, channel_name: stream.name,
topic_display_name: topic, topic_display_name: util.get_final_topic_display_name(topic),
is_empty_string_topic: topic === "",
href, href,
}); });
} }

View File

@@ -3,6 +3,7 @@ import {isValid, parseISO} from "date-fns";
import $ from "jquery"; import $ from "jquery";
import assert from "minimalistic-assert"; 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 code_buttons_container from "../templates/code_buttons_container.hbs";
import render_markdown_timestamp from "../templates/markdown_timestamp.hbs"; import render_markdown_timestamp from "../templates/markdown_timestamp.hbs";
import render_mention_content_wrapper from "../templates/mention_content_wrapper.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"); const narrow_url = $(this).attr("href");
assert(narrow_url !== undefined); assert(narrow_url !== undefined);
const channel_topic = hash_util.decode_stream_topic_from_url( 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; const topic_name = channel_topic.topic_name;
assert(topic_name !== undefined); assert(topic_name !== undefined);
const topic_display_name = util.get_final_topic_display_name(topic_name); const topic_display_name = util.get_final_topic_display_name(topic_name);
const topic_link_html = render_topic_link({ const context = {
channel_id: channel_topic.stream_id,
channel_name, channel_name,
topic_display_name, topic_display_name,
is_empty_string_topic: topic_name === "", is_empty_string_topic: topic_name === "",
href: narrow_url, 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));
}
} }
}); });

View File

@@ -1,2 +1,13 @@
<a class="message-link" href="{{href}}">#{{channel_name}} &gt; {{topic_display_name}} @ 💬</a> {{#if is_empty_string_topic}}
{{~!-- squash whitespace --~}} <a class="message-link" href="{{href}}">
{{~!-- squash whitespace --~}}
#{{channel_name}} &gt; <span class="empty-topic-display">{{topic_display_name}}</span> @ 💬
{{~!-- squash whitespace --~}}
</a>
{{~else}}
<a class="message-link" href="{{href}}">
{{~!-- squash whitespace --~}}
#{{channel_name}} &gt; {{topic_display_name}} @ 💬
{{~!-- squash whitespace --~}}
</a>
{{~/if}}

View File

@@ -426,6 +426,10 @@ test("marked", ({override}) => {
expected: expected:
'<p>Look at <a class="message-link" href="#narrow/channel/1-Denmark/topic/message_link/near/100">#Denmark &gt; message_link @ 💬</a></p>', '<p>Look at <a class="message-link" href="#narrow/channel/1-Denmark/topic/message_link/near/100">#Denmark &gt; message_link @ 💬</a></p>',
}, },
{
input: "Look at #**Denmark>@100**",
expected: `<p>Look at <a class="message-link" href="#narrow/channel/1-Denmark/topic//near/100">#Denmark &gt; <span class="empty-topic-display">translated: ${REALM_EMPTY_TOPIC_DISPLAY_NAME}</span> @ 💬</a></p>`,
},
{ {
input: "Look at #**Unknown>message_link@100**", input: "Look at #**Unknown>message_link@100**",
expected: "<p>Look at #**Unknown&gt;message_link@100**</p>", expected: "<p>Look at #**Unknown&gt;message_link@100**</p>",

View File

@@ -143,7 +143,7 @@ const get_content_element = () => {
$content.set_find_results(".topic-mention", $array([])); $content.set_find_results(".topic-mention", $array([]));
$content.set_find_results(".user-group-mention", $array([])); $content.set_find_results(".user-group-mention", $array([]));
$content.set_find_results("a.stream", $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("time", $array([]));
$content.set_find_results("span.timestamp-error", $array([])); $content.set_find_results("span.timestamp-error", $array([]));
$content.set_find_results(".emoji", $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`, `/#narrow/channel/${stream.stream_id}-random/topic/topic.20name.20.3E.20still.20the.20topic.20name`,
); );
$stream_topic.replaceWith = noop; $stream_topic.replaceWith = noop;
$stream_topic.hasClass = (class_name) => class_name === "stream-topic";
$stream_topic.text("#random > topic name > still the topic name"); $stream_topic.text("#random > topic name > still the topic name");
$content.set_find_results("a.stream", $array([$stream])); $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_context;
let topic_link_rendered_html; 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.set_find_results(".highlight", false);
$channel_topic.attr("href", `/#narrow/channel/${stream.stream_id}-random/topic/`); $channel_topic.attr("href", `/#narrow/channel/${stream.stream_id}-random/topic/`);
$channel_topic.replaceWith = noop; $channel_topic.replaceWith = noop;
$channel_topic.hasClass = (class_name) => class_name === "stream-topic";
$channel_topic.html(`#random &gt; <em>${REALM_EMPTY_TOPIC_DISPLAY_NAME}</em>`); $channel_topic.html(`#random &gt; <em>${REALM_EMPTY_TOPIC_DISPLAY_NAME}</em>`);
$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_context;
let topic_link_rendered_html; 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")); 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} &gt; <em>${REALM_EMPTY_TOPIC_DISPLAY_NAME}</em> @ 💬`,
);
$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 &gt; <em>general chat</em> @ 💬");
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", () => { run_test("timestamp without time", () => {
const $content = get_content_element(); const $content = get_content_element();
const $timestamp = $.create("timestamp without actual time"); const $timestamp = $.create("timestamp without actual time");

View File

@@ -545,7 +545,7 @@ inline.zulip = merge({}, inline.breaks, {
unicodeemoji: possible_emoji_regex, unicodeemoji: possible_emoji_regex,
usermention: /^@(_?)(?:\*\*([^\*]+)\*\*)/, // Match potentially multi-word string between @** ** usermention: /^@(_?)(?:\*\*([^\*]+)\*\*)/, // Match potentially multi-word string between @** **
groupmention: /^@(_?)(?:\*([^\*]+)\*)/, // Match multi-word string between @* * groupmention: /^@(_?)(?:\*([^\*]+)\*)/, // Match multi-word string between @* *
stream_topic_message: /^#\*\*([^\*>]+)>([^\*]+)@(\d+)\*\*/, stream_topic_message: /^#\*\*([^\*>]+)>([^\*]*)@(\d+)\*\*/,
stream_topic: /^#\*\*([^\*>]+)>([^\*]*)\*\*/, stream_topic: /^#\*\*([^\*>]+)>([^\*]*)\*\*/,
stream: /^#\*\*([^\*]+)\*\*/, stream: /^#\*\*([^\*]+)\*\*/,
tex: /^(\$\$([^\n_$](\\\$|[^\n$])*)\$\$(?!\$))\B/, tex: /^(\$\$([^\n_$](\\\$|[^\n$])*)\$\$(?!\$))\B/,

View File

@@ -200,7 +200,7 @@ STREAM_TOPIC_MESSAGE_LINK_REGEX = rf"""
\#\*\* # and after hash sign followed by double asterisks \#\*\* # and after hash sign followed by double asterisks
(?P<stream_name>[^\*>]+) # stream name can contain anything except > (?P<stream_name>[^\*>]+) # stream name can contain anything except >
> # > acts as separator > # > acts as separator
(?P<topic_name>[^\*]+) # topic name can contain anything (?P<topic_name>[^\*]*) # topic name can be an empty string or contain anything
@ @
(?P<message_id>\d+) # message id (?P<message_id>\d+) # message id
\*\* # ends by double asterisks \*\* # ends by double asterisks
@@ -2097,8 +2097,17 @@ class StreamTopicMessagePattern(StreamTopicMessageProcessor):
topic_url = hash_util_encode(topic_name) topic_url = hash_util_encode(topic_name)
link = f"/#narrow/channel/{stream_url}/topic/{topic_url}/near/{message_id}" link = f"/#narrow/channel/{stream_url}/topic/{topic_url}/near/{message_id}"
el.set("href", link) 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() return el, m.start(), m.end()

View File

@@ -3171,6 +3171,20 @@ class MarkdownStreamMentionTests(ZulipTestCase):
".</p>", ".</p>",
) )
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'<p><a class="message-link" href="/#narrow/channel/{denmark.id}-{denmark.name}/topic//near/123">#{denmark.name} &gt; <em>{Message.EMPTY_TOPIC_FALLBACK_NAME}</em> @ 💬</a></p>',
)
def test_possible_stream_names(self) -> None: def test_possible_stream_names(self) -> None:
content = """#**test here** content = """#**test here**
This mentions #**Denmark** too. This mentions #**Denmark** too.