<reponame>oluchigoodness2000/zulip<filename>static/js/buddy_list.js<gh_stars>0import $ from "jquery";

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 * as message_viewport from "./message_viewport";
import * as padded_widget from "./padded_widget";
import * as ui from "./ui";

class BuddyListConf {
    container_sel = "#user_presences";
    scroll_container_sel = "#buddy_list_wrapper";
    item_sel = "li.user_sidebar_entry";
    padding_sel = "#buddy_list_wrapper_padding";

    items_to_html(opts) {
        const html = render_presence_rows({presence_rows: opts.items});
        return html;
    }

    item_to_html(opts) {
        const html = render_presence_row(opts.item);
        return html;
    }

    get_li_from_key(opts) {
        const user_id = opts.key;
        const $container = $(this.container_sel);
        return $container.find(`${this.item_sel}[data-user-id='${CSS.escape(user_id)}']`);
    }

    get_key_from_li(opts) {
        return Number.parseInt(opts.$li.expectOne().attr("data-user-id"), 10);
    }

    get_data_from_keys(opts) {
        const keys = opts.keys;
        const data = buddy_data.get_items_for_users(keys);
        return data;
    }

    compare_function = buddy_data.compare_function;

    height_to_fill() {
        // Because the buddy list gets sized dynamically, we err on the side
        // of using the height of the entire viewport for deciding
        // how much content to render.  Even on tall monitors this should
        // still be a significant optimization for orgs with thousands of
        // users.
        const height = message_viewport.height();
        return height;
    }
}

export class BuddyList extends BuddyListConf {
    keys = [];

    populate(opts) {
        this.render_count = 0;
        this.$container.empty();

        // We rely on our caller to give us items
        // in already-sorted order.
        this.keys = opts.keys;

        this.fill_screen_with_content();
    }

    render_more(opts) {
        const chunk_size = opts.chunk_size;

        const begin = this.render_count;
        const end = begin + chunk_size;

        const more_keys = this.keys.slice(begin, end);

        if (more_keys.length === 0) {
            return;
        }

        const items = this.get_data_from_keys({
            keys: more_keys,
        });

        const html = this.items_to_html({
            items,
        });
        this.$container = $(this.container_sel);
        this.$container.append(html);

        // Invariant: more_keys.length >= items.length.
        // (Usually they're the same, but occasionally keys
        // won't return valid items.  Even though we don't
        // actually render these keys, we still "count" them
        // as rendered.

        this.render_count += more_keys.length;
        this.update_padding();
    }

    get_items() {
        const $obj = this.$container.find(`${this.item_sel}`);
        return $obj.map((i, elem) => $(elem));
    }

    first_key() {
        return this.keys[0];
    }

    prev_key(key) {
        const i = this.keys.indexOf(key);

        if (i <= 0) {
            return undefined;
        }

        return this.keys[i - 1];
    }

    next_key(key) {
        const i = this.keys.indexOf(key);

        if (i < 0) {
            return undefined;
        }

        return this.keys[i + 1];
    }

    maybe_remove_key(opts) {
        const pos = this.keys.indexOf(opts.key);

        if (pos < 0) {
            return;
        }

        this.keys.splice(pos, 1);

        if (pos < this.render_count) {
            this.render_count -= 1;
            const $li = this.find_li({key: opts.key});
            $li.remove();
            this.update_padding();
        }
    }

    find_position(opts) {
        const key = opts.key;
        let i;

        for (i = 0; i < this.keys.length; i += 1) {
            const list_key = this.keys[i];

            if (this.compare_function(key, list_key) < 0) {
                return i;
            }
        }

        return this.keys.length;
    }

    force_render(opts) {
        const pos = opts.pos;

        // Try to render a bit optimistically here.
        const cushion_size = 3;
        const chunk_size = pos + cushion_size - this.render_count;

        if (chunk_size <= 0) {
            blueslip.error("cannot show key at this position: " + pos);
        }

        this.render_more({
            chunk_size,
        });
    }

    find_li(opts) {
        const key = opts.key;

        // Try direct DOM lookup first for speed.
        let $li = this.get_li_from_key({
            key,
        });

        if ($li.length === 1) {
            return $li;
        }

        if (!opts.force_render) {
            // Most callers don't force us to render a list
            // item that wouldn't be on-screen anyway.
            return $li;
        }

        const pos = this.keys.indexOf(key);

        if (pos < 0) {
            // TODO: See ListCursor.get_row() for why this is
            //       a bit janky now.
            return [];
        }

        this.force_render({
            pos,
        });

        $li = this.get_li_from_key({
            key,
        });

        return $li;
    }

    insert_new_html(opts) {
        const new_key = opts.new_key;
        const html = opts.html;
        const pos = opts.pos;

        if (new_key === undefined) {
            if (pos === this.render_count) {
                this.render_count += 1;
                this.$container.append(html);
                this.update_padding();
            }
            return;
        }

        if (pos < this.render_count) {
            this.render_count += 1;
            const $li = this.find_li({key: new_key});
            $li.before(html);
            this.update_padding();
        }
    }

    insert_or_move(opts) {
        const key = opts.key;
        const item = opts.item;

        this.maybe_remove_key({key});

        const pos = this.find_position({
            key,
        });

        // Order is important here--get the new_key
        // before mutating our list.  An undefined value
        // corresponds to appending.
        const new_key = this.keys[pos];

        this.keys.splice(pos, 0, key);

        const html = this.item_to_html({item});
        this.insert_new_html({
            pos,
            html,
            new_key,
        });
    }

    fill_screen_with_content() {
        let height = this.height_to_fill();

        const elem = ui.get_scroll_element($(this.scroll_container_sel)).expectOne()[0];

        // Add a fudge factor.
        height += 10;

        while (this.render_count < this.keys.length) {
            const padding_height = $(this.padding_sel).height();
            const bottom_offset = elem.scrollHeight - elem.scrollTop - padding_height;

            if (bottom_offset > height) {
                break;
            }

            const chunk_size = 20;

            this.render_more({
                chunk_size,
            });
        }
    }

    // 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_sel);

    start_scroll_handler() {
        // We have our caller explicitly call this to make
        // sure everything's in place.
        const $scroll_container = ui.get_scroll_element($(this.scroll_container_sel));

        $scroll_container.on("scroll", () => {
            this.fill_screen_with_content();
        });
    }

    update_padding() {
        padded_widget.update_padding({
            shown_rows: this.render_count,
            total_rows: this.keys.length,
            content_sel: this.container_sel,
            padding_sel: this.padding_sel,
        });
    }
}

export const buddy_list = new BuddyList();
