Repository: zulip/zulip
Pull request #33513: narrow: Allow spectators to use `is:resolved`.
Author: login: opmkumar
Labels: name: size: XL
Created: 2025-02-17T20:22:38Z | Merged: 2025-03-04T19:37:35Z

<!-- Describe your pull request here.-->

[CZO thread](https://chat.zulip.org/#narrow/channel/9-issues/topic/is.3Aresolved.20filter.20incorrectly.20hidden.20for.20logged.20out.20users)

Fixes: #33568.
<!-- If the PR makes UI changes, always include one or more still screenshots to demonstrate your changes. If it seems helpful, add a screen capture of the new functionality as well.

Tooling tips: https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html
-->

**Screenshots and screen captures:**

![is_resolved](https://github.com/user-attachments/assets/18f40bbd-1bc4-4706-9cfb-65768efe5b76)

<details>
<summary>Self-review checklist</summary>

<!-- Prior to submitting a PR, follow our step-by-step guide to review your own code:
https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code -->

<!-- Once you create the PR, check off all the steps below that you have completed.
If any of these steps are not relevant or you have not completed, leave them unchecked.-->

- [x] [Self-reviewed](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code) the changes for clarity and maintainability
      (variable names, code reuse, readability, etc.).

Communicate decisions, questions, and potential concerns.

- [x] Explains differences from previous plans (e.g., issue description).
- [x] Highlights technical choices and bugs encountered.
- [ ] Calls out remaining decisions and concerns.
- [x] Automated tests verify logic where appropriate.

Individual commits are ready for review (see [commit discipline](https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html)).

- [x] Each commit is a coherent idea.
- [x] Commit message(s) explain reasoning and motivation for changes.

Completed manual review and testing of the following:

- [x] Visual appearance of the changes.
- [x] Responsiveness and internationalization.
- [x] Strings and tooltips.
- [x] End-to-end functionality of buttons, interactions and flows.
- [x] Corner cases, error conditions, and easily imagined bugs.
</details>



Diff:
diff --git a/web/src/filter.ts b/web/src/filter.ts
index 5ce33334a6a15..aac6113068f9d 100644
--- a/web/src/filter.ts
+++ b/web/src/filter.ts
@@ -865,7 +865,7 @@ export class Filter {
             if (term.operand === undefined) {
                 return false;
             }
-            if (!hash_parser.allowed_web_public_narrows.includes(term.operator)) {
+            if (!hash_parser.is_an_allowed_web_public_narrow(term.operator, term.operand)) {
                 return false;
             }
         }
diff --git a/web/src/hash_parser.ts b/web/src/hash_parser.ts
index 011c3d5cc5f2b..084804b1101bc 100644
--- a/web/src/hash_parser.ts
+++ b/web/src/hash_parser.ts
@@ -116,7 +116,14 @@ export function is_in_specified_hash_category(hash_categories: string[]): boolea
     return hash_categories.includes(main_hash);
 }
 
-export const allowed_web_public_narrows = [
+export function is_an_allowed_web_public_narrow(operator: string, operand: string): boolean {
+    if (operator === "is" && operand === "resolved") {
+        return true;
+    }
+    return allowed_web_public_narrow_operators.includes(operator);
+}
+
+export const allowed_web_public_narrow_operators = [
     "channels",
     "channel",
     "streams",
@@ -156,8 +163,12 @@ export function is_spectator_compatible(hash: string): boolean {
     const main_hash = get_hash_category(hash);
 
     if (main_hash === "narrow") {
-        const hash_section = get_hash_section(hash);
-        if (!allowed_web_public_narrows.includes(hash_section)) {
+        let hash_section = get_hash_section(hash);
+        const second_hash_section = get_nth_hash_section(hash, 2);
+        if (hash_section.startsWith("-")) {
+            hash_section = hash_section.slice(1);
+        }
+        if (!is_an_allowed_web_public_narrow(hash_section, second_hash_section)) {
             return false;
         }
         return true;
diff --git a/web/src/message_view.ts b/web/src/message_view.ts
index 0493879b366fb..e41b7d46f1e2d 100644
--- a/web/src/message_view.ts
+++ b/web/src/message_view.ts
@@ -444,7 +444,8 @@ export let show = (raw_terms: NarrowTerm[], show_opts: ShowMessageViewOpts): voi
         // policy?
         !is_combined_feed_global_view &&
         raw_terms.some(
-            (raw_term) => !hash_parser.allowed_web_public_narrows.includes(raw_term.operator),
+            (raw_term) =>
+                !hash_parser.is_an_allowed_web_public_narrow(raw_term.operator, raw_term.operand),
         )
     ) {
         spectators.login_to_access();
diff --git a/web/src/search_suggestion.ts b/web/src/search_suggestion.ts
index 6450adde19d01..1a0065cd2a34e 100644
--- a/web/src/search_suggestion.ts
+++ b/web/src/search_suggestion.ts
@@ -670,82 +670,114 @@ function get_channels_filter_suggestions(last: NarrowTerm, terms: NarrowTerm[]):
     return get_special_filter_suggestions(last, terms, suggestions);
 }
 function get_is_filter_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestion[] {
-    const suggestions: SuggestionAndIncompatiblePatterns[] = [
-        {
-            search_string: "is:dm",
-            description_html: "direct messages",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "dm"},
-                {operator: "is", operand: "resolved"},
-                {operator: "channel"},
-                {operator: "dm"},
-                {operator: "in"},
-                {operator: "topic"},
-            ],
-        },
-        {
-            search_string: "is:starred",
-            description_html: "starred messages",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "starred"}],
-        },
-        {
-            search_string: "is:mentioned",
-            description_html: "@-mentions",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "mentioned"}],
-        },
-        {
-            search_string: "is:followed",
-            description_html: "followed topics",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "followed"},
-                {operator: "is", operand: "dm"},
-                {operator: "dm"},
-                {operator: "dm-including"},
-            ],
-        },
-        {
-            search_string: "is:alerted",
-            description_html: "alerted messages",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "alerted"}],
-        },
-        {
-            search_string: "is:unread",
-            description_html: "unread messages",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "unread"}],
-        },
-        {
-            search_string: "is:resolved",
-            description_html: "resolved topics",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "resolved"},
-                {operator: "is", operand: "dm"},
-                {operator: "dm"},
-                {operator: "dm-including"},
-            ],
-        },
-        {
-            search_string: "-is:resolved",
-            description_html: "unresolved topics",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "resolved"},
-                {operator: "is", operand: "dm"},
-                {operator: "dm"},
-                {operator: "dm-including"},
-            ],
-        },
-    ];
+    let suggestions: SuggestionAndIncompatiblePatterns[];
+    if (page_params.is_spectator) {
+        suggestions = [
+            {
+                search_string: "is:resolved",
+                description_html: "resolved topics",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "resolved"},
+                    {operator: "is", operand: "dm"},
+                    {operator: "dm"},
+                    {operator: "dm-including"},
+                ],
+            },
+            {
+                search_string: "-is:resolved",
+                description_html: "unresolved topics",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "resolved"},
+                    {operator: "is", operand: "dm"},
+                    {operator: "dm"},
+                    {operator: "dm-including"},
+                ],
+            },
+        ];
+    } else {
+        suggestions = [
+            {
+                search_string: "is:dm",
+                description_html: "direct messages",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "dm"},
+                    {operator: "is", operand: "resolved"},
+                    {operator: "channel"},
+                    {operator: "dm"},
+                    {operator: "in"},
+                    {operator: "topic"},
+                ],
+            },
+            {
+                search_string: "is:starred",
+                description_html: "starred messages",
+                is_people: false,
+                incompatible_patterns: [{operator: "is", operand: "starred"}],
+            },
+            {
+                search_string: "is:mentioned",
+                description_html: "@-mentions",
+                is_people: false,
+                incompatible_patterns: [{operator: "is", operand: "mentioned"}],
+            },
+            {
+                search_string: "is:followed",
+                description_html: "followed topics",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "followed"},
+                    {operator: "is", operand: "dm"},
+                    {operator: "dm"},
+                    {operator: "dm-including"},
+                ],
+            },
+            {
+                search_string: "is:alerted",
+                description_html: "alerted messages",
+                is_people: false,
+                incompatible_patterns: [{operator: "is", operand: "alerted"}],
+            },
+            {
+                search_string: "is:unread",
+                description_html: "unread messages",
+                is_people: false,
+                incompatible_patterns: [{operator: "is", operand: "unread"}],
+            },
+            {
+                search_string: "is:resolved",
+                description_html: "resolved topics",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "resolved"},
+                    {operator: "is", operand: "dm"},
+                    {operator: "dm"},
+                    {operator: "dm-including"},
+                ],
+            },
+            {
+                search_string: "-is:resolved",
+                description_html: "unresolved topics",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "resolved"},
+                    {operator: "is", operand: "dm"},
+                    {operator: "dm"},
+                    {operator: "dm-including"},
+                ],
+            },
+        ];
+    }
     const special_filtered_suggestions = get_special_filter_suggestions(last, terms, suggestions);
     // Suggest "is:dm" to anyone with "is:private" in their muscle memory
     const other_suggestions = [];
