mirror of
https://github.com/zulip/zulip.git
synced 2025-10-28 10:33:54 +00:00
2256 lines
79 KiB
TypeScript
2256 lines
79 KiB
TypeScript
import $ from "jquery";
|
|
import _ from "lodash";
|
|
import assert from "minimalistic-assert";
|
|
import type * as tippy from "tippy.js";
|
|
import * as z from "zod/mini";
|
|
|
|
import render_inbox_folder_row from "../templates/inbox_view/inbox_folder_row.hbs";
|
|
import render_inbox_folder_with_channels from "../templates/inbox_view/inbox_folder_with_channels.hbs";
|
|
import render_inbox_row from "../templates/inbox_view/inbox_row.hbs";
|
|
import render_inbox_stream_container from "../templates/inbox_view/inbox_stream_container.hbs";
|
|
import render_inbox_view from "../templates/inbox_view/inbox_view.hbs";
|
|
import render_introduce_zulip_view_modal from "../templates/introduce_zulip_view_modal.hbs";
|
|
import render_user_with_status_icon from "../templates/user_with_status_icon.hbs";
|
|
|
|
import * as buddy_data from "./buddy_data.ts";
|
|
import * as channel_folders from "./channel_folders.ts";
|
|
import * as compose_closed_ui from "./compose_closed_ui.ts";
|
|
import * as compose_state from "./compose_state.ts";
|
|
import * as dialog_widget from "./dialog_widget.ts";
|
|
import * as dropdown_widget from "./dropdown_widget.ts";
|
|
import type {Filter} from "./filter";
|
|
import * as hash_util from "./hash_util.ts";
|
|
import {$t, $t_html} from "./i18n.ts";
|
|
import * as inbox_util from "./inbox_util.ts";
|
|
import * as keydown_util from "./keydown_util.ts";
|
|
import * as left_sidebar_navigation_area from "./left_sidebar_navigation_area.ts";
|
|
import * as list_widget from "./list_widget.ts";
|
|
import * as loading from "./loading.ts";
|
|
import {localstorage} from "./localstorage.ts";
|
|
import * as message_store from "./message_store.ts";
|
|
import type {Message} from "./message_store.ts";
|
|
import * as message_viewport from "./message_viewport.ts";
|
|
import * as onboarding_steps from "./onboarding_steps.ts";
|
|
import * as people from "./people.ts";
|
|
import * as pm_list from "./pm_list.ts";
|
|
import * as stream_color from "./stream_color.ts";
|
|
import * as stream_data from "./stream_data.ts";
|
|
import * as stream_list from "./stream_list.ts";
|
|
import * as stream_topic_history from "./stream_topic_history.ts";
|
|
import * as stream_topic_history_util from "./stream_topic_history_util.ts";
|
|
import * as sub_store from "./sub_store.ts";
|
|
import {TopicListWidget} from "./topic_list.ts";
|
|
import * as topic_list_data from "./topic_list_data.ts";
|
|
import * as unread from "./unread.ts";
|
|
import * as unread_ops from "./unread_ops.ts";
|
|
import {user_settings} from "./user_settings.ts";
|
|
import * as user_status from "./user_status.ts";
|
|
import * as user_topics from "./user_topics.ts";
|
|
import * as user_topics_ui from "./user_topics_ui.ts";
|
|
import * as util from "./util.ts";
|
|
import * as views_util from "./views_util.ts";
|
|
|
|
type DirectMessageContext = {
|
|
conversation_key: string;
|
|
is_direct: boolean;
|
|
rendered_dm_with: string;
|
|
is_group: boolean;
|
|
user_circle_class: string | false | undefined;
|
|
is_bot: boolean;
|
|
dm_url: string;
|
|
user_ids_string: string;
|
|
unread_count: number;
|
|
is_hidden: boolean;
|
|
is_collapsed: boolean;
|
|
latest_msg_id: number;
|
|
column_indexes: typeof COLUMNS;
|
|
has_unread_mention: boolean;
|
|
};
|
|
|
|
const direct_message_context_properties: (keyof DirectMessageContext)[] = [
|
|
"conversation_key",
|
|
"is_direct",
|
|
"rendered_dm_with",
|
|
"is_group",
|
|
"user_circle_class",
|
|
"is_bot",
|
|
"dm_url",
|
|
"user_ids_string",
|
|
"unread_count",
|
|
"is_hidden",
|
|
"is_collapsed",
|
|
"latest_msg_id",
|
|
"column_indexes",
|
|
];
|
|
|
|
type StreamContext = {
|
|
is_stream: boolean;
|
|
is_archived: boolean;
|
|
invite_only: boolean;
|
|
is_web_public: boolean;
|
|
stream_name: string;
|
|
pin_to_top: boolean;
|
|
is_muted: boolean;
|
|
stream_color: string;
|
|
stream_header_color: string;
|
|
stream_url: string;
|
|
stream_id: number;
|
|
is_hidden: boolean;
|
|
is_collapsed: boolean;
|
|
mention_in_unread: boolean;
|
|
unread_count?: number;
|
|
column_indexes: typeof COLUMNS;
|
|
folder_id: number;
|
|
};
|
|
|
|
const stream_context_properties: (keyof StreamContext)[] = [
|
|
"is_stream",
|
|
"invite_only",
|
|
"is_web_public",
|
|
"stream_name",
|
|
"pin_to_top",
|
|
"is_muted",
|
|
"stream_color",
|
|
"stream_header_color",
|
|
"stream_url",
|
|
"stream_id",
|
|
"is_hidden",
|
|
"is_collapsed",
|
|
"mention_in_unread",
|
|
"unread_count",
|
|
"column_indexes",
|
|
];
|
|
|
|
type TopicContext = {
|
|
is_topic: boolean;
|
|
stream_id: number;
|
|
stream_archived: boolean;
|
|
topic_name: string;
|
|
topic_display_name: string;
|
|
is_empty_string_topic: boolean;
|
|
unread_count: number;
|
|
conversation_key: string;
|
|
topic_url: string;
|
|
is_hidden: boolean;
|
|
is_collapsed: boolean;
|
|
mention_in_unread: boolean;
|
|
latest_msg_id: number;
|
|
all_visibility_policies: typeof user_topics.all_visibility_policies;
|
|
visibility_policy: number | false;
|
|
column_indexes: typeof COLUMNS;
|
|
channel_folder_id?: number;
|
|
};
|
|
|
|
const topic_context_properties: (keyof TopicContext)[] = [
|
|
"is_topic",
|
|
"stream_id",
|
|
"stream_archived",
|
|
"topic_name",
|
|
"topic_display_name",
|
|
"is_empty_string_topic",
|
|
"unread_count",
|
|
"conversation_key",
|
|
"topic_url",
|
|
"is_hidden",
|
|
"is_collapsed",
|
|
"mention_in_unread",
|
|
"latest_msg_id",
|
|
"all_visibility_policies",
|
|
"visibility_policy",
|
|
"column_indexes",
|
|
"channel_folder_id",
|
|
];
|
|
|
|
type ChannelFolderContext = {
|
|
header_id: string;
|
|
is_header_visible: boolean;
|
|
name: string;
|
|
id: number;
|
|
unread_count: number | undefined;
|
|
is_collapsed: boolean;
|
|
has_unread_mention: boolean;
|
|
};
|
|
|
|
const channel_folder_context_properties: (keyof ChannelFolderContext)[] = [
|
|
"header_id",
|
|
"is_header_visible",
|
|
"name",
|
|
"id",
|
|
"unread_count",
|
|
"is_collapsed",
|
|
"has_unread_mention",
|
|
];
|
|
|
|
let dms_dict = new Map<string, DirectMessageContext>();
|
|
let topics_dict = new Map<string, Map<string, TopicContext>>();
|
|
let streams_dict = new Map<string, StreamContext>();
|
|
const OTHER_CHANNELS_FOLDER_ID = -1;
|
|
const OTHER_CHANNEL_HEADER_ID = "inbox-channels-no-folder-header";
|
|
const CHANNEL_FOLDER_HEADER_ID_PREFIX = "inbox-channel-folder-header-";
|
|
const PINNED_CHANNEL_FOLDER_ID = -2;
|
|
const PINNED_CHANNEL_HEADER_ID = "inbox-channels-pinned-folder-header";
|
|
let channel_folders_dict = new Map<number, ChannelFolderContext>();
|
|
let update_triggered_by_user = false;
|
|
let filters_dropdown_widget;
|
|
let channel_view_topic_widget: InboxTopicListWidget | undefined;
|
|
|
|
const COLUMNS = {
|
|
FULL_ROW: 0,
|
|
UNREAD_COUNT: 1,
|
|
TOPIC_VISIBILITY: 2,
|
|
ACTION_MENU: 3,
|
|
};
|
|
|
|
const DEFAULT_ROW_FOCUS = 0;
|
|
const DEFAULT_COL_FOCUS = COLUMNS.FULL_ROW;
|
|
|
|
const channel_view_navigation_state = {
|
|
channel_id: -1,
|
|
col_focus: DEFAULT_COL_FOCUS,
|
|
row_focus: DEFAULT_ROW_FOCUS,
|
|
last_scroll_offset: 0,
|
|
};
|
|
|
|
const inbox_view_navigation_state = {
|
|
col_focus: DEFAULT_COL_FOCUS,
|
|
row_focus: DEFAULT_ROW_FOCUS,
|
|
last_scroll_offset: 0,
|
|
};
|
|
|
|
let col_focus = DEFAULT_COL_FOCUS;
|
|
let row_focus = DEFAULT_ROW_FOCUS;
|
|
|
|
let hide_other_views_callback: (() => void) | undefined;
|
|
|
|
const ls_filter_key = "inbox-filters";
|
|
const ls_per_channel_filters_key = "inbox-per-channel-filters";
|
|
const ls_collapsed_containers_key = "inbox_collapsed_containers";
|
|
|
|
const ls = localstorage();
|
|
const DEFAULT_FILTER = views_util.FILTERS.UNMUTED_TOPICS;
|
|
let filters = new Set([DEFAULT_FILTER]);
|
|
const per_channel_filters = new Map<number, Set<string>>();
|
|
let collapsed_containers = new Set<string>();
|
|
|
|
let search_keyword = "";
|
|
let inbox_last_search_keyword = "";
|
|
const per_channel_last_search_keyword = new Map<number, string>();
|
|
const INBOX_SEARCH_ID = "inbox-search";
|
|
const INBOX_FILTERS_DROPDOWN_ID = "inbox-filter_widget";
|
|
export let current_focus_id: string | undefined;
|
|
|
|
const STREAM_HEADER_PREFIX = "inbox-stream-header-";
|
|
const CONVERSATION_ID_PREFIX = "inbox-row-conversation-";
|
|
|
|
const LEFT_NAVIGATION_KEYS = ["left_arrow", "vim_left"];
|
|
const RIGHT_NAVIGATION_KEYS = ["right_arrow", "vim_right"];
|
|
|
|
// We wait for rows to render and restore focus before processing
|
|
// any new events.
|
|
let is_waiting_for_revive_current_focus = true;
|
|
// Used to store the last scroll position of the inbox before
|
|
// it is hidden to avoid scroll jumping when it is shown again.
|
|
let last_scroll_offset: number | undefined;
|
|
|
|
function get_row_from_conversation_key(key: string): JQuery {
|
|
return $(`#${CSS.escape(CONVERSATION_ID_PREFIX + key)}`);
|
|
}
|
|
|
|
function save_data_to_ls(): void {
|
|
ls.set(ls_filter_key, [...filters]);
|
|
ls.set(
|
|
ls_per_channel_filters_key,
|
|
[...per_channel_filters.entries()].map(([channel_id, filter_set]) => [
|
|
channel_id,
|
|
[...filter_set],
|
|
]),
|
|
);
|
|
ls.set(ls_collapsed_containers_key, [...collapsed_containers]);
|
|
}
|
|
|
|
function save_channel_view_state(): void {
|
|
channel_view_navigation_state.col_focus = col_focus;
|
|
channel_view_navigation_state.row_focus = row_focus;
|
|
channel_view_navigation_state.last_scroll_offset = window.scrollY;
|
|
channel_view_navigation_state.channel_id = inbox_util.get_channel_id();
|
|
per_channel_last_search_keyword.set(channel_view_navigation_state.channel_id, search_keyword);
|
|
}
|
|
|
|
function save_inbox_view_state(): void {
|
|
inbox_view_navigation_state.col_focus = col_focus;
|
|
inbox_view_navigation_state.row_focus = row_focus;
|
|
inbox_view_navigation_state.last_scroll_offset = window.scrollY;
|
|
inbox_last_search_keyword = search_keyword;
|
|
}
|
|
|
|
function restore_channel_view_state(): void {
|
|
const current_channel_id = inbox_util.get_channel_id();
|
|
search_keyword = per_channel_last_search_keyword.get(current_channel_id) ?? "";
|
|
|
|
if (channel_view_navigation_state.channel_id === current_channel_id) {
|
|
col_focus = channel_view_navigation_state.col_focus;
|
|
row_focus = channel_view_navigation_state.row_focus;
|
|
last_scroll_offset = channel_view_navigation_state.last_scroll_offset;
|
|
return;
|
|
}
|
|
|
|
// Restore default state if channel_id doesn't match.
|
|
col_focus = DEFAULT_COL_FOCUS;
|
|
row_focus = DEFAULT_ROW_FOCUS;
|
|
}
|
|
|
|
function restore_inbox_view_state(): void {
|
|
col_focus = inbox_view_navigation_state.col_focus;
|
|
row_focus = inbox_view_navigation_state.row_focus;
|
|
last_scroll_offset = inbox_view_navigation_state.last_scroll_offset;
|
|
search_keyword = inbox_last_search_keyword;
|
|
}
|
|
|
|
export function show(filter?: Filter): void {
|
|
assert(hide_other_views_callback !== undefined);
|
|
hide_other_views_callback();
|
|
const was_inbox_already_visible = inbox_util.is_visible();
|
|
|
|
// Check if we are already narrowed to the same channel view.
|
|
const was_inbox_channel_view = inbox_util.is_channel_view();
|
|
const is_new_filter_channel_view = filter?.is_channel_view();
|
|
if (was_inbox_channel_view && is_new_filter_channel_view) {
|
|
assert(filter !== undefined);
|
|
const filter_channel_id_string = filter.operands("channel")[0];
|
|
assert(filter_channel_id_string !== undefined);
|
|
const filter_channel_id = Number.parseInt(filter_channel_id_string, 10);
|
|
|
|
if (inbox_util.get_channel_id() === filter_channel_id) {
|
|
// We expect `update` to handle any live updates such that we don't need
|
|
// do anything here if view for the same channel is visible.
|
|
return;
|
|
}
|
|
} else if (was_inbox_already_visible && !was_inbox_channel_view && is_new_filter_channel_view) {
|
|
save_inbox_view_state();
|
|
}
|
|
|
|
if (was_inbox_already_visible && was_inbox_channel_view) {
|
|
save_channel_view_state();
|
|
}
|
|
|
|
// Before we set the filter, we need to check if the inbox view is already visible.
|
|
const normal_inbox_view_is_visible = inbox_util.is_visible() && !was_inbox_channel_view;
|
|
|
|
inbox_util.set_filter(filter);
|
|
if (inbox_util.is_channel_view()) {
|
|
restore_channel_view_state();
|
|
views_util.show({
|
|
highlight_view_in_left_sidebar() {
|
|
assert(filter !== undefined);
|
|
left_sidebar_navigation_area.handle_narrow_activated(filter);
|
|
stream_list.handle_narrow_activated(filter, false, false);
|
|
pm_list.handle_narrow_activated(filter);
|
|
},
|
|
$view: $("#inbox-view"),
|
|
update_compose: compose_closed_ui.update_buttons_for_non_specific_views,
|
|
// We already did a check above for that.
|
|
is_visible: () => false,
|
|
set_visible: inbox_util.set_visible,
|
|
complete_rerender,
|
|
is_recent_view: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
restore_inbox_view_state();
|
|
views_util.show({
|
|
highlight_view_in_left_sidebar() {
|
|
views_util.handle_message_view_deactivated(
|
|
left_sidebar_navigation_area.highlight_inbox_view,
|
|
);
|
|
},
|
|
$view: $("#inbox-view"),
|
|
update_compose: compose_closed_ui.update_buttons_for_non_specific_views,
|
|
is_visible: () => normal_inbox_view_is_visible,
|
|
set_visible: inbox_util.set_visible,
|
|
complete_rerender,
|
|
});
|
|
|
|
if (onboarding_steps.ONE_TIME_NOTICES_TO_DISPLAY.has("intro_inbox_view_modal")) {
|
|
const html_body = render_introduce_zulip_view_modal({
|
|
zulip_view: "inbox",
|
|
current_home_view_and_escape_navigation_enabled:
|
|
user_settings.web_home_view === "inbox" &&
|
|
user_settings.web_escape_navigates_to_home_view,
|
|
});
|
|
dialog_widget.launch({
|
|
html_heading: $t_html({defaultMessage: "Welcome to your <b>inbox</b>!"}),
|
|
html_body,
|
|
html_submit_button: $t_html({defaultMessage: "Got it"}),
|
|
on_click() {
|
|
// Do nothing
|
|
},
|
|
on_hidden() {
|
|
revive_current_focus();
|
|
},
|
|
single_footer_button: true,
|
|
focus_submit_on_open: true,
|
|
});
|
|
onboarding_steps.post_onboarding_step_as_read("intro_inbox_view_modal");
|
|
}
|
|
}
|
|
|
|
export function hide(): void {
|
|
if (!inbox_util.is_visible()) {
|
|
return;
|
|
}
|
|
|
|
if (inbox_util.is_channel_view()) {
|
|
save_channel_view_state();
|
|
} else {
|
|
save_inbox_view_state();
|
|
}
|
|
|
|
views_util.hide({
|
|
$view: $("#inbox-view"),
|
|
set_visible: inbox_util.set_visible,
|
|
});
|
|
|
|
inbox_util.set_filter(undefined);
|
|
}
|
|
|
|
function get_topic_key(stream_id: number, topic: string): string {
|
|
return stream_id + ":" + topic;
|
|
}
|
|
|
|
function get_stream_key(stream_id: number): string {
|
|
return "stream_" + stream_id;
|
|
}
|
|
|
|
function get_stream_container(stream_key: string): JQuery {
|
|
return $(`#${CSS.escape(stream_key)}`);
|
|
}
|
|
|
|
function get_stream_header_row(stream_id: number): JQuery {
|
|
const $stream_header_row = $(`#${CSS.escape(STREAM_HEADER_PREFIX + stream_id)}`);
|
|
return $stream_header_row;
|
|
}
|
|
|
|
function load_data_from_ls(): void {
|
|
const saved_filters = new Set(z.optional(z.array(z.string())).parse(ls.get(ls_filter_key)));
|
|
const valid_filters = new Set(Object.values(views_util.FILTERS));
|
|
// If saved filters are not in the list of valid filters, we reset to default.
|
|
const is_subset = [...saved_filters].every((filter) => valid_filters.has(filter));
|
|
if (saved_filters.size === 0 || !is_subset) {
|
|
filters = new Set([views_util.FILTERS.UNMUTED_TOPICS]);
|
|
} else {
|
|
filters = saved_filters;
|
|
}
|
|
collapsed_containers = new Set(
|
|
z.optional(z.array(z.string())).parse(ls.get(ls_collapsed_containers_key)),
|
|
);
|
|
const saved_per_channel_filters = z
|
|
.optional(z.array(z.tuple([z.number(), z.array(z.string())])))
|
|
.parse(ls.get(ls_per_channel_filters_key));
|
|
for (const [channel_id, filter_set] of saved_per_channel_filters ?? []) {
|
|
const valid_filter_set = new Set(filter_set.filter((filter) => valid_filters.has(filter)));
|
|
if (valid_filter_set.size > 0) {
|
|
per_channel_filters.set(channel_id, valid_filter_set);
|
|
}
|
|
}
|
|
}
|
|
|
|
function format_dm(
|
|
user_ids_string: string,
|
|
unread_count: number,
|
|
latest_msg_id: number,
|
|
): DirectMessageContext {
|
|
const recipient_ids = people.user_ids_string_to_ids_array(user_ids_string);
|
|
if (recipient_ids.length === 0) {
|
|
// Self DM
|
|
recipient_ids.push(people.my_current_user_id());
|
|
}
|
|
|
|
const reply_to = people.user_ids_string_to_emails_string(user_ids_string);
|
|
assert(reply_to !== undefined);
|
|
const rendered_dm_with = recipient_ids
|
|
.map((recipient_id) => ({
|
|
name: people.get_display_full_name(recipient_id),
|
|
status_emoji_info: user_status.get_status_emoji(recipient_id),
|
|
}))
|
|
.sort((a, b) => util.strcmp(a.name, b.name))
|
|
.map((user_info) => render_user_with_status_icon(user_info));
|
|
|
|
let user_circle_class: string | false | undefined;
|
|
let is_bot = false;
|
|
if (recipient_ids.length === 1 && recipient_ids[0] !== undefined) {
|
|
const user_id = recipient_ids[0];
|
|
const is_deactivated = !people.is_active_user_for_popover(user_id);
|
|
is_bot = people.get_by_user_id(user_id).is_bot;
|
|
user_circle_class = is_bot
|
|
? false
|
|
: buddy_data.get_user_circle_class(recipient_ids[0], is_deactivated);
|
|
}
|
|
const has_unread_mention = unread.num_unread_mentions_for_user_ids_strings(user_ids_string) > 0;
|
|
|
|
const context = {
|
|
conversation_key: user_ids_string,
|
|
is_direct: true,
|
|
rendered_dm_with: util.format_array_as_list_with_conjunction(rendered_dm_with, "long"),
|
|
is_group: recipient_ids.length > 1,
|
|
user_circle_class,
|
|
is_bot,
|
|
dm_url: hash_util.pm_with_url(reply_to),
|
|
user_ids_string,
|
|
unread_count,
|
|
is_hidden: filter_should_hide_dm_row({dm_key: user_ids_string}),
|
|
is_collapsed: collapsed_containers.has("inbox-dm-header"),
|
|
latest_msg_id,
|
|
column_indexes: COLUMNS,
|
|
has_unread_mention,
|
|
};
|
|
|
|
return context;
|
|
}
|
|
|
|
function insert_dms(keys_to_insert: string[]): void {
|
|
const sorted_keys = [...dms_dict.keys()];
|
|
// If we need to insert at the top, we do it separately to avoid edge case in loop below.
|
|
if (sorted_keys[0] !== undefined && keys_to_insert.includes(sorted_keys[0])) {
|
|
$("#inbox-direct-messages-container").prepend(
|
|
$(render_inbox_row(dms_dict.get(sorted_keys[0]))),
|
|
);
|
|
}
|
|
|
|
for (const [i, key] of sorted_keys.entries()) {
|
|
if (i === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (keys_to_insert.includes(key)) {
|
|
const $previous_row = get_row_from_conversation_key(sorted_keys[i - 1]!);
|
|
$previous_row.after($(render_inbox_row(dms_dict.get(key))));
|
|
}
|
|
}
|
|
}
|
|
|
|
function rerender_dm_inbox_row_if_needed(
|
|
new_dm_data: DirectMessageContext,
|
|
old_dm_data: DirectMessageContext | undefined,
|
|
dm_keys_to_insert: string[],
|
|
): void {
|
|
if (old_dm_data === undefined) {
|
|
// This row is not rendered yet.
|
|
dm_keys_to_insert.push(new_dm_data.conversation_key);
|
|
return;
|
|
}
|
|
|
|
if (old_dm_data.latest_msg_id !== new_dm_data.latest_msg_id) {
|
|
// Row's index likely changed in list, so remove it and insert again.
|
|
get_row_from_conversation_key(new_dm_data.conversation_key).remove();
|
|
dm_keys_to_insert.push(new_dm_data.conversation_key);
|
|
return;
|
|
}
|
|
|
|
// If row's latest_msg_id didn't change, we can inplace rerender it, if needed.
|
|
for (const property of direct_message_context_properties) {
|
|
if (new_dm_data[property] !== old_dm_data[property]) {
|
|
const $rendered_row = get_row_from_conversation_key(new_dm_data.conversation_key);
|
|
$rendered_row.replaceWith($(render_inbox_row(new_dm_data)));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function get_channel_folder_id(info: {folder_id: number | null; is_pinned: boolean}): number {
|
|
if (info.is_pinned) {
|
|
return PINNED_CHANNEL_FOLDER_ID;
|
|
}
|
|
if (info.folder_id === null) {
|
|
return OTHER_CHANNELS_FOLDER_ID;
|
|
}
|
|
return info.folder_id;
|
|
}
|
|
|
|
function format_stream(stream_id: number): StreamContext {
|
|
// NOTE: Unread count is not included in this function as it is more
|
|
// efficient for the callers to calculate it based on filters.
|
|
const stream_info = sub_store.get(stream_id);
|
|
assert(stream_info !== undefined);
|
|
|
|
return {
|
|
is_stream: true,
|
|
is_archived: stream_info.is_archived,
|
|
invite_only: stream_info.invite_only,
|
|
is_web_public: stream_info.is_web_public,
|
|
stream_name: stream_info.name,
|
|
pin_to_top: stream_info.pin_to_top,
|
|
is_muted: stream_info.is_muted,
|
|
folder_id: get_channel_folder_id({
|
|
folder_id: stream_info.folder_id,
|
|
is_pinned: stream_info.pin_to_top,
|
|
}),
|
|
stream_color: stream_color.get_stream_privacy_icon_color(stream_info.color),
|
|
stream_header_color: stream_color.get_recipient_bar_color(stream_info.color),
|
|
stream_url: hash_util.channel_url_by_user_setting(stream_id),
|
|
stream_id,
|
|
// Will be displayed if any topic is visible.
|
|
is_hidden: true,
|
|
is_collapsed: collapsed_containers.has(STREAM_HEADER_PREFIX + stream_id),
|
|
mention_in_unread: unread.stream_has_any_unread_mentions(stream_id),
|
|
column_indexes: COLUMNS,
|
|
};
|
|
}
|
|
|
|
function update_stream_data(
|
|
stream_id: number,
|
|
stream_key: string,
|
|
topic_dict: Map<string, {topic_count: number; latest_msg_id: number}>,
|
|
): void {
|
|
const stream_topics_data = new Map<string, TopicContext>();
|
|
const stream_data = format_stream(stream_id);
|
|
const stream_archived = stream_data.is_archived;
|
|
let stream_post_filter_unread_count = 0;
|
|
for (const [topic, {topic_count, latest_msg_id}] of topic_dict) {
|
|
const topic_key = get_topic_key(stream_id, topic);
|
|
if (topic_count) {
|
|
const topic_data = format_topic(
|
|
stream_id,
|
|
stream_archived,
|
|
topic,
|
|
topic_count,
|
|
latest_msg_id,
|
|
);
|
|
stream_topics_data.set(topic_key, topic_data);
|
|
if (!topic_data.is_hidden) {
|
|
stream_post_filter_unread_count += topic_data.unread_count;
|
|
}
|
|
}
|
|
}
|
|
topics_dict.set(stream_key, get_sorted_row_dict(stream_topics_data));
|
|
stream_data.is_hidden = stream_post_filter_unread_count === 0;
|
|
stream_data.unread_count = stream_post_filter_unread_count;
|
|
streams_dict.set(stream_key, stream_data);
|
|
}
|
|
|
|
function rerender_stream_inbox_header_if_needed(
|
|
new_stream_data: StreamContext,
|
|
old_stream_data: StreamContext,
|
|
): void {
|
|
for (const property of stream_context_properties) {
|
|
if (new_stream_data[property] !== old_stream_data[property]) {
|
|
const $rendered_row = get_stream_header_row(new_stream_data.stream_id);
|
|
$rendered_row.replaceWith($(render_inbox_row(new_stream_data)));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function get_channel_folder_header_id(folder_id: number): string {
|
|
if (folder_id === OTHER_CHANNELS_FOLDER_ID) {
|
|
return OTHER_CHANNEL_HEADER_ID;
|
|
} else if (folder_id === PINNED_CHANNEL_FOLDER_ID) {
|
|
return PINNED_CHANNEL_HEADER_ID;
|
|
}
|
|
return CHANNEL_FOLDER_HEADER_ID_PREFIX + folder_id;
|
|
}
|
|
|
|
function rerender_channel_folder_header_if_needed(
|
|
old_folder_data: ChannelFolderContext,
|
|
new_folder_data: ChannelFolderContext,
|
|
): void {
|
|
for (const property of channel_folder_context_properties) {
|
|
if (new_folder_data[property] !== old_folder_data[property]) {
|
|
const $rendered_row = $(`#${get_channel_folder_header_id(new_folder_data.id)}`);
|
|
$rendered_row.replaceWith($(render_inbox_folder_row(new_folder_data)));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function format_topic(
|
|
stream_id: number,
|
|
stream_archived: boolean,
|
|
topic: string,
|
|
topic_unread_count: number,
|
|
latest_msg_id: number,
|
|
is_channel_view = false,
|
|
): TopicContext {
|
|
const common_context = {
|
|
is_topic: true,
|
|
stream_id,
|
|
stream_archived,
|
|
topic_name: topic,
|
|
topic_display_name: util.get_final_topic_display_name(topic),
|
|
is_empty_string_topic: topic === "",
|
|
unread_count: topic_unread_count,
|
|
conversation_key: get_topic_key(stream_id, topic),
|
|
topic_url: hash_util.by_channel_topic_permalink(stream_id, topic),
|
|
latest_msg_id,
|
|
mention_in_unread: unread.topic_has_any_unread_mentions(stream_id, topic),
|
|
// The 'all_visibility_policies' field is not specific to this context,
|
|
// but this is the easiest way we've figured out for passing the data
|
|
// to the template rendering.
|
|
all_visibility_policies: user_topics.all_visibility_policies,
|
|
visibility_policy: user_topics.get_topic_visibility_policy(stream_id, topic),
|
|
column_indexes: COLUMNS,
|
|
};
|
|
|
|
if (is_channel_view) {
|
|
return {
|
|
...common_context,
|
|
// We use TopicListWidget to check which topics to show so
|
|
// that `update` works correctly. So we're not using the
|
|
// inbox_ui filtering/hiding logic here.
|
|
is_hidden: false,
|
|
// Inbox view setting for collapsed containers is not
|
|
// relevant in the single-channel view.
|
|
is_collapsed: false,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...common_context,
|
|
is_hidden: filter_should_hide_stream_row({stream_id, topic}),
|
|
is_collapsed: collapsed_containers.has(STREAM_HEADER_PREFIX + stream_id),
|
|
};
|
|
}
|
|
|
|
function insert_stream(stream_key: string): void {
|
|
const channel_folder_id = streams_dict.get(stream_key)!.folder_id;
|
|
const sorted_stream_keys = get_sorted_stream_keys(channel_folder_id);
|
|
const stream_index = sorted_stream_keys.indexOf(stream_key);
|
|
const rendered_stream = render_inbox_stream_container({
|
|
topics_dict: new Map([[stream_key, topics_dict.get(stream_key)]]),
|
|
streams_dict,
|
|
});
|
|
const $channel_folder_header = $(`#${get_channel_folder_header_id(channel_folder_id)}`);
|
|
if (stream_index === 0) {
|
|
$channel_folder_header.next(".inbox-folder-components").prepend($(rendered_stream));
|
|
} else {
|
|
const previous_stream_key = sorted_stream_keys[stream_index - 1]!;
|
|
$(rendered_stream).insertAfter(get_stream_container(previous_stream_key));
|
|
}
|
|
}
|
|
|
|
function insert_topics(keys: string[], stream_key: string): void {
|
|
const stream_topics_data = topics_dict.get(stream_key);
|
|
assert(stream_topics_data !== undefined);
|
|
const sorted_keys = [...stream_topics_data.keys()];
|
|
// If we need to insert at the top, we do it separately to avoid edge case in loop below.
|
|
if (sorted_keys[0] !== undefined && keys.includes(sorted_keys[0])) {
|
|
const $stream = get_stream_container(stream_key);
|
|
$stream
|
|
.find(".inbox-topic-container")
|
|
.prepend($(render_inbox_row(stream_topics_data.get(sorted_keys[0]))));
|
|
}
|
|
|
|
for (const [i, key] of sorted_keys.entries()) {
|
|
if (i === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (keys.includes(key)) {
|
|
const $previous_row = get_row_from_conversation_key(sorted_keys[i - 1]!);
|
|
$previous_row.after($(render_inbox_row(stream_topics_data.get(key))));
|
|
}
|
|
}
|
|
}
|
|
|
|
function rerender_topic_inbox_row_if_needed(
|
|
new_topic_data: TopicContext,
|
|
old_topic_data: TopicContext | undefined,
|
|
topic_keys_to_insert: string[],
|
|
): void {
|
|
if (old_topic_data === undefined) {
|
|
// This row is not rendered yet.
|
|
topic_keys_to_insert.push(new_topic_data.conversation_key);
|
|
return;
|
|
}
|
|
|
|
if (old_topic_data.latest_msg_id !== new_topic_data.latest_msg_id) {
|
|
// Row's index likely changed in list, so remove it and insert again.
|
|
get_row_from_conversation_key(new_topic_data.conversation_key).remove();
|
|
topic_keys_to_insert.push(new_topic_data.conversation_key);
|
|
}
|
|
|
|
for (const property of topic_context_properties) {
|
|
if (new_topic_data[property] !== old_topic_data[property]) {
|
|
const $rendered_row = get_row_from_conversation_key(new_topic_data.conversation_key);
|
|
$rendered_row.replaceWith($(render_inbox_row(new_topic_data)));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function get_sorted_stream_keys(channel_folder_id: number | undefined = undefined): string[] {
|
|
function compare_function(a: string, b: string): number {
|
|
const stream_a = streams_dict.get(a);
|
|
const stream_b = streams_dict.get(b);
|
|
assert(stream_a !== undefined && stream_b !== undefined);
|
|
|
|
if (channel_folder_id !== undefined) {
|
|
// Sort streams not in the folder to the end.
|
|
if (stream_a.folder_id !== channel_folder_id) {
|
|
return 1;
|
|
}
|
|
if (stream_b.folder_id !== channel_folder_id) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
// The muted stream is sorted lower.
|
|
if (stream_a.is_muted && !stream_b.is_muted) {
|
|
return 1;
|
|
}
|
|
|
|
if (stream_b.is_muted && !stream_a.is_muted) {
|
|
return -1;
|
|
}
|
|
|
|
const stream_name_a = stream_a ? stream_a.stream_name : "";
|
|
const stream_name_b = stream_b ? stream_b.stream_name : "";
|
|
return util.strcmp(stream_name_a, stream_name_b);
|
|
}
|
|
|
|
return [...topics_dict.keys()].sort(compare_function);
|
|
}
|
|
|
|
function get_sorted_stream_topic_dict(): Map<string, Map<string, TopicContext>> {
|
|
const sorted_stream_keys = get_sorted_stream_keys();
|
|
const sorted_topic_dict = new Map<string, Map<string, TopicContext>>();
|
|
for (const sorted_stream_key of sorted_stream_keys) {
|
|
sorted_topic_dict.set(sorted_stream_key, topics_dict.get(sorted_stream_key)!);
|
|
}
|
|
|
|
return sorted_topic_dict;
|
|
}
|
|
|
|
function get_sorted_row_dict<T extends DirectMessageContext | TopicContext>(
|
|
row_dict: Map<string, T>,
|
|
): Map<string, T> {
|
|
return new Map([...row_dict].sort(([, a], [, b]) => b.latest_msg_id - a.latest_msg_id));
|
|
}
|
|
|
|
function sort_channel_folders(): void {
|
|
const sorted_channel_folders = [...channel_folders_dict.values()].sort((a, b) => {
|
|
// Sort OTHER_CHANNELS_FOLDER_ID last, then by name with PINNED_CHANNEL_FOLDER_ID first.
|
|
if (a.id === OTHER_CHANNELS_FOLDER_ID) {
|
|
return 1;
|
|
}
|
|
if (b.id === OTHER_CHANNELS_FOLDER_ID) {
|
|
return -1;
|
|
}
|
|
if (a.id === PINNED_CHANNEL_FOLDER_ID) {
|
|
return -1;
|
|
}
|
|
if (b.id === PINNED_CHANNEL_FOLDER_ID) {
|
|
return 1;
|
|
}
|
|
return util.strcmp(a.name, b.name);
|
|
});
|
|
|
|
channel_folders_dict = new Map(sorted_channel_folders.map((folder) => [folder.id, folder]));
|
|
}
|
|
|
|
function get_folder_name_from_id(folder_id: number): string {
|
|
if (folder_id === PINNED_CHANNEL_FOLDER_ID) {
|
|
return $t({defaultMessage: "PINNED CHANNELS"});
|
|
}
|
|
|
|
if (folder_id === OTHER_CHANNELS_FOLDER_ID) {
|
|
return $t({defaultMessage: "OTHER CHANNELS"});
|
|
}
|
|
|
|
return channel_folders.get_channel_folder_by_id(folder_id).name;
|
|
}
|
|
|
|
function update_channel_folder_data(channel_context: StreamContext): void {
|
|
const folder_id = channel_context.folder_id;
|
|
const folder_header_id = get_channel_folder_header_id(folder_id);
|
|
let folder_context = channel_folders_dict.get(folder_id);
|
|
if (folder_context === undefined) {
|
|
folder_context = {
|
|
id: folder_id,
|
|
header_id: folder_header_id,
|
|
name: get_folder_name_from_id(folder_id),
|
|
is_header_visible: !channel_context.is_hidden,
|
|
unread_count: channel_context.unread_count,
|
|
is_collapsed: collapsed_containers.has(folder_header_id),
|
|
has_unread_mention: channel_context.mention_in_unread,
|
|
};
|
|
channel_folders_dict.set(folder_id, folder_context);
|
|
} else {
|
|
folder_context.unread_count =
|
|
(folder_context.unread_count ?? 0) + (channel_context.unread_count ?? 0);
|
|
folder_context.is_header_visible =
|
|
folder_context.is_header_visible || !channel_context.is_hidden;
|
|
folder_context.has_unread_mention =
|
|
folder_context.has_unread_mention || channel_context.mention_in_unread;
|
|
}
|
|
}
|
|
|
|
function reset_data(): {
|
|
unread_dms_count: number;
|
|
is_dms_collapsed: boolean;
|
|
has_dms_post_filter: boolean;
|
|
has_visible_unreads: boolean;
|
|
has_unread_mention: boolean;
|
|
} {
|
|
dms_dict = new Map();
|
|
topics_dict = new Map();
|
|
streams_dict = new Map();
|
|
channel_folders_dict = new Map();
|
|
|
|
const unread_dms = unread.get_unread_pm();
|
|
const unread_dms_count = unread_dms.total_count;
|
|
const unread_dms_dict = unread_dms.pm_dict;
|
|
const has_unread_mention = unread.num_unread_mentions_in_dms() > 0;
|
|
|
|
const unread_stream_message = unread.get_unread_topics();
|
|
const unread_stream_msg_count = unread_stream_message.stream_unread_messages;
|
|
const unread_streams_dict = unread_stream_message.topic_counts;
|
|
|
|
let has_dms_post_filter = false;
|
|
if (unread_dms_count) {
|
|
for (const [key, {count, latest_msg_id}] of unread_dms_dict) {
|
|
if (count) {
|
|
const dm_data = format_dm(key, count, latest_msg_id);
|
|
dms_dict.set(key, dm_data);
|
|
if (!dm_data.is_hidden) {
|
|
has_dms_post_filter = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
dms_dict = get_sorted_row_dict(dms_dict);
|
|
|
|
let has_topics_post_filter = false;
|
|
if (unread_stream_msg_count) {
|
|
for (const [stream_id, topic_dict] of unread_streams_dict) {
|
|
const stream_unread = unread.unread_count_info_for_stream(stream_id);
|
|
const stream_unread_count = stream_unread.unmuted_count + stream_unread.muted_count;
|
|
const stream_key = get_stream_key(stream_id);
|
|
if (stream_unread_count > 0) {
|
|
update_stream_data(stream_id, stream_key, topic_dict);
|
|
if (!streams_dict.get(stream_key)!.is_hidden) {
|
|
has_topics_post_filter = true;
|
|
}
|
|
} else {
|
|
topics_dict.delete(stream_key);
|
|
}
|
|
}
|
|
}
|
|
|
|
const has_visible_unreads = has_dms_post_filter || has_topics_post_filter;
|
|
topics_dict = get_sorted_stream_topic_dict();
|
|
const is_dms_collapsed = collapsed_containers.has("inbox-dm-header");
|
|
|
|
for (const [, channel_context] of streams_dict) {
|
|
update_channel_folder_data(channel_context);
|
|
}
|
|
|
|
if (is_other_channels_only_visible_folder()) {
|
|
const other_channels_folder = channel_folders_dict.get(OTHER_CHANNELS_FOLDER_ID);
|
|
if (other_channels_folder !== undefined) {
|
|
other_channels_folder.name = $t({defaultMessage: "CHANNELS"});
|
|
}
|
|
}
|
|
|
|
sort_channel_folders();
|
|
|
|
return {
|
|
has_unread_mention,
|
|
unread_dms_count,
|
|
is_dms_collapsed,
|
|
has_dms_post_filter,
|
|
has_visible_unreads,
|
|
};
|
|
}
|
|
|
|
function is_other_channels_only_visible_folder(): boolean {
|
|
const visible_channel_folders = channel_folders_dict
|
|
.values()
|
|
.filter((folder) => folder.is_header_visible)
|
|
.toArray();
|
|
|
|
if (visible_channel_folders.length !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const only_visible_folder = visible_channel_folders[0]!;
|
|
return only_visible_folder.id === OTHER_CHANNELS_FOLDER_ID;
|
|
}
|
|
|
|
function show_empty_inbox_text(has_visible_unreads: boolean): void {
|
|
if (!has_visible_unreads) {
|
|
$("#inbox-list").css("border-width", 0);
|
|
if (search_keyword) {
|
|
$("#inbox-empty-with-search").show();
|
|
$("#inbox-empty-without-search").hide();
|
|
} else {
|
|
$("#inbox-empty-with-search").hide();
|
|
// Use display value specified in CSS.
|
|
$("#inbox-empty-without-search").css("display", "");
|
|
}
|
|
} else {
|
|
$(".inbox-empty-text").hide();
|
|
$("#inbox-list").css("border-width", "1px");
|
|
}
|
|
}
|
|
|
|
function filter_click_handler(
|
|
event: JQuery.TriggeredEvent,
|
|
dropdown: tippy.Instance,
|
|
widget: dropdown_widget.DropdownWidget,
|
|
): void {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const filter_id = $(event.currentTarget).attr("data-unique-id");
|
|
assert(filter_id !== undefined);
|
|
// We don't support multiple filters yet, so we clear existing and add the new filter.
|
|
if (inbox_util.is_channel_view()) {
|
|
const channel_id = inbox_util.get_channel_id();
|
|
per_channel_filters.set(channel_id, new Set([filter_id]));
|
|
} else {
|
|
filters = new Set([filter_id]);
|
|
}
|
|
save_data_to_ls();
|
|
dropdown.hide();
|
|
widget.render();
|
|
update();
|
|
}
|
|
|
|
export function update_channel_view(channel_id: number): void {
|
|
if (
|
|
inbox_util.is_visible() &&
|
|
inbox_util.is_channel_view() &&
|
|
inbox_util.get_channel_id() === channel_id
|
|
) {
|
|
channel_view_topic_widget?.build();
|
|
}
|
|
}
|
|
|
|
function show_empty_inbox_channel_view_text(is_empty: boolean): void {
|
|
if (is_empty) {
|
|
$("#inbox-list").css("border-width", "0");
|
|
if (search_keyword) {
|
|
$("#inbox-empty-channel-view-with-search").show();
|
|
$("#inbox-empty-channel-view-without-search").hide();
|
|
} else {
|
|
$("#inbox-empty-channel-view-with-search").hide();
|
|
$("#inbox-empty-channel-view-without-search").show();
|
|
}
|
|
} else {
|
|
$("#inbox-empty-channel-view-with-search").hide();
|
|
$("#inbox-empty-channel-view-without-search").hide();
|
|
$("#inbox-list").css("border-width", "1px");
|
|
}
|
|
}
|
|
|
|
function get_min_load_count(already_rendered_count: number, load_count: number): number {
|
|
// Height of inbox row is ~28px at 16px = 1.75rem and we want this render to fill the entire view height.
|
|
const view_height = message_viewport.height();
|
|
const row_height = 1.75 * user_settings.web_font_size_px;
|
|
const extra_rows_for_viewing_pleasure = view_height / row_height;
|
|
const ideal_rendered_rows_count = row_focus + extra_rows_for_viewing_pleasure;
|
|
if (ideal_rendered_rows_count > already_rendered_count + load_count) {
|
|
return ideal_rendered_rows_count - already_rendered_count;
|
|
}
|
|
return load_count;
|
|
}
|
|
|
|
function show_channel_view_loading_indicator(): void {
|
|
$("#inbox-loading-indicator .bottom-messages-logo").show();
|
|
loading.make_indicator($("#inbox-loading-indicator #loading_more_indicator"), {
|
|
abs_positioned: true,
|
|
});
|
|
}
|
|
|
|
function hide_channel_view_loading_indicator(): void {
|
|
$("#inbox-loading-indicator .bottom-messages-logo").hide();
|
|
loading.destroy_indicator($("#inbox-loading-indicator #loading_more_indicator"));
|
|
}
|
|
|
|
class InboxTopicListWidget extends TopicListWidget {
|
|
override topic_list_class_name = "inbox-channel-topic-list";
|
|
topics_widget?: list_widget.ListWidget<topic_list_data.TopicInfo>;
|
|
|
|
override build(): this {
|
|
// Hide any existing loading indicators.
|
|
hide_channel_view_loading_indicator();
|
|
const is_zoomed = true;
|
|
const $container = $("#inbox-list");
|
|
const list_info = topic_list_data.get_list_info(
|
|
this.my_stream_id,
|
|
is_zoomed,
|
|
this.filter_topics,
|
|
);
|
|
|
|
const all_topics = list_info.items;
|
|
this.topics_widget = list_widget.create($container, all_topics, {
|
|
name: "inbox-channel-topics-list",
|
|
get_item: list_widget.default_get_item,
|
|
$parent_container: $("#inbox-view"),
|
|
modifier_html(item) {
|
|
const topic_context = format_topic(
|
|
item.stream_id,
|
|
false,
|
|
item.topic_name,
|
|
item.unread,
|
|
-1,
|
|
true,
|
|
);
|
|
return render_inbox_row(topic_context);
|
|
},
|
|
$simplebar_container: $("html"),
|
|
is_scroll_position_for_render: views_util.is_scroll_position_for_render,
|
|
get_min_load_count,
|
|
});
|
|
|
|
if (!stream_topic_history.has_history_for(this.my_stream_id)) {
|
|
show_channel_view_loading_indicator();
|
|
stream_topic_history_util.get_server_history(this.my_stream_id, () => {
|
|
if (channel_view_topic_widget?.get_stream_id() !== this.my_stream_id) {
|
|
return;
|
|
}
|
|
|
|
channel_view_topic_widget.build();
|
|
});
|
|
} else {
|
|
show_empty_inbox_channel_view_text(this.is_empty());
|
|
}
|
|
setTimeout(() => {
|
|
revive_current_focus();
|
|
}, 0);
|
|
return this;
|
|
}
|
|
|
|
override is_empty(): boolean {
|
|
if (this.topics_widget === undefined) {
|
|
return true;
|
|
}
|
|
return this.topics_widget.get_current_list().length === 0;
|
|
}
|
|
}
|
|
|
|
function filter_topics_in_channel(channel_id: number, topics: string[]): string[] {
|
|
return topics.filter((topic) => !filter_should_hide_stream_row({stream_id: channel_id, topic}));
|
|
}
|
|
|
|
function render_channel_view(channel_id: number): void {
|
|
$("#inbox-pane").html(
|
|
render_inbox_view({
|
|
normal_view: false,
|
|
search_val: search_keyword,
|
|
INBOX_SEARCH_ID,
|
|
}),
|
|
);
|
|
// Hide any empty inbox text by default.
|
|
show_empty_inbox_text(true);
|
|
channel_view_topic_widget = new InboxTopicListWidget(
|
|
$("#inbox-list"),
|
|
channel_id,
|
|
(topic_names: string[]) => filter_topics_in_channel(channel_id, topic_names),
|
|
);
|
|
channel_view_topic_widget.build();
|
|
}
|
|
|
|
function inbox_view_dropdown_options(
|
|
current_value: string | number | undefined,
|
|
): dropdown_widget.Option[] {
|
|
return views_util.filters_dropdown_options(current_value, inbox_util.is_channel_view());
|
|
}
|
|
|
|
export function complete_rerender(): void {
|
|
if (!inbox_util.is_visible()) {
|
|
return;
|
|
}
|
|
load_data_from_ls();
|
|
|
|
let first_filter: IteratorResult<string>;
|
|
if (inbox_util.is_channel_view()) {
|
|
const channel_id = inbox_util.get_channel_id();
|
|
assert(channel_id !== undefined);
|
|
|
|
if (channel_view_topic_widget?.get_stream_id() === channel_id) {
|
|
channel_view_topic_widget.build();
|
|
} else {
|
|
// Show unknown channel message if we don't have data for channel.
|
|
if (!stream_data.get_sub_by_id(channel_id)) {
|
|
$("#inbox-pane").html(
|
|
render_inbox_view({
|
|
unknown_channel: true,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
render_channel_view(channel_id);
|
|
}
|
|
const channel_filter = per_channel_filters.get(channel_id) ?? new Set([DEFAULT_FILTER]);
|
|
first_filter = channel_filter.values().next();
|
|
} else {
|
|
channel_view_topic_widget = undefined;
|
|
const {has_visible_unreads, ...additional_context} = reset_data();
|
|
$("#inbox-pane").html(
|
|
render_inbox_view({
|
|
normal_view: true,
|
|
search_val: search_keyword,
|
|
INBOX_SEARCH_ID,
|
|
dms_dict,
|
|
topics_dict,
|
|
streams_dict,
|
|
channel_folders_dict,
|
|
...additional_context,
|
|
}),
|
|
);
|
|
show_empty_inbox_channel_view_text(false);
|
|
show_empty_inbox_text(has_visible_unreads);
|
|
first_filter = filters.values().next();
|
|
}
|
|
|
|
// If the focus is not on the inbox rows, the inbox view scrolls
|
|
// down when moving from other views to the inbox view. To avoid
|
|
// this, we scroll to top before restoring focus via revive_current_focus.
|
|
if (!is_list_focused()) {
|
|
window.scrollTo(0, 0);
|
|
} else if (last_scroll_offset !== undefined) {
|
|
// It is important to restore the scroll position as soon
|
|
// as the rendering is complete to avoid scroll jumping.
|
|
window.scrollTo(0, last_scroll_offset);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
revive_current_focus();
|
|
is_waiting_for_revive_current_focus = false;
|
|
}, 0);
|
|
|
|
filters_dropdown_widget = new dropdown_widget.DropdownWidget({
|
|
...views_util.COMMON_DROPDOWN_WIDGET_PARAMS,
|
|
widget_name: "inbox-filter",
|
|
item_click_callback: filter_click_handler,
|
|
$events_container: $("#inbox-main"),
|
|
default_id: first_filter.done ? DEFAULT_FILTER : first_filter.value,
|
|
get_options: inbox_view_dropdown_options,
|
|
});
|
|
filters_dropdown_widget.setup();
|
|
}
|
|
|
|
export function search_and_update(): void {
|
|
const new_keyword = $<HTMLInputElement>("input#inbox-search").val() ?? "";
|
|
if (new_keyword === search_keyword) {
|
|
return;
|
|
}
|
|
search_keyword = new_keyword;
|
|
current_focus_id = INBOX_SEARCH_ID;
|
|
update_triggered_by_user = true;
|
|
update();
|
|
}
|
|
|
|
function row_in_search_results(keyword: string, text: string): boolean {
|
|
if (keyword === "") {
|
|
return true;
|
|
}
|
|
const search_words = keyword.toLowerCase().split(/\s+/);
|
|
return search_words.every((word) => text.includes(word));
|
|
}
|
|
|
|
function filter_should_hide_dm_row({dm_key}: {dm_key: string}): boolean {
|
|
const recipients_string = people.get_recipients(dm_key);
|
|
const text = recipients_string.join(",").toLowerCase();
|
|
|
|
if (!row_in_search_results(search_keyword, text)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function filter_should_hide_stream_row({
|
|
stream_id,
|
|
topic,
|
|
}: {
|
|
stream_id: number;
|
|
topic: string;
|
|
}): boolean {
|
|
const sub = sub_store.get(stream_id);
|
|
if (!sub?.subscribed) {
|
|
return true;
|
|
}
|
|
|
|
let current_filter = filters;
|
|
if (inbox_util.is_channel_view()) {
|
|
const channel_id = inbox_util.get_channel_id();
|
|
current_filter = per_channel_filters.get(channel_id) ?? new Set([DEFAULT_FILTER]);
|
|
}
|
|
|
|
if (
|
|
current_filter.has(views_util.FILTERS.FOLLOWED_TOPICS) &&
|
|
!user_topics.is_topic_followed(stream_id, topic)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
current_filter.has(views_util.FILTERS.UNMUTED_TOPICS) &&
|
|
(user_topics.is_topic_muted(stream_id, topic) ||
|
|
(!inbox_util.is_channel_view() && stream_data.is_muted(stream_id))) &&
|
|
!user_topics.is_topic_unmuted_or_followed(stream_id, topic)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const topic_display_name = util.get_final_topic_display_name(topic);
|
|
const text = (sub.name + " " + topic_display_name).toLowerCase();
|
|
|
|
if (!row_in_search_results(search_keyword, text)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function collapse_or_expand(container_id: string): void {
|
|
$(`#${container_id}`).toggleClass("inbox-collapsed-state");
|
|
|
|
if (collapsed_containers.has(container_id)) {
|
|
collapsed_containers.delete(container_id);
|
|
} else {
|
|
collapsed_containers.add(container_id);
|
|
}
|
|
|
|
save_data_to_ls();
|
|
}
|
|
|
|
function focus_current_id(): void {
|
|
assert(current_focus_id !== undefined);
|
|
$(`#${CSS.escape(current_focus_id)}`).trigger("focus");
|
|
}
|
|
|
|
function focus_inbox_search(): void {
|
|
current_focus_id = INBOX_SEARCH_ID;
|
|
focus_current_id();
|
|
}
|
|
|
|
function is_list_focused(): boolean {
|
|
return (
|
|
current_focus_id === undefined ||
|
|
![INBOX_SEARCH_ID, INBOX_FILTERS_DROPDOWN_ID].includes(current_focus_id)
|
|
);
|
|
}
|
|
|
|
function get_all_rows(): JQuery {
|
|
// Get all rows in the inbox list that are not hidden by filters.
|
|
if (inbox_util.is_channel_view()) {
|
|
return $(".inbox-row").not(".hidden_by_filters");
|
|
}
|
|
|
|
// This includes channel folder headers, DM / channel headers and rows.
|
|
const visible_inbox_folder_components =
|
|
"#inbox-list .inbox-folder:not(.inbox-collapsed-state) + .inbox-folder-components";
|
|
return $(
|
|
// Inbox folder headers
|
|
"#inbox-list .inbox-folder, " +
|
|
// Inbox folder components which display row without any header, i.e. DM row
|
|
`${visible_inbox_folder_components} > .inbox-row, ` +
|
|
// Inbox folder components which display header row, i.e. channel row
|
|
`${visible_inbox_folder_components} .inbox-header, ` +
|
|
// Inbox rows whose folder and header is not collapsed.
|
|
`${visible_inbox_folder_components} .inbox-header:not(.inbox-collapsed-state) + .inbox-topic-container > .inbox-row`,
|
|
).not(".hidden_by_filters");
|
|
}
|
|
|
|
function get_row_index($elt: JQuery): number {
|
|
const $all_rows = get_all_rows();
|
|
const $row = $elt.closest(".inbox-row, .inbox-header");
|
|
return $all_rows.index($row);
|
|
}
|
|
|
|
function focus_clicked_list_element($elt: JQuery): void {
|
|
row_focus = get_row_index($elt);
|
|
update_triggered_by_user = true;
|
|
}
|
|
|
|
export function revive_current_focus(): void {
|
|
if (!is_in_focus()) {
|
|
return;
|
|
}
|
|
if (is_list_focused()) {
|
|
set_list_focus();
|
|
} else {
|
|
focus_current_id();
|
|
}
|
|
}
|
|
|
|
function update_closed_compose_text($row: JQuery, is_header_row: boolean): void {
|
|
if (is_header_row) {
|
|
compose_closed_ui.set_standard_text_for_reply_button();
|
|
return;
|
|
}
|
|
|
|
let reply_recipient_information: compose_closed_ui.ReplyRecipientInformation;
|
|
const is_dm = $row.parent("#inbox-direct-messages-container").length > 0;
|
|
if (is_dm) {
|
|
const $recipients_info = $row.find(".recipients_info");
|
|
const narrow_url = $recipients_info.attr("href");
|
|
assert(narrow_url !== undefined);
|
|
const recipient_ids = hash_util.decode_dm_recipient_user_ids_from_narrow_url(narrow_url);
|
|
if (recipient_ids) {
|
|
reply_recipient_information = {
|
|
user_ids: recipient_ids,
|
|
};
|
|
} else {
|
|
reply_recipient_information = {
|
|
display_reply_to: $recipients_info.text(),
|
|
};
|
|
}
|
|
} else {
|
|
const $stream = $row.parent(".inbox-topic-container").prev(".inbox-header");
|
|
reply_recipient_information = {
|
|
stream_id: Number($stream.attr("data-stream-id")),
|
|
topic: $row.find(".inbox-topic-name a").text(),
|
|
};
|
|
}
|
|
compose_closed_ui.update_recipient_text_for_reply_button(reply_recipient_information);
|
|
}
|
|
|
|
export function get_focused_row_message(): {message?: Message | undefined} & (
|
|
| {msg_type: "private"; private_message_recipient?: string}
|
|
| {msg_type: "stream"; stream_id: number; topic?: string}
|
|
| {msg_type?: never}
|
|
) {
|
|
if (!is_list_focused()) {
|
|
return {message: undefined};
|
|
}
|
|
|
|
const $all_rows = get_all_rows();
|
|
const focused_row = $all_rows.get(row_focus);
|
|
if (!focused_row) {
|
|
// Likely `row_focus` or `current_focus_id` wasn't updated correctly.
|
|
// TODO: Debug this further.
|
|
return {message: undefined};
|
|
}
|
|
const $focused_row = $(focused_row);
|
|
if (is_row_a_header($focused_row)) {
|
|
const is_dm_header = $focused_row.attr("id") === "inbox-dm-header";
|
|
if (is_dm_header) {
|
|
return {message: undefined, msg_type: "private"};
|
|
}
|
|
|
|
if ($focused_row.hasClass("inbox-folder")) {
|
|
// This is a channel folder header.
|
|
return {};
|
|
}
|
|
const stream_id = Number($focused_row.attr("data-stream-id"));
|
|
compose_state.set_compose_recipient_id(stream_id);
|
|
return {message: undefined, msg_type: "stream", stream_id};
|
|
}
|
|
|
|
const is_dm = $focused_row.parent("#inbox-direct-messages-container").length > 0;
|
|
const conversation_key = $focused_row.attr("id")!.slice(CONVERSATION_ID_PREFIX.length);
|
|
|
|
if (is_dm) {
|
|
const row_info = dms_dict.get(conversation_key);
|
|
assert(row_info !== undefined);
|
|
const message = message_store.get(row_info.latest_msg_id);
|
|
if (message === undefined) {
|
|
const recipients = people.user_ids_string_to_emails_string(row_info.user_ids_string);
|
|
assert(recipients !== undefined);
|
|
return {
|
|
msg_type: "private",
|
|
private_message_recipient: recipients,
|
|
};
|
|
}
|
|
return {message};
|
|
}
|
|
|
|
// Last case: focused on a topic row.
|
|
// Since inbox is populated based on unread data which is part
|
|
// of /register request, it is possible that we don't have the
|
|
// actual message in our message_store. In that case, we return
|
|
// a fake message object.
|
|
const $topic_menu_elt = $focused_row.find(".inbox-topic-menu");
|
|
const topic = $topic_menu_elt.attr("data-topic-name");
|
|
assert(topic !== undefined);
|
|
const stream_id = Number($topic_menu_elt.attr("data-stream-id"));
|
|
assert(stream_id !== undefined);
|
|
|
|
return {
|
|
msg_type: "stream",
|
|
stream_id,
|
|
topic,
|
|
};
|
|
}
|
|
|
|
export function toggle_topic_visibility_policy(): boolean {
|
|
const inbox_message = get_focused_row_message();
|
|
if (inbox_message.message !== undefined) {
|
|
user_topics_ui.toggle_topic_visibility_policy(inbox_message.message);
|
|
if (inbox_message.message.type === "stream") {
|
|
// means mute/unmute action is taken
|
|
const $elt = $(".inbox-header"); // Select the element with class "inbox-header"
|
|
const $focusElement = $elt.find(get_focus_class_for_header()).first();
|
|
focus_clicked_list_element($focusElement);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function is_row_a_header($row: JQuery): boolean {
|
|
return $row.hasClass("inbox-header");
|
|
}
|
|
|
|
function set_list_focus(input_key?: string): void {
|
|
// This function is used for both revive_current_focus and
|
|
// setting focus after we modify col_focus and row_focus as per
|
|
// hotkey pressed by user.
|
|
|
|
const $all_rows = get_all_rows();
|
|
const max_row_focus = $all_rows.length - 1;
|
|
if (max_row_focus < 0) {
|
|
focus_filters_dropdown();
|
|
return;
|
|
}
|
|
|
|
if (row_focus > max_row_focus) {
|
|
row_focus = max_row_focus;
|
|
} else if (row_focus < 0) {
|
|
row_focus = 0;
|
|
}
|
|
|
|
const row_to_focus = $all_rows.get(row_focus);
|
|
assert(row_to_focus !== undefined);
|
|
const $row_to_focus = $(row_to_focus);
|
|
|
|
current_focus_id = $row_to_focus.attr("id");
|
|
const is_header_row = is_row_a_header($row_to_focus);
|
|
update_closed_compose_text($row_to_focus, is_header_row);
|
|
if (col_focus > COLUMNS.ACTION_MENU) {
|
|
col_focus = COLUMNS.FULL_ROW;
|
|
$row_to_focus.trigger("focus");
|
|
return;
|
|
}
|
|
|
|
const cols_to_focus = [row_to_focus, ...$row_to_focus.find("[tabindex=0]")];
|
|
// We assumes that the last column has the highest index is the rightmost column.
|
|
const last_col_index = Number($(cols_to_focus.at(-1)!).attr("data-col-index")!);
|
|
|
|
if (col_focus < 0) {
|
|
col_focus = last_col_index;
|
|
$(cols_to_focus.at(-1)!).trigger("focus");
|
|
return;
|
|
}
|
|
|
|
// This assumes that the last column has the highest index.
|
|
if (col_focus > last_col_index) {
|
|
col_focus = 0;
|
|
$(cols_to_focus[0]!).trigger("focus");
|
|
return;
|
|
}
|
|
|
|
// Find the closest column to focus based on the input key.
|
|
let equal = (a: number, b: number): boolean => b >= a;
|
|
if (input_key && LEFT_NAVIGATION_KEYS.includes(input_key)) {
|
|
equal = (a: number, b: number): boolean => a >= b;
|
|
cols_to_focus.reverse();
|
|
}
|
|
|
|
for (const col of cols_to_focus) {
|
|
const col_index = Number($(col).attr("data-col-index"));
|
|
if (equal(col_focus, col_index)) {
|
|
col_focus = col_index;
|
|
$(col).trigger("focus");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function focus_filters_dropdown(): void {
|
|
current_focus_id = INBOX_FILTERS_DROPDOWN_ID;
|
|
$(`#${CSS.escape(INBOX_FILTERS_DROPDOWN_ID)}`).trigger("focus");
|
|
}
|
|
|
|
function is_search_focused(): boolean {
|
|
return current_focus_id === INBOX_SEARCH_ID;
|
|
}
|
|
|
|
function is_filters_dropdown_focused(): boolean {
|
|
return current_focus_id === INBOX_FILTERS_DROPDOWN_ID;
|
|
}
|
|
|
|
function get_page_up_down_delta(): number {
|
|
const element_above = document.querySelector("#inbox-filters");
|
|
const element_down = document.querySelector("#compose");
|
|
assert(element_above !== null && element_down !== null);
|
|
const visible_top = element_above.getBoundingClientRect().bottom;
|
|
const visible_bottom = element_down.getBoundingClientRect().top;
|
|
// One usually wants PageDown to move what had been the bottom row
|
|
// to now be at the top, so one can be confident one will see
|
|
// every row using it. This offset helps achieve that goal.
|
|
//
|
|
// See navigate.amount_to_paginate for similar logic in the message feed.
|
|
const scrolling_reduction_to_maintain_context = 30;
|
|
|
|
const delta = visible_bottom - visible_top - scrolling_reduction_to_maintain_context;
|
|
return delta;
|
|
}
|
|
|
|
function page_up_navigation(): void {
|
|
const delta = get_page_up_down_delta();
|
|
const scroll_element = document.documentElement;
|
|
const new_scrollTop = scroll_element.scrollTop - delta;
|
|
if (new_scrollTop <= 0) {
|
|
row_focus = 0;
|
|
}
|
|
scroll_element.scrollTop = new_scrollTop;
|
|
set_list_focus();
|
|
}
|
|
|
|
function page_down_navigation(): void {
|
|
const delta = get_page_up_down_delta();
|
|
const scroll_element = document.documentElement;
|
|
const new_scrollTop = scroll_element.scrollTop + delta;
|
|
const $all_rows = get_all_rows();
|
|
const $last_row = $all_rows.last();
|
|
const last_row_bottom = ($last_row.offset()?.top ?? 0) + ($last_row.outerHeight() ?? 0);
|
|
// Move focus to last row if it is visible and we are at the bottom.
|
|
if (last_row_bottom <= new_scrollTop) {
|
|
row_focus = get_all_rows().length - 1;
|
|
}
|
|
scroll_element.scrollTop = new_scrollTop;
|
|
set_list_focus();
|
|
}
|
|
|
|
export function change_focused_element(input_key: string): boolean {
|
|
// Start showing visible focus outlines.
|
|
$("#inbox-view").removeClass("no-visible-focus-outlines");
|
|
if (input_key === "tab" || input_key === "shift_tab") {
|
|
// Tabbing should be handled by browser but to keep the focus element same
|
|
// when we rerender or user uses other hotkeys, we need to track
|
|
// the current focused element.
|
|
setTimeout(() => {
|
|
const post_tab_focus_elem = document.activeElement;
|
|
if (!(post_tab_focus_elem instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
post_tab_focus_elem.id === INBOX_SEARCH_ID ||
|
|
post_tab_focus_elem.id === INBOX_FILTERS_DROPDOWN_ID
|
|
) {
|
|
current_focus_id = post_tab_focus_elem.id;
|
|
}
|
|
|
|
const row_to_focus = post_tab_focus_elem.closest(".inbox-row, .inbox-header");
|
|
if (row_to_focus instanceof HTMLElement) {
|
|
const col_index = $(post_tab_focus_elem)
|
|
.closest("[tabindex=0]")
|
|
.attr("data-col-index");
|
|
if (!col_index) {
|
|
return;
|
|
}
|
|
|
|
current_focus_id = row_to_focus.id;
|
|
row_focus = get_row_index($(row_to_focus));
|
|
col_focus = Number.parseInt(col_index, 10);
|
|
}
|
|
}, 0);
|
|
return false;
|
|
}
|
|
|
|
if (is_search_focused()) {
|
|
const textInput = $<HTMLInputElement>(`input#${CSS.escape(INBOX_SEARCH_ID)}`).get(0);
|
|
assert(textInput !== undefined);
|
|
const start = textInput.selectionStart ?? 0;
|
|
const end = textInput.selectionEnd ?? 0;
|
|
const text_length = textInput.value.length;
|
|
let is_selected = false;
|
|
if (end - start > 0) {
|
|
is_selected = true;
|
|
}
|
|
|
|
switch (input_key) {
|
|
case "down_arrow":
|
|
set_list_focus();
|
|
return true;
|
|
case "right_arrow":
|
|
if (end !== text_length || is_selected) {
|
|
return false;
|
|
}
|
|
focus_filters_dropdown();
|
|
return true;
|
|
case "left_arrow":
|
|
if (start !== 0 || is_selected) {
|
|
return false;
|
|
}
|
|
focus_filters_dropdown();
|
|
return true;
|
|
case "escape":
|
|
if (get_all_rows().length === 0) {
|
|
return false;
|
|
}
|
|
set_list_focus();
|
|
return true;
|
|
}
|
|
} else if (is_filters_dropdown_focused()) {
|
|
switch (input_key) {
|
|
case "vim_down":
|
|
case "down_arrow":
|
|
set_list_focus();
|
|
return true;
|
|
case "vim_left":
|
|
case "left_arrow":
|
|
focus_inbox_search();
|
|
return true;
|
|
case "vim_right":
|
|
case "right_arrow":
|
|
focus_inbox_search();
|
|
return true;
|
|
case "escape":
|
|
if (get_all_rows().length === 0) {
|
|
return false;
|
|
}
|
|
set_list_focus();
|
|
return true;
|
|
}
|
|
} else {
|
|
switch (input_key) {
|
|
case "vim_down":
|
|
case "down_arrow":
|
|
row_focus += 1;
|
|
set_list_focus();
|
|
center_focus_if_offscreen();
|
|
return true;
|
|
case "vim_up":
|
|
case "up_arrow":
|
|
if (row_focus === 0) {
|
|
focus_filters_dropdown();
|
|
return true;
|
|
}
|
|
row_focus -= 1;
|
|
set_list_focus();
|
|
center_focus_if_offscreen();
|
|
return true;
|
|
case RIGHT_NAVIGATION_KEYS[0]:
|
|
case RIGHT_NAVIGATION_KEYS[1]:
|
|
col_focus += 1;
|
|
set_list_focus(input_key);
|
|
return true;
|
|
case LEFT_NAVIGATION_KEYS[0]:
|
|
case LEFT_NAVIGATION_KEYS[1]:
|
|
col_focus -= 1;
|
|
set_list_focus(input_key);
|
|
return true;
|
|
case "page_up":
|
|
page_up_navigation();
|
|
return true;
|
|
case "page_down":
|
|
page_down_navigation();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function bulk_insert_channel_folders(channel_folders: Set<number>): void {
|
|
sort_channel_folders();
|
|
// Insert missing channel folders.
|
|
let index = 0;
|
|
let previous_folder_id;
|
|
for (const [folder_id, folder_context] of channel_folders_dict) {
|
|
if (channel_folders.has(folder_id)) {
|
|
const $folder_row_html = render_inbox_folder_with_channels({
|
|
...folder_context,
|
|
topics_dict,
|
|
streams_dict,
|
|
});
|
|
if (index === 0) {
|
|
const $dm_container = $("#inbox-direct-messages-container");
|
|
$dm_container.after($folder_row_html);
|
|
} else {
|
|
assert(previous_folder_id !== undefined);
|
|
const $previous_folder = $(
|
|
`#${CSS.escape(get_channel_folder_header_id(previous_folder_id))} + .inbox-folder-components`,
|
|
);
|
|
$previous_folder.after($folder_row_html);
|
|
}
|
|
}
|
|
previous_folder_id = folder_id;
|
|
index += 1;
|
|
}
|
|
}
|
|
|
|
export function update(): void {
|
|
// Since inbox shows a vast amount of sorted data,
|
|
// doing surgical updates for everything is hard.
|
|
// So, we focus on updating commonly changed data
|
|
// like unread counts, mentions, collapse state, etc.
|
|
// For rare changes like stream rename, channel folder
|
|
// rename and channel folder updates, we expect the event
|
|
// path to do a complete rerender of the inbox view.
|
|
if (!inbox_util.is_visible()) {
|
|
return;
|
|
}
|
|
|
|
if (inbox_util.is_channel_view()) {
|
|
channel_view_topic_widget?.build();
|
|
return;
|
|
}
|
|
|
|
const unread_dms = unread.get_unread_pm();
|
|
const unread_dms_count = unread_dms.total_count;
|
|
const unread_dms_dict = unread_dms.pm_dict;
|
|
const has_unread_mention = unread.num_unread_mentions_in_dms() > 0;
|
|
|
|
const unread_stream_message = unread.get_unread_topics();
|
|
const unread_streams_dict = unread_stream_message.topic_counts;
|
|
|
|
let has_dms_post_filter = false;
|
|
const dm_keys_to_insert: string[] = [];
|
|
for (const [key, {count, latest_msg_id}] of unread_dms_dict) {
|
|
if (count !== 0) {
|
|
const old_dm_data = dms_dict.get(key);
|
|
const new_dm_data = format_dm(key, count, latest_msg_id);
|
|
rerender_dm_inbox_row_if_needed(new_dm_data, old_dm_data, dm_keys_to_insert);
|
|
dms_dict.set(key, new_dm_data);
|
|
if (!new_dm_data.is_hidden) {
|
|
has_dms_post_filter = true;
|
|
}
|
|
} else {
|
|
// If it is rendered.
|
|
if (dms_dict.get(key) !== undefined) {
|
|
dms_dict.delete(key);
|
|
get_row_from_conversation_key(key).remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
dms_dict = get_sorted_row_dict(dms_dict);
|
|
insert_dms(dm_keys_to_insert);
|
|
|
|
const $inbox_dm_header = $("#inbox-dm-header");
|
|
if (!has_dms_post_filter) {
|
|
$inbox_dm_header.addClass("hidden_by_filters");
|
|
} else {
|
|
$inbox_dm_header.removeClass("hidden_by_filters");
|
|
$inbox_dm_header.find(".unread_count").text(unread_dms_count);
|
|
$inbox_dm_header.find(".unread_mention_info").toggleClass("hidden", !has_unread_mention);
|
|
}
|
|
|
|
const folders_info = new Map<number, {unread_count: number; has_unread_mention: boolean}>();
|
|
const channel_folders_to_insert = new Set<number>();
|
|
|
|
let has_topics_post_filter = false;
|
|
for (const [stream_id, topic_dict] of unread_streams_dict) {
|
|
const stream_unread = unread.unread_count_info_for_stream(stream_id);
|
|
const stream_unread_count = stream_unread.unmuted_count + stream_unread.muted_count;
|
|
const stream_key = get_stream_key(stream_id);
|
|
let stream_post_filter_unread_count = 0;
|
|
if (stream_unread_count > 0) {
|
|
const stream_topics_data = topics_dict.get(stream_key);
|
|
|
|
// Stream isn't rendered.
|
|
if (stream_topics_data === undefined) {
|
|
update_stream_data(stream_id, stream_key, topic_dict);
|
|
const channel_data = streams_dict.get(stream_key);
|
|
assert(channel_data !== undefined);
|
|
// If the folder is also not rendered, it will be once we render
|
|
// the folder, so we skip adding it.
|
|
if (channel_folders_dict.get(channel_data.folder_id)) {
|
|
insert_stream(stream_key);
|
|
}
|
|
if (!channel_data.is_hidden) {
|
|
has_topics_post_filter = true;
|
|
}
|
|
const folder_id = channel_data.folder_id;
|
|
const folder_unread_count = folders_info.get(folder_id)?.unread_count ?? 0;
|
|
const folder_has_unread_mention =
|
|
folders_info.get(folder_id)?.has_unread_mention ?? false;
|
|
folders_info.set(folder_id, {
|
|
unread_count: folder_unread_count + channel_data.unread_count!,
|
|
has_unread_mention: folder_has_unread_mention || channel_data.mention_in_unread,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const topic_keys_to_insert: string[] = [];
|
|
const new_stream_data = format_stream(stream_id);
|
|
const stream_archived = new_stream_data.is_archived;
|
|
for (const [topic, {topic_count, latest_msg_id}] of topic_dict) {
|
|
const topic_key = get_topic_key(stream_id, topic);
|
|
if (topic_count) {
|
|
const old_topic_data = stream_topics_data.get(topic_key);
|
|
const new_topic_data = format_topic(
|
|
stream_id,
|
|
stream_archived,
|
|
topic,
|
|
topic_count,
|
|
latest_msg_id,
|
|
);
|
|
stream_topics_data.set(topic_key, new_topic_data);
|
|
rerender_topic_inbox_row_if_needed(
|
|
new_topic_data,
|
|
old_topic_data,
|
|
topic_keys_to_insert,
|
|
);
|
|
if (!new_topic_data.is_hidden) {
|
|
has_topics_post_filter = true;
|
|
stream_post_filter_unread_count += new_topic_data.unread_count;
|
|
}
|
|
} else {
|
|
// Remove old topic data since it can act as false data for renamed / a new
|
|
// topic having the same name as old topic.
|
|
stream_topics_data.delete(topic_key);
|
|
get_row_from_conversation_key(topic_key).remove();
|
|
}
|
|
}
|
|
const old_stream_data = streams_dict.get(stream_key);
|
|
assert(old_stream_data !== undefined);
|
|
new_stream_data.is_hidden = stream_post_filter_unread_count === 0;
|
|
new_stream_data.unread_count = stream_post_filter_unread_count;
|
|
const folder_id = new_stream_data.folder_id;
|
|
const folder_unread_count = folders_info.get(folder_id)?.unread_count ?? 0;
|
|
const folder_has_unread_mention =
|
|
folders_info.get(folder_id)?.has_unread_mention ?? false;
|
|
folders_info.set(folder_id, {
|
|
unread_count: folder_unread_count + stream_post_filter_unread_count,
|
|
has_unread_mention: folder_has_unread_mention || new_stream_data.mention_in_unread,
|
|
});
|
|
streams_dict.set(stream_key, new_stream_data);
|
|
rerender_stream_inbox_header_if_needed(new_stream_data, old_stream_data);
|
|
topics_dict.set(stream_key, get_sorted_row_dict(stream_topics_data));
|
|
insert_topics(topic_keys_to_insert, stream_key);
|
|
} else {
|
|
topics_dict.delete(stream_key);
|
|
streams_dict.delete(stream_key);
|
|
get_stream_container(stream_key).remove();
|
|
}
|
|
}
|
|
|
|
for (const [folder_id, folder_info] of folders_info.entries()) {
|
|
const folder_dict = channel_folders_dict.get(folder_id);
|
|
const name = get_folder_name_from_id(folder_id);
|
|
const is_collapsed = collapsed_containers.has(get_channel_folder_header_id(folder_id));
|
|
const header_id = get_channel_folder_header_id(folder_id);
|
|
const is_header_visible = folder_info.unread_count > 0;
|
|
channel_folders_dict.set(folder_id, {
|
|
header_id,
|
|
is_header_visible,
|
|
id: folder_id,
|
|
unread_count: folder_info.unread_count,
|
|
has_unread_mention: folder_info.has_unread_mention,
|
|
name,
|
|
is_collapsed,
|
|
});
|
|
|
|
if (folder_dict === undefined) {
|
|
channel_folders_to_insert.add(folder_id);
|
|
} else {
|
|
rerender_channel_folder_header_if_needed(
|
|
folder_dict,
|
|
channel_folders_dict.get(folder_id)!,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Remove channel folders that are not in the updated folders_info.
|
|
const folder_ids_to_keep = new Set(folders_info.keys());
|
|
for (const [folder_id] of channel_folders_dict) {
|
|
if (!folder_ids_to_keep.has(folder_id)) {
|
|
channel_folders_dict.delete(folder_id);
|
|
const $rendered_folder_row = $(
|
|
`#${CSS.escape(get_channel_folder_header_id(folder_id))}`,
|
|
);
|
|
$rendered_folder_row.next(".inbox-folder-components").remove();
|
|
$rendered_folder_row.remove();
|
|
}
|
|
}
|
|
|
|
bulk_insert_channel_folders(channel_folders_to_insert);
|
|
|
|
// Set name of other channels folder to CHANNELS if it is the only folder.
|
|
if (is_other_channels_only_visible_folder()) {
|
|
const channel_folder = channel_folders_dict.get(OTHER_CHANNELS_FOLDER_ID)!;
|
|
channel_folder.name = $t({defaultMessage: "CHANNELS"});
|
|
const $channel_folder_header = $(`#${CSS.escape(OTHER_CHANNEL_HEADER_ID)}`);
|
|
$channel_folder_header.find(".inbox-header-name a").text(channel_folder.name);
|
|
}
|
|
|
|
const has_visible_unreads = has_dms_post_filter || has_topics_post_filter;
|
|
show_empty_inbox_text(has_visible_unreads);
|
|
|
|
// We want to avoid weird jumps when user is interacting with Inbox
|
|
// and we are updating the view. So, we only reset current focus if
|
|
// the update was triggered by user. This can mean `row_focus` can
|
|
// be out of bounds, so we need to fix that.
|
|
if (update_triggered_by_user) {
|
|
setTimeout(revive_current_focus, 0);
|
|
update_triggered_by_user = false;
|
|
} else {
|
|
if (row_focus >= get_all_rows().length) {
|
|
revive_current_focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
function get_focus_class_for_header(): string {
|
|
let focus_class = ".collapsible-button";
|
|
|
|
switch (col_focus) {
|
|
case COLUMNS.UNREAD_COUNT: {
|
|
focus_class = ".unread_count";
|
|
break;
|
|
}
|
|
case COLUMNS.ACTION_MENU: {
|
|
focus_class = ".inbox-stream-menu";
|
|
}
|
|
}
|
|
|
|
return focus_class;
|
|
}
|
|
|
|
function get_focus_class_for_row(): string {
|
|
let focus_class = ".inbox-left-part";
|
|
switch (col_focus) {
|
|
case COLUMNS.UNREAD_COUNT: {
|
|
focus_class = ".unread_count";
|
|
break;
|
|
}
|
|
case COLUMNS.ACTION_MENU: {
|
|
focus_class = ".inbox-topic-menu";
|
|
break;
|
|
}
|
|
case COLUMNS.TOPIC_VISIBILITY: {
|
|
focus_class = ".change_visibility_policy";
|
|
break;
|
|
}
|
|
}
|
|
return focus_class;
|
|
}
|
|
|
|
function is_element_visible(element_position: DOMRect): boolean {
|
|
const element_above = document.querySelector("#inbox-filters");
|
|
const element_down = document.querySelector("#compose");
|
|
assert(element_above !== null && element_down !== null);
|
|
const visible_top = element_above.getBoundingClientRect().bottom;
|
|
const visible_bottom = element_down.getBoundingClientRect().top;
|
|
|
|
if (element_position.top >= visible_top && element_position.bottom <= visible_bottom) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function center_focus_if_offscreen(): void {
|
|
// Move focused to row to visible area so to avoid
|
|
// it being under compose box or inbox filters.
|
|
const $elt = $(".inbox-row:focus, .inbox-header:focus");
|
|
if ($elt[0] === undefined) {
|
|
return;
|
|
}
|
|
|
|
const elt_pos = $elt[0].getBoundingClientRect();
|
|
if (is_element_visible(elt_pos)) {
|
|
// Element is visible.
|
|
return;
|
|
}
|
|
|
|
// Scroll element into center if offscreen.
|
|
$elt[0].scrollIntoView({block: "center"});
|
|
}
|
|
|
|
function move_focus_to_visible_area(): void {
|
|
if (is_waiting_for_revive_current_focus) {
|
|
return;
|
|
}
|
|
|
|
// Focus on the row below inbox filters if the focused
|
|
// row is not visible.
|
|
if (!inbox_util.is_visible() || !is_list_focused()) {
|
|
return;
|
|
}
|
|
|
|
const $all_rows = get_all_rows();
|
|
if ($all_rows.length <= 3) {
|
|
// No need to process anything if there are only a few rows.
|
|
return;
|
|
}
|
|
|
|
let row = $all_rows[row_focus];
|
|
if (row === undefined) {
|
|
row_focus = $all_rows.length - 1;
|
|
row = $all_rows[row_focus];
|
|
assert(row !== undefined);
|
|
revive_current_focus();
|
|
}
|
|
|
|
const elt_pos = row.getBoundingClientRect();
|
|
if (is_element_visible(elt_pos)) {
|
|
return;
|
|
}
|
|
|
|
const INBOX_ROW_HEIGHT = 30;
|
|
const position = util.the($("#inbox-filters")).getBoundingClientRect();
|
|
const inbox_center_x = (position.left + position.right) / 2;
|
|
// We are aiming to get the first row if it is completely visible or the second row.
|
|
const inbox_row_below_filters = position.bottom + INBOX_ROW_HEIGHT;
|
|
const element_in_row = document.elementFromPoint(inbox_center_x, inbox_row_below_filters);
|
|
if (!element_in_row) {
|
|
// `element_in_row` can be `null` according to MDN if:
|
|
// "If the specified point is outside the visible bounds of the document or
|
|
// either coordinate is negative, the result is null."
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Document/elementFromPoint
|
|
// This means by the time we reached here user has already scrolled past the
|
|
// row and it is no longer visible. So, we just return and let the next call
|
|
// to `move_focus_to_visible_area` handle it.
|
|
return;
|
|
}
|
|
|
|
const $element_in_row = $(element_in_row);
|
|
|
|
let $inbox_row = $element_in_row.closest(".inbox-row");
|
|
if ($inbox_row.length === 0) {
|
|
$inbox_row = $element_in_row.closest(".inbox-header");
|
|
}
|
|
|
|
row_focus = $all_rows.index($inbox_row.get(0));
|
|
revive_current_focus();
|
|
}
|
|
|
|
export function is_in_focus(): boolean {
|
|
return inbox_util.is_visible() && views_util.is_in_focus();
|
|
}
|
|
|
|
export function initialize({hide_other_views}: {hide_other_views: () => void}): void {
|
|
hide_other_views_callback = hide_other_views;
|
|
$(document).on(
|
|
"scroll",
|
|
_.throttle(() => {
|
|
if (!inbox_util.is_visible()) {
|
|
// This check is duplicated with move_focus_to_visible_area. It
|
|
// is worth doing to avoid the performance hit of wrapping
|
|
// requestAnimationFramearound a likely noop.
|
|
return;
|
|
}
|
|
requestAnimationFrame(move_focus_to_visible_area);
|
|
}, 100),
|
|
);
|
|
|
|
$("body").on(
|
|
"input",
|
|
"#inbox-search",
|
|
_.debounce(() => {
|
|
search_and_update();
|
|
}, 300),
|
|
);
|
|
|
|
$("body").on("keydown", ".inbox-header", (e) => {
|
|
if (e.metaKey || e.ctrlKey) {
|
|
return;
|
|
}
|
|
|
|
if (keydown_util.is_enter_event(e)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const $elt = $(e.currentTarget);
|
|
$elt.find(get_focus_class_for_header()).trigger("click");
|
|
}
|
|
});
|
|
|
|
$("body").on(
|
|
"click",
|
|
"#inbox-list .inbox-header .collapsible-button",
|
|
function (this: HTMLElement, e) {
|
|
const $elt = $(this);
|
|
const container_id = $elt.parents(".inbox-header").attr("id");
|
|
assert(container_id !== undefined);
|
|
col_focus = COLUMNS.FULL_ROW;
|
|
focus_clicked_list_element($elt);
|
|
collapse_or_expand(container_id);
|
|
e.stopPropagation();
|
|
},
|
|
);
|
|
|
|
$("body").on("keydown", ".inbox-row", (e) => {
|
|
if (e.metaKey || e.ctrlKey) {
|
|
return;
|
|
}
|
|
|
|
if (keydown_util.is_enter_event(e)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const $elt = $(e.currentTarget);
|
|
$elt.find(get_focus_class_for_row()).trigger("click");
|
|
}
|
|
});
|
|
|
|
$("body").on("click", "#inbox-list .inbox-left-part-wrapper", function (this: HTMLElement, e) {
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
let $elt = $(this);
|
|
const href = $elt.find("a").attr("href");
|
|
col_focus = COLUMNS.FULL_ROW;
|
|
if (href !== undefined) {
|
|
window.location.href = href;
|
|
} else {
|
|
$elt = $elt.closest(".inbox-header");
|
|
collapse_or_expand($elt.attr("id")!);
|
|
}
|
|
focus_clicked_list_element($elt);
|
|
});
|
|
|
|
$("body").on("click", "#inbox-list .on_hover_dm_read", function (this: HTMLElement, e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const $elt = $(this);
|
|
col_focus = COLUMNS.UNREAD_COUNT;
|
|
focus_clicked_list_element($elt);
|
|
const user_ids_string = $elt.attr("data-user-ids-string");
|
|
if (user_ids_string) {
|
|
// direct message row
|
|
unread_ops.mark_pm_as_read(user_ids_string);
|
|
}
|
|
});
|
|
|
|
$("body").on("click", "#inbox-list .on_hover_topic_read", function (this: HTMLElement, e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const $elt = $(this);
|
|
col_focus = COLUMNS.UNREAD_COUNT;
|
|
focus_clicked_list_element($elt);
|
|
const user_ids_string = $elt.attr("data-user-ids-string");
|
|
if (user_ids_string) {
|
|
// direct message row
|
|
unread_ops.mark_pm_as_read(user_ids_string);
|
|
return;
|
|
}
|
|
const stream_id = Number($elt.attr("data-stream-id"));
|
|
const topic = $elt.attr("data-topic-name");
|
|
if (topic !== undefined) {
|
|
unread_ops.mark_topic_as_read(stream_id, topic);
|
|
} else {
|
|
unread_ops.mark_stream_as_read(stream_id);
|
|
}
|
|
});
|
|
|
|
$("body").on("click", "#inbox-list .change_visibility_policy", function (this: HTMLElement) {
|
|
const $elt = $(this);
|
|
col_focus = COLUMNS.TOPIC_VISIBILITY;
|
|
focus_clicked_list_element($elt);
|
|
});
|
|
|
|
$("body").on("click", "#inbox-search", () => {
|
|
current_focus_id = INBOX_SEARCH_ID;
|
|
compose_closed_ui.set_standard_text_for_reply_button();
|
|
});
|
|
|
|
$(document).on("compose_canceled.zulip", () => {
|
|
if (inbox_util.is_visible()) {
|
|
revive_current_focus();
|
|
}
|
|
});
|
|
}
|