mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	narrow: Implement rendering of with narrow operators.
				
					
				
			Adds server and web app support for processing the new `with` search operator. Fixes part of #21505. Co-authored-by: roanster007 <rohan.gudimetla07@gmail.com> Co-authored-by: Tim Abbott <tabbott@zulip.com>
This commit is contained in:
		@@ -20,6 +20,16 @@ format used by the Zulip server that they are interacting with.
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## Changes in Zulip 9.0
 | 
					## Changes in Zulip 9.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					**Feature level 271**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* [`GET /messages`](/api/get-messages),
 | 
				
			||||||
 | 
					  [`GET /messages/matches_narrow`](/api/check-messages-match-narrow),
 | 
				
			||||||
 | 
					  [`POST /messages/flags/narrow`](/api/update-message-flags-for-narrow),
 | 
				
			||||||
 | 
					  [`POST /register`](/api/register-queue):
 | 
				
			||||||
 | 
					  Added support for a new [search/narrow filter](/api/construct-narrow),
 | 
				
			||||||
 | 
					  `with`, which returns messages in the same channel and topic as that
 | 
				
			||||||
 | 
					  of the operand of filter, with the first unread message as anchor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Feature level 270**
 | 
					**Feature level 270**
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* `PATCH /realm`, [`POST /register`](/api/register-queue),
 | 
					* `PATCH /realm`, [`POST /register`](/api/register-queue),
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -51,7 +51,10 @@ important optimization when fetching messages in certain cases (e.g.,
 | 
				
			|||||||
when [adding the `read` flag to a user's personal
 | 
					when [adding the `read` flag to a user's personal
 | 
				
			||||||
messages](/api/update-message-flags-for-narrow)).
 | 
					messages](/api/update-message-flags-for-narrow)).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Changes**: In Zulip 9.0 (feature level 265), support was added for a
 | 
					**Changes**: In Zulip 9.0 (feature level 271), narrows gained support
 | 
				
			||||||
 | 
					for a new `with` operator for linking to topics.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					In Zulip 9.0 (feature level 265), support was added for a
 | 
				
			||||||
new `is:followed` filter, matching messages in topics that the current
 | 
					new `is:followed` filter, matching messages in topics that the current
 | 
				
			||||||
user is [following](/help/follow-a-topic).
 | 
					user is [following](/help/follow-a-topic).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -88,15 +91,24 @@ filters did.
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
### Message IDs
 | 
					### Message IDs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The `near` and `id` operators, documented in the help center, use message
 | 
					The `with` operator is designed to be used in links that should follow
 | 
				
			||||||
IDs for their operands.
 | 
					a topic across being moved. Its operand is a message ID.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					The `near` and `id` operators, documented in the help center,
 | 
				
			||||||
 | 
					also use message IDs for their operands.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* `with:12345`: Search for the conversation (stream/topic pair) which
 | 
				
			||||||
 | 
					  contains the message with ID `12345`. If such a message exists, and
 | 
				
			||||||
 | 
					  can be accessed by the user, then the search will be treated as having
 | 
				
			||||||
 | 
					  the `channel`/`topic`/`dm` operators corresponding to the
 | 
				
			||||||
 | 
					  conversation containing that message, replacing any such operators
 | 
				
			||||||
 | 
					  in the original request.
 | 
				
			||||||
* `near:12345`: Search messages around the message with ID `12345`.
 | 
					* `near:12345`: Search messages around the message with ID `12345`.
 | 
				
			||||||
* `id:12345`: Search for only message with ID `12345`.
 | 
					* `id:12345`: Search for only message with ID `12345`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The message ID operand for the `id` operator may be encoded as either a
 | 
					The message ID operand for the `with` and `id` operators may be encoded
 | 
				
			||||||
number or a string. The message ID operand for the `near` operator must
 | 
					as either a number or a string. The message ID operand for the `near`
 | 
				
			||||||
be encoded as a string.
 | 
					operator must be encoded as a string.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Changes**: Prior to Zulip 8.0 (feature level 194), the message ID
 | 
					**Changes**: Prior to Zulip 8.0 (feature level 194), the message ID
 | 
				
			||||||
