zulip/web/src/buddy_data.ts

473 lines
15 KiB
TypeScript
Raw Normal View History

import assert from "minimalistic-assert";
import * as hash_util from "./hash_util";
import {$t} from "./i18n";
import * as message_lists from "./message_lists";
import * as muted_users from "./muted_users";
import * as narrow_state from "./narrow_state";
import {page_params} from "./page_params";
import * as peer_data from "./peer_data";
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";
import * as user_status from "./user_status";
import * as util from "./util";
2018-04-19 15:46:56 +02:00
/*
This is the main model code for building the buddy list.
We also rely on presence.js to compute the actual presence
for users. We glue in other "people" data and do
filtering/sorting of the data that we'll send into the view.
*/
export let max_size_before_shrinking = 600;
export function rewire_max_size_before_shrinking(value: typeof max_size_before_shrinking): void {
max_size_before_shrinking = value;
}
export let max_channel_size_to_show_all_subscribers = 75;
export function rewire_max_channel_size_to_show_all_subscribers(
value: typeof max_channel_size_to_show_all_subscribers,
): void {
max_channel_size_to_show_all_subscribers = value;
}
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;
}
export function get_user_circle_class(user_id: number): string {
const status = presence.get_status(user_id);
switch (status) {
case "active":
return "user_circle_green";
case "idle":
return "user_circle_idle";
default:
return "user_circle_empty";
}
}
export function level(user_id: number): number {
// Put current user at the top, unless we're in a user search view.
if (people.is_my_user_id(user_id) && !is_searching_users) {
return 0;
}
const status = presence.get_status(user_id);
switch (status) {
case "active":
return 1;
case "idle":
return 2;
default:
return 3;
2018-04-19 15:46:56 +02:00
}
}
2018-04-19 15:46:56 +02:00
export let user_matches_narrow = (
user_id: number,
pm_ids: Set<number>,
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 rewire_user_matches_narrow(value: typeof user_matches_narrow): void {
user_matches_narrow = value;
}
export function compare_function(
a: number,
b: number,
current_sub: StreamSubscription | undefined,
pm_ids: Set<number>,
conversation_participants: Set<number>,
): number {
const a_is_participant = conversation_participants.has(a);
const b_is_participant = conversation_participants.has(b);
if (a_is_participant && !b_is_participant) {
return -1;
}
if (!a_is_participant && b_is_participant) {
return 1;
}
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;
2018-04-19 15:46:56 +02:00
if (diff !== 0) {
return diff;
}
// Sort equivalent direct message names alphabetically
const person_a = people.maybe_get_user_by_id(a);
const person_b = people.maybe_get_user_by_id(b);
2018-04-19 15:46:56 +02:00
const full_name_a = person_a ? person_a.full_name : "";
const full_name_b = person_b ? person_b.full_name : "";
2018-04-19 15:46:56 +02:00
return util.strcmp(full_name_a, full_name_b);
}
2018-04-19 15:46:56 +02:00
export function sort_users(user_ids: number[], conversation_participants: Set<number>): number[] {
2018-04-19 15:46:56 +02:00
// TODO sort by unread count first, once we support that
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, conversation_participants),
);
2018-04-19 15:46:56 +02:00
return user_ids;
}
2018-04-19 15:46:56 +02:00
function get_num_unread(user_id: number): number {
return unread.num_unread_for_user_ids_string(user_id.toString());
2018-04-19 15:46:56 +02:00
}
export function user_last_seen_time_status(user_id: number): string {
const status = presence.get_status(user_id);
if (status === "active") {
return $t({defaultMessage: "Active now"});
}
if (status === "idle") {
// When we complete our presence API rewrite to have the data
// plumbed, we may want to change this to also mention when
// they were last active.
return $t({defaultMessage: "Idle"});
}
const last_active_date = presence.last_active_date(user_id);
if (realm.realm_is_zephyr_mirror_realm) {
// We don't send presence data to clients in Zephyr mirroring realms
return $t({defaultMessage: "Activity unknown"});
} else if (last_active_date === undefined) {
// There are situations where the client has incomplete presence
// history on a user. This can happen when users are deactivated,
// or when the user's last activity is older than what we fetch.
assert(page_params.presence_history_limit_days_for_web_app === 365);
return $t({defaultMessage: "Not active in the last year"});
}
return timerender.last_seen_status_from_date(last_active_date);
}
export type BuddyUserInfo = {
href: string;
name: string;
user_id: number;
profile_picture: string;
status_emoji_info: user_status.UserStatusEmojiInfo | undefined;
is_current_user: boolean;
num_unread: number;
user_circle_class: string;
status_text: string | undefined;
has_status_text: boolean;
user_list_style: {
COMPACT: boolean;
WITH_STATUS: boolean;
WITH_AVATAR: boolean;
};
should_add_guest_user_indicator: boolean;
faded?: boolean;
};
export function info_for(user_id: number): BuddyUserInfo {
const user_circle_class = get_user_circle_class(user_id);
const person = people.get_by_user_id(user_id);
const status_emoji_info = user_status.get_status_emoji(user_id);
const status_text = user_status.get_status_text(user_id);
const user_list_style_value = user_settings.user_list_style;
const user_list_style = {
COMPACT: user_list_style_value === 1,
WITH_STATUS: user_list_style_value === 2,
WITH_AVATAR: user_list_style_value === 3,
};
2018-04-19 15:46:56 +02:00
return {
href: hash_util.pm_with_url(person.email),
2018-04-19 15:46:56 +02:00
name: person.full_name,
user_id,
status_emoji_info,
profile_picture: people.small_avatar_url_for_person(person),
is_current_user: people.is_my_user_id(user_id),
2018-04-19 15:46:56 +02:00
num_unread: get_num_unread(user_id),
user_circle_class,
status_text,
has_status_text: Boolean(status_text),
user_list_style,
should_add_guest_user_indicator: people.should_add_guest_user_indicator(user_id),
};
}
export function get_title_data(
user_ids_string: string,
is_group: boolean,
): {
first_line: string;
second_line: string | undefined;
third_line: string;
show_you?: boolean;
} {
if (is_group) {
// For groups, just return a string with recipient names.
return {
first_line: people.get_recipients(user_ids_string),
second_line: "",
third_line: "",
};
}
// Since it's not a group, user_ids_string is a single user ID.
const user_id = Number.parseInt(user_ids_string, 10);
const person = people.get_by_user_id(user_id);
if (person.is_bot) {
const bot_owner = people.get_bot_owner_user(person);
if (bot_owner) {
const bot_owner_name = $t(
{defaultMessage: "Owner: {name}"},
{name: bot_owner.full_name},
);
return {
first_line: person.full_name,
second_line: bot_owner_name,
third_line: "",
};
}
// Bot does not have an owner.
return {
first_line: person.full_name,
second_line: "",
third_line: "",
};
}
// For buddy list and individual direct messages.
// Since is_group=False, it's a single, human user.
const last_seen = user_last_seen_time_status(user_id);
const is_my_user = people.is_my_user_id(user_id);
// Users has a status.
if (user_status.get_status_text(user_id)) {
return {
first_line: person.full_name,
second_line: user_status.get_status_text(user_id),
third_line: last_seen,
show_you: is_my_user,
};
}
// Users does not have a status.
return {
first_line: person.full_name,
second_line: last_seen,
third_line: "",
show_you: is_my_user,
2018-04-19 15:46:56 +02:00
};
}
2018-04-19 15:46:56 +02:00
export function get_item(user_id: number): BuddyUserInfo {
const info = info_for(user_id);
return info;
}
export function get_items_for_users(user_ids: number[]): BuddyUserInfo[] {
const user_info = user_ids.map((user_id) => info_for(user_id));
return user_info;
}
function user_is_recently_active(user_id: number): boolean {
// return true if the user has a green/orange circle
return level(user_id) <= 2;
}
function maybe_shrink_list(
user_ids: number[],
user_filter_text: string,
conversation_participants: Set<number>,
): number[] {
if (user_ids.length <= max_size_before_shrinking) {
return user_ids;
}
if (user_filter_text) {
// If the user types something, we want to show all
// users matching the text, even if they have not been
// online recently.
// For super common letters like "s", we may
// eventually want to filter down to only users that
// are in presence.get_user_ids().
return user_ids;
}
// We want to always show PM recipients even if they're inactive.
const pm_ids_set = narrow_state.pm_ids_set();
const stream_id = narrow_state.stream_id();
const filter_by_stream_id =
stream_id &&
peer_data.get_subscriber_count(stream_id) <= max_channel_size_to_show_all_subscribers;
user_ids = user_ids.filter(
(user_id) =>
user_is_recently_active(user_id) ||
user_matches_narrow(user_id, pm_ids_set, filter_by_stream_id ? stream_id : undefined) ||
conversation_participants.has(user_id),
);
return user_ids;
}
function filter_user_ids(user_filter_text: string, user_ids: number[]): number[] {
// This first filter is for whether the user is eligible to be
// displayed in the right sidebar at all.
user_ids = user_ids.filter((user_id) => {
const person = people.maybe_get_user_by_id(user_id, true);
if (!person) {
// See the comments in presence.set_info for details, but this is an expected race.
// User IDs for whom we have presence but no user metadata should be skipped.
return false;
}
if (person.is_bot) {
// Bots should never appear in the right sidebar. This
// case should never happen, since bots cannot have
// presence data.
return false;
}
if (!people.is_person_active(user_id)) {
// Deactivated users are hidden from the right sidebar entirely.
return false;
}
if (muted_users.is_user_muted(user_id)) {
// Muted users are hidden from the right sidebar entirely.
return false;
}
return true;
});
if (!user_filter_text) {
return user_ids;
}
// If a query is present in "Filter users", we return matches.
const persons = user_ids.map((user_id) => people.get_by_user_id(user_id));
return [...people.filter_people_by_search_terms(persons, user_filter_text)];
}
function get_filtered_user_id_list(
user_filter_text: string,
conversation_participants: Set<number>,
): number[] {
let base_user_id_list = [];
2018-04-19 15:46:56 +02:00
if (user_filter_text) {
2018-04-19 15:46:56 +02:00
// If there's a filter, select from all users, not just those
// recently active.
base_user_id_list = people.get_active_user_ids();
2018-04-19 15:46:56 +02:00
} else {
// From large realms, the user_ids in presence may exclude
// users who have been idle more than three weeks. When the
// filter text is blank, we show only those recently active users.
base_user_id_list = presence.get_user_ids();
// Always include ourselves, even if we're "unavailable".
const my_user_id = people.my_current_user_id();
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];
}
// We want to show subscribers even if they're inactive, if there are few
// enough subscribers in the channel.
const stream_id = narrow_state.stream_id();
if (stream_id) {
const subscribers = peer_data.get_subscribers(stream_id);
if (subscribers.length <= max_channel_size_to_show_all_subscribers) {
const base_user_id_set = new Set([...base_user_id_list, ...subscribers]);
base_user_id_list = [...base_user_id_set];
}
}
2018-04-19 15:46:56 +02:00
}
const user_ids = filter_user_ids(user_filter_text, base_user_id_list);
// Make sure all the participants are in the list, even if they're inactive.
const user_ids_set = new Set([...user_ids, ...conversation_participants]);
return [...user_ids_set];
}
export function get_conversation_participants(): Set<number> {
const participant_ids_set = new Set<number>();
if (!narrow_state.stream_id() || !narrow_state.topic() || !message_lists.current) {
return participant_ids_set;
}
for (const message of message_lists.current.all_messages()) {
if (
!people.is_valid_bot_user(message.sender_id) &&
people.is_person_active(message.sender_id)
) {
participant_ids_set.add(message.sender_id);
}
}
return participant_ids_set;
}
export function get_filtered_and_sorted_user_ids(user_filter_text: string): number[] {
let user_ids;
const conversation_participants = get_conversation_participants();
user_ids = get_filtered_user_id_list(user_filter_text, conversation_participants);
user_ids = maybe_shrink_list(user_ids, user_filter_text, conversation_participants);
return sort_users(user_ids, conversation_participants);
}
export function matches_filter(user_filter_text: string, user_id: number): boolean {
// This is a roundabout way of checking a user if you look
// too hard at it, but it should be fine for now.
return filter_user_ids(user_filter_text, [user_id]).length === 1;
}