diff --git a/tools/test-js-with-node b/tools/test-js-with-node index d80949d188..4b3b48ecb5 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -62,6 +62,7 @@ EXEMPT_FILES = make_set( "web/src/blueslip.ts", "web/src/blueslip_stacktrace.ts", "web/src/browser_history.ts", + "web/src/buddy_list.js", "web/src/click_handlers.js", "web/src/compose.js", "web/src/compose_actions.js", diff --git a/web/e2e-tests/message-basics.test.ts b/web/e2e-tests/message-basics.test.ts index b910790812..15f1eb5f2f 100644 --- a/web/e2e-tests/message-basics.test.ts +++ b/web/e2e-tests/message-basics.test.ts @@ -422,24 +422,21 @@ async function test_stream_search_filters_stream_list(page: Page): Promise async function test_users_search(page: Page): Promise { console.log("Search users using right sidebar"); async function assert_in_list(page: Page, name: string): Promise { - await page.waitForSelector( - `#buddy-list-users-matching-view li [data-name="${CSS.escape(name)}"]`, - { - visible: true, - }, - ); + await page.waitForSelector(`#buddy-list-other-users li [data-name="${CSS.escape(name)}"]`, { + visible: true, + }); } async function assert_selected(page: Page, name: string): Promise { await page.waitForSelector( - `#buddy-list-users-matching-view li.highlighted_user [data-name="${CSS.escape(name)}"]`, + `#buddy-list-other-users li.highlighted_user [data-name="${CSS.escape(name)}"]`, {visible: true}, ); } async function assert_not_selected(page: Page, name: string): Promise { await page.waitForSelector( - `#buddy-list-users-matching-view li.highlighted_user [data-name="${CSS.escape(name)}"]`, + `#buddy-list-other-users li.highlighted_user [data-name="${CSS.escape(name)}"]`, {hidden: true}, ); } @@ -451,9 +448,7 @@ async function test_users_search(page: Page): Promise { // Enter the search box and test selected suggestion navigation await page.click("#user_filter_icon"); - await page.waitForSelector("#buddy-list-users-matching-view .highlighted_user", { - visible: true, - }); + await page.waitForSelector("#buddy-list-other-users .highlighted_user", {visible: true}); await assert_selected(page, "Desdemona"); await assert_not_selected(page, "Cordelia, Lear's daughter"); await assert_not_selected(page, "King Hamlet"); @@ -475,12 +470,9 @@ async function test_users_search(page: Page): Promise { await arrow(page, "Down"); // Now Iago must be highlighted - await page.waitForSelector( - '#buddy-list-users-matching-view li.highlighted_user [data-name="Iago"]', - { - visible: true, - }, - ); + await page.waitForSelector('#buddy-list-other-users li.highlighted_user [data-name="Iago"]', { + visible: true, + }); await assert_not_selected(page, "King Hamlet"); await assert_not_selected(page, "aaron"); await assert_not_selected(page, "Desdemona"); diff --git a/web/src/activity_ui.js b/web/src/activity_ui.js index 0d3ecb34e1..b28fd68d06 100644 --- a/web/src/activity_ui.js +++ b/web/src/activity_ui.js @@ -93,13 +93,14 @@ export function build_user_sidebar() { return undefined; } - const filter_text = get_filter_text(); + const filter_text = user_filter.text(); const all_user_ids = buddy_data.get_filtered_and_sorted_user_ids(filter_text); buddy_list.populate({all_user_ids}); - render_empty_user_list_message_if_needed(buddy_list.$container); + render_empty_user_list_message_if_needed(buddy_list.$users_matching_view_container); + render_empty_user_list_message_if_needed(buddy_list.$other_users_container); return all_user_ids; // for testing } diff --git a/web/src/buddy_data.ts b/web/src/buddy_data.ts index 92bbfdc29c..717da6f253 100644 --- a/web/src/buddy_data.ts +++ b/web/src/buddy_data.ts @@ -3,9 +3,12 @@ import * as compose_fade_users from "./compose_fade_users"; import * as hash_util from "./hash_util"; import {$t} from "./i18n"; import * as muted_users from "./muted_users"; +import * as narrow_state from "./narrow_state"; import * as people from "./people"; import * as presence from "./presence"; import {realm} from "./state_data"; +import * as stream_data from "./stream_data"; +import type {StreamSubscription} from "./sub_store"; import * as timerender from "./timerender"; import * as unread from "./unread"; import {user_settings} from "./user_settings"; @@ -24,6 +27,11 @@ import * as util from "./util"; export const max_size_before_shrinking = 600; let is_searching_users = false; + +export function get_is_searching_users(): boolean { + return is_searching_users; +} + export function set_is_searching_users(val: boolean): void { is_searching_users = val; } @@ -71,7 +79,35 @@ export function level(user_id: number): number { } } -export function compare_function(a: number, b: number): number { +export function user_matches_narrow( + user_id: number, + pm_ids: Set, + stream_id?: number | null, +): boolean { + if (stream_id) { + return stream_data.is_user_subscribed(stream_id, user_id); + } + if (pm_ids.size > 0) { + return pm_ids.has(user_id) || people.is_my_user_id(user_id); + } + return false; +} + +export function compare_function( + a: number, + b: number, + current_sub: StreamSubscription | undefined, + pm_ids: Set, +): number { + const a_would_receive_message = user_matches_narrow(a, pm_ids, current_sub?.stream_id); + const b_would_receive_message = user_matches_narrow(b, pm_ids, current_sub?.stream_id); + if (a_would_receive_message && !b_would_receive_message) { + return -1; + } + if (!a_would_receive_message && b_would_receive_message) { + return 1; + } + const level_a = level(a); const level_b = level(b); const diff = level_a - level_b; @@ -91,7 +127,9 @@ export function compare_function(a: number, b: number): number { export function sort_users(user_ids: number[]): number[] { // TODO sort by unread count first, once we support that - user_ids.sort(compare_function); + const current_sub = narrow_state.stream_sub(); + const pm_ids_set = narrow_state.pm_ids_set(); + user_ids.sort((a, b) => compare_function(a, b, current_sub, pm_ids_set)); return user_ids; } @@ -276,7 +314,11 @@ function maybe_shrink_list(user_ids: number[], user_filter_text: string): number return user_ids; } - user_ids = user_ids.filter((user_id) => user_is_recently_active(user_id)); + // We want to always show PM recipients even if they're inactive. + const pm_ids_set = narrow_state.pm_ids_set(); + user_ids = user_ids.filter( + (user_id) => user_is_recently_active(user_id) || user_matches_narrow(user_id, pm_ids_set), + ); return user_ids; } @@ -340,6 +382,13 @@ function get_filtered_user_id_list(user_filter_text: string): number[] { if (!base_user_id_list.includes(my_user_id)) { base_user_id_list = [my_user_id, ...base_user_id_list]; } + + // We want to always show PM recipients even if they're inactive. + const pm_ids_set = narrow_state.pm_ids_set(); + if (pm_ids_set.size) { + const base_user_id_set = new Set([...base_user_id_list, ...pm_ids_set]); + base_user_id_list = [...base_user_id_set]; + } } const user_ids = filter_user_ids(user_filter_text, base_user_id_list); diff --git a/web/src/buddy_list.js b/web/src/buddy_list.js index a21b1eb5ed..99faa27053 100644 --- a/web/src/buddy_list.js +++ b/web/src/buddy_list.js @@ -1,16 +1,40 @@ import $ from "jquery"; +import tippy from "tippy.js"; +import render_section_header from "../templates/buddy_list/section_header.hbs"; +import render_view_all_subscribers from "../templates/buddy_list/view_all_subscribers.hbs"; +import render_view_all_users from "../templates/buddy_list/view_all_users.hbs"; +import render_empty_list_widget_for_list from "../templates/empty_list_widget_for_list.hbs"; import render_presence_row from "../templates/presence_row.hbs"; import render_presence_rows from "../templates/presence_rows.hbs"; import * as blueslip from "./blueslip"; import * as buddy_data from "./buddy_data"; +import {media_breakpoints_num} from "./css_variables"; +import * as hash_util from "./hash_util"; +import {$t} from "./i18n"; import * as message_viewport from "./message_viewport"; +import * as narrow_state from "./narrow_state"; import * as padded_widget from "./padded_widget"; +import * as peer_data from "./peer_data"; +import * as people from "./people"; import * as scroll_util from "./scroll_util"; +import * as stream_data from "./stream_data"; +import {INTERACTIVE_HOVER_DELAY} from "./tippyjs"; +import {user_settings} from "./user_settings"; + +function get_formatted_sub_count(sub_count) { + if (sub_count < 1000) { + return sub_count; + } + return new Intl.NumberFormat(user_settings.default_language, {notation: "compact"}).format( + sub_count, + ); +} class BuddyListConf { - container_selector = "#buddy-list-users-matching-view"; + matching_view_list_selector = "#buddy-list-users-matching-view"; + other_user_list_selector = "#buddy-list-other-users"; scroll_container_selector = "#buddy_list_wrapper"; item_selector = "li.user_sidebar_entry"; padding_selector = "#buddy_list_wrapper_padding"; @@ -27,8 +51,10 @@ class BuddyListConf { get_li_from_user_id(opts) { const user_id = opts.user_id; - const $container = $(this.container_selector); - return $container.find(`${this.item_selector}[data-user-id='${CSS.escape(user_id)}']`); + const $buddy_list_container = $("#buddy_list_wrapper"); + return $buddy_list_container.find( + `${this.item_selector}[data-user-id='${CSS.escape(user_id)}']`, + ); } get_user_id_from_li(opts) { @@ -55,16 +81,260 @@ class BuddyListConf { export class BuddyList extends BuddyListConf { all_user_ids = []; + users_matching_view_ids = []; + other_user_ids = []; + users_matching_view_is_collapsed = false; + other_users_is_collapsed = false; + + initialize_tooltips() { + $("#right-sidebar").on("mouseenter", ".buddy-list-heading", (e) => { + e.stopPropagation(); + const $elem = $(e.currentTarget); + let placement = "left"; + if (window.innerWidth < media_breakpoints_num.md) { + // On small devices display tooltips based on available space. + // This will default to "bottom" placement for this tooltip. + placement = "auto"; + } + const get_total_subscriber_count = this.total_subscriber_count; + tippy($elem[0], { + // Because the buddy list subheadings are potential click targets + // for purposes having nothing to do with the subscriber count + // (collapsing/expanding), we delay showing the tooltip until the + // user lingers a bit longer. + delay: INTERACTIVE_HOVER_DELAY, + // Don't show tooltip on touch devices (99% mobile) since touch + // pressing on users in the left or right sidebar leads to narrow + // being changed and the sidebar is hidden. So, there is no user + // displayed to show tooltip for. It is safe to show the tooltip + // on long press but it not worth the inconvenience of having a + // tooltip hanging around on a small mobile screen if anything + // going wrong. + touch: false, + arrow: true, + placement, + showOnCreate: true, + onShow(instance) { + let tooltip_text; + const current_sub = narrow_state.stream_sub(); + const pm_ids_set = narrow_state.pm_ids_set(); + const subscriber_count = get_total_subscriber_count(current_sub, pm_ids_set); + const elem_id = $elem.attr("id"); + if (elem_id === "buddy-list-users-matching-view-section-heading") { + if (current_sub) { + tooltip_text = $t( + { + defaultMessage: + "{N, plural, one {# subscriber} other {# subscribers}}", + }, + {N: subscriber_count}, + ); + } else { + tooltip_text = $t( + { + defaultMessage: + "{N, plural, one {# participant} other {# participants}}", + }, + {N: subscriber_count}, + ); + } + } else { + const total_user_count = people.get_active_human_count(); + const other_users_count = total_user_count - subscriber_count; + tooltip_text = $t( + { + defaultMessage: + "{N, plural, one {# other user} other {# other users}}", + }, + {N: other_users_count}, + ); + } + instance.setContent(tooltip_text); + }, + onHidden(instance) { + instance.destroy(); + }, + appendTo: () => document.body, + }); + }); + } populate(opts) { this.render_count = 0; - this.$container.empty(); + this.$users_matching_view_container.empty(); + this.users_matching_view_ids = []; + this.$other_users_container.empty(); + this.other_user_ids = []; // We rely on our caller to give us items // in already-sorted order. this.all_user_ids = opts.all_user_ids; this.fill_screen_with_content(); + + // We do a handful of things once we're done rendering all the users, + // and each of these tasks need shared data that we'll compute first. + const current_sub = narrow_state.stream_sub(); + const pm_ids_set = narrow_state.pm_ids_set(); + + // If we have only "other users" and aren't in a stream/DM view + // then we don't show section headers and only show one untitled + // section. + const hide_headers = this.should_hide_headers(current_sub, pm_ids_set); + const subscriber_count = this.total_subscriber_count(current_sub, pm_ids_set); + const total_user_count = people.get_active_human_count(); + const other_users_count = total_user_count - subscriber_count; + const has_inactive_users_matching_view = + subscriber_count > this.users_matching_view_ids.length; + const has_inactive_other_users = other_users_count > this.other_user_ids.length; + + const data = { + current_sub, + hide_headers, + subscriber_count, + other_users_count, + has_inactive_users_matching_view, + has_inactive_other_users, + }; + + $("#buddy-list-users-matching-view-container .view-all-subscribers-link").remove(); + $("#buddy-list-other-users-container .view-all-users-link").remove(); + if (!buddy_data.get_is_searching_users()) { + this.render_view_user_list_links(data); + } + this.render_section_headers(data); + if (!hide_headers) { + this.update_empty_list_placeholders(data); + } + } + + update_empty_list_placeholders({has_inactive_users_matching_view, has_inactive_other_users}) { + let matching_view_empty_list_message; + let other_users_empty_list_message; + + if (buddy_data.get_is_searching_users()) { + matching_view_empty_list_message = $t({defaultMessage: "No matching users."}); + other_users_empty_list_message = $t({defaultMessage: "No matching users."}); + } else { + if (has_inactive_users_matching_view) { + matching_view_empty_list_message = $t({defaultMessage: "No active users."}); + } else { + matching_view_empty_list_message = $t({defaultMessage: "None."}); + } + + if (has_inactive_other_users) { + other_users_empty_list_message = $t({defaultMessage: "No active users."}); + } else { + other_users_empty_list_message = $t({defaultMessage: "None."}); + } + } + + $("#buddy-list-users-matching-view").data( + "search-results-empty", + matching_view_empty_list_message, + ); + if ($("#buddy-list-users-matching-view .empty-list-message").length) { + const empty_list_widget = render_empty_list_widget_for_list({ + matching_view_empty_list_message, + }); + $("#buddy-list-users-matching-view").empty(); + $("#buddy-list-users-matching-view").append(empty_list_widget); + } + + $("#buddy-list-other-users").data("search-results-empty", other_users_empty_list_message); + if ($("#buddy-list-other-users .empty-list-message").length) { + const empty_list_widget = render_empty_list_widget_for_list({ + other_users_empty_list_message, + }); + $("#buddy-list-other-users").empty(); + $("#buddy-list-other-users").append(empty_list_widget); + } + } + + render_section_headers({current_sub, hide_headers, subscriber_count, other_users_count}) { + $("#buddy-list-users-matching-view-container .buddy-list-subsection-header").empty(); + $("#buddy-list-other-users-container .buddy-list-subsection-header").empty(); + + $("#buddy-list-users-matching-view-container").toggleClass("no-display", hide_headers); + + // Usually we show the user counts in the headers, but if we're hiding + // those headers then we show the total user count in the main title. + const default_userlist_title = $t({defaultMessage: "USERS"}); + if (hide_headers) { + const total_user_count = people.get_active_human_count(); + const formatted_count = get_formatted_sub_count(total_user_count); + const userlist_title = `${default_userlist_title} (${formatted_count})`; + $("#userlist-title").text(userlist_title); + return; + } + $("#userlist-title").text(default_userlist_title); + + let header_text; + if (current_sub) { + header_text = $t({defaultMessage: "In this stream"}); + } else { + header_text = $t({defaultMessage: "In this conversation"}); + } + + $("#buddy-list-users-matching-view-container .buddy-list-subsection-header").append( + render_section_header({ + id: "buddy-list-users-matching-view-section-heading", + header_text, + user_count: get_formatted_sub_count(subscriber_count), + toggle_class: "toggle-users-matching-view", + is_collapsed: this.users_matching_view_is_collapsed, + }), + ); + + $("#buddy-list-other-users-container .buddy-list-subsection-header").append( + render_section_header({ + id: "buddy-list-other-users-section-heading", + header_text: $t({defaultMessage: "Others"}), + user_count: get_formatted_sub_count(other_users_count), + toggle_class: "toggle-other-users", + is_collapsed: this.other_users_is_collapsed, + }), + ); + } + + toggle_users_matching_view_section() { + this.users_matching_view_is_collapsed = !this.users_matching_view_is_collapsed; + $("#buddy-list-users-matching-view-container").toggleClass( + "collapsed", + this.users_matching_view_is_collapsed, + ); + $("#buddy-list-users-matching-view-container .toggle-users-matching-view").toggleClass( + "fa-caret-down", + !this.users_matching_view_is_collapsed, + ); + $("#buddy-list-users-matching-view-container .toggle-users-matching-view").toggleClass( + "fa-caret-right", + this.users_matching_view_is_collapsed, + ); + + // Collapsing and uncollapsing sections has a similar effect to + // scrolling, so we make sure to fill screen with content here as well. + this.fill_screen_with_content(); + } + + toggle_other_users_section() { + this.other_users_is_collapsed = !this.other_users_is_collapsed; + $("#buddy-list-other-users-container").toggleClass( + "collapsed", + this.other_users_is_collapsed, + ); + $("#buddy-list-other-users-container .toggle-other-users").toggleClass( + "fa-caret-down", + !this.other_users_is_collapsed, + ); + $("#buddy-list-other-users-container .toggle-other-users").toggleClass( + "fa-caret-right", + this.other_users_is_collapsed, + ); + + // Collapsing and uncollapsing sections has a similar effect to + // scrolling, so we make sure to fill screen with content here as well. + this.fill_screen_with_content(); } render_more(opts) { @@ -80,12 +350,57 @@ export class BuddyList extends BuddyListConf { } const items = this.get_data_from_user_ids(more_user_ids); + const subscribed_users = []; + const other_users = []; + const current_sub = narrow_state.stream_sub(); + const pm_ids_set = narrow_state.pm_ids_set(); - const html = this.items_to_html({ - items, + for (const item of items) { + if (buddy_data.user_matches_narrow(item.user_id, pm_ids_set, current_sub?.stream_id)) { + subscribed_users.push(item); + this.users_matching_view_ids.push(item.user_id); + } else { + other_users.push(item); + this.other_user_ids.push(item.user_id); + } + } + + // Remove the empty list message before adding users + if ( + $(`${this.matching_view_list_selector} .empty-list-message`).length > 0 && + subscribed_users.length + ) { + this.$users_matching_view_container.empty(); + } + const subscribed_users_html = this.items_to_html({ + items: subscribed_users, + }); + this.$users_matching_view_container = $(this.matching_view_list_selector); + this.$users_matching_view_container.append(subscribed_users_html); + + // Remove the empty list message before adding users + if ( + $(`${this.other_user_list_selector} .empty-list-message`).length > 0 && + other_users.length + ) { + this.$other_users_container.empty(); + } + const other_users_html = this.items_to_html({ + items: other_users, + }); + this.$other_users_container = $(this.other_user_list_selector); + this.$other_users_container.append(other_users_html); + + const hide_headers = this.should_hide_headers(current_sub, pm_ids_set); + const subscriber_count = this.total_subscriber_count(current_sub, pm_ids_set); + const total_user_count = people.get_active_human_count(); + const other_users_count = total_user_count - subscriber_count; + this.render_section_headers({ + current_sub, + hide_headers, + subscriber_count, + other_users_count, }); - this.$container = $(this.container_selector); - this.$container.append(html); // Invariant: more_user_ids.length >= items.length. // (Usually they're the same, but occasionally user ids @@ -98,44 +413,162 @@ export class BuddyList extends BuddyListConf { } get_items() { - const $obj = this.$container.find(`${this.item_selector}`); - return $obj.map((_i, elem) => $(elem)); + const $user_matching_view_obj = this.$users_matching_view_container.find( + `${this.item_selector}`, + ); + const $users_matching_view_elems = $user_matching_view_obj.map((_i, elem) => $(elem)); + + const $other_user_obj = this.$other_users_container.find(`${this.item_selector}`); + const $other_user_elems = $other_user_obj.map((_i, elem) => $(elem)); + + return [...$users_matching_view_elems, ...$other_user_elems]; + } + + should_hide_headers(current_sub, pm_ids_set) { + // If we have only "other users" and aren't in a stream/DM view + // then we don't show section headers and only show one untitled + // section. + return this.users_matching_view_ids.length === 0 && !current_sub && !pm_ids_set.size; + } + + total_subscriber_count(current_sub, pm_ids_set) { + // Includes inactive users who might not show up in the buddy list. + if (current_sub) { + return peer_data.get_subscriber_count(current_sub.stream_id, false); + } else if (pm_ids_set.size) { + const pm_ids_list = [...pm_ids_set]; + // Plus one for the "me" user, who isn't in the recipients list (except + // for when it's a private message conversation with only "me" in it). + if (pm_ids_list.length === 1 && people.is_my_user_id(pm_ids_list[0])) { + return 1; + } + return pm_ids_list.length + 1; + } + return 0; + } + + render_view_user_list_links({ + current_sub, + has_inactive_users_matching_view, + has_inactive_other_users, + }) { + // For stream views, we show a link at the bottom of the list of subscribed users that + // lets a user find the full list of subscribed users and information about them. + if ( + current_sub && + stream_data.can_view_subscribers(current_sub) && + has_inactive_users_matching_view + ) { + const stream_edit_hash = hash_util.stream_edit_url(current_sub, "subscribers"); + $("#buddy-list-users-matching-view-container").append( + render_view_all_subscribers({ + stream_edit_hash, + }), + ); + } + + // We give a link to view the list of all users to help reduce confusion about + // there being hidden (inactive) "other" users. + if (has_inactive_other_users) { + $("#buddy-list-other-users-container").append(render_view_all_users()); + } } // From `type List`, where the key is a user_id. first_key() { - return this.all_user_ids[0]; + if (this.users_matching_view_ids.length) { + return this.users_matching_view_ids[0]; + } + if (this.other_user_ids.length) { + return this.other_user_ids[0]; + } + return undefined; } // From `type List`, where the key is a user_id. prev_key(key) { - const i = this.all_user_ids.indexOf(key); - - if (i <= 0) { + let i = this.users_matching_view_ids.indexOf(key); + // This would be the middle of the list of users matching view, + // moving to a prev user matching the view. + if (i > 0) { + return this.users_matching_view_ids[i - 1]; + } + // If it's the first user matching the view, we don't move the selection. + if (i === 0) { return undefined; } - return this.all_user_ids[i - 1]; + // This would be the middle of the other users list moving to a prev other user. + i = this.other_user_ids.indexOf(key); + if (i > 0) { + return this.other_user_ids[i - 1]; + } + // The key before the first other user is the last user matching view, if that exists, + // and if it doesn't then we don't move the selection. + if (i === 0) { + if (this.users_matching_view_ids.length > 0) { + return this.users_matching_view_ids.at(-1); + } + return undefined; + } + // The only way we reach here is if the key isn't found in either list, + // which shouldn't happen. + blueslip.error("Couldn't find key in buddy list", { + key, + users_matching_view_ids: this.users_matching_view_ids, + other_user_ids: this.other_user_ids, + }); + return undefined; } // From `type List`, where the key is a user_id. next_key(key) { - const i = this.all_user_ids.indexOf(key); - - if (i < 0) { + let i = this.users_matching_view_ids.indexOf(key); + // Moving from users matching the view to the list of other users, + // if they exist, otherwise do nothing. + if (i >= 0 && i === this.users_matching_view_ids.length - 1) { + if (this.other_user_ids.length > 0) { + return this.other_user_ids[0]; + } return undefined; } + // This is a regular move within the list of users matching the view. + if (i >= 0) { + return this.users_matching_view_ids[i + 1]; + } - return this.all_user_ids[i + 1]; + i = this.other_user_ids.indexOf(key); + // If we're at the end of other users, we don't do anything. + if (i >= 0 && i === this.other_user_ids.length - 1) { + return undefined; + } + // This is a regular move within other users. + if (i >= 0) { + return this.other_user_ids[i + 1]; + } + + // The only way we reach here is if the key isn't found in either list, + // which shouldn't happen. + blueslip.error("Couldn't find key in buddy list", { + key, + users_matching_view_ids: this.users_matching_view_ids, + other_user_ids: this.other_user_ids, + }); + return undefined; } maybe_remove_user_id(opts) { - const pos = this.all_user_ids.indexOf(opts.user_id); - - if (pos < 0) { - return; + let pos = this.users_matching_view_ids.indexOf(opts.user_id); + if (pos >= 0) { + this.users_matching_view_ids.splice(pos, 1); + } else { + pos = this.other_user_ids.indexOf(opts.user_id); + if (pos < 0) { + return; + } + this.other_user_ids.splice(pos, 1); } - + pos = this.all_user_ids.indexOf(opts.user_id); this.all_user_ids.splice(pos, 1); if (pos < this.render_count) { @@ -150,15 +583,20 @@ export class BuddyList extends BuddyListConf { const user_id = opts.user_id; let i; - for (i = 0; i < this.all_user_ids.length; i += 1) { - const list_user_id = this.all_user_ids[i]; + const user_id_list = opts.user_id_list; - if (this.compare_function(user_id, list_user_id) < 0) { + const current_sub = narrow_state.stream_sub(); + const pm_ids_set = narrow_state.pm_ids_set(); + + for (i = 0; i < user_id_list.length; i += 1) { + const list_user_id = user_id_list[i]; + + if (this.compare_function(user_id, list_user_id, current_sub, pm_ids_set) < 0) { return i; } } - return this.all_user_ids.length; + return user_id_list.length; } force_render(opts) { @@ -199,6 +637,8 @@ export class BuddyList extends BuddyListConf { return $li; } + // We reference all_user_ids to see if we've rendered + // it yet. const pos = this.all_user_ids.indexOf(user_id); if (pos < 0) { @@ -221,12 +661,18 @@ export class BuddyList extends BuddyListConf { insert_new_html(opts) { const user_id_following_insertion = opts.new_user_id; const html = opts.html; - const new_pos_in_all_users = opts.pos; + const new_pos_in_all_users = opts.new_pos_in_all_users; + const is_subscribed_user = opts.is_subscribed_user; + // This means we're inserting at the end if (user_id_following_insertion === undefined) { if (new_pos_in_all_users === this.render_count) { this.render_count += 1; - this.$container.append(html); + if (is_subscribed_user) { + this.$users_matching_view_container.append(html); + } else { + this.$other_users_container.append(html); + } this.update_padding(); } return; @@ -246,22 +692,40 @@ export class BuddyList extends BuddyListConf { this.maybe_remove_user_id({user_id}); - const pos = this.find_position({ + const new_pos_in_all_users = this.find_position({ user_id, + user_id_list: this.all_user_ids, + }); + + const current_sub = narrow_state.stream_sub(); + const pm_ids_set = narrow_state.pm_ids_set(); + const is_subscribed_user = buddy_data.user_matches_narrow( + user_id, + pm_ids_set, + current_sub?.stream_id, + ); + const user_id_list = is_subscribed_user + ? this.users_matching_view_ids + : this.other_user_ids; + const new_pos_in_user_list = this.find_position({ + user_id, + user_id_list, }); // Order is important here--get the new_user_id // before mutating our list. An undefined value // corresponds to appending. - const new_user_id = this.all_user_ids[pos]; + const new_user_id = user_id_list[new_pos_in_user_list]; - this.all_user_ids.splice(pos, 0, user_id); + user_id_list.splice(new_pos_in_user_list, 0, user_id); + this.all_user_ids.splice(new_pos_in_all_users, 0, user_id); const html = this.item_to_html({item}); this.insert_new_html({ - pos, + new_pos_in_all_users, html, new_user_id, + is_subscribed_user, }); } @@ -293,7 +757,8 @@ export class BuddyList extends BuddyListConf { // This is a bit of a hack to make sure we at least have // an empty list to start, before we get the initial payload. - $container = $(this.container_selector); + $users_matching_view_container = $(this.matching_view_list_selector); + $other_users_container = $(this.other_user_list_selector); start_scroll_handler() { // We have our caller explicitly call this to make @@ -309,7 +774,7 @@ export class BuddyList extends BuddyListConf { padded_widget.update_padding({ shown_rows: this.render_count, total_rows: this.all_user_ids.length, - content_selector: this.container_selector, + content_selector: "#buddy_list_wrapper", padding_selector: this.padding_selector, }); } diff --git a/web/src/click_handlers.js b/web/src/click_handlers.js index b4201c0b5b..36acd73ab2 100644 --- a/web/src/click_handlers.js +++ b/web/src/click_handlers.js @@ -474,18 +474,16 @@ export function initialize() { }); // SIDEBARS - $("#buddy-list-users-matching-view") - .expectOne() - .on("click", ".selectable_sidebar_block", (e) => { - const $li = $(e.target).parents("li"); + $(".buddy-list-section").on("click", ".selectable_sidebar_block", (e) => { + const $li = $(e.target).parents("li"); - activity_ui.narrow_for_user({$li}); + activity_ui.narrow_for_user({$li}); - e.preventDefault(); - e.stopPropagation(); - sidebar_ui.hide_userlist_sidebar(); - $(".tooltip").remove(); - }); + e.preventDefault(); + e.stopPropagation(); + sidebar_ui.hide_userlist_sidebar(); + $(".tooltip").remove(); + }); // Doesn't show tooltip on touch devices. function do_render_buddy_list_tooltip( @@ -549,7 +547,7 @@ export function initialize() { } // BUDDY LIST TOOLTIPS (not displayed on touch devices) - $("#buddy-list-users-matching-view").on("mouseenter", ".selectable_sidebar_block", (e) => { + $(".buddy-list-section").on("mouseenter", ".selectable_sidebar_block", (e) => { e.stopPropagation(); const $elem = $(e.currentTarget).closest(".user_sidebar_entry").find(".user-presence-link"); const user_id_string = $elem.attr("data-user-id"); @@ -557,7 +555,7 @@ export function initialize() { // `target_node` is the `ul` element since it stays in DOM even after updates. function get_target_node() { - return document.querySelector("#buddy-list-users-matching-view"); + return $(e.target).parents(".buddy-list-section")[0]; } function check_reference_removed(mutation, instance) { diff --git a/web/src/list_util.ts b/web/src/list_util.ts index e64f16379f..f2d1289c9f 100644 --- a/web/src/list_util.ts +++ b/web/src/list_util.ts @@ -4,6 +4,7 @@ const list_selectors = [ "#stream_filters", "#left-sidebar-navigation-list", "#buddy-list-users-matching-view", + "#buddy-list-other-users", "#send_later_options", ]; diff --git a/web/src/narrow.js b/web/src/narrow.js index 05afdec2ad..03a3b5fdad 100644 --- a/web/src/narrow.js +++ b/web/src/narrow.js @@ -2,6 +2,7 @@ import * as Sentry from "@sentry/browser"; import $ from "jquery"; import assert from "minimalistic-assert"; +import * as activity_ui from "./activity_ui"; import {all_messages_data} from "./all_messages_data"; import * as blueslip from "./blueslip"; import * as browser_history from "./browser_history"; @@ -1031,6 +1032,7 @@ function handle_post_view_change(msg_list) { left_sidebar_navigation_area.handle_narrow_activated(filter); stream_list.handle_narrow_activated(filter); pm_list.handle_narrow_activated(filter); + activity_ui.build_user_sidebar(); } function handle_post_narrow_deactivate_processes(msg_list) { diff --git a/web/src/sidebar_ui.ts b/web/src/sidebar_ui.ts index b71e47a954..9b6141788f 100644 --- a/web/src/sidebar_ui.ts +++ b/web/src/sidebar_ui.ts @@ -3,6 +3,7 @@ import $ from "jquery"; import render_left_sidebar from "../templates/left_sidebar.hbs"; import render_right_sidebar from "../templates/right_sidebar.hbs"; +import {buddy_list} from "./buddy_list"; import {page_params} from "./page_params"; import * as rendered_markdown from "./rendered_markdown"; import * as resize from "./resize"; @@ -161,6 +162,9 @@ export function initialize_right_sidebar(): void { }); $("#right-sidebar-container").html(rendered_sidebar); + + buddy_list.initialize_tooltips(); + update_invite_user_option(); if (page_params.is_spectator) { rendered_markdown.update_elements( @@ -187,4 +191,18 @@ export function initialize_right_sidebar(): void { } } }); + + $("#buddy-list-users-matching-view-container").on( + "click", + ".buddy-list-subsection-header", + (e) => { + e.stopPropagation(); + buddy_list.toggle_users_matching_view_section(); + }, + ); + + $("#buddy-list-other-users-container").on("click", ".buddy-list-subsection-header", (e) => { + e.stopPropagation(); + buddy_list.toggle_other_users_section(); + }); } diff --git a/web/src/tippyjs.ts b/web/src/tippyjs.ts index c666bba5c6..d960f75e61 100644 --- a/web/src/tippyjs.ts +++ b/web/src/tippyjs.ts @@ -2,10 +2,13 @@ import $ from "jquery"; import assert from "minimalistic-assert"; import tippy, {delegate} from "tippy.js"; +import render_buddy_list_title_tooltip from "../templates/buddy_list/title_tooltip.hbs"; import render_tooltip_templates from "../templates/tooltip_templates.hbs"; import {$t} from "./i18n"; +import * as people from "./people"; import * as popovers from "./popovers"; +import * as ui_util from "./ui_util"; import {user_settings} from "./user_settings"; // For tooltips without data-tippy-content, we use the HTML content of @@ -240,7 +243,6 @@ export function initialize(): void { delegate("body", { target: [ "#streams_header .streams-tooltip-target", - "#userlist-title", "#user_filter_icon", "#scroll-to-bottom-button-clickable-area", ".spectator_narrow_login_button", @@ -561,4 +563,16 @@ export function initialize(): void { }, appendTo: () => document.body, }); + + delegate("body", { + target: "#userlist-header", + placement: "top", + appendTo: () => document.body, + onShow(instance) { + const total_user_count = people.get_active_human_count(); + instance.setContent( + ui_util.parse_html(render_buddy_list_title_tooltip({total_user_count})), + ); + }, + }); } diff --git a/web/src/ui_init.js b/web/src/ui_init.js index 4bf7b35632..02fc4f889e 100644 --- a/web/src/ui_init.js +++ b/web/src/ui_init.js @@ -747,6 +747,7 @@ export function initialize_everything(state_data) { sidebar_ui.hide_all(); popovers.hide_all(); narrow.by("stream", sub.name, {trigger}); + activity_ui.build_user_sidebar(); }, }); stream_list_sort.initialize(); @@ -792,7 +793,6 @@ export function initialize_everything(state_data) { search.initialize({ on_narrow_search: narrow.activate, }); - tutorial.initialize(); desktop_notifications.initialize(); audible_notifications.initialize(); compose_notifications.initialize({ @@ -814,9 +814,6 @@ export function initialize_everything(state_data) { settings_toggle.initialize(); about_zulip.initialize(); - // All overlays must be initialized before hashchange.js - hashchange.initialize(); - initialize_unread_ui(); activity.initialize(); activity_ui.initialize({ @@ -824,6 +821,13 @@ export function initialize_everything(state_data) { narrow.by("dm", email, {trigger: "sidebar"}); }, }); + // This needs to happen after activity_ui.initialize, so that user_filter + // is defined. + tutorial.initialize(); + + // All overlays, and also activity_ui, must be initialized before hashchange.js + hashchange.initialize(); + emoji_picker.initialize(); user_group_popover.initialize(); user_card_popover.initialize(); diff --git a/web/src/user_card_popover.js b/web/src/user_card_popover.js index 2844f1b98d..052e444a72 100644 --- a/web/src/user_card_popover.js +++ b/web/src/user_card_popover.js @@ -810,13 +810,13 @@ function register_click_handlers() { $("body").on("click", ".update_status_text", open_user_status_modal); // Clicking on one's own status emoji should open the user status modal. - $("#buddy-list-users-matching-view").on( + $(".buddy-list-section").on( "click", ".user_sidebar_entry_me .status-emoji", open_user_status_modal, ); - $("#buddy-list-users-matching-view").on("click", ".user-list-sidebar-menu-icon", (e) => { + $(".buddy-list-section").on("click", ".user-list-sidebar-menu-icon", (e) => { e.stopPropagation(); const $target = $(e.currentTarget).closest("li"); diff --git a/web/src/views_util.js b/web/src/views_util.js index d317cba798..7857c70414 100644 --- a/web/src/views_util.js +++ b/web/src/views_util.js @@ -1,5 +1,6 @@ import $ from "jquery"; +import * as activity_ui from "./activity_ui"; import * as compose_actions from "./compose_actions"; import * as compose_recipient from "./compose_recipient"; import * as dropdown_widget from "./dropdown_widget"; @@ -88,6 +89,10 @@ export function show(opts) { opts.complete_rerender(); compose_actions.on_show_navigation_view(); + // This has to happen after resetting the current narrow filter, so + // that the buddy list is rendered with the correct narrow state. + activity_ui.build_user_sidebar(); + // Misc. if (opts.is_recent_view) { resize.update_recent_view_filters_height(); diff --git a/web/styles/app_variables.css b/web/styles/app_variables.css index f673283c35..6a6ca2783e 100644 --- a/web/styles/app_variables.css +++ b/web/styles/app_variables.css @@ -197,6 +197,7 @@ --color-background-tab-picker-tab-option-hover: hsl(0deg 0% 100% / 60%); --color-background-popover: hsl(0deg 0% 100%); --color-background-alert-word: hsl(18deg 100% 84%); + --color-buddy-list-highlighted-user: hsl(120deg 12.3% 71.4% / 38%); /* Compose box colors */ --color-compose-send-button-icon-color: hsl(0deg 0% 100%); @@ -247,6 +248,8 @@ --color-text-personal-menu-some-status: hsl(0deg 0% 40%); --color-text-sidebar-heading: hsl(0deg 0% 43%); --color-text-sidebar-popover-menu: hsl(0deg 0% 20%); + --color-text-url: hsl(200deg 100% 40%); + --color-text-url-hover: hsl(200deg 100% 25%); /* Markdown code colors */ --color-markdown-code-text: hsl(0deg 0% 0%); @@ -484,6 +487,7 @@ --color-outline-tab-picker-tab-option: hsl(0deg 0% 100% / 12%); --color-background-tab-picker-tab-option-hover: hsl(0deg 0% 100% / 5%); --color-background-alert-word: hsl(22deg 70% 35%); + --color-buddy-list-highlighted-user: hsl(136deg 25% 73% / 20%); /* Compose box colors */ --color-compose-send-button-focus-shadow: hsl(0deg 0% 100% / 80%); @@ -532,6 +536,7 @@ --color-text-search: hsl(0deg 0% 100% / 75%); --color-text-search-hover: hsl(0deg 0% 100%); --color-text-search-placeholder: hsl(0deg 0% 100% / 50%); + --color-text-empty-list-message: hsl(0deg 0% 67%); --color-text-dropdown-menu: hsl(0deg 0% 100% / 80%); --color-text-full-name: hsl(0deg 0% 100%); --color-text-item: hsl(0deg 0% 50%); @@ -547,6 +552,7 @@ hsl(0deg 0% 75%) 75%, hsl(0deg 0% 11%) ); + --color-text-url-hover: hsl(200deg 79% 66%); /* Markdown code colors */ /* Note that Markdown code-link colors are identical diff --git a/web/styles/dark_theme.css b/web/styles/dark_theme.css index 1f5a7c9005..3a92645f71 100644 --- a/web/styles/dark_theme.css +++ b/web/styles/dark_theme.css @@ -687,11 +687,6 @@ } } - #buddy-list-users-matching-view li:hover, - #buddy-list-users-matching-view li.highlighted_user { - background-color: hsl(136deg 25% 73% / 20%); - } - .group-row.active, .stream-row.active { background-color: hsl(0deg 0% 0% / 20%); diff --git a/web/styles/right_sidebar.css b/web/styles/right_sidebar.css index 8ed92a9f6f..1b99d75451 100644 --- a/web/styles/right_sidebar.css +++ b/web/styles/right_sidebar.css @@ -20,8 +20,44 @@ $user_status_emoji_width: 24px; overflow: auto; } -#buddy-list-users-matching-view { - max-width: 95%; +.toggle-narrow-users, +.toggle-other-users { + width: 7px; +} + +#buddy-list-users-matching-view-container, +#buddy-list-other-users-container { + margin-bottom: 10px; + + &.no-display { + display: none; + } + + .view-all-subscribers-link, + .view-all-users-link { + margin-left: 15px; + } +} + +#buddy-list-users-matching-view-container.collapsed { + #buddy-list-users-matching-view, + .view-all-subscribers-link { + display: none; + } +} + +#buddy-list-other-users-container.collapsed { + #buddy-list-other-users, + .view-all-users-link { + display: none; + } +} + +#buddy-list-users-matching-view, +#buddy-list-other-users { + width: 90%; + margin-left: 10px; + margin-bottom: 0; overflow-x: hidden; list-style-position: inside; /* Draw the bullets inside our box */ @@ -31,7 +67,6 @@ $user_status_emoji_width: 24px; text-overflow: ellipsis; list-style-type: none; border-radius: 4px; - padding-right: 15px; padding-top: 1px; padding-bottom: 2px; @@ -81,7 +116,7 @@ $user_status_emoji_width: 24px; &:hover, &.highlighted_user { - background-color: hsl(120deg 12.3% 71.4% / 38%); + background-color: var(--color-buddy-list-highlighted-user); } } @@ -93,19 +128,60 @@ $user_status_emoji_width: 24px; } .empty-list-message { - font-size: 1.2em; + font-style: italic; + color: var(--color-text-empty-list-message); + /* Overwrite default empty list font size, to look better under the subheaders. */ + font-size: 14px; + /* Override .empty-list-message !important styling */ + padding: 0 !important; + margin-left: 5px; + text-align: left; &:hover { background-color: inherit; } } + /* Overwrite some stray list rules (including one in left_sidebar.css) to turn color + back to the bootstrap default. */ + .view-all-subscribers-link, + .view-all-users-link { + color: var(--color-text-url); + + &:hover { + color: var(--color-text-url-hover); + } + } + & a { color: inherit; margin-left: 0; } } +.buddy-list-subsection-header { + display: flex; + align-items: center; + cursor: pointer; + background-color: var(--color-background); + line-height: 1; + position: sticky; + top: 0; + z-index: 1; + color: var(--color-text-default); +} + +.buddy-list-heading { + user-select: none; + font-weight: 600; + margin: 0; + padding: 5px; +} + +.buddy-list-subsection-header.no-display { + display: none; +} + .user-presence-link, .user_sidebar_entry .selectable_sidebar_block { display: flex; diff --git a/web/templates/buddy_list/section_header.hbs b/web/templates/buddy_list/section_header.hbs new file mode 100644 index 0000000000..f51dcad8fe --- /dev/null +++ b/web/templates/buddy_list/section_header.hbs @@ -0,0 +1,4 @@ +
+ {{header_text}} ({{user_count}}) +
+ diff --git a/web/templates/buddy_list/title_tooltip.hbs b/web/templates/buddy_list/title_tooltip.hbs new file mode 100644 index 0000000000..2026bb335f --- /dev/null +++ b/web/templates/buddy_list/title_tooltip.hbs @@ -0,0 +1,4 @@ +{{#tr}} +Search {total_user_count, plural, =1 {1 person} other {# people}} +{{/tr}} +{{tooltip_hotkey_hints "W"}} diff --git a/web/templates/buddy_list/view_all_subscribers.hbs b/web/templates/buddy_list/view_all_subscribers.hbs new file mode 100644 index 0000000000..119ba95035 --- /dev/null +++ b/web/templates/buddy_list/view_all_subscribers.hbs @@ -0,0 +1 @@ +View all subscribers diff --git a/web/templates/buddy_list/view_all_users.hbs b/web/templates/buddy_list/view_all_users.hbs new file mode 100644 index 0000000000..41b2b58346 --- /dev/null +++ b/web/templates/buddy_list/view_all_users.hbs @@ -0,0 +1 @@ +View all users diff --git a/web/templates/right_sidebar.hbs b/web/templates/right_sidebar.hbs index dc8fc5911c..d6cc493047 100644 --- a/web/templates/right_sidebar.hbs +++ b/web/templates/right_sidebar.hbs @@ -2,8 +2,7 @@