operand for the `id` operator needed to be encoded as a string.
 | 
					operand for the `id` operator needed to be encoded as a string.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 = 270  # Last bumped for direct_message_permission_group
 | 
					API_FEATURE_LEVEL = 271  # Last bumped for `with` operator.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Bump the minor PROVISION_VERSION to indicate that folks should provision
 | 
					# Bump the minor PROVISION_VERSION to indicate that folks should provision
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -295,12 +295,12 @@ export class Filter {
 | 
				
			|||||||
    _sorted_term_types?: string[] = undefined;
 | 
					    _sorted_term_types?: string[] = undefined;
 | 
				
			||||||
    _predicate?: (message: Message) => boolean;
 | 
					    _predicate?: (message: Message) => boolean;
 | 
				
			||||||
    _can_mark_messages_read?: boolean;
 | 
					    _can_mark_messages_read?: boolean;
 | 
				
			||||||
 | 
					    requires_adjustment_for_moved_with_target?: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    constructor(terms: NarrowTerm[]) {
 | 
					    constructor(terms: NarrowTerm[]) {
 | 
				
			||||||
        this._terms = this.fix_terms(terms);
 | 
					        this._terms = terms;
 | 
				
			||||||
        if (this.has_operator("channel")) {
 | 
					        this.setup_filter(terms);
 | 
				
			||||||
            this._sub = stream_data.get_sub_by_name(this.operands("channel")[0]!);
 | 
					        this.requires_adjustment_for_moved_with_target = this.has_operator("with");
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    static canonicalize_operator(operator: string): string {
 | 
					    static canonicalize_operator(operator: string): string {
 | 
				
			||||||
@@ -389,6 +389,38 @@ export class Filter {
 | 
				
			|||||||
        };
 | 
					        };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static ensure_channel_topic_terms(orig_terms: NarrowTerm[]): NarrowTerm[] {
 | 
				
			||||||
 | 
					        // In presence of `with` term without channel or topic terms in the narrow, the
 | 
				
			||||||
 | 
					        // narrow is populated with the channel and toipic terms through this operation,
 | 
				
			||||||
 | 
					        // so that `with` can be used as a standalone operator to target conversation.
 | 
				
			||||||
 | 
					        const with_term = orig_terms.find((term: NarrowTerm) => term.operator === "with");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!with_term) {
 | 
				
			||||||
 | 
					            return orig_terms;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const updated_terms = [...orig_terms];
 | 
				
			||||||
 | 
					        let channel_term = updated_terms.find(
 | 
				
			||||||
 | 
					            (term: NarrowTerm) => Filter.canonicalize_operator(term.operator) === "channel",
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        let topic_term = updated_terms.find(
 | 
				
			||||||
 | 
					            (term: NarrowTerm) => Filter.canonicalize_operator(term.operator) === "topic",
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!topic_term) {
 | 
				
			||||||
 | 
					            topic_term = {operator: "topic", operand: ""};
 | 
				
			||||||
 | 
					            const with_index = updated_terms.indexOf(with_term);
 | 
				
			||||||
 | 
					            updated_terms.splice(with_index, 0, topic_term);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!channel_term) {
 | 
				
			||||||
 | 
					            channel_term = {operator: "channel", operand: ""};
 | 
				
			||||||
 | 
					            const topic_index = updated_terms.indexOf(topic_term);
 | 
				
			||||||
 | 
					            updated_terms.splice(topic_index, 0, channel_term);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return updated_terms;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /* We use a variant of URI encoding which looks reasonably
 | 
					    /* We use a variant of URI encoding which looks reasonably
 | 
				
			||||||
       nice and still handles unambiguously cases such as
 | 
					       nice and still handles unambiguously cases such as
 | 
				
			||||||
       spaces in operands.
 | 
					       spaces in operands.
 | 
				
			||||||
@@ -576,6 +608,7 @@ export class Filter {
 | 
				
			|||||||
            "channels-public",
 | 
					            "channels-public",
 | 
				
			||||||
            "channel",
 | 
					            "channel",
 | 
				
			||||||
            "topic",
 | 
					            "topic",
 | 
				
			||||||
 | 
					            "with",
 | 
				
			||||||
            "dm",
 | 
					            "dm",
 | 
				
			||||||
            "dm-including",
 | 
					            "dm-including",
 | 
				
			||||||
            "sender",
 | 
					            "sender",
 | 
				
			||||||
@@ -775,6 +808,11 @@ export class Filter {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    static adjusted_terms_if_moved(raw_terms: NarrowTerm[], message: Message): NarrowTerm[] | null {
 | 
					    static adjusted_terms_if_moved(raw_terms: NarrowTerm[], message: Message): NarrowTerm[] | null {
 | 
				
			||||||
        if (message.type !== "stream") {
 | 
					        if (message.type !== "stream") {
 | 
				
			||||||
 | 
					            // BUG: We should be replacing the channel/topic/dm
 | 
				
			||||||
 | 
					            // operators with the singular dm operator for the
 | 
				
			||||||
 | 
					            // conversation in question. This isn't very important,
 | 
				
			||||||
 | 
					            // because direct messages cannot be moved, but it should
 | 
				
			||||||
 | 
					            // be fixed, since it is triggerable incorrect behavior.
 | 
				
			||||||
            return null;
 | 
					            return null;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -784,6 +822,8 @@ export class Filter {
 | 
				
			|||||||
        const adjusted_terms = [];
 | 
					        const adjusted_terms = [];
 | 
				
			||||||
        let terms_changed = false;
 | 
					        let terms_changed = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        raw_terms = Filter.ensure_channel_topic_terms(raw_terms);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for (const term of raw_terms) {
 | 
					        for (const term of raw_terms) {
 | 
				
			||||||
            const adjusted_term = {...term};
 | 
					            const adjusted_term = {...term};
 | 
				
			||||||
            if (
 | 
					            if (
 | 
				
			||||||
@@ -812,6 +852,13 @@ export class Filter {
 | 
				
			|||||||
        return adjusted_terms;
 | 
					        return adjusted_terms;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setup_filter(terms: NarrowTerm[]): void {
 | 
				
			||||||
 | 
					        this._terms = this.fix_terms(terms);
 | 
				
			||||||
 | 
					        if (this.has_operator("channel")) {
 | 
				
			||||||
 | 
					            this._sub = stream_data.get_sub_by_name(this.operands("channel")[0]!);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    equals(filter: Filter, excluded_operators?: string[]): boolean {
 | 
					    equals(filter: Filter, excluded_operators?: string[]): boolean {
 | 
				
			||||||
        return _.isEqual(
 | 
					        return _.isEqual(
 | 
				
			||||||
            filter.sorted_terms(excluded_operators),
 | 
					            filter.sorted_terms(excluded_operators),
 | 
				
			||||||
@@ -929,6 +976,7 @@ export class Filter {
 | 
				
			|||||||
            "channels-web-public",
 | 
					            "channels-web-public",
 | 
				
			||||||
            "not-channels-web-public",
 | 
					            "not-channels-web-public",
 | 
				
			||||||
            "near",
 | 
					            "near",
 | 
				
			||||||
 | 
					            "with",
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for (const term of term_types) {
 | 
					        for (const term of term_types) {
 | 
				
			||||||
@@ -963,6 +1011,10 @@ export class Filter {
 | 
				
			|||||||
        // it is limited by the user's message history. Therefore, we check "channel"
 | 
					        // it is limited by the user's message history. Therefore, we check "channel"
 | 
				
			||||||
        // and "topic" together to ensure that the current filter will return all the
 | 
					        // and "topic" together to ensure that the current filter will return all the
 | 
				
			||||||
        // messages of a conversation.
 | 
					        // messages of a conversation.
 | 
				
			||||||
 | 
					        if (_.isEqual(term_types, ["channel", "topic", "with"])) {
 | 
				
			||||||
 | 
					            return true;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (_.isEqual(term_types, ["channel", "topic"])) {
 | 
					        if (_.isEqual(term_types, ["channel", "topic"])) {
 | 
				
			||||||
            return true;
 | 
					            return true;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -1180,6 +1232,7 @@ export class Filter {
 | 
				
			|||||||
        const term_types = this.sorted_term_types();
 | 
					        const term_types = this.sorted_term_types();
 | 
				
			||||||
        if (
 | 
					        if (
 | 
				
			||||||
            (term_types.length === 3 && _.isEqual(term_types, ["channel", "topic", "near"])) ||
 | 
					            (term_types.length === 3 && _.isEqual(term_types, ["channel", "topic", "near"])) ||
 | 
				
			||||||
 | 
					            (term_types.length === 3 && _.isEqual(term_types, ["channel", "topic", "with"])) ||
 | 
				
			||||||
            (term_types.length === 2 && _.isEqual(term_types, ["channel", "topic"])) ||
 | 
					            (term_types.length === 2 && _.isEqual(term_types, ["channel", "topic"])) ||
 | 
				
			||||||
            (term_types.length === 1 && _.isEqual(term_types, ["channel"]))
 | 
					            (term_types.length === 1 && _.isEqual(term_types, ["channel"]))
 | 
				
			||||||
        ) {
 | 
					        ) {
 | 
				
			||||||
@@ -1362,10 +1415,12 @@ export class Filter {
 | 
				
			|||||||
    fix_terms(terms: NarrowTerm[]): NarrowTerm[] {
 | 
					    fix_terms(terms: NarrowTerm[]): NarrowTerm[] {
 | 
				
			||||||
        terms = this._canonicalize_terms(terms);
 | 
					        terms = this._canonicalize_terms(terms);
 | 
				
			||||||
        terms = this._fix_redundant_is_private(terms);
 | 
					        terms = this._fix_redundant_is_private(terms);
 | 
				
			||||||
 | 
					        terms = this._fix_redundant_with_dm(terms);
 | 
				
			||||||
        return terms;
 | 
					        return terms;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _fix_redundant_is_private(terms: NarrowTerm[]): NarrowTerm[] {
 | 
					    _fix_redundant_is_private(terms: NarrowTerm[]): NarrowTerm[] {
 | 
				
			||||||
 | 
					        // Every DM is a DM, so drop `is:dm` if on a DM conversation.
 | 
				
			||||||
        if (!terms.some((term) => Filter.term_type(term) === "dm")) {
 | 
					        if (!terms.some((term) => Filter.term_type(term) === "dm")) {
 | 
				
			||||||
            return terms;
 | 
					            return terms;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@@ -1373,6 +1428,16 @@ export class Filter {
 | 
				
			|||||||
        return terms.filter((term) => Filter.term_type(term) !== "is-dm");
 | 
					        return terms.filter((term) => Filter.term_type(term) !== "is-dm");
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _fix_redundant_with_dm(terms: NarrowTerm[]): NarrowTerm[] {
 | 
				
			||||||
 | 
					        // Because DMs can't move, the `with` operator is a noop on a
 | 
				
			||||||
 | 
					        // DM conversation.
 | 
				
			||||||
 | 
					        if (terms.some((term) => Filter.term_type(term) === "dm")) {
 | 
				
			||||||
 | 
					            return terms.filter((term) => Filter.term_type(term) !== "with");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return terms;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _canonicalize_terms(terms_mixed_case: NarrowTerm[]): NarrowTerm[] {
 | 
					    _canonicalize_terms(terms_mixed_case: NarrowTerm[]): NarrowTerm[] {
 | 
				
			||||||
        return terms_mixed_case.map((term: NarrowTerm) => Filter.canonicalize_term(term));
 | 
					        return terms_mixed_case.map((term: NarrowTerm) => Filter.canonicalize_term(term));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -1512,4 +1577,27 @@ export class Filter {
 | 
				
			|||||||
            !this.has_operand("is", "starred")
 | 
					            !this.has_operand("is", "starred")
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try_adjusting_for_moved_with_target(message?: Message): void {
 | 
				
			||||||
 | 
					        // If we have the message named in a `with` operator
 | 
				
			||||||
 | 
					        // available, either via parameter or message_store,
 | 
				
			||||||
 | 
					        if (!this.requires_adjustment_for_moved_with_target) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!message) {
 | 
				
			||||||
 | 
					            const message_id = Number.parseInt(this.operands("with")[0]!, 10);
 | 
				
			||||||
 | 
					            message = message_store.get(message_id);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!message) {
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const adjusted_terms = Filter.adjusted_terms_if_moved(this._terms, message);
 | 
				
			||||||
 | 
					        if (adjusted_terms) {
 | 
				
			||||||
 | 
					            this.setup_filter(adjusted_terms);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.requires_adjustment_for_moved_with_target = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -127,6 +127,7 @@ export const allowed_web_public_narrows = [
 | 
				
			|||||||
    "search",
 | 
					    "search",
 | 
				
			||||||
    "near",
 | 
					    "near",
 | 
				
			||||||
    "id",
 | 
					    "id",
 | 
				
			||||||
 | 
					    "with",
 | 
				
			||||||
];
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function is_spectator_compatible(hash: string): boolean {
 | 
					export function is_spectator_compatible(hash: string): boolean {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -80,6 +80,9 @@ function process_result(data, opts) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    if (messages.length !== 0) {
 | 
					    if (messages.length !== 0) {
 | 
				
			||||||
        if (opts.msg_list) {
 | 
					        if (opts.msg_list) {
 | 
				
			||||||
 | 
					            if (opts.validate_filter_topic_post_fetch) {
 | 
				
			||||||
 | 
					                opts.msg_list.data.filter.try_adjusting_for_moved_with_target(messages[0]);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
            // Since this adds messages to the MessageList and renders MessageListView,
 | 
					            // Since this adds messages to the MessageList and renders MessageListView,
 | 
				
			||||||
            // we don't need to call it if msg_list was not defined by the caller.
 | 
					            // we don't need to call it if msg_list was not defined by the caller.
 | 
				
			||||||
            message_util.add_old_messages(messages, opts.msg_list);
 | 
					            message_util.add_old_messages(messages, opts.msg_list);
 | 
				
			||||||
@@ -394,6 +397,7 @@ export function load_messages_for_narrow(opts) {
 | 
				
			|||||||
        num_after: consts.narrow_after,
 | 
					        num_after: consts.narrow_after,
 | 
				
			||||||
        msg_list: opts.msg_list,
 | 
					        msg_list: opts.msg_list,
 | 
				
			||||||
        cont: opts.cont,
 | 
					        cont: opts.cont,
 | 
				
			||||||
 | 
					        validate_filter_topic_post_fetch: opts.validate_filter_topic_post_fetch,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -87,7 +87,19 @@ export function changehash(newhash, trigger) {
 | 
				
			|||||||
        return;
 | 
					        return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    message_viewport.stop_auto_scrolling();
 | 
					    message_viewport.stop_auto_scrolling();
 | 
				
			||||||
    browser_history.set_hash(newhash);
 | 
					
 | 
				
			||||||
 | 
					    if (trigger === "retarget topic location") {
 | 
				
			||||||
 | 
					        // It is important to use `replaceState` rather than `replace`
 | 
				
			||||||
 | 
					        // here for the `back` button to work; we don't want to use
 | 
				
			||||||
 | 
					        // any metadata potentially stored by
 | 
				
			||||||
 | 
					        // update_current_history_state_data associated with an old
 | 
				
			||||||
 | 
					        // URL for the target conversation, and conceptually we want
 | 
				
			||||||
 | 
					        // to replace the inaccurate/old URL for the conversation with
 | 
				
			||||||
 | 
					        // the current/corrected value.
 | 
				
			||||||
 | 
					        window.history.replaceState(null, "", newhash);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					        browser_history.set_hash(newhash);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function update_hash_to_match_filter(filter, trigger) {
 | 
					export function update_hash_to_match_filter(filter, trigger) {
 | 
				
			||||||
@@ -132,12 +144,14 @@ function create_and_update_message_list(filter, id_info, opts) {
 | 
				
			|||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Populate the message list if we can apply our filter locally (i.e.
 | 
					        // Populate the message list if we can apply our filter locally (i.e.
 | 
				
			||||||
        // with no backend help) and we have the message we want to select.
 | 
					        // with no server help) and we have the message we want to select.
 | 
				
			||||||
        // Also update id_info accordingly.
 | 
					        // Also update id_info accordingly.
 | 
				
			||||||
        maybe_add_local_messages({
 | 
					        if (!filter.requires_adjustment_for_moved_with_target) {
 | 
				
			||||||
            id_info,
 | 
					            maybe_add_local_messages({
 | 
				
			||||||
            msg_data,
 | 
					                id_info,
 | 
				
			||||||
        });
 | 
					                msg_data,
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (!id_info.local_select_id) {
 | 
					        if (!id_info.local_select_id) {
 | 
				
			||||||
            // If we're not actually ready to select an ID, we need to
 | 
					            // If we're not actually ready to select an ID, we need to
 | 
				
			||||||
@@ -306,6 +320,7 @@ export function show(raw_terms, opts) {
 | 
				
			|||||||
        raw_terms = [{operator: "is", operand: "home"}];
 | 
					        raw_terms = [{operator: "is", operand: "home"}];
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    const filter = new Filter(raw_terms);
 | 
					    const filter = new Filter(raw_terms);
 | 
				
			||||||
 | 
					    filter.try_adjusting_for_moved_with_target();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (try_rendering_locally_for_same_narrow(filter, opts)) {
 | 
					    if (try_rendering_locally_for_same_narrow(filter, opts)) {
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
@@ -642,7 +657,18 @@ export function show(raw_terms, opts) {
 | 
				
			|||||||
                }
 | 
					                }
 | 
				
			||||||
                message_fetch.load_messages_for_narrow({
 | 
					                message_fetch.load_messages_for_narrow({
 | 
				
			||||||
                    anchor,
 | 
					                    anchor,
 | 
				
			||||||
 | 
					                    validate_filter_topic_post_fetch:
 | 
				
			||||||
 | 
					                        filter.requires_adjustment_for_moved_with_target,
 | 
				
			||||||
                    cont() {
 | 
					                    cont() {
 | 
				
			||||||
 | 
					                        if (
 | 
				
			||||||
 | 
					                            !filter.requires_adjustment_for_moved_with_target &&
 | 
				
			||||||
 | 
					                            filter.has_operator("with")
 | 
				
			||||||
 | 
					                        ) {
 | 
				
			||||||
 | 
					                            // We've already adjusted our filter via
 | 
				
			||||||
 | 
					                            // filter.try_adjusting_for_moved_with_target, and
 | 
				
			||||||
 | 
					                            // should update the URL hash accordingly.
 | 
				
			||||||
 | 
					                            update_hash_to_match_filter(filter, "retarget topic location");
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
                        if (!select_immediately) {
 | 
					                        if (!select_immediately) {
 | 
				
			||||||
                            render_message_list_with_selected_message({
 | 
					                            render_message_list_with_selected_message({
 | 
				
			||||||
                                id_info,
 | 
					                                id_info,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,9 @@
 | 
				
			|||||||
 | 
					import assert from "minimalistic-assert";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import * as blueslip from "./blueslip";
 | 
					import * as blueslip from "./blueslip";
 | 
				
			||||||
import {Filter} from "./filter";
 | 
					import {Filter} from "./filter";
 | 
				
			||||||
import * as message_lists from "./message_lists";
 | 
					import * as message_lists from "./message_lists";
 | 
				
			||||||
 | 
					import * as message_store from "./message_store";
 | 
				
			||||||
import {page_params} from "./page_params";
 | 
					import {page_params} from "./page_params";
 | 
				
			||||||
import * as people from "./people";
 | 
					import * as people from "./people";
 | 
				
			||||||
import type {NarrowTerm} from "./state_data";
 | 
					import type {NarrowTerm} from "./state_data";
 | 
				
			||||||
@@ -265,6 +268,42 @@ export function _possible_unread_message_ids(
 | 
				
			|||||||
    let topic_name;
 | 
					    let topic_name;
 | 
				
			||||||
    let current_filter_pm_string;
 | 
					    let current_filter_pm_string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // For the `with` operator, we can only correctly compute the
 | 
				
			||||||
 | 
					    // correct channel/topic for lookup unreads in if we either
 | 
				
			||||||
 | 
					    // have the message in our local cache, or we know the filter
 | 
				
			||||||
 | 
					    // has already been updated for potentially moved messages.
 | 
				
			||||||
 | 
					    //
 | 
				
			||||||
 | 
					    // The code path that needs this function is never called in
 | 
				
			||||||
 | 
					    // the `with` code path, but for safety, we assert that
 | 
				
			||||||
 | 
					    // assumption is not violated.
 | 
				
			||||||
 | 
					    //
 | 
				
			||||||
 | 
					    // If we need to change that assumption, we can try looking up the
 | 
				
			||||||
 | 
					    // target message in message_store, but would need to return
 | 
				
			||||||
 | 
					    // undefined if the target message is not available.
 | 
				
			||||||
 | 
					    assert(!current_filter.requires_adjustment_for_moved_with_target);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (current_filter.can_bucket_by("channel", "topic", "with")) {
 | 
				
			||||||
 | 
					        sub = stream_sub(current_filter);
 | 
				
			||||||
 | 
					        topic_name = topic(current_filter);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const with_operand = current_filter.operands("with")[0]!;
 | 
				
			||||||
 | 
					        const target_id = Number.parseInt(with_operand, 10);
 | 
				
			||||||
 | 
					        const target_message = message_store.get(target_id)!;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (target_message?.type === "private") {
 | 
				
			||||||
 | 
					            // BUG: In theory, the fact that we've asserted
 | 
				
			||||||
 | 
					            // !current_filter.requires_adjustment_for_moved_with_target
 | 
				
			||||||
 | 
					            // should mean this is not possible; but
 | 
				
			||||||
 | 
					            // filter.adjusted_terms_if_moved incorrectly does not
 | 
				
			||||||
 | 
					            // ensure this. Once that bug is fixed, we can delete this case.
 | 
				
			||||||
 | 
					            return [];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (sub === undefined || topic_name === undefined) {
 | 
				
			||||||
 | 
					            return [];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return unread.get_msg_ids_for_topic(sub.stream_id, topic_name);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (current_filter.can_bucket_by("channel", "topic")) {
 | 
					    if (current_filter.can_bucket_by("channel", "topic")) {
 | 
				
			||||||
        sub = stream_sub(current_filter);
 | 
					        sub = stream_sub(current_filter);
 | 
				
			||||||
        topic_name = topic(current_filter);
 | 
					        topic_name = topic(current_filter);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -397,6 +397,25 @@ test("basics", () => {
 | 
				
			|||||||
    assert.ok(filter.is_conversation_view());
 | 
					    assert.ok(filter.is_conversation_view());
 | 
				
			||||||
    assert.ok(!filter.is_conversation_view_with_near());
 | 
					    assert.ok(!filter.is_conversation_view_with_near());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: "foo"},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "bar"},
 | 
				
			||||||
 | 
					        {operator: "with", operand: 17},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					    filter = new Filter(terms);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert.ok(!filter.is_keyword_search());
 | 
				
			||||||
 | 
					    assert.ok(filter.can_mark_messages_read());
 | 
				
			||||||
 | 
					    assert.ok(filter.supports_collapsing_recipients());
 | 
				
			||||||
 | 
					    assert.ok(!filter.contains_only_private_messages());
 | 
				
			||||||
 | 
					    assert.ok(filter.allow_use_first_unread_when_narrowing());
 | 
				
			||||||
 | 
					    assert.ok(filter.includes_full_stream_history());
 | 
				
			||||||
 | 
					    assert.ok(filter.can_apply_locally());
 | 
				
			||||||
 | 
					    assert.ok(!filter.is_personal_filter());
 | 
				
			||||||
 | 
					    assert.ok(!filter.is_conversation_view());
 | 
				
			||||||
 | 
					    assert.ok(filter.can_bucket_by("channel", "topic", "with"));
 | 
				
			||||||
 | 
					    assert.ok(!filter.is_conversation_view_with_near());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // "stream" was renamed to "channel"
 | 
					    // "stream" was renamed to "channel"
 | 
				
			||||||
    terms = [
 | 
					    terms = [
 | 
				
			||||||
        {operator: "stream", operand: "foo"},
 | 
					        {operator: "stream", operand: "foo"},
 | 
				
			||||||
@@ -723,6 +742,13 @@ test("redundancies", () => {
 | 
				
			|||||||
    ];
 | 
					    ];
 | 
				
			||||||
    filter = new Filter(terms);
 | 
					    filter = new Filter(terms);
 | 
				
			||||||
    assert.ok(filter.can_bucket_by("is-dm", "not-dm"));
 | 
					    assert.ok(filter.can_bucket_by("is-dm", "not-dm"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    terms = [
 | 
				
			||||||
 | 
					        {operator: "dm", operand: "joe@example.com,"},
 | 
				
			||||||
 | 
					        {operator: "with", operand: "12"},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					    filter = new Filter(terms);
 | 
				
			||||||
 | 
					    assert.ok(filter.can_bucket_by("dm"));
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test("canonicalization", () => {
 | 
					test("canonicalization", () => {
 | 
				
			||||||
@@ -787,6 +813,23 @@ test("canonicalization", () => {
 | 
				
			|||||||
    assert.equal(term.operand, "reaction");
 | 
					    assert.equal(term.operand, "reaction");
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test("ensure_channel_topic_terms", () => {
 | 
				
			||||||
 | 
					    const channel_term = {operator: "channel", operand: ""};
 | 
				
			||||||
 | 
					    const topic_term = {operator: "topic", operand: ""};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const term_1 = Filter.ensure_channel_topic_terms([{operator: "with", operand: 12}]);
 | 
				
			||||||
 | 
					    const term_2 = Filter.ensure_channel_topic_terms([topic_term, {operator: "with", operand: 12}]);
 | 
				
			||||||
 | 
					    const term_3 = Filter.ensure_channel_topic_terms([
 | 
				
			||||||
 | 
					        channel_term,
 | 
				
			||||||
 | 
					        {operator: "with", operand: 12},
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					    const terms = [term_1, term_2, term_3];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const term of terms) {
 | 
				
			||||||
 | 
					        assert.deepEqual(term, [channel_term, topic_term, {operator: "with", operand: 12}]);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test("predicate_basics", ({override}) => {
 | 
					test("predicate_basics", ({override}) => {
 | 
				
			||||||
    // Predicates are functions that accept a message object with the message
 | 
					    // Predicates are functions that accept a message object with the message
 | 
				
			||||||
    // attributes (not content), and return true if the message belongs in a
 | 
					    // attributes (not content), and return true if the message belongs in a
 | 
				
			||||||
@@ -1598,6 +1641,8 @@ test("term_type", () => {
 | 
				
			|||||||
        ["dm", "near", "is-unread", "has-link"],
 | 
					        ["dm", "near", "is-unread", "has-link"],
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    assert_term_sort(["topic", "channel", "with"], ["channel", "topic", "with"]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    assert_term_sort(["bogus", "channel", "topic"], ["channel", "topic", "bogus"]);
 | 
					    assert_term_sort(["bogus", "channel", "topic"], ["channel", "topic", "bogus"]);
 | 
				
			||||||
    assert_term_sort(["channel", "topic", "channel"], ["channel", "channel", "topic"]);
 | 
					    assert_term_sort(["channel", "topic", "channel"], ["channel", "channel", "topic"]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1713,6 +1758,69 @@ test("update_email", () => {
 | 
				
			|||||||
    assert.deepEqual(filter.operands("channel"), ["steve@foo.com"]);
 | 
					    assert.deepEqual(filter.operands("channel"), ["steve@foo.com"]);
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test("try_adjusting_for_moved_with_target", ({override}) => {
 | 
				
			||||||
 | 
					    const messages = {
 | 
				
			||||||
 | 
					        12: {type: "stream", display_recipient: "Scotland", topic: "Test 1", id: 12},
 | 
				
			||||||
 | 
					        17: {type: "stream", display_recipient: "Verona", topic: "Test 2", id: 17},
 | 
				
			||||||
 | 
					        2: {type: "direct", id: 2},
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    override(message_store, "get", (msg_id) => messages[msg_id]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // When the narrow terms are correct, it returns the same terms
 | 
				
			||||||
 | 
					    let terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: "Scotland", negated: false},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "Test 1", negated: false},
 | 
				
			||||||
 | 
					        {operator: "with", operand: "12", negated: false},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let filter = new Filter(terms);
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.requires_adjustment_for_moved_with_target, true);
 | 
				
			||||||
 | 
					    filter.try_adjusting_for_moved_with_target();
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.requires_adjustment_for_moved_with_target, false);
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.terms(), terms);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // When the narrow terms are incorrect, the narrow is corrected
 | 
				
			||||||
 | 
					    // to the narrow of the `with` operand.
 | 
				
			||||||
 | 
					    const incorrect_terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: "Verona", negated: false},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "Test 2", negated: false},
 | 
				
			||||||
 | 
					        {operator: "with", operand: "12", negated: false},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    filter = new Filter(incorrect_terms);
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.requires_adjustment_for_moved_with_target, true);
 | 
				
			||||||
 | 
					    filter.try_adjusting_for_moved_with_target();
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.requires_adjustment_for_moved_with_target, false);
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.terms(), terms);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // when message specified in `with` operator does not exist in
 | 
				
			||||||
 | 
					    // message_store, we rather go to the server, without any updates.
 | 
				
			||||||
 | 
					    terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: "Scotland", negated: false},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "Test 1", negated: false},
 | 
				
			||||||
 | 
					        {operator: "with", operand: "11", negated: false},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    filter = new Filter(terms);
 | 
				
			||||||
 | 
					    filter.try_adjusting_for_moved_with_target();
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.requires_adjustment_for_moved_with_target, true);
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.terms(), terms);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Since only "stream" recipient type can be moved, if the message
 | 
				
			||||||
 | 
					    // is of any type other than "stream", same narrow terms are
 | 
				
			||||||
 | 
					    // returned without the `with` operator.
 | 
				
			||||||
 | 
					    terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: "Scotland", negated: false},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "Test 1", negated: false},
 | 
				
			||||||
 | 
					        {operator: "with", operand: "2", negated: false},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					    filter = new Filter(terms);
 | 
				
			||||||
 | 
					    filter.try_adjusting_for_moved_with_target();
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.requires_adjustment_for_moved_with_target, false);
 | 
				
			||||||
 | 
					    assert.deepEqual(filter.terms(), terms);
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function make_private_sub(name, stream_id) {
 | 
					function make_private_sub(name, stream_id) {
 | 
				
			||||||
    const sub = {
 | 
					    const sub = {
 | 
				
			||||||
        name,
 | 
					        name,
 | 
				
			||||||
@@ -1732,6 +1840,12 @@ function make_web_public_sub(name, stream_id) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test("navbar_helpers", () => {
 | 
					test("navbar_helpers", () => {
 | 
				
			||||||
 | 
					    const sub = {
 | 
				
			||||||
 | 
					        name: "Foo",
 | 
				
			||||||
 | 
					        stream_id: 12,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    stream_data.add_sub(sub);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const stream_id = 43;
 | 
					    const stream_id = 43;
 | 
				
			||||||
    make_sub("Foo", stream_id);
 | 
					    make_sub("Foo", stream_id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1841,6 +1955,11 @@ test("navbar_helpers", () => {
 | 
				
			|||||||
        {operator: "dm", operand: "joe@example.com"},
 | 
					        {operator: "dm", operand: "joe@example.com"},
 | 
				
			||||||
        {operator: "near", operand: "12"},
 | 
					        {operator: "near", operand: "12"},
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					    const channel_with = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: "foo"},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "bar"},
 | 
				
			||||||
 | 
					        {operator: "with", operand: "12"},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const test_cases = [
 | 
					    const test_cases = [
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
@@ -2039,6 +2158,13 @@ test("navbar_helpers", () => {
 | 
				
			|||||||
            title: properly_separated_names([joe.full_name]),
 | 
					            title: properly_separated_names([joe.full_name]),
 | 
				
			||||||
            redirect_url_with_search: "#",
 | 
					            redirect_url_with_search: "#",
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            terms: channel_with,
 | 
				
			||||||
 | 
					            is_common_narrow: true,
 | 
				
			||||||
 | 
					            zulip_icon: "hashtag",
 | 
				
			||||||
 | 
					            title: "Foo",
 | 
				
			||||||
 | 
					            redirect_url_with_search: "#",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    realm.realm_enable_guest_user_indicator = true;
 | 
					    realm.realm_enable_guest_user_indicator = true;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -206,6 +206,7 @@ run_test("basics", ({override}) => {
 | 
				
			|||||||
            cont: opts.cont,
 | 
					            cont: opts.cont,
 | 
				
			||||||
            msg_list: opts.msg_list,
 | 
					            msg_list: opts.msg_list,
 | 
				
			||||||
            anchor: 1000,
 | 
					            anchor: 1000,
 | 
				
			||||||
 | 
					            validate_filter_topic_post_fetch: false,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        opts.cont();
 | 
					        opts.cont();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -30,6 +30,7 @@ people.add_active_user(alice);
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
function set_filter(terms) {
 | 
					function set_filter(terms) {
 | 
				
			||||||
    const filter = new Filter(terms);
 | 
					    const filter = new Filter(terms);
 | 
				
			||||||
 | 
					    filter.try_adjusting_for_moved_with_target();
 | 
				
			||||||
    message_lists.set_current({
 | 
					    message_lists.set_current({
 | 
				
			||||||
        data: {
 | 
					        data: {
 | 
				
			||||||
            filter,
 | 
					            filter,
 | 
				
			||||||
@@ -64,6 +65,7 @@ run_test("get_unread_ids", () => {
 | 
				
			|||||||
        id: 101,
 | 
					        id: 101,
 | 
				
			||||||
        type: "stream",
 | 
					        type: "stream",
 | 
				
			||||||
        stream_id: sub.stream_id,
 | 
					        stream_id: sub.stream_id,
 | 
				
			||||||
 | 
					        display_recipient: sub.name,
 | 
				
			||||||
        topic: "my topic",
 | 
					        topic: "my topic",
 | 
				
			||||||
        unread: true,
 | 
					        unread: true,
 | 
				
			||||||
        mentioned: true,
 | 
					        mentioned: true,
 | 
				
			||||||
@@ -77,8 +79,20 @@ run_test("get_unread_ids", () => {
 | 
				
			|||||||
        display_recipient: [{id: alice.user_id}],
 | 
					        display_recipient: [{id: alice.user_id}],
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const other_topic_message = {
 | 
				
			||||||
 | 
					        id: 103,
 | 
				
			||||||
 | 
					        type: "stream",
 | 
				
			||||||
 | 
					        stream_id: sub.stream_id,
 | 
				
			||||||
 | 
					        display_recipient: sub.name,
 | 
				
			||||||
 | 
					        topic: "another topic",
 | 
				
			||||||
 | 
					        unread: true,
 | 
				
			||||||
 | 
					        mentioned: false,
 | 
				
			||||||
 | 
					        mentioned_me_directly: false,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    message_store.update_message_cache(stream_msg);
 | 
					    message_store.update_message_cache(stream_msg);
 | 
				
			||||||
    message_store.update_message_cache(private_msg);
 | 
					    message_store.update_message_cache(private_msg);
 | 
				
			||||||
 | 
					    message_store.update_message_cache(other_topic_message);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    stream_data.add_sub(sub);
 | 
					    stream_data.add_sub(sub);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -202,6 +216,41 @@ run_test("get_unread_ids", () => {
 | 
				
			|||||||
        flavor: "cannot_compute",
 | 
					        flavor: "cannot_compute",
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // For a search using `with` operator, our candidate ids
 | 
				
			||||||
 | 
					    // will be the messages present in the channel/topic
 | 
				
			||||||
 | 
					    // containing the message for which the `with` operand
 | 
				
			||||||
 | 
					    // is id to.
 | 
				
			||||||
 | 
					    //
 | 
				
			||||||
 | 
					    // Here we use an empty topic for the operators, and show that
 | 
				
			||||||
 | 
					    // adding the with operator causes us to see unreads in the
 | 
				
			||||||
 | 
					    // destination topic.
 | 
				
			||||||
 | 
					    unread.process_loaded_messages([other_topic_message]);
 | 
				
			||||||
 | 
					    terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: sub.name},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "another topic"},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					    set_filter(terms);
 | 
				
			||||||
 | 
					    unread_ids = candidate_ids();
 | 
				
			||||||
 | 
					    assert.deepEqual(unread_ids, [other_topic_message.id]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: sub.name},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "another topic"},
 | 
				
			||||||
 | 
					        {operator: "with", operand: stream_msg.id},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					    set_filter(terms);
 | 
				
			||||||
 | 
					    unread_ids = candidate_ids();
 | 
				
			||||||
 | 
					    assert.deepEqual(unread_ids, [stream_msg.id]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    terms = [
 | 
				
			||||||
 | 
					        {operator: "channel", operand: sub.name},
 | 
				
			||||||
 | 
					        {operator: "topic", operand: "another topic"},
 | 
				
			||||||
 | 
					        {operator: "with", operand: private_msg.id},
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					    set_filter(terms);
 | 
				
			||||||
 | 
					    unread_ids = candidate_ids();
 | 
				
			||||||
 | 
					    assert.deepEqual(unread_ids, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    message_lists.set_current(undefined);
 | 
					    message_lists.set_current(undefined);
 | 
				
			||||||
    blueslip.expect("error", "unexpected call to get_first_unread_info");
 | 
					    blueslip.expect("error", "unexpected call to get_first_unread_info");
 | 
				
			||||||
    assert_unread_info({
 | 
					    assert_unread_info({
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,6 +16,7 @@ from typing import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
 | 
					from django.contrib.auth.models import AnonymousUser
 | 
				
			||||||
from django.core.exceptions import ValidationError
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
from django.db import connection
 | 
					from django.db import connection
 | 
				
			||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
@@ -44,8 +45,12 @@ from sqlalchemy.types import ARRAY, Boolean, Integer, Text
 | 
				
			|||||||
from typing_extensions import TypeAlias, override
 | 
					from typing_extensions import TypeAlias, override
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from zerver.lib.addressee import get_user_profiles, get_user_profiles_by_ids
 | 
					from zerver.lib.addressee import get_user_profiles, get_user_profiles_by_ids
 | 
				
			||||||
from zerver.lib.exceptions import ErrorCode, JsonableError
 | 
					from zerver.lib.exceptions import ErrorCode, JsonableError, MissingAuthenticationError
 | 
				
			||||||
from zerver.lib.message import get_first_visible_message_id
 | 
					from zerver.lib.message import (
 | 
				
			||||||
 | 
					    access_message,
 | 
				
			||||||
 | 
					    access_web_public_message,
 | 
				
			||||||
 | 
					    get_first_visible_message_id,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from zerver.lib.narrow_predicate import channel_operators, channels_operators
 | 
					from zerver.lib.narrow_predicate import channel_operators, channels_operators
 | 
				
			||||||
from zerver.lib.recipient_users import recipient_for_user_profiles
 | 
					from zerver.lib.recipient_users import recipient_for_user_profiles
 | 
				
			||||||
from zerver.lib.sqlalchemy_utils import get_sqlalchemy_connection
 | 
					from zerver.lib.sqlalchemy_utils import get_sqlalchemy_connection
 | 
				
			||||||
@@ -81,6 +86,7 @@ from zerver.models import (
 | 
				
			|||||||
    UserMessage,
 | 
					    UserMessage,
 | 
				
			||||||
    UserProfile,
 | 
					    UserProfile,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					from zerver.models.recipients import get_direct_message_group_user_ids
 | 
				
			||||||
from zerver.models.streams import get_active_streams
 | 
					from zerver.models.streams import get_active_streams
 | 
				
			||||||
from zerver.models.users import (
 | 
					from zerver.models.users import (
 | 
				
			||||||
    get_user_by_id_in_realm_including_cross_realm,
 | 
					    get_user_by_id_in_realm_including_cross_realm,
 | 
				
			||||||
@@ -124,6 +130,7 @@ class NarrowParameter(BaseModel):
 | 
				
			|||||||
            "sender",
 | 
					            "sender",
 | 
				
			||||||
            "group-pm-with",
 | 
					            "group-pm-with",
 | 
				
			||||||
            "dm-including",
 | 
					            "dm-including",
 | 
				
			||||||
 | 
					            "with",
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
        operators_supporting_ids = ["pm-with", "dm"]
 | 
					        operators_supporting_ids = ["pm-with", "dm"]
 | 
				
			||||||
        operators_non_empty_operand = {"search"}
 | 
					        operators_non_empty_operand = {"search"}
 | 
				
			||||||
@@ -161,6 +168,7 @@ def is_spectator_compatible(narrow: Iterable[NarrowParameter]) -> bool:
 | 
				
			|||||||
        "search",
 | 
					        "search",
 | 
				
			||||||
        "near",
 | 
					        "near",
 | 
				
			||||||
        "id",
 | 
					        "id",
 | 
				
			||||||
 | 
					        "with",
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
    for element in narrow:
 | 
					    for element in narrow:
 | 
				
			||||||
        operator = element.operator
 | 
					        operator = element.operator
 | 
				
			||||||
@@ -200,6 +208,19 @@ class BadNarrowOperatorError(JsonableError):
 | 
				
			|||||||
        return _("Invalid narrow operator: {desc}")
 | 
					        return _("Invalid narrow operator: {desc}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InvalidOperatorCombinationError(JsonableError):
 | 
				
			||||||
 | 
					    code = ErrorCode.BAD_NARROW
 | 
				
			||||||
 | 
					    data_fields = ["desc"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, desc: str) -> None:
 | 
				
			||||||
 | 
					        self.desc: str = desc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    @override
 | 
				
			||||||
 | 
					    def msg_format() -> str:
 | 
				
			||||||
 | 
					        return _("Invalid narrow operator combination: {desc}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ConditionTransform: TypeAlias = Callable[[ClauseElement], ClauseElement]
 | 
					ConditionTransform: TypeAlias = Callable[[ClauseElement], ClauseElement]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# These delimiters will not appear in rendered messages or HTML-escaped topics.
 | 
					# These delimiters will not appear in rendered messages or HTML-escaped topics.
 | 
				
			||||||
@@ -863,6 +884,82 @@ def get_channel_from_narrow_access_unchecked(
 | 
				
			|||||||
    return None
 | 
					    return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# This function implements the core logic of the `with` operator,
 | 
				
			||||||
 | 
					# which is designed to support permanent links to a topic that
 | 
				
			||||||
 | 
					# robustly function if the topic is moved.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# The with operator accepts a message ID as an operand. If the
 | 
				
			||||||
 | 
					# message ID does not exist or is otherwise not accessible to the
 | 
				
			||||||
 | 
					# current user, then it has no effect.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# Otherwise, the narrow terms are mutated to remove any
 | 
				
			||||||
 | 
					# channel/topic/dm operators, replacing them with the appropriate
 | 
				
			||||||
 | 
					# operators for the conversation view containing the targeted message.
 | 
				
			||||||
 | 
					def update_narrow_terms_containing_with_operator(
 | 
				
			||||||
 | 
					    realm: Realm,
 | 
				
			||||||
 | 
					    maybe_user_profile: Union[UserProfile, AnonymousUser],
 | 
				
			||||||
 | 
					    narrow: Optional[List[NarrowParameter]],
 | 
				
			||||||
 | 
					) -> Optional[List[NarrowParameter]]:
 | 
				
			||||||
 | 
					    if narrow is None:
 | 
				
			||||||
 | 
					        return narrow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    with_operator_terms = list(filter(lambda term: term.operator == "with", narrow))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if len(with_operator_terms) > 1:
 | 
				
			||||||
 | 
					        raise InvalidOperatorCombinationError(_("Duplicate 'with' operators."))
 | 
				
			||||||
 | 
					    elif len(with_operator_terms) == 0:
 | 
				
			||||||
 | 
					        return narrow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    with_term = with_operator_terms[0]
 | 
				
			||||||
 | 
					    narrow.remove(with_term)
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        message_id = int(with_term.operand)
 | 
				
			||||||
 | 
					    except ValueError:
 | 
				
			||||||
 | 
					        # TODO: This probably should be handled earlier.
 | 
				
			||||||
 | 
					        raise BadNarrowOperatorError(_("Invalid 'with' operator"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if maybe_user_profile.is_authenticated:
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            message = access_message(maybe_user_profile, message_id)
 | 
				
			||||||
 | 
					        except JsonableError:
 | 
				
			||||||
 | 
					            return narrow
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            message = access_web_public_message(realm, message_id)
 | 
				
			||||||
 | 
					        except MissingAuthenticationError:
 | 
				
			||||||
 | 
					            return narrow
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # TODO: It would be better if the legacy names here are canonicalized
 | 
				
			||||||
 | 
					    # while building a NarrowParameter.
 | 
				
			||||||
 | 
					    filtered_terms = [
 | 
				
			||||||
 | 
					        term
 | 
				
			||||||
 | 
					        for term in narrow
 | 
				
			||||||
 | 
					        if term.operator not in ["stream", "channel", "topic", "dm", "pm-with"]
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if message.recipient.type == Recipient.STREAM:
 | 
				
			||||||
 | 
					        channel_id = message.recipient.type_id
 | 
				
			||||||
 | 
					        topic = message.topic_name()
 | 
				
			||||||
 | 
					        channel_conversation_terms = [
 | 
				
			||||||
 | 
					            NarrowParameter(operator="channel", operand=channel_id),
 | 
				
			||||||
 | 
					            NarrowParameter(operator="topic", operand=topic),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        return channel_conversation_terms + filtered_terms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    elif message.recipient.type == Recipient.PERSONAL:
 | 
				
			||||||
 | 
					        dm_conversation_terms = [
 | 
				
			||||||
 | 
					            NarrowParameter(operator="dm", operand=[message.recipient.type_id])
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        return dm_conversation_terms + filtered_terms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    elif message.recipient.type == Recipient.DIRECT_MESSAGE_GROUP:
 | 
				
			||||||
 | 
					        huddle_user_ids = list(get_direct_message_group_user_ids(message.recipient))
 | 
				
			||||||
 | 
					        dm_conversation_terms = [NarrowParameter(operator="dm", operand=huddle_user_ids)]
 | 
				
			||||||
 | 
					        return dm_conversation_terms + filtered_terms
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    raise AssertionError("Invalid recipient type")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def exclude_muting_conditions(
 | 
					def exclude_muting_conditions(
 | 
				
			||||||
    user_profile: UserProfile, narrow: Optional[List[NarrowParameter]]
 | 
					    user_profile: UserProfile, narrow: Optional[List[NarrowParameter]]
 | 
				
			||||||
) -> List[ClauseElement]:
 | 
					) -> List[ClauseElement]:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -47,7 +47,7 @@ from zerver.lib.sqlalchemy_utils import get_sqlalchemy_connection
 | 
				
			|||||||
from zerver.lib.streams import StreamDict, create_streams_if_needed, get_public_streams_queryset
 | 
					from zerver.lib.streams import StreamDict, create_streams_if_needed, get_public_streams_queryset
 | 
				
			||||||
from zerver.lib.test_classes import ZulipTestCase
 | 
					from zerver.lib.test_classes import ZulipTestCase
 | 
				
			||||||
from zerver.lib.test_helpers import HostRequestMock, get_user_messages, queries_captured
 | 
					from zerver.lib.test_helpers import HostRequestMock, get_user_messages, queries_captured
 | 
				
			||||||
from zerver.lib.topic import MATCH_TOPIC, RESOLVED_TOPIC_PREFIX, TOPIC_NAME
 | 
					from zerver.lib.topic import MATCH_TOPIC, RESOLVED_TOPIC_PREFIX, TOPIC_NAME, messages_for_topic
 | 
				
			||||||
from zerver.lib.types import UserDisplayRecipient
 | 
					from zerver.lib.types import UserDisplayRecipient
 | 
				
			||||||
from zerver.lib.upload import create_attachment
 | 
					from zerver.lib.upload import create_attachment
 | 
				
			||||||
from zerver.lib.url_encoding import near_message_url
 | 
					from zerver.lib.url_encoding import near_message_url
 | 
				
			||||||
@@ -2675,6 +2675,187 @@ class GetOldMessagesTest(ZulipTestCase):
 | 
				
			|||||||
            """
 | 
					            """
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_get_visible_messages_using_narrow_with(self) -> None:
 | 
				
			||||||
 | 
					        hamlet = self.example_user("hamlet")
 | 
				
			||||||
 | 
					        othello = self.example_user("othello")
 | 
				
			||||||
 | 
					        iago = self.example_user("iago")
 | 
				
			||||||
 | 
					        realm = hamlet.realm
 | 
				
			||||||
 | 
					        self.login("iago")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.make_stream("dev team", invite_only=True, history_public_to_subscribers=False)
 | 
				
			||||||
 | 
					        self.subscribe(iago, "dev team")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test `with` operator effective when targeting a topic with
 | 
				
			||||||
 | 
					        # message which can be accessed by the user.
 | 
				
			||||||
 | 
					        msg_id = self.send_stream_message(iago, "dev team", topic_name="test")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        narrow = [
 | 
				
			||||||
 | 
					            dict(operator="channel", operand="dev team"),
 | 
				
			||||||
 | 
					            dict(operator="topic", operand="other_topic"),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_id),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
 | 
				
			||||||
 | 
					        self.assertEqual(results["messages"][0]["id"], msg_id)
 | 
				
			||||||
 | 
					        # Notably we returned the message with its actual topic.
 | 
				
			||||||
 | 
					        self.assertEqual(results["messages"][0]["subject"], "test")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test `with` operator without channel/topic operators.
 | 
				
			||||||
 | 
					        narrow = [
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_id),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
 | 
				
			||||||
 | 
					        self.assertEqual(results["messages"][0]["id"], msg_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test `with` operator ineffective when targeting a topic with
 | 
				
			||||||
 | 
					        # message that can not be accessed by the user.
 | 
				
			||||||
 | 
					        # Since !history_public_to_subscribers, hamlet cannot view.
 | 
				
			||||||
 | 
					        self.subscribe(hamlet, "dev team")
 | 
				
			||||||
 | 
					        self.login("hamlet")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        narrow = [
 | 
				
			||||||
 | 
					            dict(operator="channel", operand="dev team"),
 | 
				
			||||||
 | 
					            dict(operator="topic", operand="test"),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_id),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
 | 
				
			||||||
 | 
					        self.assert_length(results["messages"], 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Same result with topic specified incorrectly
 | 
				
			||||||
 | 
					        narrow = [
 | 
				
			||||||
 | 
					            dict(operator="channel", operand="dev team"),
 | 
				
			||||||
 | 
					            dict(operator="topic", operand="wrong_guess"),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_id),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
 | 
				
			||||||
 | 
					        self.assert_length(results["messages"], 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # If just with is specified, we get messages a la combined feed,
 | 
				
			||||||
 | 
					        # but not the target message.
 | 
				
			||||||
 | 
					        narrow = [
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_id),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(narrow).decode()))
 | 
				
			||||||
 | 
					        self.assertNotIn(msg_id, [message["id"] for message in results["messages"]])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test `with` operator is effective when targeting personal
 | 
				
			||||||
 | 
					        # messages with message id, and returns messages of that narrow.
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        # This will be relevant if we allow moving DMs in the future.
 | 
				
			||||||
 | 
					        #
 | 
				
			||||||
 | 
					        # First, attempt to view a message ID we can't access.
 | 
				
			||||||
 | 
					        msg_ids = [self.send_personal_message(iago, othello) for _ in range(2)]
 | 
				
			||||||
 | 
					        with_narrow = [
 | 
				
			||||||
 | 
					            # Important: We pass the wrong conversation.
 | 
				
			||||||
 | 
					            dict(operator="dm", operand=[hamlet.id]),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_ids[0]),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(with_narrow).decode()))
 | 
				
			||||||
 | 
					        self.assertNotIn(msg_id, [message["id"] for message in results["messages"]])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Now switch to a user how does have access.
 | 
				
			||||||
 | 
					        self.login("iago")
 | 
				
			||||||
 | 
					        with_narrow = [
 | 
				
			||||||
 | 
					            # Important: We pass the wrong conversation.
 | 
				
			||||||
 | 
					            dict(operator="dm", operand=[hamlet.id]),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_ids[0]),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(with_narrow).decode()))
 | 
				
			||||||
 | 
					        for msg in results["messages"]:
 | 
				
			||||||
 | 
					            self.assertIn(msg["id"], msg_ids)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test `with` operator is effective when targeting direct
 | 
				
			||||||
 | 
					        # messages group with message id.
 | 
				
			||||||
 | 
					        iago = self.example_user("iago")
 | 
				
			||||||
 | 
					        cordelia = self.example_user("cordelia")
 | 
				
			||||||
 | 
					        hamlet = self.example_user("othello")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        msg_ids = [self.send_group_direct_message(iago, [cordelia, hamlet]) for _ in range(2)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        with_narrow = [
 | 
				
			||||||
 | 
					            # Again, query the wrong conversation.
 | 
				
			||||||
 | 
					            dict(operator="dm", operand=[hamlet.id]),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_ids[0]),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        results = self.get_and_check_messages(dict(narrow=orjson.dumps(with_narrow).decode()))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for msg in results["messages"]:
 | 
				
			||||||
 | 
					            self.assertIn(msg["id"], msg_ids)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test `with` operator effective with spectator access when
 | 
				
			||||||
 | 
					        # spectator has access to message.
 | 
				
			||||||
 | 
					        self.logout()
 | 
				
			||||||
 | 
					        self.setup_web_public_test(5)
 | 
				
			||||||
 | 
					        channel = get_stream("web-public-channel", realm)
 | 
				
			||||||
 | 
					        assert channel.recipient_id is not None
 | 
				
			||||||
 | 
					        message_ids = messages_for_topic(realm.id, channel.recipient_id, "test").values_list(
 | 
				
			||||||
 | 
					            "id", flat=True
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        web_public_narrow = [
 | 
				
			||||||
 | 
					            dict(operator="channels", operand="web-public", negated=False),
 | 
				
			||||||
 | 
					            dict(operator="channel", operand="web-public-channel"),
 | 
				
			||||||
 | 
					            # Important: Pass a topic that doesn't contain the target message
 | 
				
			||||||
 | 
					            dict(operator="topic", operand="wrong topic"),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=message_ids[0]),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        post_params = {
 | 
				
			||||||
 | 
					            "anchor": 0,
 | 
				
			||||||
 | 
					            "num_before": 0,
 | 
				
			||||||
 | 
					            "num_after": 5,
 | 
				
			||||||
 | 
					            "narrow": orjson.dumps(web_public_narrow).decode(),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = self.client_get("/json/messages", dict(post_params))
 | 
				
			||||||
 | 
					        self.verify_web_public_query_result_success(result, 5)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test `with` operator ineffective when spectator does not have
 | 
				
			||||||
 | 
					        # access to message, by trying to access the same set of messages
 | 
				
			||||||
 | 
					        # but when the spectator access is not allowed.
 | 
				
			||||||
 | 
					        do_set_realm_property(hamlet.realm, "enable_spectator_access", False, acting_user=hamlet)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        result = self.client_get("/json/messages", dict(post_params))
 | 
				
			||||||
 | 
					        self.check_unauthenticated_response(result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test request with multiple `with` operators raises
 | 
				
			||||||
 | 
					        # InvalidOperatorCombinationError
 | 
				
			||||||
 | 
					        self.login("iago")
 | 
				
			||||||
 | 
					        iago = self.example_user("iago")
 | 
				
			||||||
 | 
					        msg_id_1 = self.send_stream_message(iago, "Verona")
 | 
				
			||||||
 | 
					        msg_id_2 = self.send_stream_message(iago, "Scotland")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        narrow = [
 | 
				
			||||||
 | 
					            dict(operator="channel", operand="Verona"),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_id_1),
 | 
				
			||||||
 | 
					            dict(operator="topic", operand="test"),
 | 
				
			||||||
 | 
					            dict(operator="with", operand=msg_id_2),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        post_params = {
 | 
				
			||||||
 | 
					            "anchor": msg_id_1,
 | 
				
			||||||
 | 
					            "num_before": 0,
 | 
				
			||||||
 | 
					            "num_after": 5,
 | 
				
			||||||
 | 
					            "narrow": orjson.dumps(narrow).decode(),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        result = self.client_get("/json/messages", dict(post_params))
 | 
				
			||||||
 | 
					        self.assert_json_error(
 | 
				
			||||||
 | 
					            result, "Invalid narrow operator combination: Duplicate 'with' operators."
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Test request with an invalid message id for `with` operator fails.
 | 
				
			||||||
 | 
					        msg_id = self.send_stream_message(iago, "Verona", topic_name="Invalid id")
 | 
				
			||||||
 | 
					        narrow = [
 | 
				
			||||||
 | 
					            dict(operator="channel", operand="Verona"),
 | 
				
			||||||
 | 
					            dict(operator="topic", operand="Invalid id"),
 | 
				
			||||||
 | 
					            dict(operator="with", operand="3.2"),
 | 
				
			||||||
 | 
					        ]
 | 
				
			||||||
 | 
					        post_params = {
 | 
				
			||||||
 | 
					            "anchor": msg_id,
 | 
				
			||||||
 | 
					            "num_before": 0,
 | 
				
			||||||
 | 
					            "num_after": 5,
 | 
				
			||||||
 | 
					            "narrow": orjson.dumps(narrow).decode(),
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        result = self.client_get("/json/messages", dict(post_params))
 | 
				
			||||||
 | 
					        self.assert_json_error(result, "Invalid narrow operator: Invalid 'with' operator")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @override_settings(USING_PGROONGA=False)
 | 
					    @override_settings(USING_PGROONGA=False)
 | 
				
			||||||
    def test_messages_in_narrow(self) -> None:
 | 
					    def test_messages_in_narrow(self) -> None:
 | 
				
			||||||
        user = self.example_user("cordelia")
 | 
					        user = self.example_user("cordelia")
 | 
				
			||||||
@@ -3615,7 +3796,7 @@ class GetOldMessagesTest(ZulipTestCase):
 | 
				
			|||||||
            ),
 | 
					            ),
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for operand in ["id", "sender", "channel", "dm-including", "group-pm-with"]:
 | 
					        for operand in ["id", "sender", "channel", "dm-including", "group-pm-with", "with"]:
 | 
				
			||||||
            self.exercise_bad_narrow_operand_using_dict_api(operand, invalid_operands)
 | 
					            self.exercise_bad_narrow_operand_using_dict_api(operand, invalid_operands)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # str or int list is required for "dm" and "pm-with" operator
 | 
					        # str or int list is required for "dm" and "pm-with" operator
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,6 +21,7 @@ from zerver.lib.narrow import (
 | 
				
			|||||||
    is_spectator_compatible,
 | 
					    is_spectator_compatible,
 | 
				
			||||||
    is_web_public_narrow,
 | 
					    is_web_public_narrow,
 | 
				
			||||||
    parse_anchor_value,
 | 
					    parse_anchor_value,
 | 
				
			||||||
 | 
					    update_narrow_terms_containing_with_operator,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from zerver.lib.request import RequestNotes
 | 
					from zerver.lib.request import RequestNotes
 | 
				
			||||||
from zerver.lib.response import json_success
 | 
					from zerver.lib.response import json_success
 | 
				
			||||||
@@ -114,7 +115,9 @@ def get_messages_backend(
 | 
				
			|||||||
    client_gravatar: Json[bool] = True,
 | 
					    client_gravatar: Json[bool] = True,
 | 
				
			||||||
    apply_markdown: Json[bool] = True,
 | 
					    apply_markdown: Json[bool] = True,
 | 
				
			||||||
) -> HttpResponse:
 | 
					) -> HttpResponse:
 | 
				
			||||||
 | 
					    realm = get_valid_realm_from_request(request)
 | 
				
			||||||
    anchor = parse_anchor_value(anchor_val, use_first_unread_anchor_val)
 | 
					    anchor = parse_anchor_value(anchor_val, use_first_unread_anchor_val)
 | 
				
			||||||
 | 
					    narrow = update_narrow_terms_containing_with_operator(realm, maybe_user_profile, narrow)
 | 
				
			||||||
    if num_before + num_after > MAX_MESSAGES_PER_FETCH:
 | 
					    if num_before + num_after > MAX_MESSAGES_PER_FETCH:
 | 
				
			||||||
        raise JsonableError(
 | 
					        raise JsonableError(
 | 
				
			||||||
            _("Too many messages requested (maximum {max_messages}).").format(
 | 
					            _("Too many messages requested (maximum {max_messages}).").format(
 | 
				
			||||||
@@ -124,7 +127,6 @@ def get_messages_backend(
 | 
				
			|||||||
    if num_before > 0 and num_after > 0 and not include_anchor:
 | 
					    if num_before > 0 and num_after > 0 and not include_anchor:
 | 
				
			||||||
        raise JsonableError(_("The anchor can only be excluded at an end of the range"))
 | 
					        raise JsonableError(_("The anchor can only be excluded at an end of the range"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    realm = get_valid_realm_from_request(request)
 | 
					 | 
				
			||||||
    if not maybe_user_profile.is_authenticated:
 | 
					    if not maybe_user_profile.is_authenticated:
 | 
				
			||||||
        # If user is not authenticated, clients must include
 | 
					        # If user is not authenticated, clients must include
 | 
				
			||||||
        # `streams:web-public` in their narrow query to indicate this
 | 
					        # `streams:web-public` in their narrow query to indicate this
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user