-    if (last.operator === "is" && common.phrase_match(last.operand, "private")) {
+    if (
+        last.operator === "is" &&
+        common.phrase_match(last.operand, "private") &&
+        !page_params.is_spectator
+    ) {
         const is_dm = format_as_suggestion([
             {operator: last.operator, operand: "dm", negated: last.negated},
         ]);
@@ -1105,6 +1137,7 @@ export function get_search_result(
 
     if (page_params.is_spectator) {
         filterers = [
+            get_is_filter_suggestions,
             get_channel_suggestions,
             get_people("sender"),
             get_people("from"),
diff --git a/web/tests/filter.test.cjs b/web/tests/filter.test.cjs
index cbd57ba14f8b7..a86da9efba881 100644
--- a/web/tests/filter.test.cjs
+++ b/web/tests/filter.test.cjs
@@ -2519,6 +2519,10 @@ test("error_cases", () => {
 run_test("is_spectator_compatible", () => {
     // tests same as test_is_spectator_compatible from test_message_fetch.py
     assert.ok(Filter.is_spectator_compatible([]));
+    assert.ok(Filter.is_spectator_compatible([{operator: "is", operand: "resolved"}]));
+    assert.ok(
+        Filter.is_spectator_compatible([{operator: "is", operand: "resolved", negated: true}]),
+    );
     assert.ok(Filter.is_spectator_compatible([{operator: "has", operand: "attachment"}]));
     assert.ok(Filter.is_spectator_compatible([{operator: "has", operand: "image"}]));
     assert.ok(Filter.is_spectator_compatible([{operator: "search", operand: "magic"}]));
diff --git a/web/tests/hashchange.test.cjs b/web/tests/hashchange.test.cjs
index 99ddcb9d5b72b..d698ee2f7b030 100644
--- a/web/tests/hashchange.test.cjs
+++ b/web/tests/hashchange.test.cjs
@@ -443,4 +443,11 @@ run_test("update_hash_to_match_filter", ({override, override_rewire}) => {
     message_view.update_hash_to_match_filter(new Filter(terms));
     helper.assert_events([[message_viewport, "stop_auto_scrolling"]]);
     assert.equal(url_pushed, "http://zulip.zulipdev.com/#narrow/is/starred");
+
+    terms = [{operator: "-is", operand: "starred"}];
+
+    helper.clear_events();
+    message_view.update_hash_to_match_filter(new Filter(terms));
+    helper.assert_events([[message_viewport, "stop_auto_scrolling"]]);
+    assert.equal(url_pushed, "http://zulip.zulipdev.com/#narrow/-is/starred");
 });
diff --git a/web/tests/search_suggestion.test.cjs b/web/tests/search_suggestion.test.cjs
index 6c37a3b5f4606..3d7cd99db0ebb 100644
--- a/web/tests/search_suggestion.test.cjs
+++ b/web/tests/search_suggestion.test.cjs
@@ -107,6 +107,8 @@ test("basic_get_suggestions_for_spectator", () => {
     const query = "";
     const suggestions = get_suggestions(query);
     assert.deepEqual(suggestions.strings, [
+        "is:resolved",
+        "-is:resolved",
         "has:link",
         "has:image",
         "has:attachment",
@@ -120,8 +122,8 @@ test("get_is_suggestions_for_spectator", () => {
 
     const query = "is:";
     const suggestions = get_suggestions(query);
-    // The list of suggestions should be empty for a spectator
-    assert.deepEqual(suggestions.strings, []);
+    // The list of suggestions should only contain "is:resolved" for a spectator
+    assert.deepEqual(suggestions.strings, ["is:resolved"]);
     page_params.is_spectator = false;
 });
 
diff --git a/zerver/lib/narrow.py b/zerver/lib/narrow.py
index 3e3a8ef123b26..be82c931140d1 100644
--- a/zerver/lib/narrow.py
+++ b/zerver/lib/narrow.py
@@ -161,6 +161,10 @@ def is_spectator_compatible(narrow: Iterable[NarrowParameter]) -> bool:
     ]
     for element in narrow:
         operator = element.operator
+        operand = element.operand
+
+        if operator == "is" and operand == "resolved":
+            continue
         if operator not in supported_operators:
             return False
     return True
@@ -389,7 +393,11 @@ def by_in(self, query: Select, operand: str, maybe_negate: ConditionTransform) -
         raise BadNarrowOperatorError("unknown 'in' operand " + operand)
 
     def by_is(self, query: Select, operand: str, maybe_negate: ConditionTransform) -> Select:
-        # This operator class does not support is_web_public_query.
+        # Only `resolved` operand of this class supports is_web_public_query.
+        if operand == "resolved":
+            cond = get_resolved_topic_condition_sa()
+            return query.where(maybe_negate(cond))
+
         assert not self.is_web_public_query
         assert self.user_profile is not None
 
@@ -421,9 +429,6 @@ def by_is(self, query: Select, operand: str, maybe_negate: ConditionTransform) -
         elif operand == "alerted":
             cond = column("flags", Integer).op("&")(UserMessage.flags.has_alert_word.mask) != 0
             return query.where(maybe_negate(cond))
-        elif operand == "resolved":
-            cond = get_resolved_topic_condition_sa()
-            return query.where(maybe_negate(cond))
         elif operand == "followed":
             cond = get_followed_topic_condition_sa(self.user_profile.id)
             return query.where(maybe_negate(cond))
diff --git a/zerver/tests/test_message_fetch.py b/zerver/tests/test_message_fetch.py
index 1a31479b89c1d..3505927cbed76 100644
--- a/zerver/tests/test_message_fetch.py
+++ b/zerver/tests/test_message_fetch.py
@@ -1028,6 +1028,14 @@ def test_build_narrow_predicate_invalid(self) -> None:
 
     def test_is_spectator_compatible(self) -> None:
         self.assertTrue(is_spectator_compatible([]))
+        self.assertTrue(
+            is_spectator_compatible([NarrowParameter(operator="is", operand="resolved")])
+        )
+        self.assertTrue(
+            is_spectator_compatible(
+                [NarrowParameter(operator="is", operand="resolved", negated=True)]
+            )
+        )
         self.assertTrue(
             is_spectator_compatible([NarrowParameter(operator="has", operand="attachment")])
         )
@@ -3602,13 +3610,29 @@ def test_get_messages_with_only_searching_anchor(self) -> None:
     def test_get_messages_for_resolved_topics(self) -> None:
         self.login("cordelia")
         cordelia = self.example_user("cordelia")
+        self.subscribe(cordelia, "Rome")
 
-        self.send_stream_message(cordelia, "Verona", "whatever1")
+        self.send_stream_message(cordelia, "Rome", "whatever1")
         resolved_topic_name = RESOLVED_TOPIC_PREFIX + "foo"
-        anchor = self.send_stream_message(cordelia, "Verona", "whatever2", resolved_topic_name)
-        self.send_stream_message(cordelia, "Verona", "whatever3")
+        anchor = self.send_stream_message(cordelia, "Rome", "whatever2", resolved_topic_name)
+        self.send_stream_message(cordelia, "Rome", "whatever3")
 
-        narrow = [dict(operator="is", operand="resolved")]
+        narrow = [
+            dict(operator="is", operand="resolved"),
+            dict(operator="channels", operand="public"),
+        ]
+        result = self.get_and_check_messages(
+            dict(narrow=orjson.dumps(narrow).decode(), anchor=anchor, num_before=0, num_after=0)
+        )
+        self.assert_length(result["messages"], 1)
+        self.assertEqual(result["messages"][0]["id"], anchor)
+
+        # "is:resolved" filter can be used by spectators as well.
+        self.logout()
+        narrow = [
+            dict(operator="is", operand="resolved"),
+            dict(operator="channels", operand="web-public"),
+        ]
         result = self.get_and_check_messages(
             dict(narrow=orjson.dumps(narrow).decode(), anchor=anchor, num_before=0, num_after=0)
         )


Reviews:
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-17T20:29:48Z
user: login: laurynmm
body: @opmkumar - Thanks for working on this issue! I've left a few review comments on your changes here, but I think overall what still needs to be done is to make the `is` operator with the `resolved` operand really be a spectator compatible narrow term.

So that a request to the server with this narrow ("/#narrow/has/link/is/resolved") would return the expected messages. Currently, with these changes, it would still just return all messages that have a link, and the client (in this case the web-app) would be doing the filtering for the resolved term.

You'll also need to test navigating to a resolved/unresolved topic view via the back/forward buttons in the browser, which doesn't work with your current changes.

Let me know if you have any questions about the above notes or my comments below!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-19T20:06:52Z
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T20:12:58Z
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T20:14:29Z
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-20T20:16:40Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-21T23:26:48Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-21T23:27:52Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-21T23:28:55Z
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-23T23:09:52Z
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-23T23:19:46Z
user: login: laurynmm
body: @opmkumar - Thanks for making those updates! I noticed that your test updates still need a little work for the backend changes.

And I think we can probably make the changes to where `allowed_web_public_narrows` is called on the frontend a bit cleaner by making a helper function that checks for the operator/operand cases as well as the operator only cases.

Let me know if you have any questions about my feedback below!
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-24T17:20:13Z
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T18:34:53Z
user: login: opmkumar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T18:37:36Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T19:24:33Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T19:26:08Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T19:27:30Z
user: login: timabbott
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T19:37:11Z

Review comments:
diff_hunk: @@ -795,6 +795,13 @@ function get_sent_by_me_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Sugg
 }
 
 function get_operator_suggestions(last: NarrowTerm): Suggestion[] {
+    if (last.operator === "is" && common.phrase_match(last.operand, "resolved")) {
+        const is_resolved = format_as_suggestion([
+            {operator: last.operator, operand: "resolved", negated: last.negated},
+        ]);
+        return [is_resolved];
+    }
+
path: web/src/search_suggestion.ts
user: login: opmkumar
body: The best place I found to add the search suggestion for `is:resolved` was in get_operator_suggestions.

Since `is:dm` was previously handled here (before #30330 corrected its position), I placed the `is:resolved` suggestion in this function as well.

If this seems odd, I would appreciate suggestions on a better way to handle this logic.
created_at: 2025-02-17T20:29:48Z
author_association: COLLABORATOR
diff_hunk: @@ -795,6 +795,13 @@ function get_sent_by_me_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Sugg
 }
 
 function get_operator_suggestions(last: NarrowTerm): Suggestion[] {
+    if (last.operator === "is" && common.phrase_match(last.operand, "resolved")) {
+        const is_resolved = format_as_suggestion([
+            {operator: last.operator, operand: "resolved", negated: last.negated},
+        ]);
+        return [is_resolved];
+    }
+
path: web/src/search_suggestion.ts
user: login: laurynmm
body: Couldn't you modify `get_is_filter_suggestions` to return a special case if `page_params.is_spectator`? And then add that to the `filterers` for spectators?
created_at: 2025-02-19T19:40:54Z
author_association: COLLABORATOR
diff_hunk: @@ -95,12 +95,17 @@ def clean_narrow_for_web_public_api(
         return None
 
     # Remove {'operator': 'in', 'operand': 'home', 'negated': False} from narrow.
-    # This is to allow spectators to access all messages. The narrow should still pass
-    # is_web_public_narrow check after this change.
+    # This is to allow spectators to access all messages.
+    # Remove {'operator': 'is', 'operand': 'resolved', 'negated': False} from narrow.
+    # This is to allow spectators to access the resolved topics.
path: zerver/views/message_fetch.py
user: login: laurynmm
body: This doesn't mean that the request to the server for the narrow is returning the expected results. It means that it's returning all messages. You can see this by looking at the response in your browser tools.
created_at: 2025-02-19T19:51:18Z
author_association: COLLABORATOR
diff_hunk: @@ -441,7 +441,9 @@ export let show = (raw_terms: NarrowTerm[], show_opts: ShowMessageViewOpts): voi
         // policy?
         !is_combined_feed_global_view &&
         raw_terms.some(
-            (raw_term) => !hash_parser.allowed_web_public_narrows.includes(raw_term.operator),
+            (raw_term) =>
+                !hash_parser.allowed_web_public_narrows.includes(raw_term.operator) &&
path: web/src/message_view.ts
user: login: laurynmm
body: I think it would be better for both `is_spectator_compatible` on the frontend and backend to be updated to handle this special `is` operator case.
created_at: 2025-02-19T19:52:25Z
author_association: COLLABORATOR
diff_hunk: @@ -441,7 +441,9 @@ export let show = (raw_terms: NarrowTerm[], show_opts: ShowMessageViewOpts): voi
         // policy?
         !is_combined_feed_global_view &&
         raw_terms.some(
-            (raw_term) => !hash_parser.allowed_web_public_narrows.includes(raw_term.operator),
+            (raw_term) =>
+                !hash_parser.allowed_web_public_narrows.includes(raw_term.operator) &&
path: web/src/message_view.ts
user: login: opmkumar
body: I have updated `is_spectator_compatible` both on the frontend and backend accordingly.
created_at: 2025-02-20T20:12:58Z
author_association: COLLABORATOR
diff_hunk: @@ -95,12 +95,17 @@ def clean_narrow_for_web_public_api(
         return None
 
     # Remove {'operator': 'in', 'operand': 'home', 'negated': False} from narrow.
-    # This is to allow spectators to access all messages. The narrow should still pass
-    # is_web_public_narrow check after this change.
+    # This is to allow spectators to access all messages.
+    # Remove {'operator': 'is', 'operand': 'resolved', 'negated': False} from narrow.
+    # This is to allow spectators to access the resolved topics.
path: zerver/views/message_fetch.py
user: login: opmkumar
body: I have removed this logic now.
created_at: 2025-02-20T20:14:29Z
author_association: COLLABORATOR
diff_hunk: @@ -795,6 +795,13 @@ function get_sent_by_me_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Sugg
 }
 
 function get_operator_suggestions(last: NarrowTerm): Suggestion[] {
+    if (last.operator === "is" && common.phrase_match(last.operand, "resolved")) {
+        const is_resolved = format_as_suggestion([
+            {operator: last.operator, operand: "resolved", negated: last.negated},
+        ]);
+        return [is_resolved];
+    }
+
path: web/src/search_suggestion.ts
user: login: opmkumar
body: Thanks for the suggestion!
I have modified `get_is_filter_suggestions` accordingly.
created_at: 2025-02-20T20:16:40Z
author_association: COLLABORATOR
diff_hunk: @@ -851,7 +851,11 @@ export class Filter {
             if (term.operand === undefined) {
                 return false;
             }
-            if (!hash_parser.allowed_web_public_narrows.includes(term.operator)) {
+
+            if (
+                !hash_parser.allowed_web_public_narrows.includes(term.operator) &&
+                !(term.operator === "is" && term.operand === "resolved")
path: web/src/filter.ts
user: login: timabbott
body: Why is this not changing `allowed_web_public_narrows` instead?
created_at: 2025-02-21T23:26:48Z
author_association: MEMBER
diff_hunk: @@ -161,7 +161,10 @@ def is_spectator_compatible(narrow: Iterable[NarrowParameter]) -> bool:
     ]
     for element in narrow:
         operator = element.operator
-        if operator not in supported_operators:
+        operand = element.operand
+        if (operator not in supported_operators) and not (
+            operator == "is" and operand == "resolved"
+        ):
             return False
path: zerver/lib/narrow.py
user: login: timabbott
body: This is confusingly written; just add a `if (condition): continue`  block above the one that does `return False`, rather than making this hard-to-read expression.
created_at: 2025-02-21T23:27:52Z
author_association: MEMBER
diff_hunk: @@ -389,7 +392,11 @@ def by_in(self, query: Select, operand: str, maybe_negate: ConditionTransform) -
         raise BadNarrowOperatorError("unknown 'in' operand " + operand)
 
     def by_is(self, query: Select, operand: str, maybe_negate: ConditionTransform) -> Select:
-        # This operator class does not support is_web_public_query.
+        # Only `resolved` operand of this class supports is_web_public_query.
+        if operand == "resolved":
+            cond = get_resolved_topic_condition_sa()
+            return query.where(maybe_negate(cond))
path: zerver/lib/narrow.py
user: login: timabbott
body: This part of the changes is totally untested. `test_message_fetch` needs to have a test where the relevant API endpoint is accessed by a spectator.
created_at: 2025-02-21T23:28:55Z
author_association: MEMBER
diff_hunk: @@ -389,7 +392,11 @@ def by_in(self, query: Select, operand: str, maybe_negate: ConditionTransform) -
         raise BadNarrowOperatorError("unknown 'in' operand " + operand)
 
     def by_is(self, query: Select, operand: str, maybe_negate: ConditionTransform) -> Select:
-        # This operator class does not support is_web_public_query.
+        # Only `resolved` operand of this class supports is_web_public_query.
+        if operand == "resolved":
+            cond = get_resolved_topic_condition_sa()
+            return query.where(maybe_negate(cond))
path: zerver/lib/narrow.py
user: login: opmkumar
body: I have modified `test_get_messages_for_resolved_topics` to test the above changes.
created_at: 2025-02-23T23:09:52Z
author_association: COLLABORATOR
diff_hunk: @@ -851,7 +851,11 @@ export class Filter {
             if (term.operand === undefined) {
                 return false;
             }
-            if (!hash_parser.allowed_web_public_narrows.includes(term.operator)) {
+
+            if (
+                !hash_parser.allowed_web_public_narrows.includes(term.operator) &&
+                !(term.operator === "is" && term.operand === "resolved")
path: web/src/filter.ts
user: login: opmkumar
body: `allowed_web_public_narrows` consists only of the **operators** of the narrow and is used throughout our codebase to check whether a particular narrow, given its operator, qualifies as a web-public narrow.
In our case, we don't want every narrow with the `is` operator to be web-public; we only want `is:resolved` to be web-public.
Therefore, I think it would be better to handle `is:resolved` separately.
created_at: 2025-02-23T23:19:46Z
author_association: COLLABORATOR
diff_hunk: @@ -3582,13 +3585,26 @@ def test_get_messages_with_only_searching_anchor(self) -> None:
     def test_get_messages_for_resolved_topics(self) -> None:
         self.login("cordelia")
path: zerver/tests/test_message_fetch.py
user: login: laurynmm
body: Cordelia is logged in for this test. See `test_unauthenticated_get_messages` for test where we get messages with an unauthenticated request.
created_at: 2025-02-24T17:04:03Z
author_association: COLLABORATOR
diff_hunk: @@ -851,7 +851,11 @@ export class Filter {
             if (term.operand === undefined) {
                 return false;
             }
-            if (!hash_parser.allowed_web_public_narrows.includes(term.operator)) {
+
+            if (
+                !hash_parser.allowed_web_public_narrows.includes(term.operator) &&
+                !(term.operator === "is" && term.operand === "resolved")
path: web/src/filter.ts
user: login: laurynmm
body: We probably could name this better so that it's clearly a list of narrow operator terms.

And then probably adding a helper function in `hash_parser` in these changes that:
- takes an operator and operand parameter
- checks this `is:resolved` case
- and otherwise checks for a valid operator

Then the new helper function can replace the three places we have `!hash_parser.allowed_web_public_narrows.includes(hash_section)`. You could add the helper in an initial prep commit and then update it for the new `is:resolved` case for spectators with these changes.
created_at: 2025-02-24T17:17:09Z
author_association: COLLABORATOR
diff_hunk: @@ -3582,13 +3585,26 @@ def test_get_messages_with_only_searching_anchor(self) -> None:
     def test_get_messages_for_resolved_topics(self) -> None:
         self.login("cordelia")
path: zerver/tests/test_message_fetch.py
user: login: opmkumar
body: Cordelia is logged in just to send some messages in the resolved topic, afterwards, Cordelia was logged out and then the test was done on the spectators too. Though, previously, the test separately covered a logged-in user and a spectator, but this was unnecessary. So I have logged out the example user the test is performed once, covering both cases efficiently.
created_at: 2025-03-04T18:34:52Z
author_association: COLLABORATOR
diff_hunk: @@ -851,7 +851,11 @@ export class Filter {
             if (term.operand === undefined) {
                 return false;
             }
-            if (!hash_parser.allowed_web_public_narrows.includes(term.operator)) {
+
+            if (
+                !hash_parser.allowed_web_public_narrows.includes(term.operator) &&
+                !(term.operator === "is" && term.operand === "resolved")
path: web/src/filter.ts
user: login: opmkumar
body: > We probably could name this better so that it's clearly a list of narrow operator terms.

Renamed `allowed_web_public_narrows` to `allowed_web_public_narrow_operators` (first commit)



> And then probably adding a helper function in `hash_parser`

Added a helper function `is_an_allowed_web_public_narrow`.
created_at: 2025-03-04T18:37:35Z
author_association: COLLABORATOR
diff_hunk: @@ -161,10 +164,11 @@ export function is_spectator_compatible(hash: string): boolean {
 
     if (main_hash === "narrow") {
         let hash_section = get_hash_section(hash);
+        const second_hash_section = get_nth_hash_section(hash, 2);
         if (hash_section.startsWith("-")) {
             hash_section = hash_section.slice(1);
         }
-        if (!is_an_allowed_web_public_narrow(hash_section)) {
+        if (!is_an_allowed_web_public_narrow(hash_section, second_hash_section)) {
             return false;
         }
         return true;
path: web/src/hash_parser.ts
user: login: timabbott
body: This can be a follow-up PR, but I'm pretty sure that this logic should be a loop over operators after parsing the narrow, rather than ignoring everything after the first operator/operand pair.
created_at: 2025-03-04T19:24:33Z
author_association: MEMBER
diff_hunk: @@ -3602,13 +3610,19 @@ def test_get_messages_with_only_searching_anchor(self) -> None:
     def test_get_messages_for_resolved_topics(self) -> None:
         self.login("cordelia")
         cordelia = self.example_user("cordelia")
+        self.subscribe(cordelia, "Rome")
 
-        self.send_stream_message(cordelia, "Verona", "whatever1")
+        self.send_stream_message(cordelia, "Rome", "whatever1")
         resolved_topic_name = RESOLVED_TOPIC_PREFIX + "foo"
-        anchor = self.send_stream_message(cordelia, "Verona", "whatever2", resolved_topic_name)
-        self.send_stream_message(cordelia, "Verona", "whatever3")
+        anchor = self.send_stream_message(cordelia, "Rome", "whatever2", resolved_topic_name)
+        self.send_stream_message(cordelia, "Rome", "whatever3")
 
-        narrow = [dict(operator="is", operand="resolved")]
+        # "is:resolved" filter can be used by spectators as well.
+        self.logout()
path: zerver/tests/test_message_fetch.py
user: login: timabbott
body: This test needs to verify the non-spectator case before doing the spectator one.
created_at: 2025-03-04T19:26:08Z
author_association: MEMBER
diff_hunk: @@ -670,82 +670,114 @@ function get_channels_filter_suggestions(last: NarrowTerm, terms: NarrowTerm[]):
     return get_special_filter_suggestions(last, terms, suggestions);
 }
 function get_is_filter_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestion[] {
-    const suggestions: SuggestionAndIncompatiblePatterns[] = [
-        {
-            search_string: "is:dm",
-            description_html: "direct messages",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "dm"},
-                {operator: "is", operand: "resolved"},
-                {operator: "channel"},
-                {operator: "dm"},
-                {operator: "in"},
-                {operator: "topic"},
-            ],
-        },
-        {
-            search_string: "is:starred",
-            description_html: "starred messages",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "starred"}],
-        },
-        {
-            search_string: "is:mentioned",
-            description_html: "@-mentions",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "mentioned"}],
-        },
-        {
-            search_string: "is:followed",
-            description_html: "followed topics",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "followed"},
-                {operator: "is", operand: "dm"},
-                {operator: "dm"},
-                {operator: "dm-including"},
-            ],
-        },
-        {
-            search_string: "is:alerted",
-            description_html: "alerted messages",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "alerted"}],
-        },
-        {
-            search_string: "is:unread",
-            description_html: "unread messages",
-            is_people: false,
-            incompatible_patterns: [{operator: "is", operand: "unread"}],
-        },
-        {
-            search_string: "is:resolved",
-            description_html: "resolved topics",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "resolved"},
-                {operator: "is", operand: "dm"},
-                {operator: "dm"},
-                {operator: "dm-including"},
-            ],
-        },
-        {
-            search_string: "-is:resolved",
-            description_html: "unresolved topics",
-            is_people: false,
-            incompatible_patterns: [
-                {operator: "is", operand: "resolved"},
-                {operator: "is", operand: "dm"},
-                {operator: "dm"},
-                {operator: "dm-including"},
-            ],
-        },
-    ];
+    let suggestions: SuggestionAndIncompatiblePatterns[];
+    if (page_params.is_spectator) {
+        suggestions = [
+            {
+                search_string: "is:resolved",
+                description_html: "resolved topics",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "resolved"},
+                    {operator: "is", operand: "dm"},
+                    {operator: "dm"},
+                    {operator: "dm-including"},
+                ],
+            },
+            {
+                search_string: "-is:resolved",
+                description_html: "unresolved topics",
+                is_people: false,
+                incompatible_patterns: [
+                    {operator: "is", operand: "resolved"},
+                    {operator: "is", operand: "dm"},
+                    {operator: "dm"},
+                    {operator: "dm-including"},
+                ],
+            },
+        ];
path: web/src/search_suggestion.ts
user: login: timabbott
body: This might be better refactored to just have an `is_spectator_compatible` property as a field we can set on individual suggestion options. I'm not sure though. Certainly would be better as a follow-up thing to try.
created_at: 2025-03-04T19:27:30Z
author_association: MEMBER
diff_hunk: @@ -3602,13 +3610,19 @@ def test_get_messages_with_only_searching_anchor(self) -> None:
     def test_get_messages_for_resolved_topics(self) -> None:
         self.login("cordelia")
         cordelia = self.example_user("cordelia")
+        self.subscribe(cordelia, "Rome")
 
-        self.send_stream_message(cordelia, "Verona", "whatever1")
+        self.send_stream_message(cordelia, "Rome", "whatever1")
         resolved_topic_name = RESOLVED_TOPIC_PREFIX + "foo"
-        anchor = self.send_stream_message(cordelia, "Verona", "whatever2", resolved_topic_name)
-        self.send_stream_message(cordelia, "Verona", "whatever3")
+        anchor = self.send_stream_message(cordelia, "Rome", "whatever2", resolved_topic_name)
+        self.send_stream_message(cordelia, "Rome", "whatever3")
 
-        narrow = [dict(operator="is", operand="resolved")]
+        # "is:resolved" filter can be used by spectators as well.
+        self.logout()
path: zerver/tests/test_message_fetch.py
user: login: timabbott
body: ``` diff
diff --git a/zerver/tests/test_message_fetch.py b/zerver/tests/test_message_fetch.py
index a7e75ce9c6..3505927cbe 100644
--- a/zerver/tests/test_message_fetch.py
+++ b/zerver/tests/test_message_fetch.py
@@ -3617,6 +3617,16 @@ class GetOldMessagesTest(ZulipTestCase):
         anchor = self.send_stream_message(cordelia, "Rome", "whatever2", resolved_topic_name)
         self.send_stream_message(cordelia, "Rome", "whatever3")
 
+        narrow = [
+            dict(operator="is", operand="resolved"),
+            dict(operator="channels", operand="public"),
+        ]
+        result = self.get_and_check_messages(
+            dict(narrow=orjson.dumps(narrow).decode(), anchor=anchor, num_before=0, num_after=0)
+        )
+        self.assert_length(result["messages"], 1)
+        self.assertEqual(result["messages"][0]["id"], anchor)
+
         # "is:resolved" filter can be used by spectators as well.
         self.logout()
         narrow = [
```
created_at: 2025-03-04T19:37:10Z
author_association: MEMBER

PR comments:
user: login: alya
created_at: 2025-02-18T16:59:05Z
body: @laurynmm  Could you please review this one? Not a major priority, but might be a quick bug to fix.

author_association: CONTRIBUTOR
user: login: timabbott
created_at: 2025-02-18T21:43:23Z
body: @kennethnrk might be another good person to help review this.
author_association: MEMBER
user: login: opmkumar
created_at: 2025-02-20T20:11:16Z
body: > You'll also need to test navigating to a resolved/unresolved topic view via the back/forward buttons in the browser, which doesn't work with your current changes.

Now the backward/forward buttons work as expected when navigating to a resolved/unresolved topic view.
<details><summary>GIF</summary>
<p>

![forward-back](https://github.com/user-attachments/assets/5b047bea-6d63-48ad-b647-5ec6b8a5c580)

</p>
</details> 

> Currently, with these changes, it would still just return all messages that have a link, and the client (in this case the web-app) would be doing the filtering for the resolved term.

Now, the expected messages are returned from the server.
<details><summary>Example using `/#narrow/has/link/is/resolved`</summary>
<p>

![image](https://github.com/user-attachments/assets/56eec913-c07c-40b0-898d-c2aef136dca3)

Data returned from the server:
![image](https://github.com/user-attachments/assets/4639146d-7e33-4459-848f-f5facd21c45b)


</p>
</details> 
author_association: COLLABORATOR
user: login: opmkumar
created_at: 2025-02-20T20:54:18Z
body: @laurynmm, this PR is ready for another round of reviews.
Thanks :)
author_association: COLLABORATOR
user: login: opmkumar
created_at: 2025-03-04T18:44:01Z
body: I have added a prep commit 748ea2457e82b786c61a136bbced9dfc50cebb5d to fix the navigation issue involved with negated operators for spectators. See this [CZO thread](https://chat.zulip.org/#narrow/channel/9-issues/topic/Problem.20with.20navigation.20when.20negated.20operator.20is.20present.2E/with/2111459) for more info.

@laurynmm, this PR is ready for your review.
I have my university exams going on, that's why, there was some delay in updating this PR, my apologies for the same.

Thanks :)
author_association: COLLABORATOR
user: login: timabbott
created_at: 2025-03-04T19:38:29Z
body: Merged, thanks @opmkumar! I think https://github.com/zulip/zulip/pull/33513#discussion_r1980101147 is worth your attention; that to me looks like a bug revealed by these changes that I'd be happy to integrate a fix for in 10.0 or backport to 10.1.
author_association: MEMBER