import $ from "jquery"; import _ from "lodash"; import * as blueslip from "./blueslip"; import * as buddy_data from "./buddy_data"; import {buddy_list} from "./buddy_list"; import * as channel from "./channel"; import * as keydown_util from "./keydown_util"; import {ListCursor} from "./list_cursor"; import * as narrow from "./narrow"; import {page_params} from "./page_params"; import * as people from "./people"; import * as pm_list from "./pm_list"; import * as popovers from "./popovers"; import * as presence from "./presence"; import * as ui_util from "./ui_util"; import {UserSearch} from "./user_search"; import * as user_status from "./user_status"; import * as watchdog from "./watchdog"; export let user_cursor; export let user_filter; /* Helpers for detecting user activity and managing user idle states */ /* Broadcast "idle" to server after 5 minutes of local inactivity */ const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; /* Time between keep-alive pings */ const ACTIVE_PING_INTERVAL_MS = 50 * 1000; /* Keep in sync with views.py:update_active_status_backend() */ export const ACTIVE = "active"; export const IDLE = "idle"; // When you open Zulip in a new browser window, client_is_active // should be true. When a server-initiated reload happens, however, // it should be initialized to false. We handle this with a check for // whether the window is focused at initialization time. export let client_is_active = document.hasFocus && document.hasFocus(); // new_user_input is a more strict version of client_is_active used // primarily for analytics. We initialize this to true, to count new // page loads, but set it to false in the onload function in reload.js // if this was a server-initiated-reload to avoid counting a // server-initiated reload as user activity. export let new_user_input = true; export function set_new_user_input(value) { new_user_input = value; } function get_pm_list_item(user_id) { return buddy_list.find_li({ key: user_id, }); } function set_pm_count(user_ids_string, count) { const pm_li = get_pm_list_item(user_ids_string); ui_util.update_unread_count_in_dom(pm_li, count); } export function update_dom_with_unread_counts(counts) { // counts is just a data object that gets calculated elsewhere // Our job is to update some DOM elements. for (const [user_ids_string, count] of counts.pm_count) { // TODO: just use user_ids_string in our markup const is_pm = !user_ids_string.includes(","); if (is_pm) { set_pm_count(user_ids_string, count); } } } export function clear_for_testing() { user_cursor = undefined; user_filter = undefined; client_is_active = false; } export function mark_client_idle() { // When we become idle, we don't immediately send anything to the // server; instead, we wait for our next periodic update, since // this data is fundamentally not timely. client_is_active = false; } export function redraw_user(user_id) { if (page_params.realm_presence_disabled) { return; } const filter_text = get_filter_text(); if (!buddy_data.matches_filter(filter_text, user_id)) { return; } const info = buddy_data.get_item(user_id); buddy_list.insert_or_move({ key: user_id, item: info, }); } export function searching() { return user_filter && user_filter.searching(); } export function build_user_sidebar() { if (page_params.realm_presence_disabled) { return undefined; } const filter_text = get_filter_text(); const user_ids = buddy_data.get_filtered_and_sorted_user_ids(filter_text); blueslip.measure_time("buddy_list.populate", () => { buddy_list.populate({keys: user_ids}); }); return user_ids; // for testing } function do_update_users_for_search() { // Hide all the popovers but not userlist sidebar // when the user is searching. popovers.hide_all_except_sidebars(); build_user_sidebar(); user_cursor.reset(); } const update_users_for_search = _.throttle(do_update_users_for_search, 50); export function compute_active_status() { // The overall algorithm intent for the `status` field is to send // `ACTIVE` (aka green circle) if we know the user is at their // computer, and IDLE (aka orange circle) if the user might not // be: // // * For the web app, we just know whether this window has focus. // * For the electron desktop app, we also know whether the // user is active or idle elsewhere on their system. // // The check for `get_idle_on_system === undefined` is feature // detection; older desktop app releases never set that property. if ( window.electron_bridge !== undefined && window.electron_bridge.get_idle_on_system !== undefined ) { if (window.electron_bridge.get_idle_on_system()) { return IDLE; } return ACTIVE; } if (client_is_active) { return ACTIVE; } return IDLE; } export function send_presence_to_server(want_redraw) { // Zulip has 2 data feeds coming from the server to the client: // The server_events data, and this presence feed. Data from // server_events is nicely serialized, but if we've been offline // and not running for a while (e.g. due to suspend), we can end // up with inconsistent state where users appear in presence that // don't appear in people.js. We handle this in 2 stages. First, // here, we trigger an extra run of the clock-jump check that // detects whether this device just resumed from suspend. This // ensures that watchdog.suspect_offline is always up-to-date // before we initiate a presence request. // // If we did just resume, it will also trigger an immediate // server_events request to the server (the success handler to // which will clear suspect_offline and potentially trigger a // reload if the device was offline for more than // DEFAULT_EVENT_QUEUE_TIMEOUT_SECS). if (page_params.is_web_public_visitor) { return; } watchdog.check_for_unsuspend(); channel.post({ url: "/json/users/me/presence", data: { status: compute_active_status(), ping_only: !want_redraw, new_user_input, slim_presence: true, }, idempotent: true, success(data) { // Update Zephyr mirror activity warning if (data.zephyr_mirror_active === false) { $("#zephyr-mirror-error").addClass("show"); } else { $("#zephyr-mirror-error").removeClass("show"); } new_user_input = false; if (want_redraw) { presence.set_info(data.presences, data.server_timestamp); redraw(); } }, }); } export function mark_client_active() { // exported for testing if (!client_is_active) { client_is_active = true; send_presence_to_server(false); } } export function initialize() { $("html").on("mousemove", () => { new_user_input = true; }); $(window).on("focus", mark_client_active); $(window).idle({ idle: DEFAULT_IDLE_TIMEOUT_MS, onIdle: mark_client_idle, onActive: mark_client_active, keepTracking: true, }); set_cursor_and_filter(); build_user_sidebar(); buddy_list.start_scroll_handler(); // Let the server know we're here, but pass "false" for // want_redraw, since we just got all this info in page_params. send_presence_to_server(false); function get_full_presence_list_update() { send_presence_to_server(true); } setInterval(get_full_presence_list_update, ACTIVE_PING_INTERVAL_MS); } export function update_presence_info(user_id, info, server_time) { presence.update_info_from_event(user_id, info, server_time); redraw_user(user_id); pm_list.update_private_messages(); } export function on_set_away(user_id) { user_status.set_away(user_id); redraw_user(user_id); pm_list.update_private_messages(); } export function on_revoke_away(user_id) { user_status.revoke_away(user_id); redraw_user(user_id); pm_list.update_private_messages(); } export function redraw() { build_user_sidebar(); user_cursor.redraw(); pm_list.update_private_messages(); } export function reset_users() { // Call this when we're leaving the search widget. build_user_sidebar(); user_cursor.clear(); } export function narrow_for_user(opts) { const user_id = buddy_list.get_key_from_li({li: opts.li}); return narrow_for_user_id({user_id}); } export function narrow_for_user_id(opts) { const person = people.get_by_user_id(opts.user_id); const email = person.email; narrow.by("pm-with", email, {trigger: "sidebar"}); user_filter.clear_and_hide_search(); } function keydown_enter_key() { const user_id = user_cursor.get_key(); if (user_id === undefined) { return; } narrow_for_user_id({user_id}); popovers.hide_all(); } export function set_cursor_and_filter() { user_cursor = new ListCursor({ list: buddy_list, highlight_class: "highlighted_user", }); user_filter = new UserSearch({ update_list: update_users_for_search, reset_items: reset_users, on_focus: () => user_cursor.reset(), }); const $input = user_filter.input_field(); $input.on("blur", () => user_cursor.clear()); keydown_util.handle({ elem: $input, handlers: { enter_key() { keydown_enter_key(); return true; }, up_arrow() { user_cursor.prev(); return true; }, down_arrow() { user_cursor.next(); return true; }, }, }); } export function initiate_search() { if (user_filter) { user_filter.initiate_search(); } } export function escape_search() { if (user_filter) { user_filter.escape_search(); } } export function get_filter_text() { if (!user_filter) { // This may be overly defensive, but there may be // situations where get called before everything is // fully initialized. The empty string is a fine // default here. blueslip.warn("get_filter_text() is called before initialization"); return ""; } return user_filter.text(); }