From 4f462970e40983ec1112a6b1e68fbd056a120183 Mon Sep 17 00:00:00 2001 From: opmkumar Date: Tue, 11 Mar 2025 00:19:15 +0530 Subject: [PATCH] search: Add is-muted search operator. Add the `is:muted` search operator. `-is:muted` is an alias for the `in:home` operator. Co-authored-by: Kenneth Fixes #16943 --- api_docs/changelog.md | 10 +++++ api_docs/construct-narrow.md | 4 ++ help/search-for-messages.md | 4 ++ version.py | 2 +- web/src/filter.ts | 21 +++++++++- web/src/narrow_banner.ts | 7 ++++ web/src/search_suggestion.ts | 9 +++++ web/templates/search_description.hbs | 4 ++ web/templates/search_operators.hbs | 6 +++ web/tests/filter.test.cjs | 37 ++++++++++++++++++ web/tests/message_view.test.cjs | 6 +++ web/tests/search_suggestion.test.cjs | 6 +++ zerver/lib/narrow.py | 13 +++++++ zerver/tests/test_message_fetch.py | 58 ++++++++++++++++++++++++++++ 14 files changed, 184 insertions(+), 3 deletions(-) diff --git a/api_docs/changelog.md b/api_docs/changelog.md index e728e50764..1e1151e535 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,16 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 10.0 +**Feature level 366** + +* [`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 a new [search/narrow filter](/api/construct-narrow), + `is:muted`, matching messages in topics and channels that the user + has [muted](/help/mute-a-topic). + **Feature level 365** * [`GET /events`](/api/get-events), [`GET /messages`](/api/get-messages), diff --git a/api_docs/construct-narrow.md b/api_docs/construct-narrow.md index fd4f393cc6..76021d1879 100644 --- a/api_docs/construct-narrow.md +++ b/api_docs/construct-narrow.md @@ -58,6 +58,10 @@ as an empty string. ## Changes +* In Zulip 10.0 (feature level ZF-f80735), support was added for a new + `is:muted` operator combination, matching messages in topics and + channels that the user has [muted](/help/mute-a-topic). + * Before Zulip 10.0 (feature level 334), empty string was not a valid topic name for channel messages. diff --git a/help/search-for-messages.md b/help/search-for-messages.md index 0c3a619d4e..c061f1cc3c 100644 --- a/help/search-for-messages.md +++ b/help/search-for-messages.md @@ -117,6 +117,10 @@ Zulip offers the following filters based on the location of the message. * `is:resolved`: Search messages in [resolved topics](/help/resolve-a-topic). * `-is:resolved`: Search messages in [unresolved topics](/help/resolve-a-topic). * `is:unread`: Search your unread messages. +* `is:muted`: Search messages in [muted topics](/help/mute-a-topic) or + [muted channels](/help/mute-a-channel). +* `-is:muted`: Search messages outside [muted topics](/help/mute-a-topic) and + [muted channels](/help/mute-a-channel). * `has:reaction`: Search messages with [emoji reactions](/help/emoji-reactions). ### Search by message ID diff --git a/version.py b/version.py index 70efbe29ce..0c10aae48a 100644 --- a/version.py +++ b/version.py @@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 365 +API_FEATURE_LEVEL = 366 # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/web/src/filter.ts b/web/src/filter.ts index 7180106c5d..a37ad22d3b 100644 --- a/web/src/filter.ts +++ b/web/src/filter.ts @@ -171,6 +171,8 @@ function message_matches_search_term(message: Message, operator: string, operand message.type === "stream" && user_topics.is_topic_followed(message.stream_id, message.topic) ); + case "muted": + return !message_in_home(message); default: return false; // is:whatever returns false } @@ -550,6 +552,7 @@ export class Filter { "unread", "resolved", "followed", + "muted", ].includes(term.operand); case "in": return ["home", "all"].includes(term.operand); @@ -646,6 +649,7 @@ export class Filter { "is-unread", "is-resolved", "is-followed", + "is-muted", "has-link", "has-image", "has-attachment", @@ -1057,7 +1061,12 @@ export class Filter { is_in_home(): boolean { // Combined feed view - return this._terms.length === 1 && this.has_operand("in", "home"); + return ( + // The `-is:muted` term is an alias for `in:home`. The `in:home` term will + // be removed in the future. + this._terms.length === 1 && + (this.has_operand("in", "home") || this.has_negated_operand("is", "muted")) + ); } has_exactly_channel_topic_operators(): boolean { @@ -1103,6 +1112,8 @@ export class Filter { "not-is-resolved", "is-followed", "not-is-followed", + "is-muted", + "not-is-muted", "in-home", "in-all", "channels-public", @@ -1177,6 +1188,10 @@ export class Filter { return true; } + if (_.isEqual(term_types, ["not-is-muted"])) { + return true; + } + if (_.isEqual(term_types, ["in-all"])) { return true; } @@ -1764,7 +1779,9 @@ export class Filter { // not narrowed to starred messages !this.has_operand("is", "starred") && // not narrowed to negated home messages - !this.has_negated_operand("in", "home") + !this.has_negated_operand("in", "home") && + // not narrowed to muted topics messages + !this.has_operand("is", "muted") ); } diff --git a/web/src/narrow_banner.ts b/web/src/narrow_banner.ts index 9493ef8be0..628c4c5eed 100644 --- a/web/src/narrow_banner.ts +++ b/web/src/narrow_banner.ts @@ -287,6 +287,13 @@ export function pick_empty_narrow_banner(current_filter: Filter): NarrowBannerDa return { title: $t({defaultMessage: "You aren't following any topics."}), }; + + case "muted": + return { + title: $t({ + defaultMessage: "You have no messages in muted topics and channels.", + }), + }; } // fallthrough to default case if no match is found break; diff --git a/web/src/search_suggestion.ts b/web/src/search_suggestion.ts index 1a0065cd2a..e7fdec1772 100644 --- a/web/src/search_suggestion.ts +++ b/web/src/search_suggestion.ts @@ -746,6 +746,15 @@ function get_is_filter_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Sugge is_people: false, incompatible_patterns: [{operator: "is", operand: "unread"}], }, + { + search_string: "is:muted", + description_html: "muted messages", + is_people: false, + incompatible_patterns: [ + {operator: "is", operand: "muted"}, + {operator: "in", operand: "home"}, + ], + }, { search_string: "is:resolved", description_html: "resolved topics", diff --git a/web/templates/search_description.hbs b/web/templates/search_description.hbs index ab2a09bf9e..84cbb0b24b 100644 --- a/web/templates/search_description.hbs +++ b/web/templates/search_description.hbs @@ -50,6 +50,10 @@ {{~!-- squash whitespace --~}} {{this.verb}}followed topics {{~!-- squash whitespace --~}} + {{else if (eq this.operand "muted")}} + {{~!-- squash whitespace --~}} + {{this.verb}}muted messages + {{~!-- squash whitespace --~}} {{else if (eq this.operand "unresolved")}} {{~!-- squash whitespace --~}} {{this.verb}}unresolved topics diff --git a/web/templates/search_operators.hbs b/web/templates/search_operators.hbs index 3cab7e02ff..367ea52ee6 100644 --- a/web/templates/search_operators.hbs +++ b/web/templates/search_operators.hbs @@ -151,6 +151,12 @@ {{t 'Narrow to unread messages.'}} + + is:muted + + {{t 'Narrow to messages in muted topics or channels.'}} + + near:id diff --git a/web/tests/filter.test.cjs b/web/tests/filter.test.cjs index 39b0996d28..8eb94deaa2 100644 --- a/web/tests/filter.test.cjs +++ b/web/tests/filter.test.cjs @@ -725,6 +725,16 @@ test("can_mark_messages_read", () => { filter = new Filter(in_home_negated); assert.ok(!filter.can_mark_messages_read()); + const is_muted = [{operator: "is", operand: "muted"}]; + const is_muted_negated = [{operator: "is", operand: "muted", negated: true}]; + filter = new Filter(is_muted); + assert.ok(!filter.can_mark_messages_read()); + assert_not_mark_read_with_is_operands(is_muted); + assert_not_mark_read_with_has_operands(is_muted); + assert_not_mark_read_when_searching(is_muted); + filter = new Filter(is_muted_negated); + assert.ok(filter.can_mark_messages_read()); + // Do not mark messages as read when in an unsupported 'in:*' filter. const in_random = [{operator: "in", operand: "xxxxxxxxx"}]; const in_random_negated = [{operator: "in", operand: "xxxxxxxxx", negated: true}]; @@ -1098,6 +1108,26 @@ test("predicate_basics", ({override}) => { assert.ok(predicate({stream_id: 1234})); }); + override(user_topics, "is_topic_visible_in_home", () => false); + predicate = get_predicate([["is", "muted"]]); + assert.ok(predicate({stream_id: unknown_stream_id, stream: "unknown"})); + assert.ok(!predicate({type: direct_message})); + + // Muted topic is a part of is-muted. + with_overrides(({override}) => { + override(user_topics, "is_topic_visible_in_home", () => false); + assert.ok(predicate({stream_id: foo_stream_id, topic: "bar"})); + }); + + // Muted stream is a part of is:muted. + assert.ok(predicate({stream_id: muted_stream.stream_id, topic: "bar"})); + + // Muted stream but topic is unmuted or followed is not a part of is-muted. + with_overrides(({override}) => { + override(user_topics, "is_topic_visible_in_home", () => true); + assert.ok(!predicate({stream_id: muted_stream.stream_id, topic: "bar"})); + }); + predicate = get_predicate([["near", 5]]); assert.ok(predicate({})); @@ -1626,6 +1656,10 @@ test("describe", ({mock_template, override}) => { string = "followed topics"; assert.equal(Filter.search_description_as_html(narrow, false), string); + narrow = [{operator: "is", operand: "muted"}]; + string = "muted messages"; + assert.equal(Filter.search_description_as_html(narrow, false), string); + // operands with their own negative words, like resolved. narrow = [{operator: "is", operand: "resolved", negated: true}]; string = "unresolved topics"; @@ -2703,6 +2737,9 @@ run_test("excludes_muted_topics", () => { let filter = new Filter([{operator: "is", operand: "starred"}]); assert.ok(!filter.excludes_muted_topics()); + filter = new Filter([{operator: "is", operand: "muted"}]); + assert.ok(!filter.excludes_muted_topics()); + filter = new Filter([{operator: "in", operand: "home", negated: true}]); assert.ok(!filter.excludes_muted_topics()); diff --git a/web/tests/message_view.test.cjs b/web/tests/message_view.test.cjs index b696dfb012..c604ebb13c 100644 --- a/web/tests/message_view.test.cjs +++ b/web/tests/message_view.test.cjs @@ -370,6 +370,12 @@ run_test("show_empty_narrow_message", ({mock_template, override}) => { empty_narrow_html("translated: You aren't following any topics."), ); + current_filter = set_filter([["is", "muted"]]); + narrow_banner.show_empty_narrow_message(current_filter); + assert.equal( + $(".empty_feed_notice_main").html(), + empty_narrow_html("translated: You have no messages in muted topics and channels."), + ); // organization has disabled sending direct messages override(realm, "realm_direct_message_permission_group", nobody.id); diff --git a/web/tests/search_suggestion.test.cjs b/web/tests/search_suggestion.test.cjs index 3d7cd99db0..dfdfce8f4a 100644 --- a/web/tests/search_suggestion.test.cjs +++ b/web/tests/search_suggestion.test.cjs @@ -378,6 +378,7 @@ test("empty_query_suggestions", () => { "is:followed", "is:alerted", "is:unread", + "is:muted", "is:resolved", "-is:resolved", "sender:myself@zulip.com", @@ -482,6 +483,7 @@ test("check_is_suggestions", ({override, mock_template}) => { "is:followed", "is:alerted", "is:unread", + "is:muted", "is:resolved", "dm:alice@zulip.com", "sender:alice@zulip.com", @@ -501,6 +503,7 @@ test("check_is_suggestions", ({override, mock_template}) => { assert.equal(describe("is:unread"), "Unread messages"); assert.equal(describe("is:resolved"), "Resolved topics"); assert.equal(describe("is:followed"), "Followed topics"); + assert.equal(describe("is:muted"), "Muted messages"); query = "-i"; suggestions = get_suggestions(query); @@ -512,6 +515,7 @@ test("check_is_suggestions", ({override, mock_template}) => { "-is:followed", "-is:alerted", "-is:unread", + "-is:muted", "-is:resolved", ]; assert.deepEqual(suggestions.strings, expected); @@ -523,6 +527,7 @@ test("check_is_suggestions", ({override, mock_template}) => { assert.equal(describe("-is:unread"), "Exclude unread messages"); assert.equal(describe("-is:resolved"), "Unresolved topics"); assert.equal(describe("-is:followed"), "Exclude followed topics"); + assert.equal(describe("-is:muted"), "Exclude muted messages"); // operand suggestions follow. @@ -535,6 +540,7 @@ test("check_is_suggestions", ({override, mock_template}) => { "is:followed", "is:alerted", "is:unread", + "is:muted", "is:resolved", ]; assert.deepEqual(suggestions.strings, expected); diff --git a/zerver/lib/narrow.py b/zerver/lib/narrow.py index be82c93114..6d40db1e3b 100644 --- a/zerver/lib/narrow.py +++ b/zerver/lib/narrow.py @@ -432,6 +432,19 @@ class NarrowBuilder: elif operand == "followed": cond = get_followed_topic_condition_sa(self.user_profile.id) return query.where(maybe_negate(cond)) + elif operand == "muted": + # TODO: If we also have a channel operator, this could be + # a lot more efficient if limited to only those muting + # rules that appear in such channels. + conditions = exclude_muting_conditions( + self.user_profile, [NarrowParameter(operator="is", operand="muted")] + ) + if conditions: + return query.where(maybe_negate(not_(and_(*conditions)))) + + # This is the case where no channels or topics were muted. + return query.where(maybe_negate(false())) + raise BadNarrowOperatorError("unknown 'is' operand " + operand) _alphanum = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") diff --git a/zerver/tests/test_message_fetch.py b/zerver/tests/test_message_fetch.py index 3505927cbe..6b6106afec 100644 --- a/zerver/tests/test_message_fetch.py +++ b/zerver/tests/test_message_fetch.py @@ -293,6 +293,16 @@ class NarrowBuilderTest(ZulipTestCase): "NOT (EXISTS (SELECT 1 \nFROM zerver_usertopic \nWHERE zerver_usertopic.user_profile_id = %(param_1)s AND zerver_usertopic.visibility_policy = %(param_2)s AND upper(zerver_usertopic.topic_name) = upper(zerver_message.subject) AND zerver_usertopic.recipient_id = zerver_message.recipient_id))", ) + def test_add_term_using_is_operator_for_muted_topics(self) -> None: + mute_channel(self.realm, self.user_profile, "Verona") + term = NarrowParameter(operator="is", operand="muted", negated=False) + self._do_add_term_test(term, "WHERE recipient_id IN (__[POSTCOMPILE_recipient_id_1])") + + def test_add_term_using_is_operator_for_negated_muted_topics(self) -> None: + mute_channel(self.realm, self.user_profile, "Verona") + term = NarrowParameter(operator="is", operand="muted", negated=True) + self._do_add_term_test(term, "WHERE (recipient_id NOT IN (__[POSTCOMPILE_recipient_id_1]))") + def test_add_term_using_non_supported_operator_should_raise_error(self) -> None: term = NarrowParameter(operator="is", operand="non_supported") self.assertRaises(BadNarrowOperatorError, self._build_query, term) @@ -5252,6 +5262,54 @@ class MessageIsTest(ZulipTestCase): messages = self.assert_json_success(result)["messages"] self.assert_length(messages, 1) + def test_message_is_muted(self) -> None: + self.login("iago") + is_muted_narrow = orjson.dumps([dict(operator="is", operand="muted")]).decode() + is_unmuted_narrow = orjson.dumps( + [dict(operator="is", operand="muted", negated=True)] + ).decode() + + # Have another user generate a message in a topic that isn't muted by the user. + msg_id = self.send_stream_message(self.example_user("hamlet"), "Denmark", topic_name="hey") + result = self.client_get( + "/json/messages", + dict(narrow=is_muted_narrow, anchor=msg_id, num_before=0, num_after=0), + ) + messages = self.assert_json_success(result)["messages"] + self.assert_length(messages, 0) + result = self.client_get( + "/json/messages", + dict(narrow=is_unmuted_narrow, anchor=msg_id, num_before=0, num_after=0), + ) + messages = self.assert_json_success(result)["messages"] + self.assert_length(messages, 1) + + stream_id = self.get_stream_id("Denmark", self.example_user("hamlet").realm) + + # Mute the topic. + payload = { + "stream_id": stream_id, + "topic": "hey", + "visibility_policy": int(UserTopic.VisibilityPolicy.MUTED), + } + self.client_post("/json/user_topics", payload) + result = self.client_get( + "/json/messages", + dict(narrow=is_muted_narrow, anchor=msg_id, num_before=0, num_after=0), + ) + messages = self.assert_json_success(result)["messages"] + self.assert_length(messages, 1) + + result = self.client_get( + "/json/messages", + dict(narrow=is_unmuted_narrow, anchor=msg_id, num_before=0, num_after=0), + ) + messages = self.assert_json_success(result)["messages"] + self.assert_length(messages, 0) + + # We could do more tests, but test_exclude_muting_conditions + # covers that code path pretty well. + class MessageVisibilityTest(ZulipTestCase): def test_update_first_visible_message_id(self) -> None: