diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index 4199541fd6..2c5ef84322 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 $tagPanelWidth: 56px; // only applies in this file, used for calculations diff --git a/res/css/views/rooms/_NotificationBadge.scss b/res/css/views/rooms/_NotificationBadge.scss index 521f1dfc20..0e6d442cc1 100644 --- a/res/css/views/rooms/_NotificationBadge.scss +++ b/res/css/views/rooms/_NotificationBadge.scss @@ -48,15 +48,15 @@ limitations under the License. } &.mx_NotificationBadge_2char { - width: 16px; - height: 16px; - border-radius: 16px; + width: $font-16px; + height: $font-16px; + border-radius: $font-16px; } &.mx_NotificationBadge_3char { - width: 26px; - height: 16px; - border-radius: 16px; + width: $font-26px; + height: $font-16px; + border-radius: $font-16px; } // The following is the floating badge diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss index d3b8ec1914..fd050cfd7c 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs2.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 .mx_RoomBreadcrumbs2 { width: 100%; diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index dff69b49fa..de577139bc 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 .mx_RoomSublist2 { // The sublist is a column of rows, essentially @@ -48,12 +48,6 @@ limitations under the License. height: 24px; color: $roomlist2-header-color; - // Hide the header container if the contained element is stickied. - // We don't use display:none as that causes the header to go away too. - &.mx_RoomSublist2_headerContainer_hasSticky { - height: 0; - } - .mx_RoomSublist2_stickable { flex: 1; max-width: 100%; @@ -182,6 +176,15 @@ limitations under the License. } } + // In the general case, we leave height of headers alone even if sticky, so + // that the sublists below them do not jump. However, that leaves a gap + // when scrolled to the top above the first sublist (whose header can only + // ever stick to top), so we force height to 0 for only that first header. + // See also https://github.com/vector-im/riot-web/issues/14429. + &:first-child .mx_RoomSublist2_headerContainer { + height: 0; + } + .mx_RoomSublist2_resizeBox { position: relative; @@ -198,6 +201,8 @@ limitations under the License. // as the box model should be top aligned. Happens in both FF and Chromium display: flex; flex-direction: column; + + mask-image: linear-gradient(0deg, transparent, black 3px); } .mx_RoomSublist2_resizerHandles_showNButton { diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index df4182246a..9b967c09b8 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -14,17 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 // Note: the room tile expects to be in a flexbox column container .mx_RoomTile2 { margin-bottom: 4px; padding: 4px; - // allow scrollIntoView to ignore the sticky headers, must match combined height of .mx_RoomSublist2_headerContainer - scroll-margin-top: 32px; - scroll-margin-bottom: 32px; - // The tile is also a flexbox row itself display: flex; @@ -168,11 +164,6 @@ limitations under the License. } } -// do not apply scroll-margin-bottom to the sublist which will not have a sticky header below it -.mx_RoomSublist2:last-child .mx_RoomTile2 { - scroll-margin-bottom: 0; -} - // We use these both in context menus and the room tiles .mx_RoomTile2_iconBell::before { mask-image: url('$(res)/img/element-icons/notifications.svg'); diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index f667c47b3c..11c955749d 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -660,7 +660,7 @@ export const Commands = [ if (args) { const cli = MatrixClientPeg.get(); - const matches = args.match(/^(\S+)$/); + const matches = args.match(/^(@[^:]+:\S+)$/); if (matches) { const userId = matches[1]; const ignoredUsers = cli.getIgnoredUsers(); @@ -690,7 +690,7 @@ export const Commands = [ if (args) { const cli = MatrixClientPeg.get(); - const matches = args.match(/^(\S+)$/); + const matches = args.match(/(^@[^:]+:\S+$)/); if (matches) { const userId = matches[1]; const ignoredUsers = cli.getIgnoredUsers(); diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 1f681c54c4..012b518093 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -35,16 +35,7 @@ import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomLi import {Key} from "../../Keyboard"; import IndicatorScrollbar from "../structures/IndicatorScrollbar"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 - -/******************************************************************* - * CAUTION * - ******************************************************************* - * This is a work in progress implementation and isn't complete or * - * even useful as a component. Please avoid using it until this * - * warning disappears. * - *******************************************************************/ +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 interface IProps { isMinimized: boolean; @@ -111,6 +102,10 @@ export default class LeftPanel2 extends React.Component { const newVal = BreadcrumbsStore.instance.visible; if (newVal !== this.state.showBreadcrumbs) { this.setState({showBreadcrumbs: newVal}); + + // Update the sticky headers too as the breadcrumbs will be popping in or out. + if (!this.listContainerRef.current) return; // ignore: no headers to sticky + this.handleStickyHeaders(this.listContainerRef.current); } }; @@ -170,7 +165,6 @@ export default class LeftPanel2 extends React.Component { // layout updates. for (const header of targetStyles.keys()) { const style = targetStyles.get(header); - const headerContainer = header.parentElement; // .mx_RoomSublist2_headerContainer if (style.makeInvisible) { // we will have already removed the 'display: none', so add it back. @@ -187,19 +181,29 @@ export default class LeftPanel2 extends React.Component { if (header.style.top !== newTop) { header.style.top = newTop; } - } else if (style.stickyBottom) { + } else { + if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) { + header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); + } + if (header.style.top) { + header.style.removeProperty('top'); + } + } + + if (style.stickyBottom) { if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) { header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); } + } else { + if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) { + header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); + } } if (style.stickyTop || style.stickyBottom) { if (!header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) { header.classList.add("mx_RoomSublist2_headerContainer_sticky"); } - if (!headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) { - headerContainer.classList.add("mx_RoomSublist2_headerContainer_hasSticky"); - } const newWidth = `${headerStickyWidth}px`; if (header.style.width !== newWidth) { @@ -209,21 +213,9 @@ export default class LeftPanel2 extends React.Component { if (header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) { header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); } - if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) { - header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); - } - if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) { - header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); - } - if (headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) { - headerContainer.classList.remove("mx_RoomSublist2_headerContainer_hasSticky"); - } if (header.style.width) { header.style.removeProperty('width'); } - if (header.style.top) { - header.style.removeProperty('top'); - } } } @@ -242,7 +234,6 @@ export default class LeftPanel2 extends React.Component { } } - // TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232 private onScroll = (ev: React.MouseEvent) => { const list = ev.target as HTMLDivElement; this.handleStickyHeaders(list); @@ -274,6 +265,14 @@ export default class LeftPanel2 extends React.Component { } }; + private onEnter = () => { + const firstRoom = this.listContainerRef.current.querySelector(".mx_RoomTile2"); + if (firstRoom) { + firstRoom.click(); + this.onSearch(""); // clear the search field + } + }; + private onMoveFocus = (up: boolean) => { let element = this.focusedElement; @@ -346,6 +345,7 @@ export default class LeftPanel2 extends React.Component { onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} onVerticalArrow={this.onKeyDown} + onEnter={this.onEnter} /> { onResize={this.onResize} />; - // TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177 - const containerClasses = classNames({ "mx_LeftPanel2": true, "mx_LeftPanel2_hasTagPanel": !!tagPanel, diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 88144c45c5..b65f176089 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -668,8 +668,7 @@ class LoggedInView extends React.Component { disabled={this.props.leftDisabled} /> ); - if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { - // TODO: Supply props like collapsed and disabled to LeftPanel2 + if (SettingsStore.getValue("feature_new_room_list")) { leftPanel = ( { onAction = (payload) => { // console.log(`MatrixClientPeg.onAction: ${payload.action}`); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // Start the onboarding process for certain actions @@ -554,6 +554,9 @@ export default class MatrixChat extends React.PureComponent { case 'leave_room': this.leaveRoom(payload.room_id); break; + case 'forget_room': + this.forgetRoom(payload.room_id); + break; case 'reject_invite': Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, { title: _t('Reject invitation'), @@ -1060,7 +1063,6 @@ export default class MatrixChat extends React.PureComponent { private leaveRoom(roomId: string) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); @@ -1124,6 +1126,21 @@ export default class MatrixChat extends React.PureComponent { }); } + private forgetRoom(roomId: string) { + MatrixClientPeg.get().forget(roomId).then(() => { + // Switch to another room view if we're currently viewing the historical room + if (this.state.currentRoomId === roomId) { + dis.dispatch({ action: "view_next_room" }); + } + }).catch((err) => { + const errCode = err.errcode || _td("unknown error code"); + Modal.createTrackedDialog("Failed to forget room", '', ErrorDialog, { + title: _t("Failed to forget room %(errCode)s", {errCode}), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); + } + /** * Starts a chat with the welcome user, if the user doesn't already have one * @returns {string} The room ID of the new room, or null if no room was created @@ -1372,7 +1389,6 @@ export default class MatrixChat extends React.PureComponent { return; } - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Signed out', '', ErrorDialog, { title: _t('Signed Out'), description: _t('For security, this session has been signed out. Please sign in again.'), @@ -1442,7 +1458,6 @@ export default class MatrixChat extends React.PureComponent { } }); cli.on("crypto.warning", (type) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); switch (type) { case 'CRYPTO_WARNING_OLD_VERSION_DETECTED': const brand = SdkConfig.get().brand; diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 8655db2e8c..517a5f2580 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -25,20 +25,11 @@ import { Key } from "../../Keyboard"; import AccessibleButton from "../views/elements/AccessibleButton"; import { Action } from "../../dispatcher/actions"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 - -/******************************************************************* - * CAUTION * - ******************************************************************* - * This is a work in progress implementation and isn't complete or * - * even useful as a component. Please avoid using it until this * - * warning disappears. * - *******************************************************************/ - interface IProps { onQueryUpdate: (newQuery: string) => void; isMinimized: boolean; onVerticalArrow(ev: React.KeyboardEvent); + onEnter(ev: React.KeyboardEvent); } interface IState { @@ -115,6 +106,8 @@ export default class RoomSearch extends React.PureComponent { defaultDispatcher.fire(Action.FocusComposer); } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { this.props.onVerticalArrow(ev); + } else if (ev.key === Key.ENTER) { + this.props.onEnter(ev); } }; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index c493cdcace..7dc2d57ff0 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1380,15 +1380,9 @@ export default createReactClass({ }, onForgetClick: function() { - this.context.forget(this.state.room.roomId).then(function() { - dis.dispatch({ action: 'view_next_room' }); - }, function(err) { - const errCode = err.errcode || _t("unknown error code"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, { - title: _t("Error"), - description: _t("Failed to forget room %(errCode)s", { errCode: errCode }), - }); + dis.dispatch({ + action: 'forget_room', + room_id: this.state.room.roomId, }); }, diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 916d4c0d83..798bfaa7fe 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -170,6 +170,7 @@ export default class UserMenu extends React.Component { ev.stopPropagation(); // TODO: Archived room view: https://github.com/vector-im/riot-web/issues/14038 + // Note: You'll need to uncomment the button too. console.log("TODO: Show archived rooms"); }; diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index aa2c0ea954..7f30a7a377 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -99,6 +99,7 @@ const BaseAvatar = (props: IProps) => { defaultToInitialLetter = true, onClick, inputRef, + className, ...otherProps } = props; @@ -138,7 +139,7 @@ const BaseAvatar = (props: IProps) => { @@ -149,7 +150,7 @@ const BaseAvatar = (props: IProps) => { } else { return ( { if (onClick !== null) { return ( { } else { return ( { }; public render() { - /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props; const roomName = room ? room.name : oobData.name; return ( - ); } diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 941a057927..d215df9126 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -18,8 +18,6 @@ import React from "react"; import classNames from "classnames"; import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils"; import SettingsStore from "../../../settings/SettingsStore"; -import { DefaultTagID, TagID } from "../../../stores/room-list/models"; -import { readReceiptChangeIsFor } from "../../../utils/read-receipts"; import AccessibleButton from "../elements/AccessibleButton"; import { XOR } from "../../../@types/common"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx index 602c697d8a..619ad6da4d 100644 --- a/src/components/views/rooms/RoomBreadcrumbs2.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -27,16 +27,7 @@ import RoomListStore from "../../../stores/room-list/RoomListStore2"; import { DefaultTagID } from "../../../stores/room-list/models"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 - -/******************************************************************* - * CAUTION * - ******************************************************************* - * This is a work in progress implementation and isn't complete or * - * even useful as a component. Please avoid using it until this * - * warning disappears. * - *******************************************************************/ +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 interface IProps { } diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index 440b0d5bb3..61f0f5c0c8 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -41,16 +41,7 @@ import { Action } from "../../../dispatcher/actions"; import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 - -/******************************************************************* - * CAUTION * - ******************************************************************* - * This is a work in progress implementation and isn't complete or * - * even useful as a component. Please avoid using it until this * - * warning disappears. * - *******************************************************************/ +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 interface IProps { onKeyDown: (ev: React.KeyboardEvent) => void; @@ -231,6 +222,7 @@ export default class RoomList2 extends React.Component { private renderCommunityInvites(): React.ReactElement[] { // TODO: Put community invites in a more sensible place (not in the room list) + // See https://github.com/vector-im/riot-web/issues/14456 return MatrixClientPeg.get().getGroups().filter(g => { if (g.myMembership !== 'invite') return false; return !this.searchFilter || this.searchFilter.matches(g.name || ""); diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index c351384be4..68b7a585b5 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -17,7 +17,7 @@ limitations under the License. */ import * as React from "react"; -import { createRef } from "react"; +import {createRef} from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from 'classnames'; import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; @@ -48,16 +48,7 @@ import { polyfillTouchEvent } from "../../../@types/polyfill"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 - -/******************************************************************* - * CAUTION * - ******************************************************************* - * This is a work in progress implementation and isn't complete or * - * even useful as a component. Please avoid using it until this * - * warning disappears. * - *******************************************************************/ +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS @@ -137,9 +128,10 @@ export default class RoomSublist2 extends React.Component { let padding = RESIZE_HANDLE_HEIGHT; // this is used for calculating the max height of the whole container, // and takes into account whether there should be room reserved for the show less button - // when fully expanded. Note that the show more button might still be shown when not fully expanded, - // but in this case it will take the space of a tile and we don't need to reserve space for it. - if (this.numTiles > this.layout.defaultVisibleTiles) { + // when fully expanded. We cannot check against the layout's defaultVisible tile count + // because there are conditions in which we need to know that the 'show more' button + // is present while well under the default tile limit. + if (this.numTiles > this.numVisibleTiles) { padding += SHOW_N_BUTTON_HEIGHT; } return padding; @@ -236,10 +228,13 @@ export default class RoomSublist2 extends React.Component { }; private onShowAllClick = () => { + // read number of visible tiles before we mutate it + const numVisibleTiles = this.numVisibleTiles; const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding); this.applyHeightChange(newHeight); this.setState({height: newHeight}, () => { - this.focusRoomTile(this.numTiles - 1); + // focus the top-most new room + this.focusRoomTile(numVisibleTiles); }); }; @@ -321,25 +316,29 @@ export default class RoomSublist2 extends React.Component { } }; - private onHeaderClick = (ev: React.MouseEvent) => { - let target = ev.target as HTMLDivElement; - if (!target.classList.contains('mx_RoomSublist2_headerText')) { - // If we don't have the headerText class, the user clicked the span in the headerText. - target = target.parentElement as HTMLDivElement; - } - - const possibleSticky = target.parentElement; + private onHeaderClick = () => { + const possibleSticky = this.headerButton.current.parentElement; const sublist = possibleSticky.parentElement.parentElement; const list = sublist.parentElement.parentElement; - // the scrollTop is capped at the height of the header in LeftPanel2 + // the scrollTop is capped at the height of the header in LeftPanel2, the top header is always sticky const isAtTop = list.scrollTop <= HEADER_HEIGHT; - const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky'); - if (isSticky && !isAtTop) { + const isAtBottom = list.scrollTop >= list.scrollHeight - list.offsetHeight; + const isStickyTop = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_stickyTop'); + const isStickyBottom = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_stickyBottom'); + + if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) { // is sticky - jump to list sublist.scrollIntoView({behavior: 'smooth'}); } else { // on screen - toggle collapse + const isExpanded = this.state.isExpanded; this.toggleCollapsed(); + // if the bottom list is collapsed then scroll it in so it doesn't expand off screen + if (!isExpanded && isStickyBottom) { + setImmediate(() => { + sublist.scrollIntoView({behavior: 'smooth'}); + }); + } } }; @@ -595,9 +594,13 @@ export default class RoomSublist2 extends React.Component { ); } - public render(): React.ReactElement { - // TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185 + private onScrollPrevent(e: React.UIEvent) { + // the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable + // this fixes https://github.com/vector-im/riot-web/issues/14413 + (e.target as HTMLDivElement).scrollTop = 0; + } + public render(): React.ReactElement { const visibleTiles = this.renderVisibleTiles(); const classes = classNames({ 'mx_RoomSublist2': true, @@ -613,11 +616,15 @@ export default class RoomSublist2 extends React.Component { const showMoreAtMinHeight = minTiles < this.numTiles; const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0); const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding); - const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding); + let maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding); const showMoreBtnClasses = classNames({ 'mx_RoomSublist2_showNButton': true, }); + if (this.numTiles > this.layout.defaultVisibleTiles) { + maxTilesPx += SHOW_N_BUTTON_HEIGHT; + } + // If we're hiding rooms, show a 'show more' button to the user. This button // floats above the resize handle, if we have one present. If the user has all // tiles visible, it becomes 'show less'. @@ -704,7 +711,7 @@ export default class RoomSublist2 extends React.Component { className="mx_RoomSublist2_resizeBox" enable={handles} > -
+
{visibleTiles}
{showNButton} diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index a7dc983fa6..fe6a19f2ed 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -55,16 +55,7 @@ import {ActionPayload} from "../../../dispatcher/payloads"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationState } from "../../../stores/notifications/NotificationState"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 - -/******************************************************************* - * CAUTION * - ******************************************************************* - * This is a work in progress implementation and isn't complete or * - * even useful as a component. Please avoid using it until this * - * warning disappears. * - *******************************************************************/ +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 interface IProps { room: Room; @@ -124,7 +115,6 @@ const NotifOption: React.FC = ({active, onClick, iconClassNam export default class RoomTile2 extends React.Component { private dispatcherRef: string; private roomTileRef = createRef(); - // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 constructor(props: IProps) { super(props); @@ -276,6 +266,17 @@ export default class RoomTile2 extends React.Component { this.setState({generalMenuPosition: null}); // hide the menu }; + private onForgetRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + dis.dispatch({ + action: 'forget_room', + room_id: this.props.room.roomId, + }); + this.setState({generalMenuPosition: null}); // hide the menu + }; + private onOpenRoomSettings = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -299,7 +300,7 @@ export default class RoomTile2 extends React.Component { await setRoomNotifsState(this.props.room.roomId, newState); } catch (error) { // TODO: some form of error notification to the user to inform them that their state change failed. - // https://github.com/vector-im/riot-web/issues/14281 + // See https://github.com/vector-im/riot-web/issues/14281 console.error(error); } @@ -315,7 +316,7 @@ export default class RoomTile2 extends React.Component { private onClickMute = ev => this.saveNotifState(ev, MUTE); private renderNotificationsMenu(isActive: boolean): React.ReactElement { - if (MatrixClientPeg.get().isGuest() || !this.showContextMenu) { + if (MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Archived || !this.showContextMenu) { // the menu makes no sense in these cases so do not show one return null; } @@ -387,8 +388,6 @@ export default class RoomTile2 extends React.Component { private renderGeneralMenu(): React.ReactElement { if (!this.showContextMenu) return null; // no menu to show - // TODO: We could do with a proper invite context menu, unlike what showContextMenu suggests - const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room); const isFavorite = roomTags.includes(DefaultTagID.Favourite); @@ -397,7 +396,20 @@ export default class RoomTile2 extends React.Component { const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite"); let contextMenu = null; - if (this.state.generalMenuPosition) { + if (this.state.generalMenuPosition && this.props.tag === DefaultTagID.Archived) { + contextMenu = ( + +
+
+ + + {_t("Forget Room")} + +
+
+
+ ); + } else if (this.state.generalMenuPosition) { contextMenu = (
@@ -441,8 +453,6 @@ export default class RoomTile2 extends React.Component { } public render(): React.ReactElement { - // TODO: Invites: https://github.com/vector-im/riot-web/issues/14198 - const classes = classNames({ 'mx_RoomTile2': true, 'mx_RoomTile2_selected': this.state.selected, @@ -471,7 +481,6 @@ export default class RoomTile2 extends React.Component { ); } - // TODO: the original RoomTile uses state for the room name. Do we need to? let name = this.props.room.name; if (typeof name !== 'string') name = ''; name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon diff --git a/src/components/views/rooms/TemporaryTile.tsx b/src/components/views/rooms/TemporaryTile.tsx index a3ee7eb5bd..9baaba817d 100644 --- a/src/components/views/rooms/TemporaryTile.tsx +++ b/src/components/views/rooms/TemporaryTile.tsx @@ -34,6 +34,7 @@ interface IState { hover: boolean; } +// TODO: Remove with community invites in the room list: https://github.com/vector-im/riot-web/issues/14456 export default class TemporaryTile extends React.Component { constructor(props: IProps) { super(props); diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index 40b622cf37..abe6b48712 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -32,12 +32,12 @@ export default class PreferencesUserSettingsTab extends React.Component { 'breadcrumbs', ]; - // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14367 static ROOM_LIST_2_SETTINGS = [ 'breadcrumbs', ]; - // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14367 static eligibleRoomListSettings = () => { if (RoomListStoreTempProxy.isUsingNewStore()) { return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cefb34ece9..91896a0582 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1239,6 +1239,7 @@ "Favourited": "Favourited", "Favourite": "Favourite", "Leave Room": "Leave Room", + "Forget Room": "Forget Room", "Room options": "Room options", "Add a topic": "Add a topic", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 5f84b11247..49a7140acf 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -147,7 +147,8 @@ export const SETTINGS = { default: false, }, "feature_new_room_list": { - isFeature: true, + // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14367 + // XXX: We shouldn't have non-features appear like features. displayName: _td("Use the improved room list (will refresh to apply changes)"), supportedLevels: LEVELS_FEATURE, default: true, diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index e51303b722..5639a9104c 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -21,6 +21,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { arrayHasDiff } from "../utils/arrays"; import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; const MAX_ROOMS = 20; // arbitrary const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up @@ -51,13 +52,17 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } public get visible(): boolean { - return this.state.enabled && this.matrixClient.getVisibleRooms().length >= 20; + return this.state.enabled && this.meetsRoomRequirement; + } + + private get meetsRoomRequirement(): boolean { + return this.matrixClient.getVisibleRooms().length >= 20; } protected async onAction(payload: ActionPayload) { if (!this.matrixClient) return; - // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367 if (!RoomListStoreTempProxy.isUsingNewStore()) return; if (payload.action === 'setting_updated') { @@ -80,7 +85,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } protected async onReady() { - // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367 if (!RoomListStoreTempProxy.isUsingNewStore()) return; await this.updateRooms(); @@ -91,7 +96,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } protected async onNotReady() { - // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367 if (!RoomListStoreTempProxy.isUsingNewStore()) return; this.matrixClient.removeListener("Room.myMembership", this.onMyMembership); @@ -99,8 +104,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } private onMyMembership = async (room: Room) => { - // We turn on breadcrumbs by default once the user has at least 1 room to show. - if (!this.state.enabled) { + // Only turn on breadcrumbs is the user hasn't explicitly turned it off again. + const settingValueRaw = SettingsStore.getValue("breadcrumbs", null, /*excludeDefault=*/true); + if (this.meetsRoomRequirement && isNullOrUndefined(settingValueRaw)) { await SettingsStore.setValue("breadcrumbs", null, SettingLevel.ACCOUNT, true); } }; diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index c19b2f8bc2..1861085a27 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -99,7 +99,7 @@ class RoomListStore extends Store { } _checkDisabled() { - this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); + this.disabled = SettingsStore.getValue("feature_new_room_list"); if (this.disabled) { console.warn("👋 legacy room list store has been disabled"); } diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index ab354c0e93..dc38f8bf0f 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -17,7 +17,7 @@ limitations under the License. import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; import { MatrixClientPeg } from "../../MatrixClientPeg"; -import { EffectiveMembership, getEffectiveMembership } from "../room-list/membership"; +import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 01ddde2e17..ea7fa830cd 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -192,7 +192,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { protected async onAction(payload: ActionPayload) { if (!this.matrixClient) return; - // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367 if (!RoomListStoreTempProxy.isUsingNewStore()) return; if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index 5ba448b93c..ca3fcf5729 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -19,7 +19,6 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import SettingsStore from "../../settings/SettingsStore"; import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; import TagOrderStore from "../TagOrderStore"; -import { AsyncStore } from "../AsyncStore"; import { Room } from "matrix-js-sdk/src/models/room"; import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models"; import { ActionPayload } from "../../dispatcher/payloads"; @@ -29,11 +28,11 @@ import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; import { TagWatcher } from "./TagWatcher"; import RoomViewStore from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; -import { EffectiveMembership, getEffectiveMembership } from "./membership"; -import { ListLayout } from "./ListLayout"; +import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import RoomListLayoutStore from "./RoomListLayoutStore"; import { MarkedExecution } from "../../utils/MarkedExecution"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; interface IState { tagsEnabled?: boolean; @@ -45,8 +44,13 @@ interface IState { */ export const LISTS_UPDATE_EVENT = "lists_update"; -export class RoomListStore2 extends AsyncStore { - private _matrixClient: MatrixClient; +export class RoomListStore2 extends AsyncStoreWithClient { + /** + * Set to true if you're running tests on the store. Should not be touched in + * any other environment. + */ + public static TEST_MODE = false; + private initialListsGenerated = false; private enabled = false; private algorithm = new Algorithm(); @@ -74,12 +78,51 @@ export class RoomListStore2 extends AsyncStore { } public get matrixClient(): MatrixClient { - return this._matrixClient; + return super.matrixClient; } - // TODO: Remove enabled flag with the old RoomListStore: https://github.com/vector-im/riot-web/issues/14231 + // Intended for test usage + public async resetStore() { + await this.reset(); + this.tagWatcher = new TagWatcher(this); + this.filterConditions = []; + this.initialListsGenerated = false; + + this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); + this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated); + this.algorithm = new Algorithm(); + this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); + this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated); + + // Reset state without causing updates as the client will have been destroyed + // and downstream code will throw NPE errors. + await this.reset(null, true); + } + + // Public for test usage. Do not call this. + public async makeReady(forcedClient?: MatrixClient) { + if (forcedClient) { + super.matrixClient = forcedClient; + } + + // TODO: Remove with https://github.com/vector-im/riot-web/issues/14367 + this.checkEnabled(); + if (!this.enabled) return; + + // Update any settings here, as some may have happened before we were logically ready. + // Update any settings here, as some may have happened before we were logically ready. + console.log("Regenerating room lists: Startup"); + await this.readAndCacheSettingsFromStore(); + await this.regenerateAllLists({trigger: false}); + await this.handleRVSUpdate({trigger: false}); // fake an RVS update to adjust sticky room, if needed + + this.updateFn.mark(); // we almost certainly want to trigger an update. + this.updateFn.trigger(); + } + + // TODO: Remove enabled flag with the old RoomListStore: https://github.com/vector-im/riot-web/issues/14367 private checkEnabled() { - this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); + this.enabled = SettingsStore.getValue("feature_new_room_list"); if (this.enabled) { console.log("⚡ new room list store engaged"); } @@ -99,7 +142,7 @@ export class RoomListStore2 extends AsyncStore { * be used if the calling code will manually trigger the update. */ private async handleRVSUpdate({trigger = true}) { - if (!this.enabled) return; // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231 + if (!this.enabled) return; // TODO: Remove with https://github.com/vector-im/riot-web/issues/14367 if (!this.matrixClient) return; // We assume there won't be RVS updates without a client const activeRoomId = RoomViewStore.getRoomId(); @@ -122,48 +165,32 @@ export class RoomListStore2 extends AsyncStore { if (trigger) this.updateFn.trigger(); } - protected onDispatch(payload: ActionPayload) { + protected async onReady(): Promise { + await this.makeReady(); + } + + protected async onNotReady(): Promise { + await this.resetStore(); + } + + protected async onAction(payload: ActionPayload) { + // When we're running tests we can't reliably use setImmediate out of timing concerns. + // As such, we use a more synchronous model. + if (RoomListStore2.TEST_MODE) { + await this.onDispatchAsync(payload); + return; + } + // We do this to intentionally break out of the current event loop task, allowing // us to instead wait for a more convenient time to run our updates. setImmediate(() => this.onDispatchAsync(payload)); } protected async onDispatchAsync(payload: ActionPayload) { - if (payload.action === 'MatrixActions.sync') { - // Filter out anything that isn't the first PREPARED sync. - if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) { - return; - } - - // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231 - this.checkEnabled(); - if (!this.enabled) return; - - this._matrixClient = payload.matrixClient; - - // Update any settings here, as some may have happened before we were logically ready. - console.log("Regenerating room lists: Startup"); - await this.readAndCacheSettingsFromStore(); - await this.regenerateAllLists({trigger: false}); - await this.handleRVSUpdate({trigger: false}); // fake an RVS update to adjust sticky room, if needed - - this.updateFn.trigger(); - - return; // no point in running the next conditions - they won't match - } - // TODO: Remove this once the RoomListStore becomes default if (!this.enabled) return; - if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') { - // Reset state without causing updates as the client will have been destroyed - // and downstream code will throw NPE errors. - await this.reset(null, true); - this._matrixClient = null; - this.initialListsGenerated = false; // we'll want to regenerate them - } - - // Everything below here requires a MatrixClient or some sort of logical readiness. + // Everything here requires a MatrixClient or some sort of logical readiness. const logicallyReady = this.matrixClient && this.initialListsGenerated; if (!logicallyReady) return; @@ -390,7 +417,8 @@ export class RoomListStore2 extends AsyncStore { // logic must match calculateListOrder private calculateTagSorting(tagId: TagID): SortAlgorithm { - const defaultSort = SortAlgorithm.Alphabetic; + const isDefaultRecent = tagId === DefaultTagID.Invite || tagId === DefaultTagID.DM; + const defaultSort = isDefaultRecent ? SortAlgorithm.Recent : SortAlgorithm.Alphabetic; const settingAlphabetical = SettingsStore.getValue("RoomList.orderAlphabetically", null, true); const definedSort = this.getTagSorting(tagId); const storedSort = this.getStoredTagSorting(tagId); @@ -496,10 +524,13 @@ export class RoomListStore2 extends AsyncStore { /** * Regenerates the room whole room list, discarding any previous results. + * + * Note: This is only exposed externally for the tests. Do not call this from within + * the app. * @param trigger Set to false to prevent a list update from being sent. Should only * be used if the calling code will manually trigger the update. */ - private async regenerateAllLists({trigger = true}) { + public async regenerateAllLists({trigger = true}) { console.warn("Regenerating all room lists"); const sorts: ITagSortingMap = {}; diff --git a/src/stores/room-list/RoomListStoreTempProxy.ts b/src/stores/room-list/RoomListStoreTempProxy.ts index 86aff178ee..2a5348ab6e 100644 --- a/src/stores/room-list/RoomListStoreTempProxy.ts +++ b/src/stores/room-list/RoomListStoreTempProxy.ts @@ -24,11 +24,11 @@ import { ITagMap } from "./algorithms/models"; * Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when * it is available to everyone. * - * TODO: Delete this: https://github.com/vector-im/riot-web/issues/14231 + * TODO: Delete this: https://github.com/vector-im/riot-web/issues/14367 */ export class RoomListStoreTempProxy { public static isUsingNewStore(): boolean { - return SettingsStore.isFeatureEnabled("feature_new_room_list"); + return SettingsStore.getValue("feature_new_room_list"); } public static addListener(handler: () => void): RoomListStoreTempToken { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 17e8283c74..3cf71f1ca8 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -30,12 +30,10 @@ import { SortAlgorithm } from "./models"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../filters/IFilterCondition"; -import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } from "../membership"; +import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } from "../../../utils/membership"; import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; -// TODO: Add locking support to avoid concurrent writes? https://github.com/vector-im/riot-web/issues/14235 - /** * Fired when the Algorithm has determined a list has been updated. */ @@ -698,8 +696,8 @@ export class Algorithm extends EventEmitter { } } + let didTagChange = false; if (cause === RoomUpdateCause.PossibleTagChange) { - let didTagChange = false; const oldTags = this.roomIdsToTags[room.roomId] || []; const newTags = this.getTagsForRoom(room); const diff = arrayDiff(oldTags, newTags); @@ -713,6 +711,11 @@ export class Algorithm extends EventEmitter { if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); this.cachedRooms[rmTag] = algorithm.orderedRooms; + + // Later on we won't update the filtered rooms or sticky room for removed + // tags, so do so now. + this.recalculateFilteredRoomsForTag(rmTag); + this.recalculateStickyRoom(rmTag); } for (const addTag of diff.added) { if (!window.mx_QuietRoomListLogging) { @@ -812,7 +815,7 @@ export class Algorithm extends EventEmitter { return false; } - let changed = false; + let changed = didTagChange; for (const tag of tags) { const algorithm: OrderingAlgorithm = this.algorithms[tag]; if (!algorithm) throw new Error(`No algorithm for ${tag}`); diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts index 88789d3a50..b3f1c2b146 100644 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -19,47 +19,29 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomUpdateCause, TagID } from "../../models"; import { SortAlgorithm } from "../models"; import { sortRoomsWithAlgorithm } from "../tag-sorting"; -import * as Unread from '../../../../Unread'; import { OrderingAlgorithm } from "./OrderingAlgorithm"; - -/** - * The determined category of a room. - */ -export enum Category { - /** - * The room has unread mentions within. - */ - Red = "RED", - /** - * The room has unread notifications within. Note that these are not unread - * mentions - they are simply messages which the user has asked to cause a - * badge count update or push notification. - */ - Grey = "GREY", - /** - * The room has unread messages within (grey without the badge). - */ - Bold = "BOLD", - /** - * The room has no relevant unread messages within. - */ - Idle = "IDLE", -} +import { NotificationColor } from "../../../notifications/NotificationColor"; +import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; interface ICategorizedRoomMap { // @ts-ignore - TS wants this to be a string, but we know better - [category: Category]: Room[]; + [category: NotificationColor]: Room[]; } interface ICategoryIndex { // @ts-ignore - TS wants this to be a string, but we know better - [category: Category]: number; // integer + [category: NotificationColor]: number; // integer } // Caution: changing this means you'll need to update a bunch of assumptions and // comments! Check the usage of Category carefully to figure out what needs changing // if you're going to change this array's order. -const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idle]; +const CATEGORY_ORDER = [ + NotificationColor.Red, + NotificationColor.Grey, + NotificationColor.Bold, + NotificationColor.None, // idle +]; /** * An implementation of the "importance" algorithm for room list sorting. Where @@ -92,10 +74,10 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { // noinspection JSMethodCanBeStatic private categorizeRooms(rooms: Room[]): ICategorizedRoomMap { const map: ICategorizedRoomMap = { - [Category.Red]: [], - [Category.Grey]: [], - [Category.Bold]: [], - [Category.Idle]: [], + [NotificationColor.Red]: [], + [NotificationColor.Grey]: [], + [NotificationColor.Bold]: [], + [NotificationColor.None]: [], }; for (const room of rooms) { const category = this.getRoomCategory(room); @@ -105,25 +87,11 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } // noinspection JSMethodCanBeStatic - private getRoomCategory(room: Room): Category { - // Function implementation borrowed from old RoomListStore - - const mentions = room.getUnreadNotificationCount('highlight') > 0; - if (mentions) { - return Category.Red; - } - - let unread = room.getUnreadNotificationCount() > 0; - if (unread) { - return Category.Grey; - } - - unread = Unread.doesRoomHaveUnreadMessages(room); - if (unread) { - return Category.Bold; - } - - return Category.Idle; + private getRoomCategory(room: Room): NotificationColor { + // It's fine for us to call this a lot because it's cached, and we shouldn't be + // wasting anything by doing so as the store holds single references + const state = RoomNotificationStateStore.instance.getRoomState(room, this.tagId); + return state.color; } public async setRooms(rooms: Room[]): Promise { @@ -217,7 +185,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } } - private async sortCategory(category: Category) { + private async sortCategory(category: NotificationColor) { // This should be relatively quick because the room is usually inserted at the top of the // category, and most popular sorting algorithms will deal with trying to keep the active // room at the top/start of the category. For the few algorithms that will have to move the @@ -234,7 +202,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } // noinspection JSMethodCanBeStatic - private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category { + private getCategoryFromIndices(index: number, indices: ICategoryIndex): NotificationColor { for (let i = 0; i < CATEGORY_ORDER.length; i++) { const category = CATEGORY_ORDER[i]; const isLast = i === (CATEGORY_ORDER.length - 1); @@ -250,7 +218,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } // noinspection JSMethodCanBeStatic - private moveRoomIndexes(nRooms: number, fromCategory: Category, toCategory: Category, indices: ICategoryIndex) { + private moveRoomIndexes(nRooms: number, fromCategory: NotificationColor, toCategory: NotificationColor, indices: ICategoryIndex) { // We have to update the index of the category *after* the from/toCategory variables // in order to update the indices correctly. Because the room is moving from/to those // categories, the next category's index will change - not the category we're modifying. @@ -261,7 +229,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { this.alterCategoryPositionBy(toCategory, +nRooms, indices); } - private alterCategoryPositionBy(category: Category, n: number, indices: ICategoryIndex) { + private alterCategoryPositionBy(category: NotificationColor, n: number, indices: ICategoryIndex) { // Note: when we alter a category's index, we actually have to modify the ones following // the target and not the target itself. diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts index ae1a2c98f6..a1c9e4eb85 100644 --- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts @@ -55,7 +55,7 @@ export class NaturalAlgorithm extends OrderingAlgorithm { } } - // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14035 + // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14457 // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags this.cachedOrderedRooms = await sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm); diff --git a/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts index 8d74ebd11e..d909fb6288 100644 --- a/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts @@ -17,8 +17,6 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { TagID } from "../../models"; import { IAlgorithm } from "./IAlgorithm"; -import { MatrixClientPeg } from "../../../../MatrixClientPeg"; -import * as Unread from "../../../../Unread"; /** * Sorts rooms according to the browser's determination of alphabetic. diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index e7ca94ed95..09182f3bfb 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -19,7 +19,7 @@ import { TagID } from "../../models"; import { IAlgorithm } from "./IAlgorithm"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import * as Unread from "../../../../Unread"; -import { EffectiveMembership, getEffectiveMembership } from "../../membership"; +import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership"; /** * Sorts rooms according to the last event's timestamp in each room that seems @@ -33,12 +33,17 @@ export class RecentAlgorithm implements IAlgorithm { // of the rooms to each other. // TODO: We could probably improve the sorting algorithm here by finding changes. - // See https://github.com/vector-im/riot-web/issues/14035 + // See https://github.com/vector-im/riot-web/issues/14459 // For example, if we spent a little bit of time to determine which elements have // actually changed (probably needs to be done higher up?) then we could do an // insertion sort or similar on the limited set of changes. - const myUserId = MatrixClientPeg.get().getUserId(); + // TODO: Don't assume we're using the same client as the peg + // See https://github.com/vector-im/riot-web/issues/14458 + let myUserId = ''; + if (MatrixClientPeg.get()) { + myUserId = MatrixClientPeg.get().getUserId(); + } const tsCache: { [roomId: string]: number } = {}; const getLastTs = (r: Room) => { @@ -68,7 +73,6 @@ export class RecentAlgorithm implements IAlgorithm { const ev = r.timeline[i]; if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) - // TODO: Don't assume we're using the same client as the peg if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { return ev.getTs(); } diff --git a/src/stores/room-list/membership.ts b/src/utils/membership.ts similarity index 100% rename from src/stores/room-list/membership.ts rename to src/utils/membership.ts diff --git a/src/utils/pillify.js b/src/utils/pillify.js index f708ab7770..cb140c61a4 100644 --- a/src/utils/pillify.js +++ b/src/utils/pillify.js @@ -111,7 +111,7 @@ export function pillifyLinks(nodes, mxEvent, pills) { type={Pill.TYPE_AT_ROOM_MENTION} inMessage={true} room={room} - shouldShowPillAvatar={true} + shouldShowPillAvatar={shouldShowPillAvatar} />; ReactDOM.render(pill, pillContainer); diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index d0694a8437..e84f943708 100644 --- a/test/components/views/rooms/RoomList-test.js +++ b/test/components/views/rooms/RoomList-test.js @@ -1,7 +1,6 @@ import React from 'react'; import ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; -import lolex from 'lolex'; import * as TestUtils from '../../../test-utils'; @@ -15,11 +14,18 @@ import GroupStore from '../../../../src/stores/GroupStore.js'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import {DefaultTagID} from "../../../../src/stores/room-list/models"; +import RoomListStore, {LISTS_UPDATE_EVENT, RoomListStore2} from "../../../../src/stores/room-list/RoomListStore2"; +import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; function generateRoomId() { return '!' + Math.random().toString().slice(2, 10) + ':domain'; } +function waitForRoomListStoreUpdate() { + return new Promise((resolve) => { + RoomListStore.instance.once(LISTS_UPDATE_EVENT, () => resolve()); + }); +} describe('RoomList', () => { function createRoom(opts) { @@ -34,7 +40,6 @@ describe('RoomList', () => { let client = null; let root = null; const myUserId = '@me:domain'; - let clock = null; const movingRoomId = '!someroomid'; let movingRoom; @@ -43,25 +48,25 @@ describe('RoomList', () => { let myMember; let myOtherMember; - beforeEach(function() { + beforeEach(async function(done) { + RoomListStore2.TEST_MODE = true; + TestUtils.stubClient(); client = MatrixClientPeg.get(); client.credentials = {userId: myUserId}; //revert this to prototype method as the test-utils monkey-patches this to return a hardcoded value client.getUserId = MatrixClient.prototype.getUserId; - clock = lolex.install(); - DMRoomMap.makeShared(); parentDiv = document.createElement('div'); document.body.appendChild(parentDiv); - const RoomList = sdk.getComponent('views.rooms.RoomList'); + const RoomList = sdk.getComponent('views.rooms.RoomList2'); const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList); root = ReactDOM.render( - + {}} /> , parentDiv); ReactTestUtils.findRenderedComponentWithType(root, RoomList); @@ -102,23 +107,29 @@ describe('RoomList', () => { }); client.getRoom = (roomId) => roomMap[roomId]; + + // Now that everything has been set up, prepare and update the store + await RoomListStore.instance.makeReady(client); + + done(); }); - afterEach((done) => { + afterEach(async (done) => { if (parentDiv) { ReactDOM.unmountComponentAtNode(parentDiv); parentDiv.remove(); parentDiv = null; } - clock.uninstall(); + await RoomListLayoutStore.instance.resetLayouts(); + await RoomListStore.instance.resetStore(); done(); }); function expectRoomInSubList(room, subListTest) { - const RoomSubList = sdk.getComponent('structures.RoomSubList'); - const RoomTile = sdk.getComponent('views.rooms.RoomTile'); + const RoomSubList = sdk.getComponent('views.rooms.RoomSublist2'); + const RoomTile = sdk.getComponent('views.rooms.RoomTile2'); const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSubList); const containingSubList = subLists.find(subListTest); @@ -140,20 +151,20 @@ describe('RoomList', () => { expect(expectedRoomTile.props.room).toBe(room); } - function expectCorrectMove(oldTag, newTag) { - const getTagSubListTest = (tag) => { - if (tag === undefined) return (s) => s.props.label.endsWith('Rooms'); - return (s) => s.props.tagName === tag; + function expectCorrectMove(oldTagId, newTagId) { + const getTagSubListTest = (tagId) => { + return (s) => s.props.tagId === tagId; }; // Default to finding the destination sublist with newTag - const destSubListTest = getTagSubListTest(newTag); - const srcSubListTest = getTagSubListTest(oldTag); + const destSubListTest = getTagSubListTest(newTagId); + const srcSubListTest = getTagSubListTest(oldTagId); // Set up the room that will be moved such that it has the correct state for a room in - // the section for oldTag - if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}}; - if (oldTag === DefaultTagID.DM) { + // the section for oldTagId + if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) { + movingRoom.tags = {[oldTagId]: {}}; + } else if (oldTagId === DefaultTagID.DM) { // Mock inverse m.direct DMRoomMap.shared().roomToUser = { [movingRoom.roomId]: '@someotheruser:domain', @@ -162,17 +173,12 @@ describe('RoomList', () => { dis.dispatch({action: 'MatrixActions.sync', prevState: null, state: 'PREPARED', matrixClient: client}); - clock.runAll(); - expectRoomInSubList(movingRoom, srcSubListTest); dis.dispatch({action: 'RoomListActions.tagRoom.pending', request: { - oldTag, newTag, room: movingRoom, + oldTagId, newTagId, room: movingRoom, }}); - // Run all setTimeouts for dispatches and room list rate limiting - clock.runAll(); - expectRoomInSubList(movingRoom, destSubListTest); } @@ -269,6 +275,12 @@ describe('RoomList', () => { }; GroupStore._notifyListeners(); + // We also have to mock the client's getGroup function for the room list to filter it. + // It's not smart enough to tell the difference between a real group and a template though. + client.getGroup = (groupId) => { + return {groupId}; + }; + // Select tag dis.dispatch({action: 'select_tag', tag: '+group:domain'}, true); } @@ -277,17 +289,14 @@ describe('RoomList', () => { setupSelectedTag(); }); - it('displays the correct rooms when the groups rooms are changed', () => { + it('displays the correct rooms when the groups rooms are changed', async () => { GroupStore.getGroupRooms = (groupId) => { return [movingRoom, otherRoom]; }; GroupStore._notifyListeners(); - // Run through RoomList debouncing - clock.runAll(); - - // By default, the test will - expectRoomInSubList(otherRoom, (s) => s.props.label.endsWith('Rooms')); + await waitForRoomListStoreUpdate(); + expectRoomInSubList(otherRoom, (s) => s.props.tagId === DefaultTagID.Untagged); }); itDoesCorrectOptimisticUpdatesForDraggedRoomTiles(); diff --git a/test/end-to-end-tests/src/usecases/accept-invite.js b/test/end-to-end-tests/src/usecases/accept-invite.js index 3f208cc1fc..d38fdcd0db 100644 --- a/test/end-to-end-tests/src/usecases/accept-invite.js +++ b/test/end-to-end-tests/src/usecases/accept-invite.js @@ -15,10 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +const {findSublist} = require("./create-room"); + module.exports = async function acceptInvite(session, name) { session.log.step(`accepts "${name}" invite`); - //TODO: brittle selector - const invitesHandles = await session.queryAll('.mx_RoomTile_name.mx_RoomTile_invite'); + const inviteSublist = await findSublist(session, "invites"); + const invitesHandles = await inviteSublist.$$(".mx_RoomTile2_name"); const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => { const text = await session.innerText(inviteHandle); return {inviteHandle, text}; diff --git a/test/end-to-end-tests/src/usecases/create-room.js b/test/end-to-end-tests/src/usecases/create-room.js index 7e219fd159..24e42b92dd 100644 --- a/test/end-to-end-tests/src/usecases/create-room.js +++ b/test/end-to-end-tests/src/usecases/create-room.js @@ -16,21 +16,27 @@ limitations under the License. */ async function openRoomDirectory(session) { - const roomDirectoryButton = await session.query('.mx_LeftPanel_explore .mx_AccessibleButton'); + const roomDirectoryButton = await session.query('.mx_LeftPanel2_exploreButton'); await roomDirectoryButton.click(); } +async function findSublist(session, name) { + const sublists = await session.queryAll('.mx_RoomSublist2'); + for (const sublist of sublists) { + const header = await sublist.$('.mx_RoomSublist2_headerText'); + const headerText = await session.innerText(header); + if (headerText.toLowerCase().includes(name.toLowerCase())) { + return sublist; + } + } + throw new Error(`could not find room list section that contains '${name}' in header`); +} + async function createRoom(session, roomName, encrypted=false) { session.log.step(`creates room "${roomName}"`); - const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); - const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); - const roomsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes("rooms")); - if (roomsIndex === -1) { - throw new Error("could not find room list section that contains 'rooms' in header"); - } - const roomsHeader = roomListHeaders[roomsIndex]; - const addRoomButton = await roomsHeader.$(".mx_RoomSubList_addRoom"); + const roomsSublist = await findSublist(session, "rooms"); + const addRoomButton = await roomsSublist.$(".mx_RoomSublist2_auxButton"); await addRoomButton.click(); const roomNameInput = await session.query('.mx_CreateRoomDialog_name input'); @@ -51,14 +57,8 @@ async function createRoom(session, roomName, encrypted=false) { async function createDm(session, invitees) { session.log.step(`creates DM with ${JSON.stringify(invitees)}`); - const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); - const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); - const dmsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes('direct messages')); - if (dmsIndex === -1) { - throw new Error("could not find room list section that contains 'direct messages' in header"); - } - const dmsHeader = roomListHeaders[dmsIndex]; - const startChatButton = await dmsHeader.$(".mx_RoomSubList_addRoom"); + const dmsSublist = await findSublist(session, "people"); + const startChatButton = await dmsSublist.$(".mx_RoomSublist2_auxButton"); await startChatButton.click(); const inviteesEditor = await session.query('.mx_InviteDialog_editor textarea'); @@ -83,4 +83,4 @@ async function createDm(session, invitees) { session.log.done(); } -module.exports = {openRoomDirectory, createRoom, createDm}; +module.exports = {openRoomDirectory, findSublist, createRoom, createDm};