From 40ff1220efeb25587ffd78db4702071f5a0c651a Mon Sep 17 00:00:00 2001 From: Pratik Chanda Date: Mon, 16 Jun 2025 17:03:38 +0530 Subject: [PATCH] filter: Implement web public channels filter for suggestions. We already had web-public support in narrow by_channels. This commit implements the web-public support for actual user search. --- help/search-for-messages.md | 3 ++ web/src/filter.ts | 41 +++++++++++++++---- web/src/search_suggestion.ts | 59 ++++++++++++++++++---------- web/src/stream_data.ts | 4 ++ web/templates/search_operators.hbs | 6 +++ web/tests/filter.test.cjs | 41 +++++++++++++++++++ web/tests/search_suggestion.test.cjs | 28 +++++++++---- 7 files changed, 147 insertions(+), 35 deletions(-) diff --git a/help/search-for-messages.md b/help/search-for-messages.md index 5bc7c69d97..f422682f5d 100644 --- a/help/search-for-messages.md +++ b/help/search-for-messages.md @@ -94,6 +94,9 @@ additional messages. * `channels:public`: Search messages in all [public](/help/channel-permissions#public-channels) and [web-public](/help/channel-permissions#web-public-channels) channels. +* `channels:web-public`: Search messages in all + [web-public](/help/change-the-privacy-of-a-channel) channels in the organization, + including channels you are not subscribed to. * `channel:design`: Search all messages in **#design**, including messages sent before you were a subscriber. diff --git a/web/src/filter.ts b/web/src/filter.ts index 40c1b17aea..f804ae706a 100644 --- a/web/src/filter.ts +++ b/web/src/filter.ts @@ -75,6 +75,8 @@ type ValidOrInvalidUser = | {valid_user: true; user_pill_context: UserPillItem} | {valid_user: false; operand: string}; +const channels_operands = new Set(["public", "web-public"]); + function zephyr_stream_name_match( message: Message & {type: "stream"}, stream_name: string, @@ -217,7 +219,14 @@ function message_matches_search_term(message: Message, operator: string, operand return false; } const stream_privacy_policy = stream_data.get_stream_privacy_policy(message.stream_id); - return ["public", "web-public"].includes(stream_privacy_policy) && operand === "public"; + switch (operand) { + case "public": + return ["public", "web-public"].includes(stream_privacy_policy); + case "web-public": + return stream_privacy_policy === "web-public"; + default: + return false; + } } case "topic": @@ -583,7 +592,7 @@ export class Filter { return stream_data.get_sub_by_id_string(term.operand) !== undefined; case "channels": case "streams": - return term.operand === "public"; + return channels_operands.has(term.operand); case "topic": return true; case "sender": @@ -655,6 +664,7 @@ export class Filter { const levels = [ "in", "channels-public", + "channels-web-public", "channel", "topic", "dm", @@ -709,7 +719,7 @@ export class Filter { case "channel": return verb + "messages in a channel"; case "channels": - return verb + "channels"; + return verb + "channel type"; case "near": return verb + "messages around"; @@ -816,10 +826,10 @@ export class Filter { }; } } - if (canonicalized_operator === "channels" && operand === "public") { + if (canonicalized_operator === "channels" && channels_operands.has(operand)) { return { type: "plain_text", - content: this.describe_public_channels(term.negated ?? false), + content: this.describe_channels_operator(term.negated ?? false, operand), }; } const prefix_for_operator = Filter.operator_to_prefix( @@ -883,12 +893,18 @@ export class Filter { return [...parts, ...more_parts]; } - static describe_public_channels(negated: boolean): string { + static describe_channels_operator(negated: boolean, operand: string): string { const possible_prefix = negated ? "exclude " : ""; - if (page_params.is_spectator || current_user.is_guest) { + assert(channels_operands.has(operand)); + if ((page_params.is_spectator || current_user.is_guest) && operand === "public") { return possible_prefix + "all public channels that you can view"; } - return possible_prefix + "all public channels"; + switch (operand) { + case "web-public": + return possible_prefix + "all web-public channels"; + default: + return possible_prefix + "all public channels"; + } } static search_description_as_html( @@ -1260,6 +1276,9 @@ export class Filter { if (_.isEqual(term_types, ["channels-public"])) { return true; } + if (_.isEqual(term_types, ["channels-web-public"])) { + return true; + } if (_.isEqual(term_types, ["sender"])) { return true; } @@ -1329,6 +1348,8 @@ export class Filter { return "/#narrow/is/mentioned"; case "channels-public": return "/#narrow/channels/public"; + case "channels-web-public": + return "/#narrow/channels/web-public"; case "dm": return "/#narrow/dm/" + people.emails_to_slug(this.operands("dm").join(",")); case "is-resolved": @@ -1502,6 +1523,8 @@ export class Filter { }); } return $t({defaultMessage: "Messages in all public channels"}); + case "channels-web-public": + return $t({defaultMessage: "Messages in all web-public channels"}); case "is-starred": return $t({defaultMessage: "Starred messages"}); case "is-mentioned": @@ -1894,6 +1917,8 @@ export class Filter { "not-is-resolved", "channels-public", "not-channels-public", + "channels-web-public", + "not-channels-web-public", "is-muted", "not-is-muted", "in-home", diff --git a/web/src/search_suggestion.ts b/web/src/search_suggestion.ts index 0213ad4a0d..6e2ff091dc 100644 --- a/web/src/search_suggestion.ts +++ b/web/src/search_suggestion.ts @@ -588,29 +588,40 @@ function get_special_filter_suggestions( } function get_channels_filter_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestion[] { - let search_string = "channels:public"; - // show "channels:public" option for users who - // have "streams" in their muscle memory - if (last.operator === "search" && common.phrase_match(last.operand, "streams")) { - search_string = "streams:public"; + if (last.operator !== "channels") { + return []; } - let description_html = "all public channels"; - const suggestions: SuggestionAndIncompatiblePatterns[] = [ - { - search_string, - description_html, - incompatible_patterns: [ - {operator: "is", operand: "dm"}, - {operator: "channel"}, - {operator: "dm-including"}, - {operator: "dm"}, - {operator: "in"}, - {operator: "channels"}, - ], - }, + const incompatible_patterns = [ + {operator: "is", operand: "dm"}, + {operator: "channel"}, + {operator: "dm-including"}, + {operator: "dm"}, + {operator: "in"}, + {operator: "channels"}, ]; + const public_channels_search_string = "channels:public"; + const web_public_channels_search_string = "channels:web-public"; + const suggestions: SuggestionAndIncompatiblePatterns[] = []; + + if (!page_params.is_spectator) { + suggestions.push({ + search_string: public_channels_search_string, + description_html: "all public channels", + incompatible_patterns, + }); + } + + if (stream_data.realm_has_web_public_streams()) { + suggestions.push({ + search_string: web_public_channels_search_string, + description_html: "all web-public channels", + incompatible_patterns, + }); + } + return get_special_filter_suggestions(last, terms, suggestions); } + function get_is_filter_suggestions(last: NarrowTerm, terms: NarrowTerm[]): Suggestion[] { let suggestions: SuggestionAndIncompatiblePatterns[]; if (page_params.is_spectator) { @@ -804,9 +815,10 @@ function get_operator_suggestions(last: NarrowTerm): Suggestion[] { let choices; if (last.operator === "") { - choices = ["channel", "stream"]; + choices = ["channels", "channel", "streams", "stream"]; } else { choices = [ + "channels", "channel", "topic", "dm", @@ -815,6 +827,7 @@ function get_operator_suggestions(last: NarrowTerm): Suggestion[] { "near", "from", "pm-with", + "streams", "stream", ]; } @@ -832,6 +845,11 @@ function get_operator_suggestions(last: NarrowTerm): Suggestion[] { if (choice === "stream") { choice = "channel"; } + // Map results for "channels:" operator for users + // who have "streams" in their muscle memory. + if (choice === "streams") { + choice = "channels"; + } const op = [{operator: choice, operand: "", negated}]; return format_as_suggestion(op, true); }); @@ -1066,6 +1084,7 @@ export function get_search_result( if (page_params.is_spectator) { filterers = [ + get_channels_filter_suggestions, get_operator_suggestions, get_is_filter_suggestions, get_channel_suggestions, diff --git a/web/src/stream_data.ts b/web/src/stream_data.ts index 585f315dda..218ce3dbc8 100644 --- a/web/src/stream_data.ts +++ b/web/src/stream_data.ts @@ -378,6 +378,10 @@ export function get_archived_subs(): StreamSubscription[] { return [...stream_info.values()].filter((sub) => sub.is_archived); } +export function realm_has_web_public_streams(): boolean { + return realm_web_public_stream_ids.size > 0; +} + export function muted_stream_ids(): number[] { return subscribed_subs() .filter((sub) => sub.is_muted) diff --git a/web/templates/search_operators.hbs b/web/templates/search_operators.hbs index bc8c830537..0677f83495 100644 --- a/web/templates/search_operators.hbs +++ b/web/templates/search_operators.hbs @@ -70,6 +70,12 @@ {{/if}} + + channels:web-public + + {{t 'Search all web-public channels.'}} + + sender:user diff --git a/web/tests/filter.test.cjs b/web/tests/filter.test.cjs index c2ab803aa2..993ca8b119 100644 --- a/web/tests/filter.test.cjs +++ b/web/tests/filter.test.cjs @@ -1092,6 +1092,7 @@ test("predicate_basics", ({override}) => { // a subscription, but these should still match by channel name. const old_sub_id = new_stream_id(); const private_sub_id = new_stream_id(); + const web_public_sub_id = new_stream_id(); const old_sub = { name: "old-subscription", stream_id: old_sub_id, @@ -1106,8 +1107,16 @@ test("predicate_basics", ({override}) => { invite_only: true, is_web_public: false, }; + const web_public_sub = { + name: "web-public-subscription", + stream_id: web_public_sub_id, + subscribed: false, + invite_only: false, + is_web_public: true, + }; stream_data.add_sub(old_sub); stream_data.add_sub(private_sub); + stream_data.add_sub(web_public_sub); predicate = get_predicate([ ["channel", old_sub_id.toString()], ["topic", "Bar"], @@ -1129,6 +1138,14 @@ test("predicate_basics", ({override}) => { predicate = get_predicate([["channels", "public"]]); assert.ok(predicate({type: stream_message, stream_id: old_sub_id})); assert.ok(!predicate({type: stream_message, stream_id: private_sub_id})); + assert.ok(predicate({type: stream_message, stream_id: web_public_sub_id})); + + predicate = get_predicate([["channels", "web-public"]]); + assert.ok(predicate({type: stream_message, stream_id: web_public_sub_id})); + assert.ok(!predicate({type: stream_message, stream_id: old_sub_id})); + + predicate = get_predicate([["channels", "bogus"]]); + assert.ok(!predicate({type: stream_message, stream_id: old_sub_id})); predicate = get_predicate([["is", "starred"]]); assert.ok(predicate({starred: true})); @@ -1696,10 +1713,26 @@ test("describe", ({mock_template, override}) => { string = "exclude all public channels"; assert.equal(Filter.search_description_as_html(narrow, false), string); + narrow = [{operator: "channels", operand: "web-public"}]; + string = "all web-public channels"; + assert.equal(Filter.search_description_as_html(narrow, false), string); + + narrow = [{operator: "channels", operand: "web-public", negated: true}]; + string = "exclude all web-public channels"; + assert.equal(Filter.search_description_as_html(narrow, false), string); + page_params.is_spectator = true; narrow = [{operator: "channels", operand: "public"}]; string = "all public channels that you can view"; assert.equal(Filter.search_description_as_html(narrow, false), string); + + narrow = [{operator: "channels", operand: "web-public"}]; + string = "all web-public channels"; + assert.equal(Filter.search_description_as_html(narrow, false), string); + + narrow = [{operator: "channels", operand: "web-public", negated: true}]; + string = "exclude all web-public channels"; + assert.equal(Filter.search_description_as_html(narrow, false), string); page_params.is_spectator = false; const devel_id = new_stream_id(); @@ -2349,6 +2382,7 @@ test("navbar_helpers", ({override}) => { const is_resolved = [{operator: "is", operand: "resolved"}]; const is_followed = [{operator: "is", operand: "followed"}]; const channels_public = [{operator: "channels", operand: "public"}]; + const channels_web_public = [{operator: "channels", operand: "web-public"}]; const channel_topic_terms = [ {operator: "channel", operand: foo_stream_id.toString()}, {operator: "topic", operand: "bar"}, @@ -2505,6 +2539,13 @@ test("navbar_helpers", ({override}) => { title: "translated: Messages in all public channels", redirect_url_with_search: "/#narrow/channels/public", }, + { + terms: channels_web_public, + is_common_narrow: true, + icon: undefined, + title: "translated: Messages in all web-public channels", + redirect_url_with_search: "/#narrow/channels/web-public", + }, { terms: channel_term, is_common_narrow: true, diff --git a/web/tests/search_suggestion.test.cjs b/web/tests/search_suggestion.test.cjs index 15424ba776..a7effc8718 100644 --- a/web/tests/search_suggestion.test.cjs +++ b/web/tests/search_suggestion.test.cjs @@ -103,10 +103,14 @@ test("basic_get_suggestions", ({override}) => { test("basic_get_suggestions_for_spectator", () => { page_params.is_spectator = true; + const web_public_id = new_stream_id(); + const sub = {name: "Web public", stream_id: web_public_id, is_web_public: true}; + stream_data.add_sub(sub); - const query = ""; - const suggestions = get_suggestions(query); + let query = ""; + let suggestions = get_suggestions(query); assert.deepEqual(suggestions.strings, [ + "channels:", "channel:", "is:resolved", "-is:resolved", @@ -115,6 +119,11 @@ test("basic_get_suggestions_for_spectator", () => { "has:attachment", "has:reaction", ]); + + stream_data.delete_sub(sub.stream_id); + query = "channels:"; + suggestions = get_suggestions(query); + assert.deepEqual(suggestions.strings, []); page_params.is_spectator = false; }); @@ -384,13 +393,18 @@ test("empty_query_suggestions", () => { const devel_id = new_stream_id(); const office_id = new_stream_id(); - stream_data.add_sub({stream_id: devel_id, name: "devel", subscribed: true}); + stream_data.add_sub({ + stream_id: devel_id, + name: "devel", + subscribed: true, + is_web_public: true, + }); stream_data.add_sub({stream_id: office_id, name: "office", subscribed: true}); const suggestions = get_suggestions(query); const expected = [ - "channels:public", + "channels:", "channel:", "is:dm", "is:starred", @@ -578,7 +592,7 @@ test("check_is_suggestions", ({override, mock_template}) => { // but shows html description used for "channels:public" query = "st"; suggestions = get_suggestions(query); - expected = ["st", "streams:public", "channel:", "is:starred"]; + expected = ["st", "channels:", "channel:", "is:starred"]; assert.deepEqual(suggestions.strings, expected); query = "channel:66 has:link is:sta"; @@ -992,12 +1006,12 @@ test("operator_suggestions", ({override, mock_template}) => { query = "ch"; suggestions = get_suggestions(query); - expected = ["ch", "channels:public", "channel:"]; + expected = ["ch", "channels:", "channel:"]; assert.deepEqual(suggestions.strings, expected); query = "-s"; suggestions = get_suggestions(query); - expected = ["-s", "-sender:", "-channel:", "-sender:myself@zulip.com"]; + expected = ["-s", "-sender:", "-channels:", "-channel:", "-sender:myself@zulip.com"]; assert.deepEqual(suggestions.strings, expected); // 66 is a misc channel id.