topic_list: Filter topics by resolved state in "more topics" view.

Fixes: #24200.
This commit is contained in:
Maneesh Shukla
2025-04-11 14:51:55 +05:30
committed by Tim Abbott
parent 2a325b4530
commit 52b83f7b58
11 changed files with 263 additions and 16 deletions

View File

@@ -424,6 +424,7 @@ export function zoom_in_topics(options: {stream_id: number | undefined}): void {
// Add search box for topics list.
$elt.children("div.bottom_left_row").append($(render_filter_topics()));
$("#left-sidebar-filter-topic-input").trigger("focus");
topic_list.setup_topic_search_typeahead();
} else {
$elt.hide();
}
@@ -769,7 +770,7 @@ export function update_stream_sidebar_for_narrow(filter: Filter): JQuery | undef
// we want to the topics list here.
update_inbox_channel_view_callback(stream_id);
topic_list.rebuild_left_sidebar($stream_li, stream_id);
topic_list.topic_state_typeahead?.lookup(true);
return $stream_li;
}

View File

@@ -1,5 +1,7 @@
import assert from "minimalistic-assert";
import * as resolved_topics from "../shared/src/resolved_topic.ts";
import * as echo_state from "./echo_state.ts";
import {FoldDict} from "./fold_dict.ts";
import * as message_util from "./message_util.ts";
@@ -31,6 +33,17 @@ export function stream_has_topics(stream_id: number): boolean {
return history.has_topics();
}
export function stream_has_locally_available_resolved_topics(stream_id: number): boolean {
if (!stream_dict.has(stream_id)) {
return false;
}
const history = stream_dict.get(stream_id);
assert(history !== undefined);
return history.has_resolved_topics();
}
export type TopicHistoryEntry = {
count: number;
message_id: number;
@@ -67,6 +80,10 @@ export class PerStreamHistory {
this.stream_id = stream_id;
}
has_resolved_topics(): boolean {
return [...this.topics.keys()].some((topic) => resolved_topics.is_resolved(topic));
}
has_topics(): boolean {
return this.topics.size > 0;
}

View File

@@ -8,9 +8,14 @@ import render_topic_list_item from "../templates/topic_list_item.hbs";
import {all_messages_data} from "./all_messages_data.ts";
import * as blueslip from "./blueslip.ts";
import {Typeahead} from "./bootstrap_typeahead.ts";
import type {TypeaheadInputElement} from "./bootstrap_typeahead.ts";
import {$t} from "./i18n.ts";
import * as popover_menus from "./popover_menus.ts";
import * as popovers from "./popovers.ts";
import * as scroll_util from "./scroll_util.ts";
import type {SearchPillWidget} from "./search_pill.ts";
import * as search_pill from "./search_pill.ts";
import * as sidebar_ui from "./sidebar_ui.ts";
import * as stream_topic_history from "./stream_topic_history.ts";
import * as stream_topic_history_util from "./stream_topic_history_util.ts";
@@ -18,6 +23,7 @@ import type {StreamSubscription} from "./sub_store.ts";
import * as sub_store from "./sub_store.ts";
import * as topic_list_data from "./topic_list_data.ts";
import type {TopicInfo} from "./topic_list_data.ts";
import * as typeahead_helper from "./typeahead_helper.ts";
import * as vdom from "./vdom.ts";
/*
@@ -29,6 +35,8 @@ import * as vdom from "./vdom.ts";
*/
const active_widgets = new Map<number, LeftSidebarTopicListWidget>();
export let search_pill_widget: SearchPillWidget | null = null;
export let topic_state_typeahead: Typeahead<string> | undefined;
// We know whether we're zoomed or not.
let zoomed = false;
@@ -61,7 +69,7 @@ export function clear(): void {
export function focus_topic_search_filter(): void {
popovers.hide_all();
sidebar_ui.show_left_sidebar();
const $filter = $("#left-sidebar-filter-topic-input").expectOne();
const $filter = $("#topic_filter_query");
$filter.trigger("focus");
}
@@ -344,7 +352,11 @@ export class TopicListWidget {
function filter_topics_left_sidebar(topic_names: string[]): string[] {
const search_term = get_left_sidebar_topic_search_term();
return topic_list_data.filter_topics_by_search_term(topic_names, search_term);
return topic_list_data.filter_topics_by_search_term(
topic_names,
search_term,
get_typeahead_search_term(),
);
}
export class LeftSidebarTopicListWidget extends TopicListWidget {
@@ -362,11 +374,14 @@ export class LeftSidebarTopicListWidget extends TopicListWidget {
export function clear_topic_search(e: JQuery.Event): void {
e.stopPropagation();
const $input = $("#left-sidebar-filter-topic-input");
const $input = $("#topic_filter_query");
if ($input.length > 0) {
$input.val("");
$input.text("");
$input.trigger("blur");
search_pill_widget?.clear(true);
update_clear_button();
// Since this changes the contents of the search input, we
// need to rerender the topic list.
const stream_ids = [...active_widgets.keys()];
@@ -467,6 +482,7 @@ export function zoom_in(): void {
// position since we just added some topics to the list which moved user
// to a different position anyway.
left_sidebar_scroll_zoomed_in_topic_into_view();
topic_state_typeahead?.lookup(true);
}
}
@@ -478,12 +494,123 @@ export function zoom_in(): void {
}
export function get_left_sidebar_topic_search_term(): string {
const $filter = $<HTMLInputElement>("input#left-sidebar-filter-topic-input");
const filter_val = $filter.val();
if (filter_val === undefined) {
return "";
return $("#left-sidebar-filter-topic-input .input").text().trim();
}
return filter_val.trim();
export function get_typeahead_search_term(): string {
const $pills = $("#left-sidebar-filter-topic-input .pill");
const value = $pills.find(".pill-value").text().trim();
return value;
}
function set_search_bar_text(text: string): void {
const $input = $("#topic_filter_query");
$input.text(text);
$input.trigger("input");
}
const filter_options = new Map<string, string>([
[$t({defaultMessage: "Unresolved topics"}), "-is:resolved"],
[$t({defaultMessage: "Resolved topics"}), "is:resolved"],
[$t({defaultMessage: "All topics"}), ""],
]);
export function update_clear_button(): void {
const $filter_query = $("#topic_filter_query");
const $clear_button = $("#clear_search_topic_button");
if (get_left_sidebar_topic_search_term() === "" && get_typeahead_search_term() === "") {
$clear_button.css("visibility", "hidden");
// When we use backspace to clear the content of the search box,
// a <br> tag is left inside it, preventing the data-placeholder
// value from reappearing as the element never becomes truly empty.
// Therefore, we manually set the text to empty.
$filter_query.empty();
} else {
$clear_button.css("visibility", "visible");
}
}
export function setup_topic_search_typeahead(): void {
const $input = $("#topic_filter_query");
const $pill_container = $("#left-sidebar-filter-topic-input");
if ($input.length === 0 || $pill_container.length === 0) {
return;
}
search_pill_widget = search_pill.create_pills($pill_container);
const typeahead_input: TypeaheadInputElement = {
$element: $input,
type: "contenteditable",
};
const options = {
items: filter_options.size,
source() {
const stream_id = active_stream_id();
assert(stream_id !== undefined);
if (!stream_topic_history.stream_has_locally_available_resolved_topics(stream_id)) {
return [];
}
const $pills = $("#left-sidebar-filter-topic-input .pill");
if ($pills.length > 0) {
return [];
}
return [...filter_options.keys()];
},
highlighter_html(item: string) {
return typeahead_helper.render_topic_state(item);
},
matcher(item: string, query: string) {
return item.toLowerCase().includes(query.toLowerCase());
},
sorter(items: string[]) {
return items;
},
updater(item: string) {
const value = filter_options.get(item)!;
assert(search_pill_widget !== null);
search_pill_widget.clear(true);
search_pill_widget.appendValue(value);
set_search_bar_text("");
$input.trigger("focus");
return get_left_sidebar_topic_search_term();
},
// Prevents key events from propagating to other handlers or triggering default browser actions.
stopAdvance: true,
// Use dropup, to match compose typeahead.
dropup: true,
// Display typeahead menu when input gains focus and is empty.
helpOnEmptyStrings: true,
// Prevents displaying the typeahead menu when a pill is deleted via backspace.
hideOnEmptyAfterBackspace: true,
};
topic_state_typeahead = new Typeahead(typeahead_input, options);
$input.on("keydown", (e: JQuery.KeyDownEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
$input.addClass("shake");
} else if (e.key === ",") {
e.stopPropagation();
return;
}
});
search_pill_widget.onPillRemove(() => {
update_clear_button();
const stream_id = active_stream_id();
if (stream_id !== undefined) {
const widget = active_widgets.get(stream_id);
if (widget) {
widget.build();
}
}
});
}
export function initialize({
@@ -522,5 +649,8 @@ export function initialize({
const stream_id = active_stream_id();
assert(stream_id !== undefined);
active_widgets.get(stream_id)?.build();
update_clear_button();
});
update_clear_button();
}

View File

@@ -156,19 +156,31 @@ type TopicListInfo = {
more_topics_unread_count_muted: boolean;
};
export function filter_topics_by_search_term(topic_names: string[], search_term: string): string[] {
if (search_term === "") {
export function filter_topics_by_search_term(
topic_names: string[],
search_term: string,
topics_state = "",
): string[] {
if (search_term === "" && topics_state === "") {
return topic_names;
}
const word_separator_regex = /[\s/:_-]/; // Use -, _, :, / as word separators in addition to spaces.
const empty_string_topic_display_name = util.get_final_topic_display_name("");
return util.filter_by_word_prefix_match(
topic_names = util.filter_by_word_prefix_match(
topic_names,
search_term,
(topic) => (topic === "" ? empty_string_topic_display_name : topic),
word_separator_regex,
);
if (topics_state === "is: resolved") {
topic_names = topic_names.filter((name) => resolved_topic.is_resolved(name));
} else if (topics_state === "-is: resolved") {
topic_names = topic_names.filter((name) => !resolved_topic.is_resolved(name));
}
return topic_names;
}
export function get_list_info(

View File

@@ -176,6 +176,15 @@ export function rewire_render_person(value: typeof render_person): void {
render_person = value;
}
export let render_topic_state = (state: string): string =>
render_typeahead_item({
primary: state,
});
export function rewire_render_topic_state(value: typeof render_topic_state): void {
render_topic_state = value;
}
export let render_user_group = (user_group: {name: string; description: string}): string =>
render_typeahead_item({
primary: user_groups.get_display_group_name(user_group.name),

View File

@@ -1332,11 +1332,24 @@ li.top_left_scheduled_messages {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
gap: 0.125em; /* 2px at 16px em */
background-color: var(--color-background-active-narrow-filter);
font-weight: 400;
.input:empty::before {
color: var(--color-text-placeholder);
content: attr(data-placeholder);
}
#left-sidebar-filter-topic-input:placeholder-shown
+ #clear_search_topic_button {
.input {
flex-grow: 1;
min-width: 0;
}
}
#clear_search_topic_button {
visibility: hidden;
height: 2em; /* 32px at 16px/1em */
}
.searching-for-more-topics img {

View File

@@ -1,5 +1,10 @@
<div class="topic_search_section filter-topics left-sidebar-filter-row">
<input class="topic-list-filter home-page-input filter_text_input" id="left-sidebar-filter-topic-input" type="text" autocomplete="off" placeholder="{{t 'Filter topics'}}" />
<div class="topic-list-filter home-page-input filter_text_input pill-container" id="left-sidebar-filter-topic-input">
<div class="input" contenteditable="true" id="topic_filter_query"
data-placeholder="{{t 'Filter topics' }}">
{{~! Squash whitespace so that placeholder is displayed when empty. ~}}
</div>
</div>
<button type="button" class="clear_search_button" id="clear_search_topic_button">
<i class="zulip-icon zulip-icon-close" aria-hidden="true"></i>
</button>

View File

@@ -38,6 +38,7 @@ let sort_stream_or_group_members_options_called = false;
const $fake_rendered_person = $.create("fake-rendered-person");
const $fake_rendered_stream = $.create("fake-rendered-stream");
const $fake_rendered_group = $.create("fake-rendered-group");
const $fake_rendered_topic_state = $.create("fake-rendered-topic-state");
function override_typeahead_helper({mock_template, override_rewire}) {
mock_template("typeahead_list_item.hbs", false, (args) => {
@@ -422,6 +423,21 @@ run_test("set_up_user_group", ({mock_template, override, override_rewire}) => {
assert.ok(input_pill_typeahead_called);
});
run_test("render_topic_state", ({override_rewire}) => {
override_rewire(typeahead_helper, "render_typeahead_item", (args) => {
assert.equal(args.primary, "Resolved");
return $fake_rendered_topic_state;
});
const result = typeahead_helper.render_topic_state("Resolved");
assert.equal(result, $fake_rendered_topic_state);
override_rewire(typeahead_helper, "render_topic_state", (state) => `${state}`);
const new_result = typeahead_helper.render_topic_state("Unresolved");
assert.equal(new_result, "Unresolved");
});
run_test("set_up_combined", ({mock_template, override, override_rewire}) => {
override_typeahead_helper({mock_template, override_rewire});
mock_template("input_pill.hbs", true, (_data, html) => html);

View File

@@ -389,6 +389,8 @@ function elem($obj) {
}
test_ui("zoom_in_and_zoom_out", ({mock_template}) => {
topic_list.setup_topic_search_typeahead = noop;
const $label1 = $.create("label1 stub");
const $label2 = $.create("label2 stub");

View File

@@ -279,6 +279,26 @@ test("test_stream_has_topics", () => {
assert.equal(stream_topic_history.stream_has_topics(stream_id), true);
});
test("test_stream_has_resolved_topics", () => {
const stream_id = 89;
assert.equal(
stream_topic_history.stream_has_locally_available_resolved_topics(stream_id),
false,
);
stream_topic_history.add_message({
stream_id,
message_id: 889,
topic_name: "✔ whatever",
});
assert.equal(
stream_topic_history.stream_has_locally_available_resolved_topics(stream_id),
true,
);
});
test("server_history_end_to_end", () => {
stream_topic_history.reset();

View File

@@ -57,6 +57,28 @@ function get_list_info(zoom, search) {
);
}
test("filter_topics_by_search_term with resolved topics_state", () => {
const topic_names = ["topic 1", "✔ resolved topic", "topic 2"];
const search_term = "";
// Filter for resolved topics.
let topics_state = "is: resolved";
let result = topic_list_data.filter_topics_by_search_term(
topic_names,
search_term,
topics_state,
);
assert.deepEqual(result, ["✔ resolved topic"]);
// Filter for unresolved topics.
topics_state = "-is: resolved";
result = topic_list_data.filter_topics_by_search_term(topic_names, search_term, topics_state);
assert.deepEqual(result, ["topic 1", "topic 2"]);
});
function test(label, f) {
run_test(label, (helpers) => {
stream_topic_history.reset();