zulip/web/src/presence.ts

301 lines
9.1 KiB
TypeScript

import type {z} from "zod";
import * as people from "./people";
import type {StateData, presence_schema} from "./state_data";
import {realm} from "./state_data";
import {user_settings} from "./user_settings";
export type RawPresence = z.infer<typeof presence_schema> & {
server_timestamp: number;
};
export type PresenceStatus = {
status: "active" | "idle" | "offline";
last_active?: number | undefined;
};
export type PresenceInfoFromEvent = {
website: {
client: "website";
status: "idle" | "active";
timestamp: number;
pushable: boolean;
};
};
// This module just manages data. See activity.js for
// the UI of our buddy list.
// The following Maps have user_id as the key. Some of the
// user_ids may not yet be registered in people.js.
// See the long comment in `set_info` below for details.
// In future commits we'll use raw_info to facilitate
// handling server events and/or timeout events.
const raw_info = new Map<number, RawPresence>();
export const presence_info = new Map<number, PresenceStatus>();
// An integer that is updated whenever we get new presence data.
// TODO: Improve this comment.
export let presence_last_update_id = -1;
// We keep and export this for testing convenience.
export function clear_internal_data(): void {
raw_info.clear();
presence_info.clear();
presence_last_update_id = -1;
}
const BIG_REALM_COUNT = 250;
export function get_status(user_id: number): PresenceStatus["status"] {
if (people.is_my_user_id(user_id)) {
if (user_settings.presence_enabled) {
// if the current user is sharing presence, they always see themselves as online.
return "active";
}
// if the current user is not sharing presence, they always see themselves as offline.
return "offline";
}
if (presence_info.has(user_id)) {
return presence_info.get(user_id)!.status;
}
return "offline";
}
export function get_user_ids(): number[] {
return [...presence_info.keys()];
}
export function get_active_or_idle_user_ids(): number[] {
return [...presence_info.entries()]
.filter((entry) => entry[1].status !== "offline")
.map((entry) => entry[0]);
}
export function status_from_raw(raw: RawPresence): PresenceStatus {
/*
Example of `raw`:
{
active_timestamp: 1585745133
idle_timestamp: 1585745091
server_timestamp: 1585745140
}
*/
/* Mark users as offline after this many seconds since their last check-in, */
const offline_threshold_secs = realm.server_presence_offline_threshold_seconds;
function age(timestamp = 0): number {
return raw.server_timestamp - timestamp;
}
const active_timestamp = raw.active_timestamp;
const idle_timestamp = raw.idle_timestamp;
let last_active: number | undefined;
if (active_timestamp !== undefined || idle_timestamp !== undefined) {
last_active = Math.max(active_timestamp ?? 0, idle_timestamp ?? 0);
}
/*
If the server sends us `active_timestamp`, this
means at least one client was active at this time
(and hasn't changed since).
As long as the timestamp is current enough, we will
show the user as active (even if there's a newer
timestamp for idle).
*/
if (age(active_timestamp) < offline_threshold_secs) {
return {
status: "active",
last_active,
};
}
if (age(idle_timestamp) < offline_threshold_secs) {
return {
status: "idle",
last_active,
};
}
return {
status: "offline",
last_active,
};
}
export function update_info_from_event(
user_id: number,
info: PresenceInfoFromEvent | null,
server_timestamp: number,
): void {
/*
Example of `info`:
{
website: {
client: 'website',
pushable: false,
status: 'active',
timestamp: 1585745225
}
}
Example of `raw`:
{
active_timestamp: 1585745133
idle_timestamp: 1585745091
server_timestamp: 1585745140
}
*/
const raw = raw_info.get(user_id) ?? {
server_timestamp: 0,
};
raw.server_timestamp = server_timestamp;
for (const rec of Object.values(info ?? {})) {
if (rec.status === "active" && rec.timestamp > (raw.active_timestamp ?? 0)) {
raw.active_timestamp = rec.timestamp;
}
if (rec.status === "idle" && rec.timestamp > (raw.idle_timestamp ?? 0)) {
raw.idle_timestamp = rec.timestamp;
}
}
raw_info.set(user_id, raw);
const status = status_from_raw(raw);
presence_info.set(user_id, status);
}
export function set_info(
presences: Record<number, z.infer<typeof presence_schema>>,
server_timestamp: number,
last_update_id = -1,
): void {
/*
Example `presences` data:
{
6: Object { idle_timestamp: 1585746028 },
7: Object { active_timestamp: 1585745774 },
8: Object { active_timestamp: 1585745578 }
}
*/
presence_last_update_id = last_update_id;
const all_active_or_idle_user_ids = new Set(get_active_or_idle_user_ids());
for (const [user_id_str, info] of Object.entries(presences)) {
const user_id = Number.parseInt(user_id_str, 10);
// Remove the user from all_active_or_idle_user_ids since we already
// updated their presence info.
all_active_or_idle_user_ids.delete(user_id);
// Note: In contrast with all other state updates received
// from the server, presence data is updated via a
// polling process rather than the events system
// (server_events_dispatch.js).
//
// This means that if we're coming back from being offline and
// new users were created in the meantime, we may see user IDs
// not yet present in people.js if server_events doesn't have
// current data (or we've been offline, our event queue was
// GC'ed, and we're about to reload).
// Despite that, we still add the presence data to our structures,
// and it is the job of the code using them to correctly
// ignore these until we receive the basic metadata on this user.
// We skip inaccessible users here, as we do in other places;
// presence info for them is not used.
const person = people.maybe_get_user_by_id(user_id, true);
if (person?.is_inaccessible_user) {
// There are a number of situations where it is expected
// that we get presence data for a user ID that we do
// not have in our user database, including when we're
// offline/reloading (watchdog.suspects_user_is_offline()
// || reload_state.is_in_progress()), when
// CAN_ACCESS_ALL_USERS_GROUP_LIMITS_PRESENCE is disabled,
// and whenever presence wins a race with the events system
// for events regarding a newly created or visible user.
//
// Either way, we still record the information unless
// we're dealing with an inaccessible user.
continue;
}
const raw: RawPresence = {
server_timestamp,
active_timestamp: info.active_timestamp,
idle_timestamp: info.idle_timestamp,
};
raw_info.set(user_id, raw);
const status = status_from_raw(raw);
presence_info.set(user_id, status);
}
for (const user_id of all_active_or_idle_user_ids) {
update_info_from_event(user_id, null, server_timestamp);
}
update_info_for_small_realm();
}
export function update_info_for_small_realm(): void {
if (people.get_active_human_count() >= BIG_REALM_COUNT) {
// For big realms, we don't want to bloat our buddy
// lists with lots of long-time-inactive users.
return;
}
// For small realms, we create presence info for users
// that the server didn't include in its presence update.
const persons = people.get_realm_users();
for (const person of persons) {
const user_id = person.user_id;
let status: PresenceStatus["status"] = "offline";
if (presence_info.has(user_id)) {
// this is normal, we have data for active
// users that we don't want to clobber.
continue;
}
if (person.is_bot) {
// we don't show presence for bots
continue;
}
if (people.is_my_user_id(user_id)) {
status = "active";
}
presence_info.set(user_id, {
status,
last_active: undefined,
});
}
}
export function last_active_date(user_id: number): Date | undefined {
const info = presence_info.get(user_id);
if (!info?.last_active) {
return undefined;
}
return new Date(info.last_active * 1000);
}
export function initialize(params: StateData["presence"]): void {
set_info(params.presences, params.server_timestamp, params.presence_last_update_id);
}