mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-03 21:43:21 +00:00 
			
		
		
		
	markdown: Convert topic links generated by "#-mentions" to permalinks.
This commit converts the links generated by the markdown of the "#-mention" of topics to permalinks -- the links containing the "with" narrow operator, the operand being the last message of the channel and topic of the mention. Fixes part of #21505
This commit is contained in:
		@@ -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 347**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* [Markdown message formatting](/api/message-formatting#links-to-channels-topics-and-messages):
 | 
				
			||||||
 | 
					  Links to topic without a specified message now use the `with`
 | 
				
			||||||
 | 
					  operator to follow moves of topics.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Feature level 346**
 | 
					**Feature level 346**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* [Markdown message formatting](/api/message-formatting#links-to-channels-topics-and-messages):
 | 
					* [Markdown message formatting](/api/message-formatting#links-to-channels-topics-and-messages):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,7 +38,7 @@ Sample HTML formats are as follows:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<!-- Syntax: #**announce>Zulip updates** -->
 | 
					<!-- Syntax: #**announce>Zulip updates** -->
 | 
				
			||||||
<a class="stream-topic" data-stream-id="9"
 | 
					<a class="stream-topic" data-stream-id="9"
 | 
				
			||||||
  href="/#narrow/channel/9-announce/topic/Zulip.20updates">
 | 
					  href="/#narrow/channel/9-announce/topic/Zulip.20updates/with/214">
 | 
				
			||||||
 #announce > Zulip updates
 | 
					 #announce > Zulip updates
 | 
				
			||||||
</a>
 | 
					</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -47,9 +47,27 @@ Sample HTML formats are as follows:
 | 
				
			|||||||
  href="/#narrow/channel/9-announce/topic/Zulip.20updates/near/214">
 | 
					  href="/#narrow/channel/9-announce/topic/Zulip.20updates/near/214">
 | 
				
			||||||
 #announce > Zulip updates @ 💬
 | 
					 #announce > Zulip updates @ 💬
 | 
				
			||||||
</a>
 | 
					</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!-- Syntax: #**announce>Zulip updates**
 | 
				
			||||||
 | 
					     Generated only if topic is empty or the link was rendered before
 | 
				
			||||||
 | 
					     Zulip 10.0 (feature level 347) -->
 | 
				
			||||||
 | 
					<a class="stream-topic" data-stream-id="9"
 | 
				
			||||||
 | 
					  href="/#narrow/channel/9-announce/topic/Zulip.20updates">
 | 
				
			||||||
 | 
					 #announce > Zulip updates
 | 
				
			||||||
 | 
					</a>
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The older stream/topic elements include a `data-stream-id`, which
 | 
					The `near` and `with` operators are documented in more detail in the
 | 
				
			||||||
 | 
					[search and URL documentation](/api/construct-narrow). When rendering
 | 
				
			||||||
 | 
					topic links with the `with` operator, the code doing the rendering may
 | 
				
			||||||
 | 
					pick the ID arbitrarily among messages accessible to the client and/or
 | 
				
			||||||
 | 
					acting user at the time of rendering. Currently, the server chooses
 | 
				
			||||||
 | 
					the message ID to use for `with` operators as the oldest message ID in
 | 
				
			||||||
 | 
					the topic accessible to the user who wrote the message. In channels
 | 
				
			||||||
 | 
					with protected history, this means the same Markdown syntax may be
 | 
				
			||||||
 | 
					rendered differently for users who joined at different times.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The older stream/topic link elements include a `data-stream-id`, which
 | 
				
			||||||
historically was used in order to display the current channel name if
 | 
					historically was used in order to display the current channel name if
 | 
				
			||||||
the channel had been renamed. That field is **deprecated**, because
 | 
					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
 | 
				
			||||||
@@ -64,7 +82,7 @@ are as follows:
 | 
				
			|||||||
```html
 | 
					```html
 | 
				
			||||||
<!-- Syntax: #**announce>** -->
 | 
					<!-- Syntax: #**announce>** -->
 | 
				
			||||||
<a class="stream-topic" data-stream-id="9"
 | 
					<a class="stream-topic" data-stream-id="9"
 | 
				
			||||||
  href="/#narrow/channel/9-announce/topic/">
 | 
					  href="/#narrow/channel/9-announce/topic/with/214">
 | 
				
			||||||
 #announce > <em>general chat</em>
 | 
					 #announce > <em>general chat</em>
 | 
				
			||||||
</a>
 | 
					</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -73,9 +91,21 @@ are as follows:
 | 
				
			|||||||
  href="/#narrow/channel/9-announce/topic//near/214">
 | 
					  href="/#narrow/channel/9-announce/topic//near/214">
 | 
				
			||||||
 #announce > <em>general chat</em> @ 💬
 | 
					 #announce > <em>general chat</em> @ 💬
 | 
				
			||||||
</a>
 | 
					</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<!-- Syntax: #**announce>**
 | 
				
			||||||
 | 
					     Generated only if topic is empty or the link was rendered before
 | 
				
			||||||
 | 
					     Zulip 10.0 (feature level 347) -->
 | 
				
			||||||
 | 
					<a class="stream-topic" data-stream-id="9"
 | 
				
			||||||
 | 
					  href="/#narrow/channel/9-announce/topic/">
 | 
				
			||||||
 | 
					 #announce > <em>general chat</em>
 | 
				
			||||||
 | 
					</a>
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Changes**: Before Zulip 10.0 (feature level 346), empty string
 | 
					**Changes**: Before Zulip 10.0 (feature level 347), the `with` field
 | 
				
			||||||
 | 
					was never used in topic link URLs generated by the server; the markup
 | 
				
			||||||
 | 
					currently used only for empty topics was used for all topic links.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Before Zulip 10.0 (feature level 346), empty string
 | 
				
			||||||
was not a valid topic name in syntaxes for linking to topics and
 | 
					was not a valid topic name in syntaxes for linking to topics and
 | 
				
			||||||
messages.
 | 
					messages.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 = 346  # Last bumped for topic="" support in link to topics/messages.
 | 
					API_FEATURE_LEVEL = 347  # Last bumped for /with/ in topic links.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# 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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -48,6 +48,7 @@ from zerver.lib.markdown import fenced_code
 | 
				
			|||||||
from zerver.lib.markdown.fenced_code import FENCE_RE
 | 
					from zerver.lib.markdown.fenced_code import FENCE_RE
 | 
				
			||||||
from zerver.lib.mention import (
 | 
					from zerver.lib.mention import (
 | 
				
			||||||
    BEFORE_MENTION_ALLOWED_REGEX,
 | 
					    BEFORE_MENTION_ALLOWED_REGEX,
 | 
				
			||||||
 | 
					    ChannelTopicInfo,
 | 
				
			||||||
    FullNameInfo,
 | 
					    FullNameInfo,
 | 
				
			||||||
    MentionBackend,
 | 
					    MentionBackend,
 | 
				
			||||||
    MentionData,
 | 
					    MentionData,
 | 
				
			||||||
@@ -130,6 +131,7 @@ class DbData:
 | 
				
			|||||||
    active_realm_emoji: dict[str, EmojiInfo]
 | 
					    active_realm_emoji: dict[str, EmojiInfo]
 | 
				
			||||||
    sent_by_bot: bool
 | 
					    sent_by_bot: bool
 | 
				
			||||||
    stream_names: dict[str, int]
 | 
					    stream_names: dict[str, int]
 | 
				
			||||||
 | 
					    topic_info: dict[ChannelTopicInfo, int | None]
 | 
				
			||||||
    translate_emoticons: bool
 | 
					    translate_emoticons: bool
 | 
				
			||||||
    user_upload_previews: dict[str, MarkdownImageMetadata]
 | 
					    user_upload_previews: dict[str, MarkdownImageMetadata]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2049,6 +2051,13 @@ class StreamPattern(StreamTopicMessageProcessor):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class StreamTopicPattern(StreamTopicMessageProcessor):
 | 
					class StreamTopicPattern(StreamTopicMessageProcessor):
 | 
				
			||||||
 | 
					    def get_with_operand(self, channel_topic: ChannelTopicInfo) -> int | None:
 | 
				
			||||||
 | 
					        db_data: DbData | None = self.zmd.zulip_db_data
 | 
				
			||||||
 | 
					        if db_data is None:
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        with_operand = db_data.topic_info.get(channel_topic)
 | 
				
			||||||
 | 
					        return with_operand
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @override
 | 
					    @override
 | 
				
			||||||
    def handleMatch(  # type: ignore[override] # https://github.com/python/mypy/issues/10197
 | 
					    def handleMatch(  # type: ignore[override] # https://github.com/python/mypy/issues/10197
 | 
				
			||||||
        self, m: Match[str], data: str
 | 
					        self, m: Match[str], data: str
 | 
				
			||||||
@@ -2064,7 +2073,13 @@ class StreamTopicPattern(StreamTopicMessageProcessor):
 | 
				
			|||||||
        el.set("data-stream-id", str(stream_id))
 | 
					        el.set("data-stream-id", str(stream_id))
 | 
				
			||||||
        stream_url = encode_stream(stream_id, stream_name)
 | 
					        stream_url = encode_stream(stream_id, stream_name)
 | 
				
			||||||
        topic_url = hash_util_encode(topic_name)
 | 
					        topic_url = hash_util_encode(topic_name)
 | 
				
			||||||
        link = f"/#narrow/channel/{stream_url}/topic/{topic_url}"
 | 
					        channel_topic_object = ChannelTopicInfo(stream_name, topic_name)
 | 
				
			||||||
 | 
					        with_operand = self.get_with_operand(channel_topic_object)
 | 
				
			||||||
 | 
					        if with_operand is not None:
 | 
				
			||||||
 | 
					            link = f"/#narrow/channel/{stream_url}/topic/{topic_url}/with/{with_operand}"
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            link = f"/#narrow/channel/{stream_url}/topic/{topic_url}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        el.set("href", link)
 | 
					        el.set("href", link)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if topic_name == "":
 | 
					        if topic_name == "":
 | 
				
			||||||
@@ -2131,6 +2146,15 @@ def possible_linked_stream_names(content: str) -> set[str]:
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def possible_linked_topics(content: str) -> set[ChannelTopicInfo]:
 | 
				
			||||||
 | 
					    # Here, we do not consider STREAM_TOPIC_MESSAGE_LINK_REGEX, since
 | 
				
			||||||
 | 
					    # our callers only want to process links without a message ID.
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        ChannelTopicInfo(match.group("stream_name"), match.group("topic_name"))
 | 
				
			||||||
 | 
					        for match in re.finditer(STREAM_TOPIC_LINK_REGEX, content, re.VERBOSE)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class AlertWordNotificationProcessor(markdown.preprocessors.Preprocessor):
 | 
					class AlertWordNotificationProcessor(markdown.preprocessors.Preprocessor):
 | 
				
			||||||
    allowed_before_punctuation = {" ", "\n", "(", '"', ".", ",", "'", ";", "[", "*", "`", ">"}
 | 
					    allowed_before_punctuation = {" ", "\n", "(", '"', ".", ",", "'", ";", "[", "*", "`", ">"}
 | 
				
			||||||
    allowed_after_punctuation = {
 | 
					    allowed_after_punctuation = {
 | 
				
			||||||
@@ -2753,6 +2777,9 @@ def do_convert(
 | 
				
			|||||||
            stream_names, acting_user=message_sender
 | 
					            stream_names, acting_user=message_sender
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        linked_stream_topic_data = possible_linked_topics(content)
 | 
				
			||||||
 | 
					        topic_info = mention_data.get_topic_info_map(linked_stream_topic_data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if content_has_emoji_syntax(content):
 | 
					        if content_has_emoji_syntax(content):
 | 
				
			||||||
            active_realm_emoji = get_name_keyed_dict_for_active_realm_emoji(message_realm.id)
 | 
					            active_realm_emoji = get_name_keyed_dict_for_active_realm_emoji(message_realm.id)
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
@@ -2766,6 +2793,7 @@ def do_convert(
 | 
				
			|||||||
            realm_url=message_realm.url,
 | 
					            realm_url=message_realm.url,
 | 
				
			||||||
            sent_by_bot=sent_by_bot,
 | 
					            sent_by_bot=sent_by_bot,
 | 
				
			||||||
            stream_names=stream_name_info,
 | 
					            stream_names=stream_name_info,
 | 
				
			||||||
 | 
					            topic_info=topic_info,
 | 
				
			||||||
            translate_emoticons=translate_emoticons,
 | 
					            translate_emoticons=translate_emoticons,
 | 
				
			||||||
            user_upload_previews=user_upload_previews,
 | 
					            user_upload_previews=user_upload_previews,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,6 +9,7 @@ from django.db.models import Q
 | 
				
			|||||||
from django_stubs_ext import StrPromise
 | 
					from django_stubs_ext import StrPromise
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from zerver.lib.streams import filter_stream_authorization
 | 
					from zerver.lib.streams import filter_stream_authorization
 | 
				
			||||||
 | 
					from zerver.lib.topic import get_first_message_for_user_in_topic
 | 
				
			||||||
from zerver.lib.user_groups import get_root_id_annotated_recursive_subgroups_for_groups
 | 
					from zerver.lib.user_groups import get_root_id_annotated_recursive_subgroups_for_groups
 | 
				
			||||||
from zerver.lib.users import get_inaccessible_user_ids
 | 
					from zerver.lib.users import get_inaccessible_user_ids
 | 
				
			||||||
from zerver.models import NamedUserGroup, UserProfile
 | 
					from zerver.models import NamedUserGroup, UserProfile
 | 
				
			||||||
@@ -67,6 +68,23 @@ class PossibleMentions:
 | 
				
			|||||||
    message_has_stream_wildcards: bool
 | 
					    message_has_stream_wildcards: bool
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass(frozen=True)
 | 
				
			||||||
 | 
					class ChannelTopicInfo:
 | 
				
			||||||
 | 
					    channel_name: str
 | 
				
			||||||
 | 
					    topic_name: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class ChannelInfo:
 | 
				
			||||||
 | 
					    channel_id: int
 | 
				
			||||||
 | 
					    recipient_id: int
 | 
				
			||||||
 | 
					    history_public_to_subscribers: bool
 | 
				
			||||||
 | 
					    # TODO: Track whether the current user has only metadata access or
 | 
				
			||||||
 | 
					    # content access, so that we can allow mentioning channels with
 | 
				
			||||||
 | 
					    # only metadata access, while still enforcing content access to
 | 
				
			||||||
 | 
					    # mention topics or messages within channels.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MentionBackend:
 | 
					class MentionBackend:
 | 
				
			||||||
    # Be careful about reuse: MentionBackend contains caches which are
 | 
					    # Be careful about reuse: MentionBackend contains caches which are
 | 
				
			||||||
    # designed to only have the lifespan of a sender user (typically a
 | 
					    # designed to only have the lifespan of a sender user (typically a
 | 
				
			||||||
@@ -78,7 +96,8 @@ class MentionBackend:
 | 
				
			|||||||
    def __init__(self, realm_id: int) -> None:
 | 
					    def __init__(self, realm_id: int) -> None:
 | 
				
			||||||
        self.realm_id = realm_id
 | 
					        self.realm_id = realm_id
 | 
				
			||||||
        self.user_cache: dict[tuple[int, str], FullNameInfo] = {}
 | 
					        self.user_cache: dict[tuple[int, str], FullNameInfo] = {}
 | 
				
			||||||
        self.stream_cache: dict[str, int] = {}
 | 
					        self.stream_cache: dict[str, ChannelInfo] = {}
 | 
				
			||||||
 | 
					        self.topic_cache: dict[ChannelTopicInfo, int | None] = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_full_name_info_list(
 | 
					    def get_full_name_info_list(
 | 
				
			||||||
        self, user_filters: list[UserFilter], message_sender: UserProfile | None
 | 
					        self, user_filters: list[UserFilter], message_sender: UserProfile | None
 | 
				
			||||||
@@ -152,7 +171,7 @@ class MentionBackend:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        for stream_name in stream_names:
 | 
					        for stream_name in stream_names:
 | 
				
			||||||
            if stream_name in self.stream_cache:
 | 
					            if stream_name in self.stream_cache:
 | 
				
			||||||
                result[stream_name] = self.stream_cache[stream_name]
 | 
					                result[stream_name] = self.stream_cache[stream_name].channel_id
 | 
				
			||||||
            else:
 | 
					            else:
 | 
				
			||||||
                unseen_stream_names.append(stream_name)
 | 
					                unseen_stream_names.append(stream_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -171,10 +190,14 @@ class MentionBackend:
 | 
				
			|||||||
                .values(
 | 
					                .values(
 | 
				
			||||||
                    "id",
 | 
					                    "id",
 | 
				
			||||||
                    "name",
 | 
					                    "name",
 | 
				
			||||||
 | 
					                    "recipient_id",
 | 
				
			||||||
 | 
					                    "history_public_to_subscribers",
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            for row in rows:
 | 
					            for row in rows:
 | 
				
			||||||
                self.stream_cache[row["name"]] = row["id"]
 | 
					                self.stream_cache[row["name"]] = ChannelInfo(
 | 
				
			||||||
 | 
					                    row["id"], row["recipient_id"], row["history_public_to_subscribers"]
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
                result[row["name"]] = row["id"]
 | 
					                result[row["name"]] = row["id"]
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            authorization = filter_stream_authorization(
 | 
					            authorization = filter_stream_authorization(
 | 
				
			||||||
@@ -189,11 +212,54 @@ class MentionBackend:
 | 
				
			|||||||
                is_subscribing_other_users=False,
 | 
					                is_subscribing_other_users=False,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            for stream in authorization.authorized_streams:
 | 
					            for stream in authorization.authorized_streams:
 | 
				
			||||||
                self.stream_cache[stream.name] = stream.id
 | 
					                assert stream.recipient_id is not None
 | 
				
			||||||
 | 
					                self.stream_cache[stream.name] = ChannelInfo(
 | 
				
			||||||
 | 
					                    stream.id, stream.recipient_id, stream.history_public_to_subscribers
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
                result[stream.name] = stream.id
 | 
					                result[stream.name] = stream.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return result
 | 
					        return result
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_topic_info_map(
 | 
				
			||||||
 | 
					        self, channel_topics: set[ChannelTopicInfo], acting_user: UserProfile | None
 | 
				
			||||||
 | 
					    ) -> dict[ChannelTopicInfo, int | None]:
 | 
				
			||||||
 | 
					        if not channel_topics:
 | 
				
			||||||
 | 
					            return {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result: dict[ChannelTopicInfo, int | None] = {}
 | 
				
			||||||
 | 
					        unseen_channel_topic: list[ChannelTopicInfo] = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for channel_topic in channel_topics:
 | 
				
			||||||
 | 
					            if channel_topic in self.topic_cache:
 | 
				
			||||||
 | 
					                result[channel_topic] = self.topic_cache[channel_topic]
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                unseen_channel_topic.append(channel_topic)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for channel_topic in unseen_channel_topic:
 | 
				
			||||||
 | 
					            channel_info = self.stream_cache.get(channel_topic.channel_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if channel_info is None:
 | 
				
			||||||
 | 
					                # The acting user does not have access to content in this channel.
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            recipient_id = channel_info.recipient_id
 | 
				
			||||||
 | 
					            topic_name = channel_topic.topic_name
 | 
				
			||||||
 | 
					            history_public_to_subscribers = channel_info.history_public_to_subscribers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            topic_latest_message = get_first_message_for_user_in_topic(
 | 
				
			||||||
 | 
					                self.realm_id,
 | 
				
			||||||
 | 
					                acting_user,
 | 
				
			||||||
 | 
					                recipient_id,
 | 
				
			||||||
 | 
					                topic_name,
 | 
				
			||||||
 | 
					                history_public_to_subscribers,
 | 
				
			||||||
 | 
					                acting_user_has_channel_content_access=True,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.topic_cache[channel_topic] = topic_latest_message
 | 
				
			||||||
 | 
					            result[channel_topic] = topic_latest_message
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return result
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def user_mention_matches_topic_wildcard(mention: str) -> bool:
 | 
					def user_mention_matches_topic_wildcard(mention: str) -> bool:
 | 
				
			||||||
    return mention in topic_wildcards
 | 
					    return mention in topic_wildcards
 | 
				
			||||||
@@ -271,6 +337,7 @@ class MentionData:
 | 
				
			|||||||
    ) -> None:
 | 
					    ) -> None:
 | 
				
			||||||
        self.mention_backend = mention_backend
 | 
					        self.mention_backend = mention_backend
 | 
				
			||||||
        realm_id = mention_backend.realm_id
 | 
					        realm_id = mention_backend.realm_id
 | 
				
			||||||
 | 
					        self.message_sender = message_sender
 | 
				
			||||||
        mentions = possible_mentions(content)
 | 
					        mentions = possible_mentions(content)
 | 
				
			||||||
        possible_mentions_info = get_possible_mentions_info(
 | 
					        possible_mentions_info = get_possible_mentions_info(
 | 
				
			||||||
            mention_backend, mentions.mention_texts, message_sender
 | 
					            mention_backend, mentions.mention_texts, message_sender
 | 
				
			||||||
@@ -356,7 +423,16 @@ class MentionData:
 | 
				
			|||||||
    def get_stream_name_map(
 | 
					    def get_stream_name_map(
 | 
				
			||||||
        self, stream_names: set[str], acting_user: UserProfile | None
 | 
					        self, stream_names: set[str], acting_user: UserProfile | None
 | 
				
			||||||
    ) -> dict[str, int]:
 | 
					    ) -> dict[str, int]:
 | 
				
			||||||
        return self.mention_backend.get_stream_name_map(stream_names, acting_user=acting_user)
 | 
					        return self.mention_backend.get_stream_name_map(
 | 
				
			||||||
 | 
					            stream_names, acting_user=self.message_sender
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_topic_info_map(
 | 
				
			||||||
 | 
					        self, channel_topics: set[ChannelTopicInfo]
 | 
				
			||||||
 | 
					    ) -> dict[ChannelTopicInfo, int | None]:
 | 
				
			||||||
 | 
					        return self.mention_backend.get_topic_info_map(
 | 
				
			||||||
 | 
					            channel_topics, acting_user=self.message_sender
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def silent_mention_syntax_for_user(user_profile: UserProfile) -> str:
 | 
					def silent_mention_syntax_for_user(user_profile: UserProfile) -> str:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -78,6 +78,39 @@ def messages_for_topic(
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_first_message_for_user_in_topic(
 | 
				
			||||||
 | 
					    realm_id: int,
 | 
				
			||||||
 | 
					    user_profile: UserProfile | None,
 | 
				
			||||||
 | 
					    recipient_id: int,
 | 
				
			||||||
 | 
					    topic_name: str,
 | 
				
			||||||
 | 
					    history_public_to_subscribers: bool,
 | 
				
			||||||
 | 
					    acting_user_has_channel_content_access: bool = False,
 | 
				
			||||||
 | 
					) -> int | None:
 | 
				
			||||||
 | 
					    # Guard against incorrectly calling this function without
 | 
				
			||||||
 | 
					    # first checking for channel access.
 | 
				
			||||||
 | 
					    assert acting_user_has_channel_content_access
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if history_public_to_subscribers:
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            messages_for_topic(realm_id, recipient_id, topic_name)
 | 
				
			||||||
 | 
					            .values_list("id", flat=True)
 | 
				
			||||||
 | 
					            .first()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    elif user_profile is not None:
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            UserMessage.objects.filter(
 | 
				
			||||||
 | 
					                user_profile=user_profile,
 | 
				
			||||||
 | 
					                message__recipient_id=recipient_id,
 | 
				
			||||||
 | 
					                message__subject__iexact=topic_name,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .values_list("message_id", flat=True)
 | 
				
			||||||
 | 
					            .first()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def save_message_for_edit_use_case(message: Message) -> None:
 | 
					def save_message_for_edit_use_case(message: Message) -> None:
 | 
				
			||||||
    message.save(
 | 
					    message.save(
 | 
				
			||||||
        update_fields=[
 | 
					        update_fields=[
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3007,7 +3007,7 @@ class MarkdownMentionTest(ZulipTestCase):
 | 
				
			|||||||
        assert_silent_mention("```quote\n@_*backend*\n```")
 | 
					        assert_silent_mention("```quote\n@_*backend*\n```")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MarkdownStreamMentionTests(ZulipTestCase):
 | 
					class MarkdownStreamTopicMentionTests(ZulipTestCase):
 | 
				
			||||||
    def test_stream_single(self) -> None:
 | 
					    def test_stream_single(self) -> None:
 | 
				
			||||||
        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
					        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
				
			||||||
        sender_user_profile = self.example_user("othello")
 | 
					        sender_user_profile = self.example_user("othello")
 | 
				
			||||||
@@ -3084,7 +3084,7 @@ class MarkdownStreamMentionTests(ZulipTestCase):
 | 
				
			|||||||
            "<p>#<strong>casesens</strong></p>",
 | 
					            "<p>#<strong>casesens</strong></p>",
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_topic_single(self) -> None:
 | 
					    def test_topic_single_containing_no_message(self) -> None:
 | 
				
			||||||
        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
					        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
				
			||||||
        sender_user_profile = self.example_user("othello")
 | 
					        sender_user_profile = self.example_user("othello")
 | 
				
			||||||
        msg = Message(
 | 
					        msg = Message(
 | 
				
			||||||
@@ -3104,6 +3104,23 @@ class MarkdownStreamMentionTests(ZulipTestCase):
 | 
				
			|||||||
            f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/">#{denmark.name} > <em>{Message.EMPTY_TOPIC_FALLBACK_NAME}</em></a></p>',
 | 
					            f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/">#{denmark.name} > <em>{Message.EMPTY_TOPIC_FALLBACK_NAME}</em></a></p>',
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_topic_single_containing_message(self) -> None:
 | 
				
			||||||
 | 
					        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
				
			||||||
 | 
					        sender_user_profile = self.example_user("othello")
 | 
				
			||||||
 | 
					        first_message_id = self.send_stream_message(
 | 
				
			||||||
 | 
					            sender_user_profile, "Denmark", topic_name="some topic", content="test"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        msg = Message(
 | 
				
			||||||
 | 
					            sender=sender_user_profile,
 | 
				
			||||||
 | 
					            sending_client=get_client("test"),
 | 
				
			||||||
 | 
					            realm=sender_user_profile.realm,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        content = "#**Denmark>some topic**"
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            render_message_markdown(msg, content).rendered_content,
 | 
				
			||||||
 | 
					            f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/some.20topic/with/{first_message_id}">#{denmark.name} > some topic</a></p>',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_topic_atomic_string(self) -> None:
 | 
					    def test_topic_atomic_string(self) -> None:
 | 
				
			||||||
        realm = get_realm("zulip")
 | 
					        realm = get_realm("zulip")
 | 
				
			||||||
        # Create a linkifier.
 | 
					        # Create a linkifier.
 | 
				
			||||||
@@ -3119,17 +3136,23 @@ class MarkdownStreamMentionTests(ZulipTestCase):
 | 
				
			|||||||
        )
 | 
					        )
 | 
				
			||||||
        # Create a topic link that potentially interferes with the pattern.
 | 
					        # Create a topic link that potentially interferes with the pattern.
 | 
				
			||||||
        denmark = get_stream("Denmark", realm)
 | 
					        denmark = get_stream("Denmark", realm)
 | 
				
			||||||
 | 
					        first_message_id = self.send_stream_message(
 | 
				
			||||||
 | 
					            sender_user_profile, "Denmark", topic_name="#1234", content="test"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        msg = Message(sender=sender_user_profile, sending_client=get_client("test"), realm=realm)
 | 
					        msg = Message(sender=sender_user_profile, sending_client=get_client("test"), realm=realm)
 | 
				
			||||||
        content = "#**Denmark>#1234**"
 | 
					        content = "#**Denmark>#1234**"
 | 
				
			||||||
        self.assertEqual(
 | 
					        self.assertEqual(
 | 
				
			||||||
            render_message_markdown(msg, content).rendered_content,
 | 
					            render_message_markdown(msg, content).rendered_content,
 | 
				
			||||||
            f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/.231234">#{denmark.name} > #1234</a></p>',
 | 
					            f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/.231234/with/{first_message_id}">#{denmark.name} > #1234</a></p>',
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_topic_multiple(self) -> None:
 | 
					    def test_topic_multiple(self) -> None:
 | 
				
			||||||
        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
					        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
				
			||||||
        scotland = get_stream("Scotland", get_realm("zulip"))
 | 
					        scotland = get_stream("Scotland", get_realm("zulip"))
 | 
				
			||||||
        sender_user_profile = self.example_user("othello")
 | 
					        sender_user_profile = self.example_user("othello")
 | 
				
			||||||
 | 
					        first_message_id = self.send_stream_message(
 | 
				
			||||||
 | 
					            sender_user_profile, "Denmark", topic_name="some topic", content="test"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        msg = Message(
 | 
					        msg = Message(
 | 
				
			||||||
            sender=sender_user_profile,
 | 
					            sender=sender_user_profile,
 | 
				
			||||||
            sending_client=get_client("test"),
 | 
					            sending_client=get_client("test"),
 | 
				
			||||||
@@ -3140,7 +3163,7 @@ class MarkdownStreamMentionTests(ZulipTestCase):
 | 
				
			|||||||
            render_message_markdown(msg, content).rendered_content,
 | 
					            render_message_markdown(msg, content).rendered_content,
 | 
				
			||||||
            "<p>This has two links: "
 | 
					            "<p>This has two links: "
 | 
				
			||||||
            f'<a class="stream-topic" data-stream-id="{denmark.id}" '
 | 
					            f'<a class="stream-topic" data-stream-id="{denmark.id}" '
 | 
				
			||||||
            f'href="/#narrow/channel/{denmark.id}-{denmark.name}/topic/some.20topic">'
 | 
					            f'href="/#narrow/channel/{denmark.id}-{denmark.name}/topic/some.20topic/with/{first_message_id}">'
 | 
				
			||||||
            f"#{denmark.name} > some topic</a>"
 | 
					            f"#{denmark.name} > some topic</a>"
 | 
				
			||||||
            " and "
 | 
					            " and "
 | 
				
			||||||
            f'<a class="stream-topic" data-stream-id="{scotland.id}" '
 | 
					            f'<a class="stream-topic" data-stream-id="{scotland.id}" '
 | 
				
			||||||
@@ -3149,6 +3172,94 @@ class MarkdownStreamMentionTests(ZulipTestCase):
 | 
				
			|||||||
            ".</p>",
 | 
					            ".</p>",
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_topic_permalink(self) -> None:
 | 
				
			||||||
 | 
					        realm = get_realm("zulip")
 | 
				
			||||||
 | 
					        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
				
			||||||
 | 
					        sender_user_profile = self.example_user("othello")
 | 
				
			||||||
 | 
					        first_message_id = self.send_stream_message(
 | 
				
			||||||
 | 
					            sender_user_profile, "Denmark", topic_name="some topic", content="test"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        msg = Message(
 | 
				
			||||||
 | 
					            sender=sender_user_profile,
 | 
				
			||||||
 | 
					            sending_client=get_client("test"),
 | 
				
			||||||
 | 
					            realm=sender_user_profile.realm,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # test caching of topic data for user.
 | 
				
			||||||
 | 
					        content = "#**Denmark>some topic**"
 | 
				
			||||||
 | 
					        mention_backend = MentionBackend(realm.id)
 | 
				
			||||||
 | 
					        mention_data = MentionData(mention_backend, content, message_sender=None)
 | 
				
			||||||
 | 
					        render_message_markdown(msg, content, mention_data=mention_data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with (
 | 
				
			||||||
 | 
					            self.assert_database_query_count(1, keep_cache_warm=True),
 | 
				
			||||||
 | 
					            self.assert_memcached_count(0),
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            self.assertEqual(
 | 
				
			||||||
 | 
					                render_message_markdown(msg, content, mention_data=mention_data).rendered_content,
 | 
				
			||||||
 | 
					                f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/some.20topic/with/{first_message_id}">#{denmark.name} > some topic</a></p>',
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # test topic linked doesn't have any message in it in case
 | 
				
			||||||
 | 
					        # the topic mentioned doesn't have any messages.
 | 
				
			||||||
 | 
					        content = "#**Denmark>random topic**"
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            render_message_markdown(msg, content).rendered_content,
 | 
				
			||||||
 | 
					            f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/random.20topic">#{denmark.name} > random topic</a></p>',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test when trying to render a topic link of a channel with shared
 | 
				
			||||||
 | 
					        # history, if message_sender is None, topic link is permalink.
 | 
				
			||||||
 | 
					        content = "#**Denmark>some topic**"
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            markdown_convert_wrapper(content),
 | 
				
			||||||
 | 
					            f'<p><a class="stream-topic" data-stream-id="{denmark.id}" href="/#narrow/channel/{denmark.id}-Denmark/topic/some.20topic/with/{first_message_id}">#{denmark.name} > some topic</a></p>',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # test topic links for channel with protected history
 | 
				
			||||||
 | 
					        core_stream = self.make_stream("core", realm, True, history_public_to_subscribers=False)
 | 
				
			||||||
 | 
					        iago = self.example_user("iago")
 | 
				
			||||||
 | 
					        hamlet = self.example_user("hamlet")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.subscribe(iago, "core")
 | 
				
			||||||
 | 
					        msg_id = self.send_stream_message(iago, "core", topic_name="testing")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        msg = Message(
 | 
				
			||||||
 | 
					            sender=iago,
 | 
				
			||||||
 | 
					            sending_client=get_client("test"),
 | 
				
			||||||
 | 
					            realm=realm,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        content = "#**core>testing**"
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            render_message_markdown(msg, content).rendered_content,
 | 
				
			||||||
 | 
					            f'<p><a class="stream-topic" data-stream-id="{core_stream.id}" href="/#narrow/channel/{core_stream.id}-core/topic/testing/with/{msg_id}">#{core_stream.name} > testing</a></p>',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test newly subscribed user to a channel with protected history
 | 
				
			||||||
 | 
					        # won't have accessed to this message, and hence, the topic
 | 
				
			||||||
 | 
					        # link would not be a permalink.
 | 
				
			||||||
 | 
					        self.subscribe(hamlet, "core")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        msg = Message(
 | 
				
			||||||
 | 
					            sender=hamlet,
 | 
				
			||||||
 | 
					            sending_client=get_client("test"),
 | 
				
			||||||
 | 
					            realm=realm,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        content = "#**core>testing**"
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            render_message_markdown(msg, content).rendered_content,
 | 
				
			||||||
 | 
					            f'<p><a class="stream-topic" data-stream-id="{core_stream.id}" href="/#narrow/channel/{core_stream.id}-core/topic/testing">#{core_stream.name} > testing</a></p>',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test when trying to render a topic link of a channel with protected
 | 
				
			||||||
 | 
					        # history, if message_sender is None, topic link is not permalink.
 | 
				
			||||||
 | 
					        content = "#**core>testing**"
 | 
				
			||||||
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
					            markdown_convert_wrapper(content),
 | 
				
			||||||
 | 
					            f'<p><a class="stream-topic" data-stream-id="{core_stream.id}" href="/#narrow/channel/{core_stream.id}-core/topic/testing">#{core_stream.name} > testing</a></p>',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_message_id_multiple(self) -> None:
 | 
					    def test_message_id_multiple(self) -> None:
 | 
				
			||||||
        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
					        denmark = get_stream("Denmark", get_realm("zulip"))
 | 
				
			||||||
        sender_user_profile = self.example_user("othello")
 | 
					        sender_user_profile = self.example_user("othello")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1201,7 +1201,7 @@ class MessageMoveStreamTest(ZulipTestCase):
 | 
				
			|||||||
            "iago", "test move stream", "new stream", "test"
 | 
					            "iago", "test move stream", "new stream", "test"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        with self.assert_database_query_count(57), self.assert_memcached_count(14):
 | 
					        with self.assert_database_query_count(59), self.assert_memcached_count(14):
 | 
				
			||||||
            result = self.client_patch(
 | 
					            result = self.client_patch(
 | 
				
			||||||
                f"/json/messages/{msg_id}",
 | 
					                f"/json/messages/{msg_id}",
 | 
				
			||||||
                {
 | 
					                {
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user