buddy_list: Fetch full subscriber data before showing user counts.

Work towards #34244.

Note that this doesn't show the right counts when the counts appear, because
yet. We can double check functionality after that change is complete.
This commit is contained in:
Evy Kassirer
2025-04-07 14:25:15 -07:00
committed by Tim Abbott
parent e2474d8e44
commit 8efe6a7b67
4 changed files with 186 additions and 59 deletions

View File

@@ -75,6 +75,10 @@ function should_hide_headers(
type BuddyListRenderData = { type BuddyListRenderData = {
current_sub: StreamSubscription | undefined; current_sub: StreamSubscription | undefined;
pm_ids_set: Set<number>; pm_ids_set: Set<number>;
// Note: Until #34246 is complete, this might be incorrect when
// we are dealing with a partial subscriber matrix, leading to
// some counting bugs in the headers. This shouldn't show up in
// production.
total_human_subscribers_count: number; total_human_subscribers_count: number;
other_users_count: number; other_users_count: number;
hide_headers: boolean; hide_headers: boolean;
@@ -168,8 +172,8 @@ export class BuddyList extends BuddyListConf {
current_filter: Filter | undefined | "unset" = "unset"; current_filter: Filter | undefined | "unset" = "unset";
initialize_tooltips(): void { initialize_tooltips(): void {
const non_participant_users_matching_view_count = (): number => const non_participant_users_matching_view_count = async (): Promise<number> =>
this.non_participant_users_matching_view_count(); await this.non_participant_users_matching_view_count();
const total_human_subscribers_count = (): number => const total_human_subscribers_count = (): number =>
this.render_data.total_human_subscribers_count; this.render_data.total_human_subscribers_count;
@@ -203,52 +207,56 @@ export class BuddyList extends BuddyListConf {
placement, placement,
showOnCreate: true, showOnCreate: true,
onShow(instance) { onShow(instance) {
let tooltip_text;
const participant_count = Number.parseInt( const participant_count = Number.parseInt(
$("#buddy-list-participants-section-heading").attr("data-user-count")!, $("#buddy-list-participants-section-heading").attr("data-user-count")!,
10, 10,
); );
const elem_id = $elem.attr("id"); const elem_id = $elem.attr("id");
if (elem_id === "buddy-list-participants-section-heading") { if (elem_id === "buddy-list-participants-section-heading") {
tooltip_text = $t( const tooltip_text = $t(
{ {
defaultMessage: defaultMessage:
"{N, plural, one {# participant} other {# participants}}", "{N, plural, one {# participant} other {# participants}}",
}, },
{N: participant_count}, {N: participant_count},
); );
instance.setContent(tooltip_text);
} else if (elem_id === "buddy-list-users-matching-view-section-heading") { } else if (elem_id === "buddy-list-users-matching-view-section-heading") {
const users_matching_view_count = void (async () => {
non_participant_users_matching_view_count(); let tooltip_text;
if (narrow_state.stream_sub()) { const users_matching_view_count =
tooltip_text = $t( await non_participant_users_matching_view_count();
{ if (narrow_state.stream_sub()) {
defaultMessage: tooltip_text = $t(
"{N, plural, one {# other subscriber} other {# other subscribers}}", {
}, defaultMessage:
{N: users_matching_view_count}, "{N, plural, one {# other subscriber} other {# other subscribers}}",
); },
} else { {N: users_matching_view_count},
tooltip_text = $t( );
{ } else {
defaultMessage: tooltip_text = $t(
"{N, plural, one {# participant} other {# participants}}", {
}, defaultMessage:
{N: users_matching_view_count}, "{N, plural, one {# participant} other {# participants}}",
); },
} {N: users_matching_view_count},
);
}
instance.setContent(tooltip_text);
})();
} else { } else {
const other_users_count = const other_users_count =
people.get_active_human_count() - total_human_subscribers_count(); people.get_active_human_count() - total_human_subscribers_count();
tooltip_text = $t( const tooltip_text = $t(
{ {
defaultMessage: defaultMessage:
"{N, plural, one {# other user} other {# other users}}", "{N, plural, one {# other user} other {# other users}}",
}, },
{N: other_users_count}, {N: other_users_count},
); );
instance.setContent(tooltip_text);
} }
instance.setContent(tooltip_text);
}, },
onHidden(instance) { onHidden(instance) {
instance.destroy(); instance.destroy();
@@ -259,7 +267,7 @@ export class BuddyList extends BuddyListConf {
); );
} }
non_participant_users_matching_view_count(): number { async non_participant_users_matching_view_count(): Promise<number> {
const {current_sub, get_all_participant_ids} = this.render_data; const {current_sub, get_all_participant_ids} = this.render_data;
// We don't show "participants" for DMs, we just show the // We don't show "participants" for DMs, we just show the
// "in this narrow" section (i.e. everyone in the conversation). // "in this narrow" section (i.e. everyone in the conversation).
@@ -268,11 +276,16 @@ export class BuddyList extends BuddyListConf {
return this.render_data.total_human_subscribers_count; return this.render_data.total_human_subscribers_count;
} }
const participant_ids_list = [...get_all_participant_ids()]; const participant_ids_list = [...get_all_participant_ids()];
const subscribed_human_participant_ids = participant_ids_list.filter( const subscribed_human_participant_ids = [];
(user_id) => for (const user_id of participant_ids_list) {
peer_data.is_user_subscribed(current_sub.stream_id, user_id) && const is_subscribed = await peer_data.maybe_fetch_is_user_subscribed(
!people.is_valid_bot_user(user_id), current_sub.stream_id,
); user_id,
);
if (is_subscribed && !people.is_valid_bot_user(user_id)) {
subscribed_human_participant_ids.push(user_id);
}
}
return ( return (
this.render_data.total_human_subscribers_count - subscribed_human_participant_ids.length this.render_data.total_human_subscribers_count - subscribed_human_participant_ids.length
); );
@@ -327,10 +340,10 @@ export class BuddyList extends BuddyListConf {
$("#buddy-list-users-matching-view-container .view-all-subscribers-link").remove(); $("#buddy-list-users-matching-view-container .view-all-subscribers-link").remove();
$("#buddy-list-other-users-container .view-all-users-link").remove(); $("#buddy-list-other-users-container .view-all-users-link").remove();
if (!buddy_data.get_is_searching_users()) { if (!buddy_data.get_is_searching_users()) {
this.render_view_user_list_links(); void this.render_view_user_list_links();
} }
this.display_or_hide_sections(); this.display_or_hide_sections();
this.update_empty_list_placeholders(); void this.update_empty_list_placeholders();
// `populate` always rerenders all user rows, so we need new load handlers. // `populate` always rerenders all user rows, so we need new load handlers.
// This logic only does something is a user has enabled the setting to // This logic only does something is a user has enabled the setting to
@@ -349,7 +362,7 @@ export class BuddyList extends BuddyListConf {
// Otherwise we hide sections with no users in them, except the "this // Otherwise we hide sections with no users in them, except the "this
// channel" section, since it could confuse users to show other sections // channel" section, since it could confuse users to show other sections
// without that one. // without that one.
update_empty_list_placeholders(): void { async update_empty_list_placeholders(): Promise<void> {
function add_or_update_empty_list_placeholder( function add_or_update_empty_list_placeholder(
list_selector: string, list_selector: string,
message: string, message: string,
@@ -378,9 +391,10 @@ export class BuddyList extends BuddyListConf {
} }
const {get_all_participant_ids} = this.render_data; const {get_all_participant_ids} = this.render_data;
if (this.non_participant_users_matching_view_count() > 0) { $("#buddy-list-users-matching-view .empty-list-message").remove();
if ((await this.non_participant_users_matching_view_count()) > 0) {
// There are more subscribers, so we don't need an empty list message. // There are more subscribers, so we don't need an empty list message.
$("#buddy-list-users-matching-view .empty-list-message").remove(); return;
} else if (get_all_participant_ids().size > 0) { } else if (get_all_participant_ids().size > 0) {
add_or_update_empty_list_placeholder( add_or_update_empty_list_placeholder(
"#buddy-list-users-matching-view", "#buddy-list-users-matching-view",
@@ -396,13 +410,20 @@ export class BuddyList extends BuddyListConf {
} }
} }
update_section_header_counts(): void { async update_section_header_counts(): Promise<void> {
const {other_users_count} = this.render_data; const {other_users_count, current_sub} = this.render_data;
const all_participant_ids = this.render_data.get_all_participant_ids(); const all_participant_ids = this.render_data.get_all_participant_ids();
// Hide the counts until we have the data
if (current_sub && !peer_data.has_full_subscriber_data(current_sub.stream_id)) {
$(".buddy-list-heading-user-count-with-parens").addClass("hide");
}
const formatted_participants_count = get_formatted_user_count(all_participant_ids.size); const formatted_participants_count = get_formatted_user_count(all_participant_ids.size);
const non_participant_users_matching_view_count =
await this.non_participant_users_matching_view_count();
const formatted_matching_users_count = get_formatted_user_count( const formatted_matching_users_count = get_formatted_user_count(
this.non_participant_users_matching_view_count(), non_participant_users_matching_view_count,
); );
const formatted_other_users_count = get_formatted_user_count(other_users_count); const formatted_other_users_count = get_formatted_user_count(other_users_count);
@@ -415,6 +436,7 @@ export class BuddyList extends BuddyListConf {
$("#buddy-list-other-users-container .buddy-list-heading-user-count").text( $("#buddy-list-other-users-container .buddy-list-heading-user-count").text(
formatted_other_users_count, formatted_other_users_count,
); );
$(".buddy-list-heading-user-count-with-parens").removeClass("hide");
$("#buddy-list-participants-section-heading").attr( $("#buddy-list-participants-section-heading").attr(
"data-user-count", "data-user-count",
@@ -422,7 +444,7 @@ export class BuddyList extends BuddyListConf {
); );
$("#buddy-list-users-matching-view-section-heading").attr( $("#buddy-list-users-matching-view-section-heading").attr(
"data-user-count", "data-user-count",
this.non_participant_users_matching_view_count(), non_participant_users_matching_view_count,
); );
$("#buddy-list-users-other-users-section-heading").attr( $("#buddy-list-users-other-users-section-heading").attr(
"data-user-count", "data-user-count",
@@ -430,20 +452,19 @@ export class BuddyList extends BuddyListConf {
); );
} }
render_section_headers(): void { async render_section_headers(): Promise<void> {
const {hide_headers} = this.render_data; const {hide_headers} = this.render_data;
const all_participant_ids = this.render_data.get_all_participant_ids();
// If we're not changing filters, this just means some users were added or // If we're not changing filters, this just means some users were added or
// removed but otherwise everything is the same, so we don't need to do a full // removed but otherwise everything is the same, so we don't need to do a full
// rerender. // rerender.
if (this.current_filter === narrow_state.filter()) { if (this.current_filter === narrow_state.filter()) {
this.update_section_header_counts(); await this.update_section_header_counts();
return; return;
} }
this.current_filter = narrow_state.filter(); this.current_filter = narrow_state.filter();
const {current_sub, other_users_count} = this.render_data; const {current_sub} = this.render_data;
$(".buddy-list-subsection-header").empty(); $(".buddy-list-subsection-header").empty();
$(".buddy-list-subsection-header").toggleClass("no-display", hide_headers); $(".buddy-list-subsection-header").toggleClass("no-display", hide_headers);
if (hide_headers) { if (hide_headers) {
@@ -455,7 +476,6 @@ export class BuddyList extends BuddyListConf {
render_section_header({ render_section_header({
id: "buddy-list-participants-section-heading", id: "buddy-list-participants-section-heading",
header_text: $t({defaultMessage: "THIS CONVERSATION"}), header_text: $t({defaultMessage: "THIS CONVERSATION"}),
user_count: get_formatted_user_count(all_participant_ids.size),
is_collapsed: this.participants_is_collapsed, is_collapsed: this.participants_is_collapsed,
}), }),
), ),
@@ -468,9 +488,6 @@ export class BuddyList extends BuddyListConf {
header_text: current_sub header_text: current_sub
? $t({defaultMessage: "THIS CHANNEL"}) ? $t({defaultMessage: "THIS CHANNEL"})
: $t({defaultMessage: "THIS CONVERSATION"}), : $t({defaultMessage: "THIS CONVERSATION"}),
user_count: get_formatted_user_count(
this.non_participant_users_matching_view_count(),
),
is_collapsed: this.users_matching_view_is_collapsed, is_collapsed: this.users_matching_view_is_collapsed,
}), }),
), ),
@@ -481,11 +498,11 @@ export class BuddyList extends BuddyListConf {
render_section_header({ render_section_header({
id: "buddy-list-other-users-section-heading", id: "buddy-list-other-users-section-heading",
header_text: $t({defaultMessage: "OTHERS"}), header_text: $t({defaultMessage: "OTHERS"}),
user_count: get_formatted_user_count(other_users_count),
is_collapsed: this.other_users_is_collapsed, is_collapsed: this.other_users_is_collapsed,
}), }),
), ),
); );
await this.update_section_header_counts();
} }
set_section_collapse(container_selector: string, is_collapsed: boolean): void { set_section_collapse(container_selector: string, is_collapsed: boolean): void {
@@ -638,10 +655,11 @@ export class BuddyList extends BuddyListConf {
$("#buddy-list-other-users-container").toggleClass("no-display", hide_other_users); $("#buddy-list-other-users-container").toggleClass("no-display", hide_other_users);
} }
render_view_user_list_links(): void { async render_view_user_list_links(): Promise<void> {
const {current_sub, other_users_count} = this.render_data; const {current_sub, other_users_count} = this.render_data;
const has_inactive_users_matching_view = const has_inactive_users_matching_view =
this.non_participant_users_matching_view_count() > this.users_matching_view_ids.length; (await this.non_participant_users_matching_view_count()) >
this.users_matching_view_ids.length;
const has_inactive_other_users = other_users_count > this.other_user_ids.length; const has_inactive_other_users = other_users_count > this.other_user_ids.length;
// For stream views, we show a link at the bottom of the list of subscribed users that // For stream views, we show a link at the bottom of the list of subscribed users that
@@ -989,8 +1007,8 @@ export class BuddyList extends BuddyListConf {
} }
this.display_or_hide_sections(); this.display_or_hide_sections();
this.update_empty_list_placeholders(); void this.update_empty_list_placeholders();
this.render_section_headers(); void this.render_section_headers();
} }
rerender_participants(): void { rerender_participants(): void {
@@ -1035,7 +1053,7 @@ export class BuddyList extends BuddyListConf {
chunk_size, chunk_size,
}); });
} }
this.render_section_headers(); void this.render_section_headers();
} }
start_scroll_handler(): void { start_scroll_handler(): void {

View File

@@ -1,5 +1,10 @@
import assert from "minimalistic-assert";
import {z} from "zod";
import * as blueslip from "./blueslip.ts"; import * as blueslip from "./blueslip.ts";
import * as channel from "./channel.ts";
import {LazySet} from "./lazy_set.ts"; import {LazySet} from "./lazy_set.ts";
import {page_params} from "./page_params.ts";
import type {User} from "./people.ts"; import type {User} from "./people.ts";
import * as people from "./people.ts"; import * as people from "./people.ts";
import * as sub_store from "./sub_store.ts"; import * as sub_store from "./sub_store.ts";
@@ -7,9 +12,47 @@ import * as sub_store from "./sub_store.ts";
// This maps a stream_id to a LazySet of user_ids who are subscribed. // This maps a stream_id to a LazySet of user_ids who are subscribed.
const stream_subscribers = new Map<number, LazySet>(); const stream_subscribers = new Map<number, LazySet>();
const fetched_stream_ids = new Set<number>(); const fetched_stream_ids = new Set<number>();
export function has_full_subscriber_data(stream_id: number): boolean {
return fetched_stream_ids.has(stream_id);
}
const pending_subscriber_requests = new Map<number, Promise<LazySet>>();
export function clear_for_testing(): void { export function clear_for_testing(): void {
stream_subscribers.clear(); stream_subscribers.clear();
fetched_stream_ids.clear();
}
const fetch_stream_subscribers_response_schema = z.object({
subscribers: z.array(z.number()),
});
export async function maybe_fetch_stream_subscribers(stream_id: number): Promise<LazySet> {
if (pending_subscriber_requests.has(stream_id)) {
return pending_subscriber_requests.get(stream_id)!;
}
const promise = (async () => {
let subscribers: number[];
try {
const xhr = await channel.get({
url: `/json/streams/${stream_id}/members`,
});
subscribers = fetch_stream_subscribers_response_schema.parse(xhr).subscribers;
} catch {
blueslip.error("Failure fetching channel subscribers", {
stream_id,
});
pending_subscriber_requests.delete(stream_id);
// Fall back to what we already have.
return get_loaded_subscriber_subset(stream_id);
}
set_subscribers(stream_id, subscribers);
pending_subscriber_requests.delete(stream_id);
return new LazySet(subscribers);
})();
pending_subscriber_requests.set(stream_id, promise);
return promise;
} }
function get_loaded_subscriber_subset(stream_id: number): LazySet { function get_loaded_subscriber_subset(stream_id: number): LazySet {
@@ -31,6 +74,17 @@ function get_loaded_subscriber_subset(stream_id: number): LazySet {
return subscribers; return subscribers;
} }
async function get_full_subscriber_set(stream_id: number): Promise<LazySet> {
assert(!page_params.is_spectator);
// This function parallels `get_loaded_subscriber_subset` but ensures we include all
// subscribers, possibly fetching that data from the server.
if (!fetched_stream_ids.has(stream_id) && sub_store.get(stream_id)) {
const fetched_subscribers = await maybe_fetch_stream_subscribers(stream_id);
stream_subscribers.set(stream_id, fetched_subscribers);
}
return get_loaded_subscriber_subset(stream_id);
}
export function is_subscriber_subset(stream_id1: number, stream_id2: number): boolean { export function is_subscriber_subset(stream_id1: number, stream_id2: number): boolean {
const sub1_set = get_loaded_subscriber_subset(stream_id1); const sub1_set = get_loaded_subscriber_subset(stream_id1);
const sub2_set = get_loaded_subscriber_subset(stream_id2); const sub2_set = get_loaded_subscriber_subset(stream_id2);
@@ -169,6 +223,17 @@ export function is_user_subscribed(stream_id: number, user_id: number): boolean
return subscribers.has(user_id); return subscribers.has(user_id);
} }
// TODO: If the server sends us a list of users for whom we have complete data,
// we can use that to avoid waiting for the `get_full_subscriber_set` check. We'd
// like to add that optimization in the future.
export async function maybe_fetch_is_user_subscribed(
stream_id: number,
user_id: number,
): Promise<boolean> {
const subscribers = await get_full_subscriber_set(stream_id);
return subscribers.has(user_id);
}
export function get_unique_subscriber_count_for_streams(stream_ids: number[]): number { export function get_unique_subscriber_count_for_streams(stream_ids: number[]): number {
const valid_subscribers = new LazySet([]); const valid_subscribers = new LazySet([]);

View File

@@ -1,7 +1,8 @@
<i class="buddy-list-section-toggle zulip-icon zulip-icon-heading-triangle-right {{#if is_collapsed}}rotate-icon-right{{else}}rotate-icon-down{{/if}}" aria-hidden="true"></i> <i class="buddy-list-section-toggle zulip-icon zulip-icon-heading-triangle-right {{#if is_collapsed}}rotate-icon-right{{else}}rotate-icon-down{{/if}}" aria-hidden="true"></i>
<h5 id="{{id}}" data-user-count="{{user_count}}" class="buddy-list-heading no-style hidden-for-spectators"> <h5 id="{{id}}" class="buddy-list-heading no-style hidden-for-spectators">
<span class="buddy-list-heading-text">{{header_text}}</span> <span class="buddy-list-heading-text">{{header_text}}</span>
<span class="buddy-list-heading-user-count-with-parens"> {{!-- Hide the count until we have fetched data to display the correct count --}}
(<span class="buddy-list-heading-user-count">{{user_count}}</span>) <span class="buddy-list-heading-user-count-with-parens hide">
(<span class="buddy-list-heading-user-count"></span>)
</span> </span>
</h5> </h5>

View File

@@ -9,11 +9,13 @@
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const example_settings = require("./lib/example_settings.cjs"); const example_settings = require("./lib/example_settings.cjs");
const {zrequire} = require("./lib/namespace.cjs"); const {mock_esm, zrequire} = require("./lib/namespace.cjs");
const {run_test} = require("./lib/test.cjs"); const {run_test} = require("./lib/test.cjs");
const blueslip = require("./lib/zblueslip.cjs"); const blueslip = require("./lib/zblueslip.cjs");
const {page_params} = require("./lib/zpage_params.cjs"); const {page_params} = require("./lib/zpage_params.cjs");
const channel = mock_esm("../src/channel");
const peer_data = zrequire("peer_data"); const peer_data = zrequire("peer_data");
const people = zrequire("people"); const people = zrequire("people");
const {set_current_user, set_realm} = zrequire("state_data"); const {set_current_user, set_realm} = zrequire("state_data");
@@ -83,7 +85,7 @@ function test(label, f) {
); );
override(realm, "realm_can_access_all_users_group", nobody_group.id); override(realm, "realm_can_access_all_users_group", nobody_group.id);
f({override}); return f({override});
}); });
} }
@@ -240,6 +242,47 @@ test("subscribers", () => {
blueslip.reset(); blueslip.reset();
}); });
test("maybe_fetch_stream_subscribers", async () => {
const india = {
stream_id: 102,
name: "India",
subscribed: true,
};
stream_data.add_sub(india);
let channel_get_calls = 0;
channel.get = (opts) => {
assert.equal(opts.url, `/json/streams/${india.stream_id}/members`);
channel_get_calls += 1;
return {
subscribers: [1, 2, 3, 4],
};
};
// Only one of these will do the fetch, and the other will wait
// for the first fetch to complete.
const promise1 = peer_data.maybe_fetch_stream_subscribers(india.stream_id);
const promise2 = peer_data.maybe_fetch_stream_subscribers(india.stream_id);
await promise1;
await promise2;
assert.equal(channel_get_calls, 1);
peer_data.clear_for_testing();
assert.equal(await peer_data.maybe_fetch_is_user_subscribed(india.stream_id, 2), true);
assert.equal(peer_data.has_full_subscriber_data(india.stream_id), true);
peer_data.clear_for_testing();
assert.equal(peer_data.has_full_subscriber_data(india.stream_id), false);
assert.equal(await peer_data.maybe_fetch_is_user_subscribed(india.stream_id, 2), true);
assert.equal(await peer_data.maybe_fetch_is_user_subscribed(india.stream_id, 5), false);
channel.get = () => {
throw new Error("error");
};
peer_data.clear_for_testing();
blueslip.expect("error", "Failure fetching channel subscribers");
peer_data.maybe_fetch_stream_subscribers(india.stream_id);
});
test("get_subscriber_count", () => { test("get_subscriber_count", () => {
people.add_active_user(fred); people.add_active_user(fred);
people.add_active_user(gail); people.add_active_user(gail);