diff --git a/templates/zerver/app/index.html b/templates/zerver/app/index.html index d50a8e967f..a04bdf0640 100644 --- a/templates/zerver/app/index.html +++ b/templates/zerver/app/index.html @@ -231,15 +231,26 @@
-
- -
+
+ + +
+
+ +
+
+ +
+ +
diff --git a/web/src/list_widget.ts b/web/src/list_widget.ts index cd39b26fc5..45005dacc6 100644 --- a/web/src/list_widget.ts +++ b/web/src/list_widget.ts @@ -18,6 +18,7 @@ type ListWidgetMeta = { filtered_list: Item[]; reverse_mode: boolean; $scroll_container: JQuery; + $scroll_listening_element: JQuery | JQuery; }; // This type ensures the mutually exclusive nature of the predicate and filterer options. @@ -261,6 +262,17 @@ export function create( old_widget.clear_event_handlers(); } + let $scroll_listening_element: JQuery | JQuery = opts.$simplebar_container; + if ($scroll_listening_element.is("html")) { + // When `$scroll_container` is the entire page (`html`), + // scroll events are fired on `window/document`, so we need to + // listen for scrolling events on that. + // + // We still keep `html` as `$scroll_container` to use + // its various methods as `HTMLElement`. + $scroll_listening_element = $(window); + } + const meta: ListWidgetMeta = { sorting_function: null, sorting_functions: new Map(), @@ -270,6 +282,7 @@ export function create( reverse_mode: false, filter_value: "", $scroll_container: scroll_util.get_scroll_element(opts.$simplebar_container), + $scroll_listening_element, }; const widget: ListWidget = { @@ -417,20 +430,23 @@ export function create( set_up_event_handlers() { // on scroll of the nearest scrolling container, if it hits the bottom // of the container then fetch a new block of items and render them. - meta.$scroll_container.on("scroll.list_widget_container", function () { - if (opts.post_scroll__pre_render_callback) { - opts.post_scroll__pre_render_callback(); - } + meta.$scroll_listening_element.on( + "scroll.list_widget_container", + function (this: HTMLElement) { + if (opts.post_scroll__pre_render_callback) { + opts.post_scroll__pre_render_callback(); + } - if (opts.is_scroll_position_for_render === undefined) { - opts.is_scroll_position_for_render = is_scroll_position_for_render; - } + if (opts.is_scroll_position_for_render === undefined) { + opts.is_scroll_position_for_render = is_scroll_position_for_render; + } - const should_render = opts.is_scroll_position_for_render(this); - if (should_render) { - widget.render(); - } - }); + const should_render = opts.is_scroll_position_for_render(this); + if (should_render) { + widget.render(); + } + }, + ); if (opts.$parent_container) { opts.$parent_container.on( @@ -450,7 +466,12 @@ export function create( }, clear_event_handlers() { - meta.$scroll_container.off("scroll.list_widget_container"); + // Since `$scroll_listening_element` is of type `JQuery | JQuery` instead + // of just `JQuery`, Typescript is expecting `off` to be called on + // TypeEventHandlers which is confusing. + // + // @ts-expect-error Maybe JQuery.TypeEventHandlers is not defined? + meta.$scroll_listening_element.off("scroll.list_widget_container"); if (opts.$parent_container) { opts.$parent_container.off("click.list_widget_sort", "[data-sort]"); diff --git a/web/src/recent_view_ui.ts b/web/src/recent_view_ui.ts index 2d4763d715..b94098acae 100644 --- a/web/src/recent_view_ui.ts +++ b/web/src/recent_view_ui.ts @@ -40,7 +40,6 @@ import * as recent_senders from "./recent_senders"; import * as recent_view_data from "./recent_view_data"; import type {ConversationData} from "./recent_view_data"; import * as recent_view_util from "./recent_view_util"; -import * as scroll_util from "./scroll_util"; import * as sidebar_ui from "./sidebar_ui"; import * as stream_data from "./stream_data"; import * as sub_store from "./sub_store"; @@ -114,6 +113,9 @@ let is_initial_message_fetch_pending = true; // 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 recent view before +// it is hidden to avoid scroll jumping when it is shown again. +let last_scroll_offset: number | undefined; export function set_initial_message_fetch_status(value: boolean): void { is_initial_message_fetch_pending = value; @@ -206,7 +208,7 @@ function set_oldest_message_date(msg_list_data: MessageListData): void { // We might be loading messages in another narrow before the recent view // is shown, so we keep the state updated and update the banner only // once it's actually rendered. - if ($("#recent_view_table table tbody").length) { + if ($("#recent-view-content-tbody tr").length) { update_load_more_banner(); } } @@ -278,7 +280,7 @@ function get_row_type(row: number): string { // Return "private" or "stream" // We use CSS method for finding row type until topics_widget gets initialized. if (!topics_widget) { - const $topic_rows = $("#recent_view_table table tbody tr"); + const $topic_rows = $("#recent-view-content-tbody tr"); const $topic_row = $topic_rows.eq(row); const is_private = $topic_row.attr("data-private"); if (is_private) { @@ -310,7 +312,7 @@ function set_table_focus(row: number, col: number, using_keyboard = false): bool return true; } - const $topic_rows = $("#recent_view_table table tbody tr"); + const $topic_rows = $("#recent-view-content-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. @@ -338,9 +340,7 @@ function set_table_focus(row: number, col: number, using_keyboard = false): bool $current_focus_elem = "table"; if (using_keyboard) { - const scroll_element = $( - "#recent_view_table .table_fix_head .simplebar-content-wrapper", - )[0]!; + const scroll_element = $("html")[0]!; const half_height_of_visible_area = scroll_element.offsetHeight / 2; const topic_offset = topic_offset_to_visible_area($topic_row); @@ -379,7 +379,7 @@ export function get_focused_row_message(): Message | undefined { return undefined; } - const $topic_rows = $("#recent_view_table table tbody tr"); + const $topic_rows = $("#recent-view-content-tbody tr"); const $topic_row = $topic_rows.eq(row_focus); const topic_id = $topic_row.attr("id"); assert(topic_id !== undefined); @@ -1127,22 +1127,18 @@ function topic_offset_to_visible_area($topic_row: JQuery): string | undefined { // topic and the callers will take care of undefined being returned. return undefined; } - const $scroll_container = $("#recent_view_table .table_fix_head"); - const thead_height = $scroll_container.find("thead").outerHeight(true)!; - const scroll_container_props = $scroll_container[0]!.getBoundingClientRect(); - // Since user cannot see row under thead, exclude it as part of the scroll container. - const scroll_container_top = scroll_container_props.top + thead_height; - const compose_height = $("#compose").outerHeight(true)!; - const scroll_container_bottom = scroll_container_props.bottom - compose_height; + // Rows are only visible below thead bottom and above compose top. + const thead_bottom = $("#recent-view-table-headers")[0]!.getBoundingClientRect().bottom; + const compose_top = window.innerHeight - $("#compose").outerHeight(true)!; const topic_props = $topic_row[0]!.getBoundingClientRect(); // Topic is above the visible scroll region. - if (topic_props.top < scroll_container_top) { + if (topic_props.top < thead_bottom) { return "above"; // Topic is below the visible scroll region. - } else if (topic_props.bottom > scroll_container_bottom) { + } else if (topic_props.bottom > compose_top) { return "below"; } @@ -1155,9 +1151,7 @@ function recenter_focus_if_off_screen(): void { return; } - const table_wrapper_element = $("#recent_view_table .table_fix_head")[0]!; - const $topic_rows = $("#recent_view_table table tbody tr"); - + const $topic_rows = $("#recent-view-content-tbody tr"); if (row_focus >= $topic_rows.length) { // User used a filter which reduced // the number of visible rows. @@ -1172,9 +1166,10 @@ function recenter_focus_if_off_screen(): void { 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; + const thead_props = $("#recent-view-table-headers")[0]!.getBoundingClientRect(); + const compose_top = window.innerHeight - $("#compose").outerHeight(true)!; + const topic_center_x = (thead_props.left + thead_props.right) / 2; + const topic_center_y = (thead_props.bottom + compose_top) / 2; const topic_element = document.elementFromPoint(topic_center_x, topic_center_y); if ( @@ -1184,7 +1179,7 @@ function recenter_focus_if_off_screen(): void { // There are two theoretical reasons that the center // element might be null. One is that we haven't rendered // the view yet; but in that case, we should have returned - // early checking is_waiting_for_revive_current_focus: + // early checking is_waiting_for_revive_current_focus. // // The other possibility is that the table is too short // for there to be an topic row element at the center of @@ -1199,19 +1194,29 @@ function recenter_focus_if_off_screen(): void { } } -function is_scroll_position_for_render(scroll_container: HTMLElement): boolean { - 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 - ); +function is_scroll_position_for_render(): boolean { + const scroll_position = window.scrollY; + const window_height = window.innerHeight; + // We allocate `--max-unexpanded-compose-height` in empty space + // below the last rendered row in recent view. + // + // We don't want user to see this empty space until there are no + // new rows to render when the user is scrolling to the bottom of + // the view. So, we render new rows when user has scrolled 2 / 3 + // of (the total scrollable height - the empty space). + const compose_max_height = $("html").css("--max-unexpanded-compose-height"); + assert(typeof compose_max_height === "string"); + const scroll_max = document.body.scrollHeight - Number.parseInt(compose_max_height, 10); + return scroll_position + window_height >= (2 / 3) * scroll_max; } function callback_after_render(): void { + // It is important to restore the scroll position as soon + // as the rendering is complete to avoid scroll jumping. + if (last_scroll_offset !== undefined) { + window.scrollTo(0, last_scroll_offset); + } + update_load_more_banner(); setTimeout(() => { revive_current_focus(); @@ -1261,6 +1266,11 @@ export function complete_rerender(): void { return; } + // This is the first time we are rendering the Recent Conversations view. + // So, we always scroll to the top to avoid any scroll jumping in case + // user is returning from another view. + window.scrollTo(0, 0); + const rendered_body = render_recent_view_body({ search_val: $("#recent_view_search").val() ?? "", ...get_recent_view_filters_params(), @@ -1273,7 +1283,7 @@ export function complete_rerender(): void { // was not the first view loaded in the app. show_selected_filters(); - const $container = $("#recent_view_table table tbody"); + const $container = $("#recent-view-content-tbody"); $container.empty(); topics_widget = list_widget.create($container, mapped_topic_values, { name: "recent_view_table", @@ -1296,7 +1306,7 @@ export function complete_rerender(): void { ...list_widget.generic_sort_functions("numeric", ["last_msg_id"]), }, html_selector: get_topic_row, - $simplebar_container: $("#recent_view_table .table_fix_head"), + $simplebar_container: $("html"), callback_after_render, is_scroll_position_for_render, post_scroll__pre_render_callback() { @@ -1309,6 +1319,10 @@ export function complete_rerender(): void { } export function show(): void { + // We remove event handler before hiding, so they need to + // be attached again, checking for topics_widget to be defined + // is a reliable solution to check if recent view was displayed earlier. + const reattach_event_handlers = topics_widget !== undefined; views_util.show({ highlight_view_in_left_sidebar: left_sidebar_navigation_area.highlight_recent_view, $view: $("#recent_view"), @@ -1321,6 +1335,12 @@ export function show(): void { set_visible: recent_view_util.set_visible, complete_rerender, }); + last_scroll_offset = undefined; + + if (reattach_event_handlers) { + assert(topics_widget !== undefined); + topics_widget.set_up_event_handlers(); + } if (onboarding_steps.ONE_TIME_NOTICES_TO_DISPLAY.has("intro_recent_view_modal")) { const html_body = render_introduce_zulip_view_modal({ @@ -1348,7 +1368,11 @@ function filter_buttons(): JQuery { } export function hide(): void { + // Since we have events attached to element (window) which are present in + // views others than recent view, it is important to clear events here. + topics_widget?.clear_event_handlers(); is_waiting_for_revive_current_focus = true; + last_scroll_offset = window.scrollY; views_util.hide({ $view: $("#recent_view"), set_visible: recent_view_util.set_visible, @@ -1433,9 +1457,8 @@ function down_arrow_navigation(): void { } function get_page_up_down_delta(): number { - const table_height = $("#recent_view_table .table_fix_head").height()!; - const table_header_height = $("#recent_view_table table thead").height()!; - const compose_box_height = $("#compose").height()!; + const thead_bottom = $("#recent-view-table-headers")[0]!.getBoundingClientRect().bottom; + const compose_box_top = window.innerHeight - $("#compose").outerHeight(true)!; // 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. @@ -1443,38 +1466,45 @@ function get_page_up_down_delta(): number { // See navigate.amount_to_paginate for similar logic in the message feed. const scrolling_reduction_to_maintain_context = 75; - const delta = - table_height - - table_header_height - - compose_box_height - - scrolling_reduction_to_maintain_context; + const delta = compose_box_top - thead_bottom - scrolling_reduction_to_maintain_context; return delta; } function page_up_navigation(): void { - const $scroll_container = scroll_util.get_scroll_element( - $("#recent_view_table .table_fix_head"), - ); const delta = get_page_up_down_delta(); - const new_scrollTop = $scroll_container.scrollTop()! - delta; + const new_scrollTop = window.scrollY - delta; if (new_scrollTop <= 0) { row_focus = 0; + // If we are already at the scroll top, a scroll event + // is not triggered since the window doesn't actually scroll so + // we need to update `row_focus` manually. + if (window.scrollY === 0) { + set_table_focus(row_focus, col_focus); + return; + } } - $scroll_container.scrollTop(new_scrollTop); + + window.scroll(0, new_scrollTop); } function page_down_navigation(): void { - const $scroll_container = scroll_util.get_scroll_element( - $("#recent_view_table .table_fix_head"), - ); const delta = get_page_up_down_delta(); - const new_scrollTop = $scroll_container.scrollTop()! + delta; - const table_height = $("#recent_view_table .table_fix_head").height()!; - if (new_scrollTop >= table_height) { + const new_scrollTop = window.scrollY + delta; + const max_scroll_top = document.body.scrollHeight - window.innerHeight; + + if (new_scrollTop >= max_scroll_top) { assert(topics_widget !== undefined); row_focus = topics_widget.get_current_list().length - 1; + // If we are already at the scroll bottom, a scroll event + // is not triggered since the window doesn't actually scroll so + // we need to update `row_focus` manually. + if (window.scrollY === max_scroll_top) { + set_table_focus(row_focus, col_focus); + return; + } } - $scroll_container.scrollTop(new_scrollTop); + + window.scroll(0, new_scrollTop); } function check_row_type_transition(row: number, col: number): boolean { @@ -1706,14 +1736,18 @@ export function initialize({ }): void { load_filters(); - $("body").on("click", "#recent_view_table .recent_view_participant_avatar", function (e) { - const user_id_string = $(this).parent().attr("data-user-id"); - assert(user_id_string !== undefined); - const participant_user_id = Number.parseInt(user_id_string, 10); - e.stopPropagation(); - assert(this instanceof Element); - on_click_participant(this, participant_user_id); - }); + $("body").on( + "click", + "#recent-view-content-table .recent_view_participant_avatar", + function (e) { + const user_id_string = $(this).parent().attr("data-user-id"); + assert(user_id_string !== undefined); + const participant_user_id = Number.parseInt(user_id_string, 10); + e.stopPropagation(); + assert(this instanceof Element); + on_click_participant(this, participant_user_id); + }, + ); $("body").on( "keydown", @@ -1722,7 +1756,7 @@ export function initialize({ ); // Mute topic in a unmuted stream - $("body").on("click", "#recent_view_table .stream_unmuted.on_hover_topic_mute", (e) => { + $("body").on("click", "#recent-view-content-table .stream_unmuted.on_hover_topic_mute", (e) => { e.stopPropagation(); assert(e.target instanceof HTMLElement); const $elt = $(e.target); @@ -1735,20 +1769,24 @@ export function initialize({ }); // Unmute topic in a unmuted stream - $("body").on("click", "#recent_view_table .stream_unmuted.on_hover_topic_unmute", (e) => { - e.stopPropagation(); - assert(e.target instanceof HTMLElement); - const $elt = $(e.target); - const topic_row_index = $elt.closest("tr").index(); - focus_clicked_element(topic_row_index, COLUMNS.mute); - user_topics.set_visibility_policy_for_element( - $elt, - user_topics.all_visibility_policies.INHERIT, - ); - }); + $("body").on( + "click", + "#recent-view-content-table .stream_unmuted.on_hover_topic_unmute", + (e) => { + e.stopPropagation(); + assert(e.target instanceof HTMLElement); + const $elt = $(e.target); + const topic_row_index = $elt.closest("tr").index(); + focus_clicked_element(topic_row_index, COLUMNS.mute); + user_topics.set_visibility_policy_for_element( + $elt, + user_topics.all_visibility_policies.INHERIT, + ); + }, + ); // Unmute topic in a muted stream - $("body").on("click", "#recent_view_table .stream_muted.on_hover_topic_unmute", (e) => { + $("body").on("click", "#recent-view-content-table .stream_muted.on_hover_topic_unmute", (e) => { e.stopPropagation(); assert(e.target instanceof HTMLElement); const $elt = $(e.target); @@ -1761,7 +1799,7 @@ export function initialize({ }); // Mute topic in a muted stream - $("body").on("click", "#recent_view_table .stream_muted.on_hover_topic_mute", (e) => { + $("body").on("click", "#recent-view-content-table .stream_muted.on_hover_topic_mute", (e) => { e.stopPropagation(); assert(e.target instanceof HTMLElement); const $elt = $(e.target); @@ -1779,7 +1817,7 @@ export function initialize({ change_focused_element($(e.target), "click"); }); - $("body").on("click", "#recent_view_table .on_hover_topic_read", (e) => { + $("body").on("click", "#recent-view-content-table .on_hover_topic_read", (e) => { e.stopPropagation(); assert(e.currentTarget instanceof HTMLElement); const $elt = $(e.currentTarget); diff --git a/web/styles/app_variables.css b/web/styles/app_variables.css index 6f889420d8..f36ffc33ba 100644 --- a/web/styles/app_variables.css +++ b/web/styles/app_variables.css @@ -263,6 +263,16 @@ --color-buddy-list-highlighted-user: hsl(120deg 12.3% 71.4% / 38%); --color-border-sidebar: hsl(0deg 0% 87%); + /* Recent view */ + --color-border-recent-view-row: hsl(0deg 0% 87%); + --color-border-recent-view-table: hsl(0deg 0% 0% / 60%); + --color-background-recent-view-row: hsl(100deg 11% 96%); + --color-background-recent-view-row-hover: hsl(210deg 100% 97%); + --color-background-recent-view-unread-row: hsl(0deg 0% 100%); + --color-background-recent-view-unread-row-hover: hsl(210deg 100% 97%); + --color-recent-view-link: hsl(205deg 47% 42%); + --color-recent-view-link-hover: hsl(214deg 40% 58%); + /* Compose box colors */ --color-compose-send-button-icon-color: hsl(0deg 0% 100%); --color-compose-send-button-background: hsl(240deg 96% 68%); @@ -591,6 +601,16 @@ --color-buddy-list-highlighted-user: hsl(136deg 25% 73% / 20%); --color-border-sidebar: hsl(0deg 0% 0% / 20%); + /* Recent view */ + --color-border-recent-view-row: hsl(0deg 0% 0% / 20%); + --color-border-recent-view-table: hsl(0deg 0% 88%); + --color-background-recent-view-row: hsl(0deg 0% 11%); + --color-background-recent-view-row-hover: hsl(208deg 26% 11% / 60%); + --color-background-recent-view-unread-row: hsl(212deg 30% 22% / 40%); + --color-background-recent-view-unread-row-hover: hsl(212deg 30% 22% / 60%); + --color-recent-view-link: hsl(206deg 89% 74%); + --color-recent-view-link-hover: hsl(208deg 64% 52%); + /* Compose box colors */ --color-compose-send-button-focus-shadow: hsl(0deg 0% 100% / 80%); --color-compose-send-control-button: hsl(240deg 30% 70%); diff --git a/web/styles/dark_theme.css b/web/styles/dark_theme.css index fb348d27ef..d16a6114da 100644 --- a/web/styles/dark_theme.css +++ b/web/styles/dark_theme.css @@ -689,22 +689,6 @@ } } - #recent_view_table tr { - background-color: var(--color-background); - - &:hover { - background-color: hsl(208deg 26% 11% / 60%); - } - } - - #recent_view_table .unread_topic { - background-color: hsl(212deg 30% 22% / 40%); - - &:hover { - background-color: hsl(212deg 30% 22% / 60%); - } - } - .btn-recent-selected, #recent_view_table thead th { background-color: hsl(228deg 11% 17%) !important; @@ -714,18 +698,7 @@ } } - #recent_view_table td a { - color: hsl(206deg 89% 74%); - text-decoration: none; - - &:hover { - color: hsl(208deg 64% 52%); - } - } - #recent_view_table { - border-color: hsl(0deg 0% 0% / 60%); - .fa-envelope, .fa-group { opacity: 0.7; @@ -788,8 +761,7 @@ #settings_page .sidebar-wrapper *, table, table th, - table td, - #recent_view_table table td { + table td { border-color: hsl(0deg 0% 0% / 20%); } diff --git a/web/styles/recent_view.css b/web/styles/recent_view.css index 87cd84ad0c..f78a188373 100644 --- a/web/styles/recent_view.css +++ b/web/styles/recent_view.css @@ -4,548 +4,519 @@ overflow: hidden; display: flex; flex-direction: column; - max-height: 100vh; - - #recent_view_table { - max-width: 100%; - overflow: hidden !important; - display: flex; - flex-direction: column; - border-style: solid; - border-color: hsl(0deg 0% 88%); - border-width: 0 1px; - border-radius: 0; - /* To make the table span full height - * when rows don't reach bottom of the - * window. This makes the border span - * fully to bottom in that case. - */ - min-height: 100vh; - - & td { - vertical-align: middle; - padding: 3px 8px; - } - - .recent_view_focusable { - /* Use flexbox to align icons vertically */ - display: flex; - align-items: center; - - .filter-icon { - /* Maintain righthand space between icon - and stream name. */ - margin-right: 3px; - } - - & > * { - outline: 0; - } - - &:focus-within { - /* Use the same color as the message feed pointer */ - box-shadow: 0 3px 0 var(--color-outline-focus); - } - - &.change_visibility_policy.visibility-policy-popover-visible { - .zulip-icon-inherit { - opacity: 0.4; - } - } - - &.change_visibility_policy .zulip-icon-inherit { - opacity: 0; - - &:focus { - opacity: 0.2; - } - } - } - - & a { - color: hsl(205deg 47% 42%); - text-decoration: none; - - &:hover { - color: hsl(214deg 40% 58%); - } - } - - .empty-table-message { - background-color: var(--color-background); - padding: 3em 1em; - } - - .fa-check-square-o, - .fa-square-o { - padding: 0 2px; - width: 10px; - } - - .fa-envelope { - font-size: 0.7rem; - margin-right: 2px; - position: relative; - top: -1px; - opacity: 0.6; - } - - .table_fix_head { - padding: 0 !important; - max-height: calc( - 100vh - var(--recent-topics-filters-height) - - var(--navbar-fixed-height) - ); - } - - .recent-view-container { - /* - Add margin bottom equal to `#bottom-whitespace`. This helps us keep - #compose visible at its max-height without overlapping with any visible - topics. - - Alternative is to adjust the max-height of `table_fix_head` based on compose height which is an - expensive repaint. The downside of not doing so is that the scrollbar is not visible to user when - user is at the bottom of scroll container when the compose box is open. - */ - margin-bottom: var(--max-unexpanded-compose-height); - } - - .recent-view-load-more-container { - margin: 20px 10px; - align-items: center; - } - - .fetch-messages-button { - display: grid; - justify-items: center; - - .loading_indicator_spinner { - height: 20px; - width: 20px; - } - - path { - fill: var(--color-recent-view-loading-spinner); - } - } - - .table_fix_head table { - /* To keep border properties to the thead th. */ - border-collapse: separate; - - border-spacing: 0; - width: 100%; - - th { - padding: 8px; - text-align: left; - } - - .unread_sort { - padding-left: 6px; - - .zulip-icon-unread { - position: absolute; - right: 30px; - top: 11px; - } - - &::after { - right: 15px; - top: 7px; - } - } - - td { - border-top: 1px solid hsl(0deg 0% 87%); - } - } - - #recent_view_filter_buttons { - padding-top: 12px; - margin: 0 10px; - display: flex; - /* Search box has no height without this in safari. */ - flex: 0 0 auto; - flex-wrap: wrap; - justify-content: flex-start; - } - - .search_group { - display: flex; - flex-grow: 1; - margin: 0 -27px 10px 0; - } - - #recent_view_search { - flex-grow: 1; - padding-right: 20px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .clear_search_button { - /* Overrides app_components.css property. */ - padding-top: 4px !important; - } - - .btn-recent-filters { - border-radius: 40px; - margin: 0 5px 10px 0; - - &:focus { - background-color: hsl(0deg 0% 80%); - outline: 0; - } - - &.fake_disabled_button { - cursor: not-allowed; - opacity: 0.5; - - &:hover { - background-color: hsl(0deg 0% 100%); - border-color: hsl(0deg 0% 80%); - } - } - } - - .btn-recent-selected { - background-color: hsl(0deg 11% 93%); - } - - .unread_count { - /* Focus underline can only occupy the total length of the unread count */ - margin-right: 1px; - margin-left: 1px; - align-self: center; - background-color: hsl(105deg 2% 50%); - } - - .unread_mention_info:not(:empty) { - /* Zero out right margin from left sidebar presentation. */ - margin-right: 0; - /* Match with its font-size. */ - line-height: 14px; - /* Present a default/arrow cursor */ - cursor: default; - } - - .unread_hidden { - visibility: hidden; - } - - .flex_container_pm { - /* Flex container to fit in user circle and group icon */ - display: flex; - justify-content: space-between; - - .tippy-content { - font-weight: 400; - } - } - - .flex_container { - display: flex; - align-items: center; - } - - .flex_container .right_part { - margin-left: auto; - display: inline-flex; - align-items: center; - } - - .recent_topic_actions { - /* Add spacing between mention marker, unread count - and mute icon */ - margin-left: 5px; - display: flex; - flex-flow: row nowrap; - } - - .mention_in_unread { - opacity: 0.7; - } - - .recent_topic_actions.dummy_action_button { - visibility: hidden; - } - - .recent_topic_actions .recent_view_focusable { - /* Keep a uniform distance from the focus-within - indicator at bottom. */ - padding-bottom: 3px; - /* But push down with margin by the same amount, - so as to preserve vertical alignment introduced - by the parent flexbox. */ - margin-top: 3px; - } - - .recent_topic_actions .recipient_bar_icon { - /* Zero out padding used in recipient bar. */ - padding-right: 0; - padding-left: 0; - } - - .recent_view_participants { - display: inline-flex; /* Causes LI items to display in row. */ - list-style-type: none; - margin: auto; /* Centers vertically / horizontally in flex container. */ - height: 24px; - padding: 4px; - border-radius: 6px; - overflow: hidden; - - /* - By using the row-reverse layout, the visual ordering will be opposite of - the DOM ordering. This will allows us to stack the items in the opposite - direction of the natural stacking order without having to mess with the - zIndex value. The MAJOR DOWNSIDE is that the HTML itself now reads - backwards, which super janky. - */ - flex-direction: row-reverse; - } - - .recent_view_participant_item { - height: 24px; - margin: 0; - padding: 0 1.5px; - position: relative; - min-width: 24px; - cursor: pointer; - - .fa-user { - opacity: 0.7; - } - } - - .recent_view_participant_avatar, - .recent_view_participant_overflow { - border: 0; - border-radius: 6px; - color: hsl(0deg 0% 100%); - display: block; - height: 24px; - text-align: center; - background-color: hsl(0deg 0% 88%); - } - - .recent_view_participant_overflow { - color: hsl(0deg 0% 0%); - line-height: 24px; - } - - & tr { - background-color: hsl(100deg 11% 96%); - - &:hover { - background-color: hsl(210deg 100% 97%); - - .change_visibility_policy .zulip-icon-inherit { - opacity: 0.4; - } - } - } - - .unread_topic { - background-color: hsl(0deg 0% 100%); - } - - .last_msg_time { - float: left; - margin-right: 5px; - } - - & thead th { - background-color: hsl(0deg 0% 100%); - color: inherit; - border-top: 1px solid hsl(0deg 0% 0% / 20%) !important; - border-bottom: 1px solid hsl(0deg 0% 0% / 20%) !important; - position: sticky; - top: 0; - z-index: 1; - - &.active::after, - &[data-sort]:hover::after { - content: " \f0d8"; - white-space: pre; - display: inline-block; - position: absolute; - padding-top: 3px; - font: normal normal normal 12px/1 FontAwesome; - font-size: inherit; - } - - &.active { - opacity: 1; - transition: opacity 100ms ease-out; - - &.descend::after { - content: " \f0d7"; - } - } - - &[data-sort]:hover { - cursor: pointer; - background-color: hsl(0deg 0% 95%); - transition: background-color 100ms ease-in-out; - - &:not(.active)::after { - opacity: 0.3; - } - } - } - - /* These fixed column widths prevent column widths from being adjusted - as new messages arrive from the server. */ - .recent_topic_stream { - width: 25%; - padding: 8px 0 8px 8px; - - & a { - word-break: break-word; - hyphens: auto; - } - } - - .recent_topic_name { - width: 40%; - - & a { - word-break: break-word; - /* No hyphes for word break since it caused hyphens to appear before - the ellipsis `longText-...` which is not desirable. Ellipsis appears due - to the line clamp applied below. - */ - } - - .line_clamp { - /* This -webkit-box display property is webkit-specific, but - it appears that line clamping works fine for this component - on Firefox anyway. */ - /* stylelint-disable-next-line value-no-vendor-prefix */ - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - } - - .recent_topic_users { - width: 20%; - } - - .recent_topic_timestamp { - width: 15%; - } - - & thead .last_msg_time_header { - /* The responsive table of bootstrap - somehow ignores the width of ::after - element. This ensures it is always visible. - 20px = space occupied by ::after (icon) + - some extra padding. - */ - padding-right: 20px; - } - - @media (width < $md_min) { - /* Hide participants and last message time - on smaller screens. This ensures user always - has a nice UI experience. */ - .recent_topic_users, - .recent_topic_timestamp, - thead .participants_header, - thead .last_msg_time_header { - display: none; - } - - .recent_topic_actions { - margin-right: 5px; - font-size: 15px; - } - } - - .private_conversation_row { - .recent_topic_stream { - /* Reduce padding of stream section so that user status - icon can have more padding without impacting height of the row */ - padding: 5px 0 5px 8px; - } - - .pm_status_icon { - display: flex; - justify-content: center; - align-items: center; - /* Increasing vertical padding any further will increase - the height of the row. */ - padding: 8px; - position: relative; - right: -8px; /* To cancel padding-right */ - /* To accommodate fa-group icon */ - width: 14px; - height: 14px; - - .fa-group, - .zulip-icon.zulip-icon-bot { - font-size: 0.8rem; - opacity: 0.6; - } - - .user_circle { - /* Shrink the user activity circle for the Recent Conversations context. */ - min-width: 7px; - height: 7px; - float: left; - position: unset; - } - } - } - } - - #recent_view_bottom_whitespace { - /* For visual reasons, in a message feed, we require a large - * bottom_whitespace to make it convenient to display new - * messages as they come in and prevent occluding the last - * message with an open compose box. Here, the bottom item - * is rarely interesting context for a message one is - * composing, but we do need at least 41px to allow the - * close-compose-box UI element (including border) to not - * overlap content. Add some more margin so that user - * can clearly see the end of the topics. - */ - height: 120px; - - #recent_view_loading_messages_indicator, - .bottom-messages-logo { - display: block; - position: absolute; - top: 200px; - left: 0; - right: 0; - margin: auto; - - .loading_indicator_spinner { - position: relative; - top: -7px; - } - } - } - - .stream-privacy { - .zulip-icon { - position: relative; - left: -1px; - top: 1.5px; - } - } + position: sticky; + top: var(--navbar-fixed-height); + z-index: 1; +} + +.recent_view_container #recent_view_table { + max-width: 100%; + overflow: hidden !important; + display: flex; + flex-direction: column; + border: 0; +} + +#recent_view_table .table, +#recent-view-content-table { + /* To keep border properties to the thead th. */ + border-collapse: separate; + + border-spacing: 0; + width: 100%; } #recent_view { display: none; - position: relative; + padding-top: var(--navbar-fixed-height); + /* Add bottom padding equal to `#bottom-whitespace`. This helps us keep #compose visible + at its max-height without overlapping with any visible topics. */ + padding-bottom: var(--max-unexpanded-compose-height); + + & td { + vertical-align: middle; + padding: 3px 8px; + border-top: 1px solid var(--color-border-recent-view-row); + } + + .recent_view_focusable { + /* Use flexbox to align icons vertically */ + display: flex; + align-items: center; + + .filter-icon { + /* Maintain righthand space between icon + and stream name. */ + margin-right: 3px; + } + + & > * { + outline: 0; + } + + &:focus-within { + /* Use the same color as the message feed pointer */ + box-shadow: 0 3px 0 var(--color-outline-focus); + } + + &.change_visibility_policy.visibility-policy-popover-visible { + .zulip-icon-inherit { + opacity: 0.4; + } + } + + &.change_visibility_policy .zulip-icon-inherit { + opacity: 0; + + &:focus { + opacity: 0.2; + } + } + } + + & a { + color: var(--color-recent-view-link); + text-decoration: none; + + &:hover { + color: var(--color-recent-view-link-hover); + } + } + + .empty-table-message { + background-color: var(--color-background); + padding: 3em 1em; + } + + .fa-check-square-o, + .fa-square-o { + padding: 0 2px; + width: 10px; + } + + .fa-envelope { + font-size: 0.7rem; + margin-right: 2px; + position: relative; + top: -1px; + opacity: 0.6; + } + + .table_fix_head { + padding: 0 !important; + } + + .recent-view-load-more-container { + margin: 20px 10px; + align-items: center; + } + + .fetch-messages-button { + display: grid; + justify-items: center; + + .loading_indicator_spinner { + height: 20px; + width: 20px; + } + + path { + fill: var(--color-recent-view-loading-spinner); + } + } + + .table_fix_head table th { + padding: 8px; + text-align: left; + } + + #recent_view_filter_buttons { + padding: 12px 10px 0; + display: flex; + /* Search box has no height without this in safari. */ + flex: 0 0 auto; + flex-wrap: wrap; + justify-content: flex-start; + background: var(--color-background); + } + + .search_group { + display: flex; + flex-grow: 1; + margin: 0 -27px 10px 0; + } + + #recent_view_search { + flex-grow: 1; + padding-right: 20px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .clear_search_button { + /* Overrides app_components.css property. */ + padding-top: 4px !important; + } + + .btn-recent-filters { + border-radius: 40px; + margin: 0 5px 10px 0; + + &:focus { + background-color: hsl(0deg 0% 80%); + outline: 0; + } + + &.fake_disabled_button { + cursor: not-allowed; + opacity: 0.5; + + &:hover { + background-color: hsl(0deg 0% 100%); + border-color: hsl(0deg 0% 80%); + } + } + } + + .btn-recent-selected { + background-color: hsl(0deg 11% 93%); + } + + .unread_count { + /* Focus underline can only occupy the total length of the unread count */ + margin-right: 1px; + margin-left: 1px; + align-self: center; + background-color: hsl(105deg 2% 50%); + } + + .unread_mention_info:not(:empty) { + /* Zero out right margin from left sidebar presentation. */ + margin-right: 0; + /* Match with its font-size. */ + line-height: 14px; + /* Present a default/arrow cursor */ + cursor: default; + } + + .unread_hidden { + visibility: hidden; + } + + .flex_container_pm { + /* Flex container to fit in user circle and group icon */ + display: flex; + justify-content: space-between; + + .tippy-content { + font-weight: 400; + } + } + + .flex_container { + display: flex; + align-items: center; + } + + .flex_container .right_part { + margin-left: auto; + display: inline-flex; + align-items: center; + } + + .recent_topic_actions { + /* Add spacing between mention marker, unread count + and mute icon */ + margin-left: 5px; + display: flex; + flex-flow: row nowrap; + } + + .mention_in_unread { + opacity: 0.7; + } + + .recent_topic_actions.dummy_action_button { + visibility: hidden; + } + + .recent_topic_actions .recent_view_focusable { + /* Keep a uniform distance from the focus-within + indicator at bottom. */ + padding-bottom: 3px; + /* But push down with margin by the same amount, + so as to preserve vertical alignment introduced + by the parent flexbox. */ + margin-top: 3px; + } + + .recent_topic_actions .recipient_bar_icon { + /* Zero out padding used in recipient bar. */ + padding-right: 0; + padding-left: 0; + } + + .recent_view_participants { + display: inline-flex; /* Causes LI items to display in row. */ + list-style-type: none; + margin: auto; /* Centers vertically / horizontally in flex container. */ + height: 24px; + padding: 4px; + border-radius: 6px; + overflow: hidden; + + /* + By using the row-reverse layout, the visual ordering will be opposite of + the DOM ordering. This will allows us to stack the items in the opposite + direction of the natural stacking order without having to mess with the + zIndex value. The MAJOR DOWNSIDE is that the HTML itself now reads + backwards, which super janky. + */ + flex-direction: row-reverse; + } + + .recent_view_participant_item { + height: 24px; + margin: 0; + padding: 0 1.5px; + position: relative; + min-width: 24px; + cursor: pointer; + + .fa-user { + opacity: 0.7; + } + } + + .recent_view_participant_avatar, + .recent_view_participant_overflow { + border: 0; + border-radius: 6px; + color: hsl(0deg 0% 100%); + display: block; + height: 24px; + text-align: center; + background-color: hsl(0deg 0% 88%); + } + + .recent_view_participant_overflow { + color: hsl(0deg 0% 0%); + line-height: 24px; + } + + & tr { + background-color: var(--color-background-recent-view-row); + + &:hover { + background-color: var(--color-background-recent-view-row-hover); + + .change_visibility_policy .zulip-icon-inherit { + opacity: 0.4; + } + } + } + + .unread_topic { + background-color: var(--color-background-recent-view-unread-row); + + &:hover { + background-color: var( + --color-background-recent-view-unread-row-hover + ); + } + } + + .last_msg_time { + float: left; + margin-right: 5px; + } + + & thead th { + background-color: hsl(0deg 0% 100%); + color: inherit; + border-top: 1px solid hsl(0deg 0% 0% / 20%) !important; + border-bottom: 1px solid hsl(0deg 0% 0% / 20%) !important; + z-index: 1; + + &.active::after, + &[data-sort]:hover::after { + content: " \f0d8"; + white-space: pre; + display: inline-block; + position: absolute; + padding-top: 3px; + font: normal normal normal 12px/1 FontAwesome; + font-size: inherit; + } + + &.active { + opacity: 1; + transition: opacity 100ms ease-out; + + &.descend::after { + content: " \f0d7"; + } + } + + &[data-sort]:hover { + cursor: pointer; + background-color: hsl(0deg 0% 95%); + transition: background-color 100ms ease-in-out; + + &:not(.active)::after { + opacity: 0.3; + } + } + } + + .recent_topic_stream, + .recent-view-stream-header { + width: 25%; + } + + .recent-view-topic-header { + width: 35%; + } + + .recent-view-unread-header { + width: 5%; + + .zulip-icon-unread { + position: relative; + top: 3px; + } + } + + .recent_topic_users, + .recent-view-participants-header { + width: 20%; + } + + .recent_topic_timestamp, + .recent-view-last-msg-time-header { + width: 15%; + } + + /* These fixed column widths prevent column widths from being adjusted + as new messages arrive from the server. */ + .recent_topic_stream { + padding: 8px 0 8px 8px; + + & a { + word-break: break-word; + hyphens: auto; + } + } + + .recent_topic_name { + width: 40%; + + & a { + word-break: break-word; + /* No hyphes for word break since it caused hyphens to appear before + the ellipsis `longText-...` which is not desirable. Ellipsis appears due + to the line clamp applied below. + */ + } + + .line_clamp { + /* This -webkit-box display property is webkit-specific, but + it appears that line clamping works fine for this component + on Firefox anyway. */ + /* stylelint-disable-next-line value-no-vendor-prefix */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + & thead .last_msg_time_header { + /* The responsive table of bootstrap + somehow ignores the width of ::after + element. This ensures it is always visible. + 20px = space occupied by ::after (icon) + + some extra padding. + */ + padding-right: 20px; + } + + @media (width < $md_min) { + /* Hide participants and last message time + on smaller screens. This ensures user always + has a nice UI experience. */ + .recent_topic_users, + .recent_topic_timestamp, + thead .participants_header, + thead .last_msg_time_header { + display: none; + } + + .recent_topic_actions { + margin-right: 5px; + font-size: 15px; + } + } + + .private_conversation_row { + .recent_topic_stream { + /* Reduce padding of stream section so that user status + icon can have more padding without impacting height of the row */ + padding: 5px 0 5px 8px; + } + + .pm_status_icon { + display: flex; + justify-content: center; + align-items: center; + /* Increasing vertical padding any further will increase + the height of the row. */ + padding: 8px; + position: relative; + right: -8px; /* To cancel padding-right */ + /* To accommodate fa-group icon */ + width: 14px; + height: 14px; + + .fa-group, + .zulip-icon.zulip-icon-bot { + font-size: 0.8rem; + opacity: 0.6; + } + + .user_circle { + /* Shrink the user activity circle for the Recent Conversations context. */ + min-width: 7px; + height: 7px; + float: left; + position: unset; + } + } + } + + .stream-privacy .zulip-icon { + position: relative; + left: -1px; + top: 1.5px; + } +} + +#recent_view_bottom_whitespace { + #recent_view_loading_messages_indicator, + .bottom-messages-logo { + display: block; + position: absolute; + top: 200px; + left: 0; + right: 0; + margin: auto; + + .loading_indicator_spinner { + position: relative; + top: -7px; + } + } } #recent-view-filter_widget { diff --git a/web/styles/zulip.css b/web/styles/zulip.css index 90873f0df7..eda0ba9205 100644 --- a/web/styles/zulip.css +++ b/web/styles/zulip.css @@ -323,10 +323,6 @@ p.n-margin { } } -.recent_view_container #recent_view_table { - margin-top: var(--navbar-fixed-height); -} - .app { min-width: 100%; min-height: 100%; diff --git a/web/templates/recent_view_table.hbs b/web/templates/recent_view_table.hbs index c957a68d28..2c53e570c5 100644 --- a/web/templates/recent_view_table.hbs +++ b/web/templates/recent_view_table.hbs @@ -9,29 +9,20 @@
-
+
- - + - - - + + - - + +
{{t 'Channel' }}{{t 'Topic' }} + {{t 'Channel' }}{{t 'Topic' }} {{t 'Participants' }}{{t 'Time' }}{{t 'Participants' }}{{t 'Time' }}
- {{!-- Don't show the banner until we have some messages loaded. --}} -
- - -
diff --git a/web/tests/list_widget.test.js b/web/tests/list_widget.test.js index f932894e16..8e805d6d29 100644 --- a/web/tests/list_widget.test.js +++ b/web/tests/list_widget.test.js @@ -76,6 +76,7 @@ function make_scroll_container() { assert.equal(ev, "scroll.list_widget_container"); $scroll_container.cleared = true; }; + $scroll_container.is = () => false; return $scroll_container; } diff --git a/web/tests/recent_view.test.js b/web/tests/recent_view.test.js index 05cdcb8591..89c2cc57e4 100644 --- a/web/tests/recent_view.test.js +++ b/web/tests/recent_view.test.js @@ -7,6 +7,7 @@ const {run_test, noop} = require("./lib/test"); const $ = require("./lib/zjquery"); const {page_params} = require("./lib/zpage_params"); +window.scrollTo = noop; const test_url = () => "https://www.example.com"; // We assign this in our test() wrapper.