zulip/static/js/recent_topics_ui.js

878 lines
30 KiB
JavaScript
Raw Normal View History

import $ from "jquery";
import _ from "lodash";
import render_recent_topic_row from "../templates/recent_topic_row.hbs";
import render_recent_topics_filters from "../templates/recent_topics_filters.hbs";
import render_recent_topics_body from "../templates/recent_topics_table.hbs";
import * as compose_closed_ui from "./compose_closed_ui";
import * as hash_util from "./hash_util";
import {$t} from "./i18n";
import * as ListWidget from "./list_widget";
import * as loading from "./loading";
import {localstorage} from "./localstorage";
import * as message_store from "./message_store";
import * as message_util from "./message_util";
import * as message_view_header from "./message_view_header";
import * as muted_topics from "./muted_topics";
import * as narrow from "./narrow";
import * as narrow_state from "./narrow_state";
import * as navbar_alerts from "./navbar_alerts";
import * as navigate from "./navigate";
import * as people from "./people";
import * as recent_senders from "./recent_senders";
import {get, process_message, topics} from "./recent_topics_data";
recent_topics: Don't rely on ":visible" to avoid forced reflow. Previously, navigating from any stream to the recent topics view would cause a forced reflow every time we checked `is_visible()` because it would call `$("#recent_topics_view").is(":visible")`. The reason for this is related to how browsers ship frames, the process follows these steps: JavaScript > style calculations > layout > paint > composite. (The layout step is called Reflow in firefox.) Typically, the browser will handle these steps in the most optimal manner possible, delaying expensive operations until they're needed. However, it is possible to cause the browser to perform a layout earlier than necessary. An example of this is what we previously did: When we call `top_left_corner.narrow_to_recent_topics()`, we ask to add a class via `.addClass()`, this schedules a Style Recalculation, then, when we call `message_view_header.make_message_view_header()` it calls `recent_topics_util.is_visible()` which calls `$("#recent_topics_view").is(":visible")`. Before the browser can get this value, it realizes that our dom was invalidated by `.addClass()` and so it must execute the scheduled Style Recalculation and cause a layout. This is called a forced synchronous layout. This commit adds a JavaScript variable representing the visible state, in order to prevent the above behavior. This commit reduces the main thread run time of `build_message_view_header` from 131.81 ms to 5.20 ms. Unfortunately we still have the case where `recent_topics_ui.revive_current_focus()` calls `recent_topics_ui.set_table_focus()` which causes a reflow. However, by eliminating this reflow we still save ~100ms. (It's important to note that we only save this sometimes, as other things can still cost us a reflow.) Further reading: https://developers.google.com/web/fundamentals/ performance/rendering/avoid-large-complex-layouts-and-layout-thrashing
2021-11-05 21:21:43 +01:00
import {get_topic_key, is_in_focus, is_visible, set_visible} from "./recent_topics_util";
import * as stream_data from "./stream_data";
import * as stream_list from "./stream_list";
import * as sub_store from "./sub_store";
import * as timerender from "./timerender";
import * as top_left_corner from "./top_left_corner";
import * as unread from "./unread";
2020-08-20 21:24:06 +02:00
let topics_widget;
// Sets the number of avatars to display.
// Rest of the avatars, if present, are displayed as {+x}
const MAX_AVATAR = 4;
const MAX_EXTRA_SENDERS = 10;
// Use this to set the focused element.
//
// We set it's value to `table` in case the
// focus in one of the table rows, since the
// table rows are constantly updated and tracking
// the selected element in them would be tedious via
// jquery.
//
// So, we use table as a grid system and
// track the coordinates of the focus element via
// `row_focus` and `col_focus`.
export let $current_focus_elem = "table";
// If user clicks a topic in recent topics, then
// we store that topic here so that we can restore focus
// to that topic when user revisits.
let last_visited_topic = "";
let row_focus = 0;
// Start focus on the topic column, so Down+Enter works to visit a topic.
let col_focus = 1;
export const COLUMNS = {
stream: 0,
topic: 1,
mute: 2,
read: 3,
};
// The number of selectable actions in a recent_topics. Used to
// implement wraparound of elements with the right/left keys. Must be
// increased when we add new actions, or rethought if we add optional
// actions that only appear in some rows.
const MAX_SELECTABLE_COLS = 4;
// we use localstorage to persist the recent topic filters
const ls_key = "recent_topic_filters";
const ls = localstorage();
let filters = new Set();
export function clear_for_tests() {
filters.clear();
topics.clear();
topics_widget = undefined;
}
export function save_filters() {
ls.set(ls_key, Array.from(filters));
}
export function load_filters() {
filters = new Set(ls.get(ls_key));
}
export function set_default_focus() {
// If at any point we are confused about the currently
// focused element, we switch focus to search.
$current_focus_elem = $("#recent_topics_search");
$current_focus_elem.trigger("focus");
compose_closed_ui.set_standard_text_for_reply_button();
}
function get_min_load_count(already_rendered_count, load_count) {
const extra_rows_for_viewing_pleasure = 15;
if (row_focus > already_rendered_count + load_count) {
return row_focus + extra_rows_for_viewing_pleasure - already_rendered_count;
}
return load_count;
}
function is_table_focused() {
return $current_focus_elem === "table";
}
function set_table_focus(row, col, using_keyboard) {
const $topic_rows = $("#recent_topics_table table tbody tr");
if ($topic_rows.length === 0 || row < 0 || row >= $topic_rows.length) {
row_focus = 0;
// return focus back to filters if we cannot focus on the table.
set_default_focus();
return true;
}
const $topic_row = $topic_rows.eq(row);
// We need to allow table to render first before setting focus.
setTimeout(
() => $topic_row.find(".recent_topics_focusable").eq(col).children().trigger("focus"),
0,
);
$current_focus_elem = "table";
if (using_keyboard) {
const scroll_element = document.querySelector(
"#recent_topics_table .table_fix_head .simplebar-content-wrapper",
);
const half_height_of_visible_area = scroll_element.offsetHeight / 2;
const topic_offset = topic_offset_to_visible_area($topic_row);
if (topic_offset === "above") {
scroll_element.scrollBy({top: -1 * half_height_of_visible_area});
} else if (topic_offset === "below") {
scroll_element.scrollBy({top: half_height_of_visible_area});
}
}
const message = {
stream: $topic_row.find(".recent_topic_stream a").text(),
topic: $topic_row.find(".recent_topic_name a").text(),
};
compose_closed_ui.update_reply_recipient_label(message);
return true;
}
export function get_focused_row_message() {
if (is_table_focused()) {
const recent_topic_id_prefix_len = "recent_topic:".length;
const $topic_rows = $("#recent_topics_table table tbody tr");
if ($topic_rows.length === 0) {
return undefined;
}
const $topic_row = $topic_rows.eq(row_focus);
const topic_id = $topic_row.attr("id").slice(recent_topic_id_prefix_len);
const topic_last_msg_id = topics.get(topic_id).last_msg_id;
return message_store.get(topic_last_msg_id);
}
return undefined;
}
export function revive_current_focus() {
// After re-render, the current_focus_elem is no longer linked
// to the focused element, this function attempts to revive the
// link and focus to the element prior to the rerender.
// We try to avoid setting focus when user
// is not focused on recent topics.
if (!is_in_focus()) {
return false;
}
if (!$current_focus_elem) {
set_default_focus();
return false;
}
if (is_table_focused()) {
if (last_visited_topic) {
const topic_last_msg_id = topics.get(last_visited_topic).last_msg_id;
const current_list = topics_widget.get_current_list();
const last_visited_topic_index = current_list.findIndex(
(topic) => topic.last_msg_id === topic_last_msg_id,
);
if (last_visited_topic_index >= 0) {
row_focus = last_visited_topic_index;
}
last_visited_topic = "";
}
set_table_focus(row_focus, col_focus);
return true;
}
const filter_button = $current_focus_elem.data("filter");
if (!filter_button) {
set_default_focus();
} else {
$current_focus_elem = $("#recent_topics_filter_buttons").find(
`[data-filter='${CSS.escape(filter_button)}']`,
);
$current_focus_elem.trigger("focus");
}
return true;
}
export function show_loading_indicator() {
loading.make_indicator($("#recent_topics_loading_messages_indicator"));
}
export function hide_loading_indicator() {
$("#recent_topics_bottom_whitespace").hide();
loading.destroy_indicator($("#recent_topics_loading_messages_indicator"), {
abs_positioned: false,
});
// Show empty table text if there are no messages fetched.
$("#recent_topics_table tbody").addClass("required-text");
}
export function process_messages(messages) {
// While this is inexpensive and handles all the cases itself,
// the UX can be bad if user wants to scroll down the list as
// the UI will be returned to the beginning of the list on every
// update.
//
// Only rerender if topic_data actually
// changed.
let topic_data_changed = false;
for (const msg of messages) {
if (process_message(msg)) {
topic_data_changed = true;
}
}
if (topic_data_changed) {
complete_rerender();
}
}
function format_topic(topic_data) {
const last_msg = message_store.get(topic_data.last_msg_id);
const stream = last_msg.stream;
const stream_id = last_msg.stream_id;
const stream_info = sub_store.get(stream_id);
if (stream_info === undefined) {
// stream was deleted
return {};
}
const topic = last_msg.topic;
const time = new Date(last_msg.timestamp * 1000);
const last_msg_time = timerender.format_time_modern(time);
const full_datetime = timerender.get_full_datetime(time);
// We hide the row according to filters or if it's muted.
// We only supply the data to the topic rows and let jquery
// display / hide them according to filters instead of
// doing complete re-render.
const topic_muted = Boolean(muted_topics.is_topic_muted(stream_id, topic));
const stream_muted = stream_data.is_muted(stream_id);
const muted = topic_muted || stream_muted;
const unread_count = unread.num_unread_for_topic(stream_id, topic);
// Display in most recent sender first order
const all_senders = recent_senders.get_topic_recent_senders(stream_id, topic);
const senders = all_senders.slice(-MAX_AVATAR);
const senders_info = people.sender_info_for_recent_topics_row(senders);
// Collect extra senders fullname for tooltip.
const extra_sender_ids = all_senders.slice(0, -MAX_AVATAR);
const displayed_other_senders = extra_sender_ids.slice(-MAX_EXTRA_SENDERS);
const displayed_other_names = people.get_display_full_names(displayed_other_senders.reverse());
if (extra_sender_ids.length > MAX_EXTRA_SENDERS) {
// We display only 10 extra senders in tooltips,
// and just display remaining number of senders.
const remaining_senders = extra_sender_ids.length - MAX_EXTRA_SENDERS;
// Pluralization syntax from:
// https://formatjs.io/docs/core-concepts/icu-syntax/#plural-format
displayed_other_names.push(
$t(
{
defaultMessage:
"and {remaining_senders, plural, one {1 other} other {# others}}.",
},
{remaining_senders},
),
);
}
const other_sender_names_html = displayed_other_names
.map((name) => _.escape(name))
.join("<br />");
return {
// stream info
stream_id,
stream,
stream_color: stream_info.color,
invite_only: stream_info.invite_only,
is_web_public: stream_info.is_web_public,
stream_url: hash_util.by_stream_url(stream_id),
topic,
topic_key: get_topic_key(stream_id, topic),
unread_count,
last_msg_time,
topic_url: hash_util.by_stream_topic_url(stream_id, topic),
senders: senders_info,
other_senders_count: Math.max(0, all_senders.length - MAX_AVATAR),
other_sender_names_html,
muted,
topic_muted,
participated: topic_data.participated,
full_last_msg_date_time: full_datetime,
};
}
function get_topic_row(topic_data) {
const msg = message_store.get(topic_data.last_msg_id);
const topic_key = get_topic_key(msg.stream_id, msg.topic);
return $(`#${CSS.escape("recent_topic:" + topic_key)}`);
}
export function process_topic_edit(old_stream_id, old_topic, new_topic, new_stream_id) {
// See `recent_senders.process_topic_edit` for
// logic behind this and important notes on use of this function.
topics.delete(get_topic_key(old_stream_id, old_topic));
const old_topic_msgs = message_util.get_messages_in_topic(old_stream_id, old_topic);
process_messages(old_topic_msgs);
new_stream_id = new_stream_id || old_stream_id;
const new_topic_msgs = message_util.get_messages_in_topic(new_stream_id, new_topic);
process_messages(new_topic_msgs);
}
export function topic_in_search_results(keyword, stream, topic) {
if (keyword === "") {
return true;
}
const text = (stream + " " + topic).toLowerCase();
const search_words = keyword.toLowerCase().split(/\s+/);
return search_words.every((word) => text.includes(word));
}
export function update_topics_of_deleted_message_ids(message_ids) {
const topics_to_rerender = message_util.get_topics_for_message_ids(message_ids);
for (const [stream_id, topic] of topics_to_rerender.values()) {
topics.delete(get_topic_key(stream_id, topic));
const msgs = message_util.get_messages_in_topic(stream_id, topic);
process_messages(msgs);
}
}
export function filters_should_hide_topic(topic_data) {
const msg = message_store.get(topic_data.last_msg_id);
const sub = sub_store.get(msg.stream_id);
if (sub === undefined || !sub.subscribed) {
// Never try to process deactivated & unsubscribed stream msgs.
return true;
}
if (filters.has("unread")) {
const unreadCount = unread.num_unread_for_topic(msg.stream_id, msg.topic);
if (unreadCount === 0) {
return true;
}
}
if (!topic_data.participated && filters.has("participated")) {
return true;
}
if (!filters.has("include_muted")) {
const topic_muted = Boolean(muted_topics.is_topic_muted(msg.stream_id, msg.topic));
const stream_muted = stream_data.is_muted(msg.stream_id);
if (topic_muted || stream_muted) {
return true;
}
}
const search_keyword = $("#recent_topics_search").val();
if (!topic_in_search_results(search_keyword, msg.stream, msg.topic)) {
return true;
}
return false;
}
export function inplace_rerender(topic_key) {
if (!is_visible()) {
return false;
}
if (!topics.has(topic_key)) {
return false;
}
const topic_data = topics.get(topic_key);
topics_widget.render_item(topic_data);
const topic_row = get_topic_row(topic_data);
if (filters_should_hide_topic(topic_data)) {
topic_row.hide();
} else {
topic_row.show();
}
revive_current_focus();
return true;
}
export function update_topic_is_muted(stream_id, topic) {
const key = get_topic_key(stream_id, topic);
if (!topics.has(key)) {
// we receive mute request for a topic we are
// not tracking currently
return false;
}
inplace_rerender(key);
return true;
}
export function update_topic_unread_count(message) {
const topic_key = get_topic_key(message.stream_id, message.topic);
inplace_rerender(topic_key);
}
export function set_filter(filter) {
// This function updates the `filters` variable
// after user clicks on one of the filter buttons
// based on `btn-recent-selected` class and current
// set `filters`.
// Get the button which was clicked.
const $filter_elem = $("#recent_topics_filter_buttons").find(
`[data-filter="${CSS.escape(filter)}"]`,
);
// If user clicks `All`, we clear all filters.
if (filter === "all" && filters.size !== 0) {
filters = new Set();
// If the button was already selected, remove the filter.
} else if ($filter_elem.hasClass("btn-recent-selected")) {
filters.delete(filter);
// If the button was not selected, we add the filter.
} else {
filters.add(filter);
}
save_filters();
}
function show_selected_filters() {
// Add `btn-selected-filter` to the buttons to show
// which filters are applied.
if (filters.size === 0) {
$("#recent_topics_filter_buttons")
.find('[data-filter="all"]')
.addClass("btn-recent-selected")
.attr("aria-checked", "true");
} else {
for (const filter of filters) {
$("#recent_topics_filter_buttons")
.find(`[data-filter="${CSS.escape(filter)}"]`)
.addClass("btn-recent-selected")
.attr("aria-checked", "true");
}
}
}
export function update_filters_view() {
const rendered_filters = render_recent_topics_filters({
filter_participated: filters.has("participated"),
filter_unread: filters.has("unread"),
filter_muted: filters.has("include_muted"),
});
$("#recent_filters_group").html(rendered_filters);
show_selected_filters();
topics_widget.hard_redraw();
}
function stream_sort(a, b) {
const a_stream = message_store.get(a.last_msg_id).stream;
const b_stream = message_store.get(b.last_msg_id).stream;
if (a_stream > b_stream) {
return 1;
} else if (a_stream === b_stream) {
return 0;
}
return -1;
}
function topic_sort(a, b) {
const a_topic = message_store.get(a.last_msg_id).topic;
const b_topic = message_store.get(b.last_msg_id).topic;
if (a_topic > b_topic) {
return 1;
} else if (a_topic === b_topic) {
return 0;
}
return -1;
}
function topic_offset_to_visible_area(topic_row) {
const $scroll_container = $("#recent_topics_table .table_fix_head");
const thead_height = 30;
const under_closed_compose_region_height = 50;
const scroll_container_top = $($scroll_container).offset().top + thead_height;
const scroll_container_bottom =
scroll_container_top + $($scroll_container).height() - under_closed_compose_region_height;
const topic_row_top = $(topic_row).offset().top;
const topic_row_bottom = topic_row_top + $(topic_row).height();
// Topic is above the visible scroll region.
if (topic_row_top < scroll_container_top) {
return "above";
// Topic is below the visible scroll region.
} else if (topic_row_bottom > scroll_container_bottom) {
return "below";
}
// Topic is visible
return "visible";
}
function set_focus_to_element_in_center() {
const table_wrapper_element = document.querySelector("#recent_topics_table .table_fix_head");
const $topic_rows = $("#recent_topics_table table tbody tr");
if (row_focus > $topic_rows.length) {
// User used a filter which reduced
// the number of visible rows.
return;
}
let $topic_row = $topic_rows.eq(row_focus);
const topic_offset = topic_offset_to_visible_area($topic_row);
if (topic_offset !== "visible") {
// Get the element at the center of the table.
const position = table_wrapper_element.getBoundingClientRect();
const topic_center_x = (position.left + position.right) / 2;
const topic_center_y = (position.top + position.bottom) / 2;
$topic_row = $(document.elementFromPoint(topic_center_x, topic_center_y)).closest("tr");
row_focus = $topic_rows.index($topic_row);
set_table_focus(row_focus, col_focus);
}
}
function is_scroll_position_for_render(scroll_container) {
const table_bottom_margin = 100; // Extra margin at the bottom of table.
const table_row_height = 50;
return (
scroll_container.scrollTop +
scroll_container.clientHeight +
table_bottom_margin +
table_row_height >
scroll_container.scrollHeight
);
}
export function complete_rerender() {
if (!is_visible()) {
return;
}
// Update header
load_filters();
show_selected_filters();
// Show topics list
const mapped_topic_values = Array.from(get().values()).map((value) => value);
if (topics_widget) {
topics_widget.replace_list_data(mapped_topic_values);
return;
}
const rendered_body = render_recent_topics_body({
filter_participated: filters.has("participated"),
filter_unread: filters.has("unread"),
filter_muted: filters.has("include_muted"),
search_val: $("#recent_topics_search").val() || "",
});
$("#recent_topics_table").html(rendered_body);
const $container = $("#recent_topics_table table tbody");
$container.empty();
topics_widget = ListWidget.create($container, mapped_topic_values, {
name: "recent_topics_table",
$parent_container: $("#recent_topics_table"),
modifier(item) {
return render_recent_topic_row(format_topic(item));
},
filter: {
// We use update_filters_view & filters_should_hide_topic to do all the
// filtering for us, which is called using click_handlers.
predicate(topic_data) {
return !filters_should_hide_topic(topic_data);
},
},
sort_fields: {
stream_sort,
topic_sort,
},
html_selector: get_topic_row,
$simplebar_container: $("#recent_topics_table .table_fix_head"),
callback_after_render: revive_current_focus,
is_scroll_position_for_render,
post_scroll__pre_render_callback: set_focus_to_element_in_center,
get_min_load_count,
});
}
export function show() {
if (is_visible()) {
// If we're already visible, E.g. because the user hit Esc
// while already in the recent topics view, do nothing.
return;
}
// Hide selected elements in the left sidebar.
top_left_corner.narrow_to_recent_topics();
stream_list.handle_narrow_deactivated();
// Hide "middle-column" which has html for rendering
// a messages narrow. We hide it and show recent topics.
$("#message_feed_container").hide();
$("#recent_topics_view").show();
recent_topics: Don't rely on ":visible" to avoid forced reflow. Previously, navigating from any stream to the recent topics view would cause a forced reflow every time we checked `is_visible()` because it would call `$("#recent_topics_view").is(":visible")`. The reason for this is related to how browsers ship frames, the process follows these steps: JavaScript > style calculations > layout > paint > composite. (The layout step is called Reflow in firefox.) Typically, the browser will handle these steps in the most optimal manner possible, delaying expensive operations until they're needed. However, it is possible to cause the browser to perform a layout earlier than necessary. An example of this is what we previously did: When we call `top_left_corner.narrow_to_recent_topics()`, we ask to add a class via `.addClass()`, this schedules a Style Recalculation, then, when we call `message_view_header.make_message_view_header()` it calls `recent_topics_util.is_visible()` which calls `$("#recent_topics_view").is(":visible")`. Before the browser can get this value, it realizes that our dom was invalidated by `.addClass()` and so it must execute the scheduled Style Recalculation and cause a layout. This is called a forced synchronous layout. This commit adds a JavaScript variable representing the visible state, in order to prevent the above behavior. This commit reduces the main thread run time of `build_message_view_header` from 131.81 ms to 5.20 ms. Unfortunately we still have the case where `recent_topics_ui.revive_current_focus()` calls `recent_topics_ui.set_table_focus()` which causes a reflow. However, by eliminating this reflow we still save ~100ms. (It's important to note that we only save this sometimes, as other things can still cost us a reflow.) Further reading: https://developers.google.com/web/fundamentals/ performance/rendering/avoid-large-complex-layouts-and-layout-thrashing
2021-11-05 21:21:43 +01:00
set_visible(true);
$("#message_view_header_underpadding").hide();
$(".header").css("padding-bottom", "0px");
narrow.hide_mark_as_read_turned_off_banner();
// We want to show `new stream message` instead of
// `new topic`, which we are already doing in this
// function. So, we reuse it here.
compose_closed_ui.update_buttons_for_recent_topics();
narrow_state.reset_current_filter();
narrow.set_narrow_title("Recent topics");
message_view_header.render_title_area();
complete_rerender();
}
function filter_buttons() {
return $("#recent_filters_group").children();
}
export function hide() {
// On firefox (and flaky on other browsers), focus
// remains on the focused element even after it is hidden. We
// forcefully blur it so that focus returns to the visible
// focused element.
const $focused_element = $(document.activeElement);
if ($("#recent_topics_view").has($focused_element)) {
$focused_element.trigger("blur");
}
$("#message_view_header_underpadding").show();
$("#message_feed_container").show();
$("#recent_topics_view").hide();
recent_topics: Don't rely on ":visible" to avoid forced reflow. Previously, navigating from any stream to the recent topics view would cause a forced reflow every time we checked `is_visible()` because it would call `$("#recent_topics_view").is(":visible")`. The reason for this is related to how browsers ship frames, the process follows these steps: JavaScript > style calculations > layout > paint > composite. (The layout step is called Reflow in firefox.) Typically, the browser will handle these steps in the most optimal manner possible, delaying expensive operations until they're needed. However, it is possible to cause the browser to perform a layout earlier than necessary. An example of this is what we previously did: When we call `top_left_corner.narrow_to_recent_topics()`, we ask to add a class via `.addClass()`, this schedules a Style Recalculation, then, when we call `message_view_header.make_message_view_header()` it calls `recent_topics_util.is_visible()` which calls `$("#recent_topics_view").is(":visible")`. Before the browser can get this value, it realizes that our dom was invalidated by `.addClass()` and so it must execute the scheduled Style Recalculation and cause a layout. This is called a forced synchronous layout. This commit adds a JavaScript variable representing the visible state, in order to prevent the above behavior. This commit reduces the main thread run time of `build_message_view_header` from 131.81 ms to 5.20 ms. Unfortunately we still have the case where `recent_topics_ui.revive_current_focus()` calls `recent_topics_ui.set_table_focus()` which causes a reflow. However, by eliminating this reflow we still save ~100ms. (It's important to note that we only save this sometimes, as other things can still cost us a reflow.) Further reading: https://developers.google.com/web/fundamentals/ performance/rendering/avoid-large-complex-layouts-and-layout-thrashing
2021-11-05 21:21:43 +01:00
set_visible(false);
$(".header").css("padding-bottom", "10px");
// This solves a bug with message_view_header
// being broken sometimes when we narrow
// to a filter and back to recent topics
// before it completely re-rerenders.
message_view_header.render_title_area();
// Fixes misaligned message_view and hidden
// floating_recipient_bar.
navbar_alerts.resize_app();
// This makes sure user lands on the selected message
// and not always at the top of the narrow.
navigate.plan_scroll_to_selected();
}
function is_focus_at_last_table_row() {
const $topic_rows = $("#recent_topics_table table tbody tr");
return row_focus === $topic_rows.length - 1;
}
export function focus_clicked_element(topic_row_index, col, topic_key) {
$current_focus_elem = "table";
col_focus = col;
row_focus = topic_row_index;
if (col === COLUMNS.topic) {
last_visited_topic = topic_key;
}
// Set compose_closed_ui reply button text. The rest of the table
// focus logic should be a noop.
set_table_focus(row_focus, col_focus);
}
export function change_focused_element($elt, input_key) {
// Called from hotkeys.js; like all logic in that module,
// returning true will cause the caller to do
// preventDefault/stopPropagation; false will let the browser
// handle the key.
if ($elt.attr("id") === "recent_topics_search") {
// Since the search box a text area, we want the browser to handle
// Left/Right and selection within the widget; but if the user
// arrows off the edges, we should move focus to the adjacent widgets..
const textInput = $("#recent_topics_search").get(0);
const start = textInput.selectionStart;
const end = textInput.selectionEnd;
const text_length = textInput.value.length;
let is_selected = false;
if (end - start > 0) {
is_selected = true;
}
switch (input_key) {
// Allow browser to handle all
// character keypresses.
case "vim_left":
case "vim_right":
case "vim_down":
case "vim_up":
case "open_recent_topics":
return false;
case "shift_tab":
$current_focus_elem = filter_buttons().last();
break;
case "left_arrow":
if (start !== 0 || is_selected) {
return false;
}
$current_focus_elem = filter_buttons().last();
break;
case "tab":
$current_focus_elem = filter_buttons().first();
break;
case "right_arrow":
if (end !== text_length || is_selected) {
return false;
}
$current_focus_elem = filter_buttons().first();
break;
case "down_arrow":
set_table_focus(row_focus, col_focus);
return true;
case "click":
// Note: current_focus_elem can be different here, so we just
// set current_focus_elem to the input box, we don't want .trigger("focus") on
// it since it is already focused.
// We only do this for search because we don't want the focus to
// go away from the input box when `revive_current_focus` is called
// on rerender when user is typing.
$current_focus_elem = $("#recent_topics_search");
compose_closed_ui.set_standard_text_for_reply_button();
return true;
case "escape":
if (is_table_focused()) {
return false;
}
set_table_focus(row_focus, col_focus);
return true;
}
} else if ($elt.hasClass("btn-recent-filters")) {
switch (input_key) {
case "click":
$current_focus_elem = $elt;
return true;
case "shift_tab":
case "vim_left":
case "left_arrow":
if (filter_buttons().first()[0] === $elt[0]) {
$current_focus_elem = $("#recent_topics_search");
} else {
$current_focus_elem = $elt.prev();
}
break;
case "tab":
case "vim_right":
case "right_arrow":
if (filter_buttons().last()[0] === $elt[0]) {
$current_focus_elem = $("#recent_topics_search");
} else {
$current_focus_elem = $elt.next();
}
break;
case "vim_down":
case "down_arrow":
set_table_focus(row_focus, col_focus);
return true;
case "escape":
if (is_table_focused()) {
return false;
}
set_table_focus(row_focus, col_focus);
return true;
}
} else if (is_table_focused()) {
// For arrowing around the table of topics, we implement left/right
// wraparound. Going off the top or the bottom takes one
// to the navigation at the top (see set_table_focus).
switch (input_key) {
case "escape":
return false;
case "open_recent_topics":
set_default_focus();
return true;
case "shift_tab":
case "vim_left":
case "left_arrow":
col_focus -= 1;
if (col_focus < 0) {
col_focus = MAX_SELECTABLE_COLS - 1;
}
break;
case "tab":
case "vim_right":
case "right_arrow":
col_focus += 1;
if (col_focus >= MAX_SELECTABLE_COLS) {
col_focus = 0;
}
break;
case "vim_down":
// We stop user at last table row
// so that user doesn't end up in
// input box where it is impossible to
// get out of using vim_up / vim_down
// keys. This also blocks the user from
// having `jjjj` typed in the input box
// when continuously pressing `j`.
if (is_focus_at_last_table_row()) {
return true;
}
row_focus += 1;
break;
case "down_arrow":
row_focus += 1;
break;
case "vim_up":
// See comment on vim_down.
// Similarly, blocks the user from
// having `kkkk` typed in the input box
// when continuously pressing `k`.
if (row_focus === 0) {
return true;
}
row_focus -= 1;
break;
case "up_arrow":
row_focus -= 1;
}
set_table_focus(row_focus, col_focus, true);
return true;
}
if ($current_focus_elem && input_key !== "escape") {
$current_focus_elem.trigger("focus");
if ($current_focus_elem.hasClass("btn-recent-filters")) {
compose_closed_ui.set_standard_text_for_reply_button();
}
return true;
}
return false;
}