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 <Kenneth012004@outlook.com>

Fixes #16943
This commit is contained in:
opmkumar
2025-03-11 00:19:15 +05:30
committed by Tim Abbott
parent 4becea3993
commit 4f462970e4
14 changed files with 184 additions and 3 deletions

View File

@@ -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),

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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")
);
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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

View File

@@ -151,6 +151,12 @@
{{t 'Narrow to unread messages.'}}
</td>
</tr>
<tr>
<td class="operator">is:muted</td>
<td class="definition">
{{t 'Narrow to messages in muted topics or channels.'}}
</td>
</tr>
<tr>
<td class="operator">near:<span class="operator_value">id</span></td>
<td class="definition">

View File

@@ -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());

View File

@@ -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);

View File

@@ -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);

View File

@@ -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")

View File

@@ -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: