diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index e64057d16c9..1dea6332f58 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpaceButton:hover, .mx_SpaceButton:focus-within, .mx_SpaceButton_hasMenuOpen { - &:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { + &:not(.mx_SpaceButton_invite) { // Hide the badge container on hover because it'll be a menu button .mx_SpacePanel_badgeContainer { width: 0; @@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_iconExplore::before { mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); } + + .mx_SpacePanel_noIcon { + display: none; + + & + .mx_IconizedContextMenu_label { + padding-left: 5px !important; // override default iconized label style to align with header + } + } } diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 2aa43283b58..ff176eef7e4 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -149,12 +149,17 @@ limitations under the License. } } - .mx_IconizedContextMenu_checked { + .mx_IconizedContextMenu_checked, + .mx_IconizedContextMenu_unchecked { margin-left: 16px; margin-right: -5px; + } - &::before { - mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); - } + .mx_IconizedContextMenu_checked::before { + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + + .mx_IconizedContextMenu_unchecked::before { + content: unset; } } diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index 8e6b99871c0..9f403726906 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -72,6 +72,13 @@ limitations under the License. padding-right: 10px; } +.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_microcopy { + margin-top: 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; +} + .mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch { float: right; } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 407dc6f04c3..0822d3768b5 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { CSSProperties, RefObject, useRef, useState } from "react"; +import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; @@ -461,10 +461,14 @@ type ContextMenuTuple = [boolean, RefObject, () => void, () => void, (val: export const useContextMenu = (): ContextMenuTuple => { const button = useRef(null); const [isOpen, setIsOpen] = useState(false); - const open = () => { + const open = (ev?: SyntheticEvent) => { + ev?.preventDefault(); + ev?.stopPropagation(); setIsOpen(true); }; - const close = () => { + const close = (ev?: SyntheticEvent) => { + ev?.preventDefault(); + ev?.stopPropagation(); setIsOpen(false); }; diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index c9506d7c987..571b0b39bf7 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -86,7 +86,10 @@ export const IconizedContextMenuCheckbox: React.FC = ({ > { label } - { active && } + ; }; diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx new file mode 100644 index 00000000000..3da00e71aa7 --- /dev/null +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -0,0 +1,216 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useContext } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { + IProps as IContextMenuProps, +} from "../../structures/ContextMenu"; +import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; +import { _t } from "../../../languageHandler"; +import { + leaveSpace, + shouldShowSpaceSettings, + showAddExistingRooms, + showCreateNewRoom, + showCreateNewSubspace, + showSpaceInvite, + showSpaceSettings, +} from "../../../utils/space"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import RoomViewStore from "../../../stores/RoomViewStore"; +import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; +import { Action } from "../../../dispatcher/actions"; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; +import { BetaPill } from "../beta/BetaCard"; + +interface IProps extends IContextMenuProps { + space: Room; +} + +const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => { + const cli = useContext(MatrixClientContext); + const userId = cli.getUserId(); + + let inviteOption; + if (space.getJoinRule() === "public" || space.canInvite(userId)) { + const onInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceInvite(space); + onFinished(); + }; + + inviteOption = ( + + ); + } + + let settingsOption; + let leaveSection; + if (shouldShowSpaceSettings(space)) { + const onSettingsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showSpaceSettings(space); + onFinished(); + }; + + settingsOption = ( + + ); + } else { + const onLeaveClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + leaveSpace(space); + onFinished(); + }; + + leaveSection = + + ; + } + + const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + + let newRoomSection; + if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { + const onNewRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewRoom(space); + onFinished(); + }; + + const onAddExistingRoomClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showAddExistingRooms(space); + onFinished(); + }; + + const onNewSubspaceClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCreateNewSubspace(space); + onFinished(); + }; + + newRoomSection = + + + + + + ; + } + + const onMembersClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (!RoomViewStore.getRoomId()) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }, true); + } + + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space: space }, + }); + onFinished(); + }; + + const onExploreRoomsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }); + onFinished(); + }; + + return +
+ { space.name } +
+ + { inviteOption } + + { settingsOption } + + + { newRoomSection } + { leaveSection } +
; +}; + +export default SpaceContextMenu; + diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 2e5db59d9b3..d3da8a77844 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -26,6 +26,7 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent" import SettingsFlag from '../../../elements/SettingsFlag'; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import AccessibleButton from "../../../elements/AccessibleButton"; +import SpaceStore from "../../../../../stores/SpaceStore"; interface IState { autoLaunch: boolean; @@ -47,6 +48,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta 'breadcrumbs', ]; + static SPACES_SETTINGS = [ + "Spaces.allRoomsInHome", + ]; + static KEYBINDINGS_SETTINGS = [ 'ctrlFForSearch', ]; @@ -231,6 +236,11 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } + { SpaceStore.spacesEnabled &&
+ { _t("Spaces") } + { this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) } +
} +
{ _t("Keyboard shortcuts") } diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 1c4043f1504..58e1db4b1dd 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -14,141 +14,157 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; -import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; +import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; +import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; import { _t } from "../../../languageHandler"; -import RoomAvatar from "../avatars/RoomAvatar"; import { useContextMenu } from "../../structures/ContextMenu"; import SpaceCreateMenu from "./SpaceCreateMenu"; -import { SpaceItem } from "./SpaceTreeLevel"; +import { SpaceButton, SpaceItem } from "./SpaceTreeLevel"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import SpaceStore, { HOME_SPACE, + UPDATE_HOME_BEHAVIOUR, UPDATE_INVITED_SPACES, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES, } from "../../../stores/SpaceStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; -import NotificationBadge from "../rooms/NotificationBadge"; -import { - RovingAccessibleButton, - RovingAccessibleTooltipButton, - RovingTabIndexProvider, -} from "../../../accessibility/RovingTabIndex"; +import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import { Key } from "../../../Keyboard"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; -import { NotificationState } from "../../../stores/notifications/NotificationState"; +import SpaceContextMenu from "../context_menus/SpaceContextMenu"; +import IconizedContextMenu, { + IconizedContextMenuCheckbox, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; -interface IButtonProps { - space?: Room; - className?: string; - selected?: boolean; - tooltip?: string; - notificationState?: NotificationState; - isNarrow?: boolean; - onClick(): void; +const useSpaces = (): [Room[], Room[], Room | null] => { + const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { + return SpaceStore.instance.invitedSpaces; + }); + const spaces = useEventEmitterState(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, () => { + return SpaceStore.instance.spacePanelSpaces; + }); + const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => { + return SpaceStore.instance.activeSpace; + }); + return [invites, spaces, activeSpace]; +}; + +interface IInnerSpacePanelProps { + children?: ReactNode; + isPanelCollapsed: boolean; + setPanelCollapsed: Dispatch>; } -const SpaceButton: React.FC = ({ - space, - className, - selected, - onClick, - tooltip, - notificationState, - isNarrow, - children, -}) => { - const classes = classNames("mx_SpaceButton", className, { - mx_SpaceButton_active: selected, - mx_SpaceButton_narrow: isNarrow, +const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps) => { + const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => { + return SpaceStore.instance.allRoomsInHome; }); - let avatar =
; - if (space) { - avatar = ; - } - - let notifBadge; - if (notificationState) { - notifBadge =
- SpaceStore.instance.setActiveRoomInSpace(space)} - forceCount={false} - notification={notificationState} + return +
+ { _t("Home") } +
+ + { + SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome); + }} /> -
; - } + + ; +}; - let button; - if (isNarrow) { - button = ( - -
- { avatar } - { notifBadge } - { children } -
-
- ); - } else { - button = ( - -
- { avatar } - { tooltip } - { notifBadge } - { children } -
-
- ); - } +interface IHomeButtonProps { + selected: boolean; + isPanelCollapsed: boolean; +} + +const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => { + const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => { + return SpaceStore.instance.allRoomsInHome; + }); - return
  • - { button } + SpaceStore.instance.setActiveSpace(null)} + selected={selected} + label={allRoomsInHome ? _t("All rooms") : _t("Home")} + notificationState={allRoomsInHome + ? RoomNotificationStateStore.instance.globalState + : SpaceStore.instance.getNotificationState(HOME_SPACE)} + isNarrow={isPanelCollapsed} + ContextMenuComponent={HomeButtonContextMenu} + contextMenuTooltip={_t("Options")} + />
  • ; }; -const useSpaces = (): [Room[], Room[], Room | null] => { - const [invites, setInvites] = useState(SpaceStore.instance.invitedSpaces); - useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites); - const [spaces, setSpaces] = useState(SpaceStore.instance.spacePanelSpaces); - useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); - const [activeSpace, setActiveSpace] = useState(SpaceStore.instance.activeSpace); - useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); - return [invites, spaces, activeSpace]; -}; +const CreateSpaceButton = ({ + isPanelCollapsed, + setPanelCollapsed, +}: Pick) => { + // We don't need the handle as we position the menu in a constant location + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); -interface IInnerSpacePanelProps { - children?: ReactNode; - isPanelCollapsed: boolean; - setPanelCollapsed: Dispatch>; -} + useEffect(() => { + if (!isPanelCollapsed && menuDisplayed) { + closeMenu(); + } + }, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps + + let contextMenu = null; + if (menuDisplayed) { + contextMenu = ; + } + + const onNewClick = menuDisplayed ? closeMenu : () => { + if (!isPanelCollapsed) setPanelCollapsed(true); + openMenu(); + }; + + return
  • + + + { contextMenu } +
  • ; +}; // Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation const InnerSpacePanel = React.memo(({ children, isPanelCollapsed, setPanelCollapsed }) => { const [invites, spaces, activeSpace] = useSpaces(); const activeSpaces = activeSpace ? [activeSpace] : []; - const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled - ? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE); - return
    - SpaceStore.instance.setActiveSpace(null)} - selected={!activeSpace} - tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")} - notificationState={homeNotificationState} - isNarrow={isPanelCollapsed} - /> + { invites.map(s => ( (({ children, isPanelCo )) } { children } +
    ; }); const SpacePanel = () => { - // We don't need the handle as we position the menu in a constant location - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const [isPanelCollapsed, setPanelCollapsed] = useState(true); - useEffect(() => { - if (!isPanelCollapsed && menuDisplayed) { - closeMenu(); - } - }, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps - - let contextMenu = null; - if (menuDisplayed) { - contextMenu = ; - } - const onKeyDown = (ev: React.KeyboardEvent) => { let handled = true; @@ -259,11 +262,6 @@ const SpacePanel = () => { } }; - const onNewClick = menuDisplayed ? closeMenu : () => { - if (!isPanelCollapsed) setPanelCollapsed(true); - openMenu(); - }; - return ( { if (!result.destination) return; // dropped outside the list @@ -291,15 +289,6 @@ const SpacePanel = () => { > { provided.placeholder } - - ) } @@ -308,7 +297,6 @@ const SpacePanel = () => { onClick={() => setPanelCollapsed(!isPanelCollapsed)} title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")} /> - { contextMenu } ) } diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 827fc6bde17..bb2184853e3 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -14,7 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, InputHTMLAttributes, LegacyRef } from "react"; +import React, { + createRef, + MouseEvent, + InputHTMLAttributes, + LegacyRef, + ComponentProps, + ComponentType, +} from "react"; import classNames from "classnames"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -23,34 +30,104 @@ import SpaceStore from "../../../stores/SpaceStore"; import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore"; import NotificationBadge from "../rooms/NotificationBadge"; import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../context_menus/IconizedContextMenu"; import { _t } from "../../../languageHandler"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; -import { toRightOf } from "../../structures/ContextMenu"; -import { - leaveSpace, - shouldShowSpaceSettings, - showAddExistingRooms, - showCreateNewRoom, - showCreateNewSubspace, - showSpaceInvite, - showSpaceSettings, -} from "../../../utils/space"; +import { toRightOf, useContextMenu } from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; -import defaultDispatcher from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; -import RoomViewStore from "../../../stores/RoomViewStore"; -import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; -import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; -import { EventType } from "matrix-js-sdk/src/@types/event"; +import AccessibleButton from "../elements/AccessibleButton"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager"; -import { BetaPill } from "../beta/BetaCard"; +import { NotificationState } from "../../../stores/notifications/NotificationState"; +import SpaceContextMenu from "../context_menus/SpaceContextMenu"; + +interface IButtonProps extends Omit, "title"> { + space?: Room; + className?: string; + selected?: boolean; + label: string; + contextMenuTooltip?: string; + notificationState?: NotificationState; + isNarrow?: boolean; + avatarSize?: number; + ContextMenuComponent?: ComponentType>; + onClick(ev: MouseEvent): void; +} + +export const SpaceButton: React.FC = ({ + space, + className, + selected, + onClick, + label, + contextMenuTooltip, + notificationState, + avatarSize, + isNarrow, + children, + ContextMenuComponent, + ...props +}) => { + const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); + + let avatar =
    ; + if (space) { + avatar = ; + } + + let notifBadge; + if (notificationState) { + notifBadge =
    + SpaceStore.instance.setActiveRoomInSpace(space || null)} + forceCount={false} + notification={notificationState} + /> +
    ; + } + + let contextMenu: JSX.Element; + if (menuDisplayed && ContextMenuComponent) { + contextMenu = ; + } + + return ( + + { children } +
    + { avatar } + { !isNarrow && { label } } + { notifBadge } + + { ContextMenuComponent && } + + { contextMenu } +
    +
    + ); +}; interface IItemProps extends InputHTMLAttributes { space?: Room; @@ -64,7 +141,6 @@ interface IItemProps extends InputHTMLAttributes { interface IItemState { collapsed: boolean; - contextMenuPosition: Pick; childSpaces: Room[]; } @@ -84,7 +160,6 @@ export class SpaceItem extends React.PureComponent { this.state = { collapsed: collapsed, - contextMenuPosition: null, childSpaces: this.childSpaces, }; @@ -127,19 +202,6 @@ export class SpaceItem extends React.PureComponent { evt.stopPropagation(); }; - private onContextMenu = (ev: React.MouseEvent) => { - if (this.props.space.getMyMembership() !== "join") return; - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - contextMenuPosition: { - right: ev.clientX, - top: ev.clientY, - height: 0, - }, - }); - }; - private onKeyDown = (ev: React.KeyboardEvent) => { let handled = true; const action = getKeyBindingsManager().getRoomListAction(ev); @@ -183,200 +245,6 @@ export class SpaceItem extends React.PureComponent { SpaceStore.instance.setActiveSpace(this.props.space); }; - private onMenuOpenClick = (ev: React.MouseEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - const target = ev.target as HTMLButtonElement; - this.setState({ contextMenuPosition: target.getBoundingClientRect() }); - }; - - private onMenuClose = () => { - this.setState({ contextMenuPosition: null }); - }; - - private onInviteClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showSpaceInvite(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onSettingsClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showSpaceSettings(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onLeaveClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - leaveSpace(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onNewRoomClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showCreateNewRoom(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onAddExistingRoomClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showAddExistingRooms(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onNewSubspaceClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - showCreateNewSubspace(this.props.space); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onMembersClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - if (!RoomViewStore.getRoomId()) { - defaultDispatcher.dispatch({ - action: "view_room", - room_id: this.props.space.roomId, - }, true); - } - - defaultDispatcher.dispatch({ - action: Action.SetRightPanelPhase, - phase: RightPanelPhases.SpaceMemberList, - refireParams: { space: this.props.space }, - }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private onExploreRoomsClick = (ev: ButtonEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - - defaultDispatcher.dispatch({ - action: "view_room", - room_id: this.props.space.roomId, - }); - this.setState({ contextMenuPosition: null }); // also close the menu - }; - - private renderContextMenu(): React.ReactElement { - if (this.props.space.getMyMembership() !== "join") return null; - - let contextMenu = null; - if (this.state.contextMenuPosition) { - const userId = this.context.getUserId(); - - let inviteOption; - if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) { - inviteOption = ( - - ); - } - - let settingsOption; - let leaveSection; - if (shouldShowSpaceSettings(this.props.space)) { - settingsOption = ( - - ); - } else { - leaveSection = - - ; - } - - const canAddRooms = this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId); - - let newRoomSection; - if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { - newRoomSection = - - - - - - ; - } - - contextMenu = -
    - { this.props.space.name } -
    - - { inviteOption } - - { settingsOption } - - - { newRoomSection } - { leaveSection } -
    ; - } - - return ( - - - { contextMenu } - - ); - } - render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, @@ -384,7 +252,6 @@ export class SpaceItem extends React.PureComponent { const collapsed = this.isCollapsed; - const isActive = activeSpaces.includes(space); const itemClasses = classNames(this.props.className, { "mx_SpaceItem": true, "mx_SpaceItem_narrow": isPanelCollapsed, @@ -393,12 +260,7 @@ export class SpaceItem extends React.PureComponent { }); const isInvite = space.getMyMembership() === "invite"; - const classes = classNames("mx_SpaceButton", { - mx_SpaceButton_active: isActive, - mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition, - mx_SpaceButton_narrow: isPanelCollapsed, - mx_SpaceButton_invite: isInvite, - }); + const notificationState = isInvite ? StaticNotificationState.forSymbol("!", NotificationColor.Red) : SpaceStore.instance.getNotificationState(space.roomId); @@ -413,19 +275,6 @@ export class SpaceItem extends React.PureComponent { />; } - let notifBadge; - if (notificationState) { - notifBadge =
    - SpaceStore.instance.setActiveRoomInSpace(space)} - forceCount={false} - notification={notificationState} - /> -
    ; - } - - const avatarSize = isNested ? 24 : 32; - const toggleCollapseButton = this.state.childSpaces?.length ? { return (
  • - { toggleCollapseButton } -
    - - { !isPanelCollapsed && { space.name } } - { notifBadge } - { this.renderContextMenu() } -
    -
    + { childItems }
  • diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 06cbbba46cd..2a8ce7a08b4 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -198,4 +198,11 @@ export enum Action { * Signals to the visible space hierarchy that a change has occurred an that it should refresh. */ UpdateSpaceHierarchy = "update_space_hierarchy", + + /** + * Fires when a monitored setting is updated, + * see SettingsStore::monitorSetting for more details. + * Should be used with SettingUpdatedPayload. + */ + SettingUpdated = "setting_updated", } diff --git a/src/dispatcher/payloads/SettingUpdatedPayload.ts b/src/dispatcher/payloads/SettingUpdatedPayload.ts new file mode 100644 index 00000000000..8d457facfbb --- /dev/null +++ b/src/dispatcher/payloads/SettingUpdatedPayload.ts @@ -0,0 +1,29 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ActionPayload } from "../payloads"; +import { Action } from "../actions"; +import { SettingLevel } from "../../settings/SettingLevel"; + +export interface SettingUpdatedPayload extends ActionPayload { + action: Action.SettingUpdated; + + settingName: string; + roomId: string; + level: SettingLevel; + newValueAtLevel: SettingLevel; + newValue: any; +} diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts index a81bba56995..74b23f0198f 100644 --- a/src/hooks/useEventEmitter.ts +++ b/src/hooks/useEventEmitter.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState, useCallback } from "react"; import type { EventEmitter } from "events"; type Handler = (...args: any[]) => void; @@ -48,3 +48,14 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo [eventName, emitter], // Re-run if eventName or emitter changes ); }; + +type Mapper = (...args: any[]) => T; + +export const useEventEmitterState = (emitter: EventEmitter, eventName: string | symbol, fn: Mapper): T => { + const [value, setValue] = useState(fn()); + const handler = useCallback((...args: any[]) => { + setValue(fn(...args)); + }, [fn]); + useEventEmitter(emitter, eventName, handler); + return value; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 11d1f2140ac..95625479620 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -796,7 +796,6 @@ "You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.", "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.", "Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.", - "Show all rooms in Home": "Show all rooms in Home", "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", "Send and receive voice messages": "Send and receive voice messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages", @@ -867,6 +866,8 @@ "Manually verify all remote sessions": "Manually verify all remote sessions", "IRC display name width": "IRC display name width", "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", + "Show all rooms in Home": "Show all rooms in Home", + "All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading logs": "Uploading logs", @@ -1025,8 +1026,10 @@ "You can change these anytime.": "You can change these anytime.", "Creating...": "Creating...", "Create": "Create", - "All rooms": "All rooms", "Home": "Home", + "Show all rooms": "Show all rooms", + "All rooms": "All rooms", + "Options": "Options", "Expand space panel": "Expand space panel", "Collapse space panel": "Collapse space panel", "Click to copy": "Click to copy", @@ -1056,17 +1059,9 @@ "Preview Space": "Preview Space", "Allow people to preview your space before they join.": "Allow people to preview your space before they join.", "Recommended for public spaces.": "Recommended for public spaces.", - "Settings": "Settings", - "Leave space": "Leave space", - "Create new room": "Create new room", - "Add existing room": "Add existing room", - "Add space": "Add space", - "Members": "Members", - "Manage & explore rooms": "Manage & explore rooms", - "Explore rooms": "Explore rooms", - "Space options": "Space options", "Expand": "Expand", "Collapse": "Collapse", + "Space options": "Space options", "Remove": "Remove", "This bridge was provisioned by .": "This bridge was provisioned by .", "This bridge is managed by .": "This bridge is managed by .", @@ -1592,8 +1587,11 @@ "Start chat": "Start chat", "Rooms": "Rooms", "Add room": "Add room", + "Create new room": "Create new room", "You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space", + "Add existing room": "Add existing room", "You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space", + "Explore rooms": "Explore rooms", "Explore community rooms": "Explore community rooms", "Explore public rooms": "Explore public rooms", "Low priority": "Low priority", @@ -1671,6 +1669,7 @@ "Low Priority": "Low Priority", "Invite People": "Invite People", "Copy Room Link": "Copy Room Link", + "Settings": "Settings", "Leave Room": "Leave Room", "Room options": "Room options", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", @@ -1764,13 +1763,13 @@ "The homeserver the user you’re verifying is connected to": "The homeserver the user you’re verifying is connected to", "Yours, or the other users’ internet connection": "Yours, or the other users’ internet connection", "Yours, or the other users’ session": "Yours, or the other users’ session", + "Members": "Members", "Nothing pinned, yet": "Nothing pinned, yet", "If you have permissions, open the menu on any message and select Pin to stick them here.": "If you have permissions, open the menu on any message and select Pin to stick them here.", "Pinned messages": "Pinned messages", "Room Info": "Room Info", "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", "Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel", - "Options": "Options", "Set my room layout for everyone": "Set my room layout for everyone", "Widgets": "Widgets", "Edit widgets, bridges & bots": "Edit widgets, bridges & bots", @@ -2385,6 +2384,7 @@ "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.", "Leave %(spaceName)s": "Leave %(spaceName)s", "Are you sure you want to leave ?": "Are you sure you want to leave ?", + "Leave space": "Leave space", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", "Start using Key Backup": "Start using Key Backup", "I don't want my encrypted messages": "I don't want my encrypted messages", @@ -2593,6 +2593,8 @@ "Source URL": "Source URL", "Collapse reply thread": "Collapse reply thread", "Report": "Report", + "Add space": "Add space", + "Manage & explore rooms": "Manage & explore rooms", "Clear status": "Clear status", "Update status": "Update status", "Set status": "Set status", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 64edd4c2027..7ca57adb083 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -180,17 +180,8 @@ export const SETTINGS: {[setting: string]: ISetting} = { feedbackSubheading: _td("Your feedback will help make spaces better. " + "The more detail you can go into, the better."), feedbackLabel: "spaces-feedback", - extraSettings: [ - "feature_spaces.all_rooms", - ], }, }, - "feature_spaces.all_rooms": { - displayName: _td("Show all rooms in Home"), - supportedLevels: LEVELS_FEATURE, - default: true, - controller: new ReloadOnChangeController(), - }, "feature_dnd": { isFeature: true, displayName: _td("Show options to enable 'Do not disturb' mode"), @@ -758,6 +749,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, }, + "Spaces.allRoomsInHome": { + displayName: _td("Show all rooms in Home"), + description: _td("All rooms you're in will appear in Home."), + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + default: false, + }, [UIFeature.RoomHistorySettings]: { supportedLevels: LEVELS_UI_FEATURE, default: true, diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 44f3d5d838b..c5b83cbcd00 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -29,6 +29,8 @@ import LocalEchoWrapper from "./handlers/LocalEchoWrapper"; import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager"; import { SettingLevel } from "./SettingLevel"; import SettingsHandler from "./handlers/SettingsHandler"; +import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; +import { Action } from "../dispatcher/actions"; const defaultWatchManager = new WatchManager(); @@ -147,7 +149,7 @@ export default class SettingsStore { * if the change in value is worthwhile enough to react upon. * @returns {string} A reference to the watcher that was employed. */ - public static watchSetting(settingName: string, roomId: string, callbackFn: CallbackFn): string { + public static watchSetting(settingName: string, roomId: string | null, callbackFn: CallbackFn): string { const setting = SETTINGS[settingName]; const originalSettingName = settingName; if (!setting) throw new Error(`${settingName} is not a setting`); @@ -193,7 +195,7 @@ export default class SettingsStore { * @param {string} settingName The setting name to monitor. * @param {String} roomId The room ID to monitor for changes in. Use null for all rooms. */ - public static monitorSetting(settingName: string, roomId: string) { + public static monitorSetting(settingName: string, roomId: string | null) { roomId = roomId || null; // the thing wants null specifically to work, so appease it. if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map()); @@ -201,8 +203,8 @@ export default class SettingsStore { const registerWatcher = () => { this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting( settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => { - dis.dispatch({ - action: 'setting_updated', + dis.dispatch({ + action: Action.SettingUpdated, settingName, roomId: inRoomId, level, diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index aceaf8b8988..8a85ca354fd 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -23,6 +23,8 @@ import { arrayHasDiff } from "../utils/arrays"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { SettingLevel } from "../settings/SettingLevel"; import SpaceStore from "./SpaceStore"; +import { Action } from "../dispatcher/actions"; +import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; const MAX_ROOMS = 20; // arbitrary const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up @@ -63,10 +65,11 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { protected async onAction(payload: ActionPayload) { if (!this.matrixClient) return; - if (payload.action === 'setting_updated') { - if (payload.settingName === 'breadcrumb_rooms') { + if (payload.action === Action.SettingUpdated) { + const settingUpdatedPayload = payload as SettingUpdatedPayload; + if (settingUpdatedPayload.settingName === 'breadcrumb_rooms') { await this.updateRooms(); - } else if (payload.settingName === 'breadcrumbs') { + } else if (settingUpdatedPayload.settingName === 'breadcrumbs') { await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); } } else if (payload.action === 'view_room') { diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index dfa8bef8be5..da18646d0f5 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -37,9 +37,8 @@ import { EnhancedMap, mapDiff } from "../utils/maps"; import { setHasDiff } from "../utils/sets"; import RoomViewStore from "./RoomViewStore"; import { Action } from "../dispatcher/actions"; -import { arrayHasDiff } from "../utils/arrays"; +import { arrayHasDiff, arrayHasOrderChange } from "../utils/arrays"; import { objectDiff } from "../utils/objects"; -import { arrayHasOrderChange } from "../utils/arrays"; import { reorderLexicographically } from "../utils/stringOrderField"; import { TAG_ORDER } from "../components/views/rooms/RoomList"; import { shouldShowSpaceSettings } from "../utils/space"; @@ -48,6 +47,7 @@ import { _t } from "../languageHandler"; import GenericToast from "../components/views/toasts/GenericToast"; import Modal from "../Modal"; import InfoDialog from "../components/views/dialogs/InfoDialog"; +import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; type SpaceKey = string | symbol; @@ -61,6 +61,7 @@ export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); +export const UPDATE_HOME_BEHAVIOUR = Symbol("home-behaviour"); // Space Room ID/HOME_SPACE will be emitted when a Space's children change export interface ISuggestedRoom extends ISpaceSummaryRoom { @@ -69,12 +70,10 @@ export interface ISuggestedRoom extends ISpaceSummaryRoom { const MAX_SUGGESTED_ROOMS = 20; -// All of these settings cause the page to reload and can be costly if read frequently, so read them here only +// This setting causes the page to reload and can be costly if read frequently, so read it here only const spacesEnabled = SettingsStore.getValue("feature_spaces"); -const spacesTweakAllRoomsEnabled = SettingsStore.getValue("feature_spaces.all_rooms"); -const homeSpaceKey = spacesTweakAllRoomsEnabled ? "ALL_ROOMS" : "HOME_SPACE"; -const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`; +const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "HOME_SPACE"}`; const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] return arr.reduce((result, room: Room) => { @@ -102,10 +101,6 @@ const getRoomFn: FetchRoomFn = (room: Room) => { }; export class SpaceStoreClass extends AsyncStoreWithClient { - constructor() { - super(defaultDispatcher, {}); - } - // The spaces representing the roots of the various tree-like hierarchies private rootSpaces: Room[] = []; // The list of rooms not present in any currently joined spaces @@ -122,6 +117,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private _invitedSpaces = new Set(); private spaceOrderLocalEchoMap = new Map(); private _restrictedJoinRuleSupport?: IRoomCapability; + private _allRoomsInHome: boolean = SettingsStore.getValue("Spaces.allRoomsInHome"); + + constructor() { + super(defaultDispatcher, {}); + + SettingsStore.monitorSetting("Spaces.allRoomsInHome", null); + } public get invitedSpaces(): Room[] { return Array.from(this._invitedSpaces); @@ -139,13 +141,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._suggestedRooms; } + public get allRoomsInHome(): boolean { + return this._allRoomsInHome; + } + public async setActiveRoomInSpace(space: Room | null): Promise { if (space && !space.isSpaceRoom()) return; if (space !== this.activeSpace) await this.setActiveSpace(space); if (space) { - const notificationState = this.getNotificationState(space.roomId); - const roomId = notificationState.getFirstRoomWithNotifications(); + const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications(); defaultDispatcher.dispatch({ action: "view_room", room_id: roomId, @@ -200,7 +205,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // else if the last viewed room in this space is joined then view that // else view space home or home depending on what is being clicked on if (space?.getMyMembership() !== "invite" && - this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" + this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" && + this.getSpaceFilteredRoomIds(space).has(roomId) ) { defaultDispatcher.dispatch({ action: "view_room", @@ -377,7 +383,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getSpaceFilteredRoomIds = (space: Room | null): Set => { - if (!space && spacesTweakAllRoomsEnabled) { + if (!space && this.allRoomsInHome) { return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); } return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); @@ -474,7 +480,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private showInHomeSpace = (room: Room) => { - if (spacesTweakAllRoomsEnabled) return true; + if (this.allRoomsInHome) return true; if (room.isSpaceRoom()) return false; return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space @@ -506,7 +512,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - if (!spacesTweakAllRoomsEnabled) { + if (!this.allRoomsInHome) { // put all room invites in the Home Space const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); @@ -562,8 +568,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); this.spaceFilteredRooms.forEach((roomIds, s) => { + if (this.allRoomsInHome && s === HOME_SPACE) return; // we'll be using the global notification state, skip + // Update NotificationStates - this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { + this.getNotificationState(s).setRooms(visibleRooms.filter(room => { if (!roomIds.has(room.roomId)) return false; if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { @@ -663,7 +671,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { this.onSpaceUpdate(); - } else if (!spacesTweakAllRoomsEnabled) { + } else if (!this.allRoomsInHome) { this.onRoomUpdate(room); } this.emit(room.roomId); @@ -687,7 +695,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (order !== lastOrder) { this.notifyIfOrderChanged(); } - } else if (ev.getType() === EventType.Tag && !spacesTweakAllRoomsEnabled) { + } else if (ev.getType() === EventType.Tag && !this.allRoomsInHome) { // If the room was in favourites and now isn't or the opposite then update its position in the trees const oldTags = lastEv?.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {}; @@ -698,7 +706,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { - if (ev.getType() === EventType.Direct) { + if (!this.allRoomsInHome && ev.getType() === EventType.Direct) { const lastContent = lastEvent.getContent(); const content = ev.getContent(); @@ -733,9 +741,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("RoomState.events", this.onRoomState); - if (!spacesTweakAllRoomsEnabled) { - this.matrixClient.removeListener("accountData", this.onAccountData); - } + this.matrixClient.removeListener("accountData", this.onAccountData); } await this.reset(); } @@ -746,9 +752,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("RoomState.events", this.onRoomState); - if (!spacesTweakAllRoomsEnabled) { - this.matrixClient.on("accountData", this.onAccountData); - } + this.matrixClient.on("accountData", this.onAccountData); this.matrixClient.getCapabilities().then(capabilities => { this._restrictedJoinRuleSupport = capabilities @@ -779,7 +783,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // as it will cause you to end up in the wrong room this.setActiveSpace(room, false); } else if ( - (!spacesTweakAllRoomsEnabled || this.activeSpace) && + (!this.allRoomsInHome || this.activeSpace) && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId) ) { this.switchToRelatedSpace(roomId); @@ -791,17 +795,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient { window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id); break; } + case "after_leave_room": if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { this.setActiveSpace(null, false); } break; + case Action.SwitchSpace: if (payload.num === 0) { this.setActiveSpace(null); } else if (this.spacePanelSpaces.length >= payload.num) { this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]); } + break; + + case Action.SettingUpdated: { + const settingUpdatedPayload = payload as SettingUpdatedPayload; + if (settingUpdatedPayload.settingName === "Spaces.allRoomsInHome") { + const newValue = SettingsStore.getValue("Spaces.allRoomsInHome"); + if (this.allRoomsInHome !== newValue) { + this._allRoomsInHome = newValue; + this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome); + this.rebuild(); // rebuild everything + } + } + break; + } } } @@ -872,7 +892,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { export default class SpaceStore { public static spacesEnabled = spacesEnabled; - public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled; private static internalInstance = new SpaceStoreClass(); diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 3913a2220f2..1a5ef0484e7 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -36,6 +36,8 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta import { VisibilityProvider } from "./filters/VisibilityProvider"; import { SpaceWatcher } from "./SpaceWatcher"; import SpaceStore from "../SpaceStore"; +import { Action } from "../../dispatcher/actions"; +import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload"; interface IState { tagsEnabled?: boolean; @@ -213,10 +215,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient { const logicallyReady = this.matrixClient && this.initialListsGenerated; if (!logicallyReady) return; - if (payload.action === 'setting_updated') { - if (this.watchedSettings.includes(payload.settingName)) { + if (payload.action === Action.SettingUpdated) { + const settingUpdatedPayload = payload as SettingUpdatedPayload; + if (this.watchedSettings.includes(settingUpdatedPayload.settingName)) { // TODO: Remove with https://github.com/vector-im/element-web/issues/14602 - if (payload.settingName === "advancedRoomListLogging") { + if (settingUpdatedPayload.settingName === "advancedRoomListLogging") { // Log when the setting changes so we know when it was turned on in the rageshake const enabled = SettingsStore.getValue("advancedRoomListLogging"); console.warn("Advanced room list logging is enabled? " + enabled); @@ -708,6 +711,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } let promise = Promise.resolve(); let idx = this.filterConditions.indexOf(filter); + let removed = false; if (idx >= 0) { this.filterConditions.splice(idx, 1); @@ -718,14 +722,20 @@ export class RoomListStoreClass extends AsyncStoreWithClient { if (SpaceStore.spacesEnabled) { promise = this.recalculatePrefiltering(); } + removed = true; } + idx = this.prefilterConditions.indexOf(filter); if (idx >= 0) { filter.off(FILTER_CHANGED, this.onPrefilterUpdated); this.prefilterConditions.splice(idx, 1); promise = this.recalculatePrefiltering(); + removed = true; + } + + if (removed) { + promise.then(() => this.updateFn.trigger()); } - promise.then(() => this.updateFn.trigger()); } /** diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index 1cec612e6f2..fe2eb1e881b 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -18,41 +18,49 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomListStoreClass } from "./RoomListStore"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; -import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; +import SpaceStore, { UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../SpaceStore"; /** * Watches for changes in spaces to manage the filter on the provided RoomListStore */ export class SpaceWatcher { - private filter: SpaceFilterCondition; + private readonly filter = new SpaceFilterCondition(); + // we track these separately to the SpaceStore as we need to observe transitions private activeSpace: Room = SpaceStore.instance.activeSpace; + private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome; constructor(private store: RoomListStoreClass) { - if (!SpaceStore.spacesTweakAllRoomsEnabled) { - this.filter = new SpaceFilterCondition(); + if (!this.allRoomsInHome || this.activeSpace) { this.updateFilter(); store.addFilter(this.filter); } SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); + SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourUpdated); } - private onSelectedSpaceUpdated = (activeSpace?: Room) => { + private onSelectedSpaceUpdated = (activeSpace?: Room, allRoomsInHome = this.allRoomsInHome) => { + if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop + + const oldActiveSpace = this.activeSpace; + const oldAllRoomsInHome = this.allRoomsInHome; this.activeSpace = activeSpace; + this.allRoomsInHome = allRoomsInHome; - if (this.filter) { - if (activeSpace || !SpaceStore.spacesTweakAllRoomsEnabled) { - this.updateFilter(); - } else { - this.store.removeFilter(this.filter); - this.filter = null; - } - } else if (activeSpace) { - this.filter = new SpaceFilterCondition(); + if (activeSpace || !allRoomsInHome) { this.updateFilter(); + } + + if (oldAllRoomsInHome && !oldActiveSpace) { this.store.addFilter(this.filter); + } else if (allRoomsInHome && !activeSpace) { + this.store.removeFilter(this.filter); } }; + private onHomeBehaviourUpdated = (allRoomsInHome: boolean) => { + this.onSelectedSpaceUpdated(this.activeSpace, allRoomsInHome); + }; + private updateFilter = () => { if (this.activeSpace) { SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => { diff --git a/test/stores/SpaceStore-setup.ts b/test/stores/SpaceStore-setup.ts index b9b865e89a3..78418d45cc2 100644 --- a/test/stores/SpaceStore-setup.ts +++ b/test/stores/SpaceStore-setup.ts @@ -18,4 +18,3 @@ limitations under the License. // SpaceStore reads the SettingsStore which needs the localStorage values set at init time. localStorage.setItem("mx_labs_feature_feature_spaces", "true"); -localStorage.setItem("mx_labs_feature_feature_spaces.all_rooms", "true"); diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index d772a7a6588..2e823aa72bb 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -16,41 +16,26 @@ limitations under the License. import { EventEmitter } from "events"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import "./SpaceStore-setup"; // enable space lab import "../skinned-sdk"; // Must be first for skinning to work import SpaceStore, { + UPDATE_HOME_BEHAVIOUR, UPDATE_INVITED_SPACES, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES, } from "../../src/stores/SpaceStore"; -import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; -import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; -import { EnhancedMap } from "../../src/utils/maps"; +import * as testUtils from "../utils/test-utils"; +import { mkEvent, stubClient } from "../test-utils"; import DMRoomMap from "../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import defaultDispatcher from "../../src/dispatcher/dispatcher"; +import SettingsStore from "../../src/settings/SettingsStore"; +import { SettingLevel } from "../../src/settings/SettingLevel"; jest.useFakeTimers(); -const mockStateEventImplementation = (events: MatrixEvent[]) => { - const stateMap = new EnhancedMap>(); - events.forEach(event => { - stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); - }); - - return (eventType: string, stateKey?: string) => { - if (stateKey || stateKey === "") { - return stateMap.get(eventType)?.get(stateKey) || null; - } - return Array.from(stateMap.get(eventType)?.values() || []); - }; -}; - -const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); - const testUserId = "@test:user"; const getUserIdForRoomId = jest.fn(); @@ -87,45 +72,30 @@ describe("SpaceStore", () => { const client = MatrixClientPeg.get(); let rooms = []; - - const mkRoom = (roomId: string) => { - const room = mkStubRoom(roomId, roomId, client); - room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([])); - rooms.push(room); - return room; - }; - - const mkSpace = (spaceId: string, children: string[] = []) => { - const space = mkRoom(spaceId); - space.isSpaceRoom.mockReturnValue(true); - space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId => - mkEvent({ - event: true, - type: EventType.SpaceChild, - room: spaceId, - user: testUserId, - skey: roomId, - content: { via: [] }, - ts: Date.now(), - }), - ))); - return space; - }; - + const mkRoom = (roomId: string) => testUtils.mkRoom(client, roomId, rooms); + const mkSpace = (spaceId: string, children: string[] = []) => testUtils.mkSpace(client, spaceId, rooms, children); const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true); const run = async () => { client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); - await setupAsyncStoreWithClient(store, client); + await testUtils.setupAsyncStoreWithClient(store, client); jest.runAllTimers(); }; + const setShowAllRooms = async (value: boolean) => { + if (store.allRoomsInHome === value) return; + const emitProm = testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR); + await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, value); + jest.runAllTimers(); // run async dispatch + await emitProm; + }; + beforeEach(() => { - jest.runAllTimers(); + jest.runAllTimers(); // run async dispatch client.getVisibleRooms.mockReturnValue(rooms = []); }); afterEach(async () => { - await resetAsyncStoreWithClient(store); + await testUtils.resetAsyncStoreWithClient(store); }); describe("static hierarchy resolution tests", () => { @@ -387,10 +357,16 @@ describe("SpaceStore", () => { expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); }); - it("home space does contain rooms/low priority even if they are also shown in a space", () => { + it("all rooms space does contain rooms/low priority even if they are also shown in a space", async () => { + await setShowAllRooms(true); expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy(); }); + it("home space doesn't contain rooms/low priority if they are also shown in a space", async () => { + await setShowAllRooms(false); + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); + }); + it("space contains child rooms", () => { const space = client.getRoom(space1); expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); @@ -488,7 +464,7 @@ describe("SpaceStore", () => { await run(); expect(store.spacePanelSpaces).toStrictEqual([]); const space = mkSpace(space1); - const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); emitter.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); @@ -501,7 +477,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); - const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); emitter.emit("Room.myMembership", space, "leave", "join"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); @@ -513,7 +489,7 @@ describe("SpaceStore", () => { expect(store.invitedSpaces).toStrictEqual([]); const space = mkSpace(space1); space.getMyMembership.mockReturnValue("invite"); - const prom = emitPromise(store, UPDATE_INVITED_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); emitter.emit("Room", space); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); @@ -528,7 +504,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("join"); - const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES); emitter.emit("Room.myMembership", space, "join", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([space]); @@ -543,7 +519,7 @@ describe("SpaceStore", () => { expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([space]); space.getMyMembership.mockReturnValue("leave"); - const prom = emitPromise(store, UPDATE_INVITED_SPACES); + const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES); emitter.emit("Room.myMembership", space, "leave", "invite"); await prom; expect(store.spacePanelSpaces).toStrictEqual([]); @@ -563,7 +539,7 @@ describe("SpaceStore", () => { const invite = mkRoom(invite1); invite.getMyMembership.mockReturnValue("invite"); - const prom = emitPromise(store, space1); + const prom = testUtils.emitPromise(store, space1); emitter.emit("Room", space); await prom; @@ -633,20 +609,30 @@ describe("SpaceStore", () => { }); describe("context switching tests", () => { - const fn = jest.spyOn(defaultDispatcher, "dispatch"); + let dispatcherRef; + let currentRoom = null; beforeEach(async () => { [room1, room2, orphan1].forEach(mkRoom); mkSpace(space1, [room1, room2]); mkSpace(space2, [room2]); await run(); + + dispatcherRef = defaultDispatcher.register(payload => { + if (payload.action === "view_room" || payload.action === "view_home_page") { + currentRoom = payload.room_id || null; + } + }); }); afterEach(() => { - fn.mockClear(); localStorage.clear(); + defaultDispatcher.unregister(dispatcherRef); }); - const getCurrentRoom = () => fn.mock.calls.reverse().find(([p]) => p.action === "view_room")?.[0].room_id; + const getCurrentRoom = () => { + jest.runAllTimers(); + return currentRoom; + }; it("last viewed room in target space is the current viewed and in both spaces", async () => { await store.setActiveSpace(client.getRoom(space1)); @@ -683,6 +669,14 @@ describe("SpaceStore", () => { expect(getCurrentRoom()).toBe(space2); }); + it("last viewed room is target space is no longer in that space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + localStorage.setItem(`mx_space_context_${space2}`, room1); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); // Space home instead of room1 + }); + it("no last viewed room in target space", async () => { await store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); @@ -694,7 +688,7 @@ describe("SpaceStore", () => { await store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); await store.setActiveSpace(null); - expect(fn.mock.calls[fn.mock.calls.length - 1][0]).toStrictEqual({ action: "view_home_page" }); + expect(getCurrentRoom()).toBeNull(); // Home }); }); @@ -704,7 +698,8 @@ describe("SpaceStore", () => { mkSpace(space1, [room1, room2, room3]); mkSpace(space2, [room1, room2]); - client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ + const cliRoom2 = client.getRoom(room2); + cliRoom2.currentState.getStateEvents.mockImplementation(testUtils.mockStateEventImplementation([ mkEvent({ event: true, type: EventType.SpaceParent, @@ -747,6 +742,7 @@ describe("SpaceStore", () => { }); it("when switching rooms in the all rooms home space don't switch to related space", async () => { + await setShowAllRooms(true); viewRoom(room2); await store.setActiveSpace(null, false); viewRoom(room1); diff --git a/test/stores/room-list/SpaceWatcher-test.ts b/test/stores/room-list/SpaceWatcher-test.ts new file mode 100644 index 00000000000..85f79c75b6a --- /dev/null +++ b/test/stores/room-list/SpaceWatcher-test.ts @@ -0,0 +1,186 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import "../SpaceStore-setup"; // enable space lab +import "../../skinned-sdk"; // Must be first for skinning to work +import { SpaceWatcher } from "../../../src/stores/room-list/SpaceWatcher"; +import type { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import SpaceStore, { UPDATE_HOME_BEHAVIOUR } from "../../../src/stores/SpaceStore"; +import { stubClient } from "../../test-utils"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { setupAsyncStoreWithClient } from "../../utils/test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import * as testUtils from "../../utils/test-utils"; +import { SpaceFilterCondition } from "../../../src/stores/room-list/filters/SpaceFilterCondition"; + +let filter: SpaceFilterCondition = null; + +const mockRoomListStore = { + addFilter: f => filter = f, + removeFilter: () => filter = null, +} as unknown as RoomListStoreClass; + +const space1Id = "!space1:server"; +const space2Id = "!space2:server"; + +describe("SpaceWatcher", () => { + stubClient(); + const store = SpaceStore.instance; + const client = MatrixClientPeg.get(); + + let rooms = []; + const mkSpace = (spaceId: string, children: string[] = []) => testUtils.mkSpace(client, spaceId, rooms, children); + + const setShowAllRooms = async (value: boolean) => { + if (store.allRoomsInHome === value) return; + await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, value); + await testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR); + }; + + let space1; + let space2; + + beforeEach(async () => { + filter = null; + store.removeAllListeners(); + await store.setActiveSpace(null); + client.getVisibleRooms.mockReturnValue(rooms = []); + + space1 = mkSpace(space1Id); + space2 = mkSpace(space2Id); + + client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); + await setupAsyncStoreWithClient(store, client); + }); + + it("initialises sanely with home behaviour", async () => { + await setShowAllRooms(false); + new SpaceWatcher(mockRoomListStore); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + }); + + it("initialises sanely with all behaviour", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + expect(filter).toBeNull(); + }); + + it("sets space=null filter for all -> home transition", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + await setShowAllRooms(false); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBeNull(); + }); + + it("sets filter correctly for all -> space transition", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + await SpaceStore.instance.setActiveSpace(space1); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); + + it("removes filter for home -> all transition", async () => { + await setShowAllRooms(false); + new SpaceWatcher(mockRoomListStore); + + await setShowAllRooms(true); + + expect(filter).toBeNull(); + }); + + it("sets filter correctly for home -> space transition", async () => { + await setShowAllRooms(false); + new SpaceWatcher(mockRoomListStore); + + await SpaceStore.instance.setActiveSpace(space1); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); + + it("removes filter for space -> all transition", async () => { + await setShowAllRooms(true); + new SpaceWatcher(mockRoomListStore); + + await SpaceStore.instance.setActiveSpace(space1); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await SpaceStore.instance.setActiveSpace(null); + + expect(filter).toBeNull(); + }); + + it("updates filter correctly for space -> home transition", async () => { + await setShowAllRooms(false); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await SpaceStore.instance.setActiveSpace(null); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(null); + }); + + it("updates filter correctly for space -> space transition", async () => { + await setShowAllRooms(false); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await SpaceStore.instance.setActiveSpace(space2); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space2); + }); + + it("doesn't change filter when changing showAllRooms mode to true", async () => { + await setShowAllRooms(false); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await setShowAllRooms(true); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); + + it("doesn't change filter when changing showAllRooms mode to false", async () => { + await setShowAllRooms(true); + await SpaceStore.instance.setActiveSpace(space1); + + new SpaceWatcher(mockRoomListStore); + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + await setShowAllRooms(false); + + expect(filter).toBeInstanceOf(SpaceFilterCondition); + expect(filter["space"]).toBe(space1); + }); +}); diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts index af92987a3d0..8bc602fe35e 100644 --- a/test/utils/test-utils.ts +++ b/test/utils/test-utils.ts @@ -15,7 +15,13 @@ limitations under the License. */ import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; +import { mkEvent, mkStubRoom } from "../test-utils"; +import { EnhancedMap } from "../../src/utils/maps"; +import { EventEmitter } from "events"; // These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent // ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. @@ -31,3 +37,48 @@ export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient // @ts-ignore await store.onNotReady(); }; + +export const mockStateEventImplementation = (events: MatrixEvent[]) => { + const stateMap = new EnhancedMap>(); + events.forEach(event => { + stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); + }); + + return (eventType: string, stateKey?: string) => { + if (stateKey || stateKey === "") { + return stateMap.get(eventType)?.get(stateKey) || null; + } + return Array.from(stateMap.get(eventType)?.values() || []); + }; +}; + +export const mkRoom = (client: MatrixClient, roomId: string, rooms?: ReturnType[]) => { + const room = mkStubRoom(roomId, roomId, client); + room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([])); + rooms?.push(room); + return room; +}; + +export const mkSpace = ( + client: MatrixClient, + spaceId: string, + rooms?: ReturnType[], + children: string[] = [], +) => { + const space = mkRoom(client, spaceId, rooms); + space.isSpaceRoom.mockReturnValue(true); + space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId => + mkEvent({ + event: true, + type: EventType.SpaceChild, + room: spaceId, + user: "@user:server", + skey: roomId, + content: { via: [] }, + ts: Date.now(), + }), + ))); + return space; +}; + +export const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r));