Merge pull request #6497 from matrix-org/t3chguy/fix/18093

pull/21833/head
Michael Telatynski 2021-07-30 12:25:44 +01:00 committed by GitHub
commit 42d6afd4b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 953 additions and 545 deletions

View File

@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpaceButton:hover, .mx_SpaceButton:hover,
.mx_SpaceButton:focus-within, .mx_SpaceButton:focus-within,
.mx_SpaceButton_hasMenuOpen { .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 // Hide the badge container on hover because it'll be a menu button
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
width: 0; width: 0;
@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_iconExplore::before { .mx_SpacePanel_iconExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); 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
}
}
} }

View File

@ -149,12 +149,17 @@ limitations under the License.
} }
} }
.mx_IconizedContextMenu_checked { .mx_IconizedContextMenu_checked,
.mx_IconizedContextMenu_unchecked {
margin-left: 16px; margin-left: 16px;
margin-right: -5px; margin-right: -5px;
}
&::before { .mx_IconizedContextMenu_checked::before {
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
} }
.mx_IconizedContextMenu_unchecked::before {
content: unset;
} }
} }

View File

@ -72,6 +72,13 @@ limitations under the License.
padding-right: 10px; 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 { .mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch {
float: right; float: right;
} }

View File

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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 ReactDOM from "react-dom";
import classNames from "classnames"; import classNames from "classnames";
@ -471,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => { export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null); const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const open = () => { const open = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(true); setIsOpen(true);
}; };
const close = () => { const close = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(false); setIsOpen(false);
}; };

View File

@ -86,7 +86,10 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
> >
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span> <span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> } <span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
</MenuItemCheckbox>; </MenuItemCheckbox>;
}; };

View File

@ -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 = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(space);
onFinished();
};
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={onSettingsClick}
/>
);
} else {
const onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
leaveSpace(space);
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
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 = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
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 <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
};
export default SpaceContextMenu;

View File

@ -26,6 +26,7 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
import SettingsFlag from '../../../elements/SettingsFlag'; import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
interface IState { interface IState {
autoLaunch: boolean; autoLaunch: boolean;
@ -47,6 +48,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
'breadcrumbs', 'breadcrumbs',
]; ];
static SPACES_SETTINGS = [
"Spaces.allRoomsInHome",
];
static KEYBINDINGS_SETTINGS = [ static KEYBINDINGS_SETTINGS = [
'ctrlFForSearch', 'ctrlFForSearch',
]; ];
@ -231,6 +236,11 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div> </div>
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
</div> }
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span> <span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span>
<AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}> <AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}>

View File

@ -14,115 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import RoomAvatar from "../avatars/RoomAvatar";
import { useContextMenu } from "../../structures/ContextMenu"; import { useContextMenu } from "../../structures/ContextMenu";
import SpaceCreateMenu from "./SpaceCreateMenu"; import SpaceCreateMenu from "./SpaceCreateMenu";
import { SpaceItem } from "./SpaceTreeLevel"; import { SpaceButton, SpaceItem } from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import SpaceStore, { import SpaceStore, {
HOME_SPACE, HOME_SPACE,
UPDATE_HOME_BEHAVIOUR,
UPDATE_INVITED_SPACES, UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE, UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES, UPDATE_TOP_LEVEL_SPACES,
} from "../../../stores/SpaceStore"; } from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import NotificationBadge from "../rooms/NotificationBadge"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import {
RovingAccessibleButton,
RovingAccessibleTooltipButton,
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState"; import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import IconizedContextMenu, {
interface IButtonProps { IconizedContextMenuCheckbox,
space?: Room; IconizedContextMenuOptionList,
className?: string; } from "../context_menus/IconizedContextMenu";
selected?: boolean; import SettingsStore from "../../../settings/SettingsStore";
tooltip?: string; import { SettingLevel } from "../../../settings/SettingLevel";
notificationState?: NotificationState;
isNarrow?: boolean;
onClick(): void;
}
const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
tooltip,
notificationState,
isNarrow,
children,
}) => {
const classes = classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_narrow: isNarrow,
});
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={32} height={32} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
let button;
if (isNarrow) {
button = (
<RovingAccessibleTooltipButton className={classes} title={tooltip} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ notifBadge }
{ children }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton className={classes} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
<span className="mx_SpaceButton_name">{ tooltip }</span>
{ notifBadge }
{ children }
</div>
</RovingAccessibleButton>
);
}
return <li className={classNames({
"mx_SpaceItem": true,
"collapsed": isNarrow,
})}>
{ button }
</li>;
};
const useSpaces = (): [Room[], Room[], Room | null] => { const useSpaces = (): [Room[], Room[], Room | null] => {
const [invites, setInvites] = useState<Room[]>(SpaceStore.instance.invitedSpaces); const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites); return SpaceStore.instance.invitedSpaces;
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces); });
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); const spaces = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, () => {
const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace); return SpaceStore.instance.spacePanelSpaces;
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); });
const activeSpace = useEventEmitterState<Room>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpace;
});
return [invites, spaces, activeSpace]; return [invites, spaces, activeSpace];
}; };
@ -132,23 +63,108 @@ interface IInnerSpacePanelProps {
setPanelCollapsed: Dispatch<SetStateAction<boolean>>; setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
} }
const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps<typeof SpaceContextMenu>) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ _t("Home") }
</div>
<IconizedContextMenuOptionList first>
<IconizedContextMenuCheckbox
iconClassName="mx_SpacePanel_noIcon"
label={_t("Show all rooms")}
active={allRoomsInHome}
onClick={() => {
SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome);
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
interface IHomeButtonProps {
selected: boolean;
isPanelCollapsed: boolean;
}
const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => 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")}
/>
</li>;
};
const CreateSpaceButton = ({
isPanelCollapsed,
setPanelCollapsed,
}: Pick<IInnerSpacePanelProps, "isPanelCollapsed" | "setPanelCollapsed">) => {
// 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<void>();
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
closeMenu();
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
label={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
{ contextMenu }
</li>;
};
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation // Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => { const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
const [invites, spaces, activeSpace] = useSpaces(); const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : []; const activeSpaces = activeSpace ? [activeSpace] : [];
const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return <div className="mx_SpaceTreeLevel"> return <div className="mx_SpaceTreeLevel">
<SpaceButton <HomeButton selected={!activeSpace} isPanelCollapsed={isPanelCollapsed} />
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState}
isNarrow={isPanelCollapsed}
/>
{ invites.map(s => ( { invites.map(s => (
<SpaceItem <SpaceItem
key={s.roomId} key={s.roomId}
@ -178,26 +194,13 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
</Draggable> </Draggable>
)) } )) }
{ children } { children }
<CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} />
</div>; </div>;
}); });
const SpacePanel = () => { 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<void>();
const [isPanelCollapsed, setPanelCollapsed] = useState(true); const [isPanelCollapsed, setPanelCollapsed] = useState(true);
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
closeMenu();
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onKeyDown = (ev: React.KeyboardEvent) => { const onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true; let handled = true;
@ -259,11 +262,6 @@ const SpacePanel = () => {
} }
}; };
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return ( return (
<DragDropContext onDragEnd={result => { <DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list if (!result.destination) return; // dropped outside the list
@ -291,15 +289,6 @@ const SpacePanel = () => {
> >
{ provided.placeholder } { provided.placeholder }
</InnerSpacePanel> </InnerSpacePanel>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar> </AutoHideScrollbar>
) } ) }
</Droppable> </Droppable>
@ -308,7 +297,6 @@ const SpacePanel = () => {
onClick={() => setPanelCollapsed(!isPanelCollapsed)} onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")} title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
/> />
{ contextMenu }
</ul> </ul>
) } ) }
</RovingTabIndexProvider> </RovingTabIndexProvider>

View File

@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. 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 classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
@ -23,34 +30,104 @@ import SpaceStore from "../../../stores/SpaceStore";
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore"; import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
import NotificationBadge from "../rooms/NotificationBadge"; import NotificationBadge from "../rooms/NotificationBadge";
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton"; import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { toRightOf } from "../../structures/ContextMenu"; import { toRightOf, useContextMenu } from "../../structures/ContextMenu";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton 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 { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager"; 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<ComponentProps<typeof RovingAccessibleTooltipButton>, "title"> {
space?: Room;
className?: string;
selected?: boolean;
label: string;
contextMenuTooltip?: string;
notificationState?: NotificationState;
isNarrow?: boolean;
avatarSize?: number;
ContextMenuComponent?: ComponentType<ComponentProps<typeof SpaceContextMenu>>;
onClick(ev: MouseEvent): void;
}
export const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
label,
contextMenuTooltip,
notificationState,
avatarSize,
isNarrow,
children,
ContextMenuComponent,
...props
}) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>();
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={avatarSize} height={avatarSize} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
let contextMenu: JSX.Element;
if (menuDisplayed && ContextMenuComponent) {
contextMenu = <ContextMenuComponent
{...toRightOf(handle.current?.getBoundingClientRect(), 0)}
space={space}
onFinished={closeMenu}
/>;
}
return (
<RovingAccessibleTooltipButton
{...props}
className={classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_hasMenuOpen: menuDisplayed,
mx_SpaceButton_narrow: isNarrow,
})}
title={label}
onClick={onClick}
onContextMenu={openMenu}
forceHide={!isNarrow || menuDisplayed}
role="treeitem"
inputRef={handle}
>
{ children }
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ !isNarrow && <span className="mx_SpaceButton_name">{ label }</span> }
{ notifBadge }
{ ContextMenuComponent && <ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={openMenu}
title={contextMenuTooltip}
isExpanded={menuDisplayed}
/> }
{ contextMenu }
</div>
</RovingAccessibleTooltipButton>
);
};
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> { interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
space?: Room; space?: Room;
@ -64,7 +141,6 @@ interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
interface IItemState { interface IItemState {
collapsed: boolean; collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
childSpaces: Room[]; childSpaces: Room[];
} }
@ -84,7 +160,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
this.state = { this.state = {
collapsed: collapsed, collapsed: collapsed,
contextMenuPosition: null,
childSpaces: this.childSpaces, childSpaces: this.childSpaces,
}; };
@ -127,19 +202,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
evt.stopPropagation(); 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) => { private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true; let handled = true;
const action = getKeyBindingsManager().getRoomListAction(ev); const action = getKeyBindingsManager().getRoomListAction(ev);
@ -183,200 +245,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
SpaceStore.instance.setActiveSpace(this.props.space); 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<SetRightPanelPhasePayload>({
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 = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={this.onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(this.props.space)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={this.onSettingsClick}
/>
);
} else {
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={this.onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={this.onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={this.onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={this.onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
contextMenu = <IconizedContextMenu
{...toRightOf(this.state.contextMenuPosition, 0)}
onFinished={this.onMenuClose}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ this.props.space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={this.onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={this.onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={this.onMenuOpenClick}
title={_t("Space options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{ contextMenu }
</React.Fragment>
);
}
render() { render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
@ -384,7 +252,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
const collapsed = this.isCollapsed; const collapsed = this.isCollapsed;
const isActive = activeSpaces.includes(space);
const itemClasses = classNames(this.props.className, { const itemClasses = classNames(this.props.className, {
"mx_SpaceItem": true, "mx_SpaceItem": true,
"mx_SpaceItem_narrow": isPanelCollapsed, "mx_SpaceItem_narrow": isPanelCollapsed,
@ -393,12 +260,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
}); });
const isInvite = space.getMyMembership() === "invite"; 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 const notificationState = isInvite
? StaticNotificationState.forSymbol("!", NotificationColor.Red) ? StaticNotificationState.forSymbol("!", NotificationColor.Red)
: SpaceStore.instance.getNotificationState(space.roomId); : SpaceStore.instance.getNotificationState(space.roomId);
@ -413,19 +275,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/>; />;
} }
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
const avatarSize = isNested ? 24 : 32;
const toggleCollapseButton = this.state.childSpaces?.length ? const toggleCollapseButton = this.state.childSpaces?.length ?
<AccessibleButton <AccessibleButton
className="mx_SpaceButton_toggleCollapse" className="mx_SpaceButton_toggleCollapse"
@ -436,25 +285,23 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
return ( return (
<li {...otherProps} className={itemClasses} ref={innerRef}> <li {...otherProps} className={itemClasses} ref={innerRef}>
<RovingAccessibleTooltipButton <SpaceButton
className={classes} space={space}
title={space.name} className={isInvite ? "mx_SpaceButton_invite" : undefined}
selected={activeSpaces.includes(space)}
label={space.name}
contextMenuTooltip={_t("Space options")}
notificationState={notificationState}
isNarrow={isPanelCollapsed}
avatarSize={isNested ? 24 : 32}
onClick={this.onClick} onClick={this.onClick}
onContextMenu={this.onContextMenu}
forceHide={!isPanelCollapsed || !!this.state.contextMenuPosition}
role="treeitem"
aria-expanded={!collapsed}
inputRef={this.buttonRef}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
aria-expanded={!collapsed}
ContextMenuComponent={this.props.space.getMyMembership() === "join"
? SpaceContextMenu : undefined}
> >
{ toggleCollapseButton } { toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper"> </SpaceButton>
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ !isPanelCollapsed && <span className="mx_SpaceButton_name">{ space.name }</span> }
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
{ childItems } { childItems }
</li> </li>

View File

@ -198,4 +198,11 @@ export enum Action {
* Signals to the visible space hierarchy that a change has occurred an that it should refresh. * Signals to the visible space hierarchy that a change has occurred an that it should refresh.
*/ */
UpdateSpaceHierarchy = "update_space_hierarchy", 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",
} }

View File

@ -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;
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { useRef, useEffect } from "react"; import { useRef, useEffect, useState, useCallback } from "react";
import type { EventEmitter } from "events"; import type { EventEmitter } from "events";
type Handler = (...args: any[]) => void; 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 [eventName, emitter], // Re-run if eventName or emitter changes
); );
}; };
type Mapper<T> = (...args: any[]) => T;
export const useEventEmitterState = <T>(emitter: EventEmitter, eventName: string | symbol, fn: Mapper<T>): T => {
const [value, setValue] = useState<T>(fn());
const handler = useCallback((...args: any[]) => {
setValue(fn(...args));
}, [fn]);
useEventEmitter(emitter, eventName, handler);
return value;
};

View File

@ -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.", "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.", "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.", "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", "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", "Send and receive voice messages": "Send and receive voice messages",
"Render LaTeX maths in messages": "Render LaTeX maths in 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", "Manually verify all remote sessions": "Manually verify all remote sessions",
"IRC display name width": "IRC display name width", "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 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 app version information": "Collecting app version information",
"Collecting logs": "Collecting logs", "Collecting logs": "Collecting logs",
"Uploading logs": "Uploading logs", "Uploading logs": "Uploading logs",
@ -1025,8 +1026,10 @@
"You can change these anytime.": "You can change these anytime.", "You can change these anytime.": "You can change these anytime.",
"Creating...": "Creating...", "Creating...": "Creating...",
"Create": "Create", "Create": "Create",
"All rooms": "All rooms",
"Home": "Home", "Home": "Home",
"Show all rooms": "Show all rooms",
"All rooms": "All rooms",
"Options": "Options",
"Expand space panel": "Expand space panel", "Expand space panel": "Expand space panel",
"Collapse space panel": "Collapse space panel", "Collapse space panel": "Collapse space panel",
"Click to copy": "Click to copy", "Click to copy": "Click to copy",
@ -1056,17 +1059,9 @@
"Preview Space": "Preview Space", "Preview Space": "Preview Space",
"Allow people to preview your space before they join.": "Allow people to preview your space before they join.", "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.", "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", "Expand": "Expand",
"Collapse": "Collapse", "Collapse": "Collapse",
"Space options": "Space options",
"Remove": "Remove", "Remove": "Remove",
"This bridge was provisioned by <user />.": "This bridge was provisioned by <user />.", "This bridge was provisioned by <user />.": "This bridge was provisioned by <user />.",
"This bridge is managed by <user />.": "This bridge is managed by <user />.", "This bridge is managed by <user />.": "This bridge is managed by <user />.",
@ -1592,8 +1587,11 @@
"Start chat": "Start chat", "Start chat": "Start chat",
"Rooms": "Rooms", "Rooms": "Rooms",
"Add room": "Add room", "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", "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", "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 community rooms": "Explore community rooms",
"Explore public rooms": "Explore public rooms", "Explore public rooms": "Explore public rooms",
"Low priority": "Low priority", "Low priority": "Low priority",
@ -1671,6 +1669,7 @@
"Low Priority": "Low Priority", "Low Priority": "Low Priority",
"Invite People": "Invite People", "Invite People": "Invite People",
"Copy Room Link": "Copy Room Link", "Copy Room Link": "Copy Room Link",
"Settings": "Settings",
"Leave Room": "Leave Room", "Leave Room": "Leave Room",
"Room options": "Room options", "Room options": "Room options",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
@ -1764,13 +1763,13 @@
"The homeserver the user youre verifying is connected to": "The homeserver the user youre verifying is connected to", "The homeserver the user youre verifying is connected to": "The homeserver the user youre verifying is connected to",
"Yours, or the other users internet connection": "Yours, or the other users internet connection", "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", "Yours, or the other users session": "Yours, or the other users session",
"Members": "Members",
"Nothing pinned, yet": "Nothing pinned, yet", "Nothing pinned, yet": "Nothing pinned, yet",
"If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.", "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
"Pinned messages": "Pinned messages", "Pinned messages": "Pinned messages",
"Room Info": "Room Info", "Room Info": "Room Info",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", "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", "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", "Set my room layout for everyone": "Set my room layout for everyone",
"Widgets": "Widgets", "Widgets": "Widgets",
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots", "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.", "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", "Leave %(spaceName)s": "Leave %(spaceName)s",
"Are you sure you want to leave <spaceName/>?": "Are you sure you want to leave <spaceName/>?", "Are you sure you want to leave <spaceName/>?": "Are you sure you want to leave <spaceName/>?",
"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.", "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", "Start using Key Backup": "Start using Key Backup",
"I don't want my encrypted messages": "I don't want my encrypted messages", "I don't want my encrypted messages": "I don't want my encrypted messages",
@ -2593,6 +2593,8 @@
"Source URL": "Source URL", "Source URL": "Source URL",
"Collapse reply thread": "Collapse reply thread", "Collapse reply thread": "Collapse reply thread",
"Report": "Report", "Report": "Report",
"Add space": "Add space",
"Manage & explore rooms": "Manage & explore rooms",
"Clear status": "Clear status", "Clear status": "Clear status",
"Update status": "Update status", "Update status": "Update status",
"Set status": "Set status", "Set status": "Set status",

View File

@ -180,17 +180,8 @@ export const SETTINGS: {[setting: string]: ISetting} = {
feedbackSubheading: _td("Your feedback will help make spaces better. " + feedbackSubheading: _td("Your feedback will help make spaces better. " +
"The more detail you can go into, the better."), "The more detail you can go into, the better."),
feedbackLabel: "spaces-feedback", 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": { "feature_dnd": {
isFeature: true, isFeature: true,
displayName: _td("Show options to enable 'Do not disturb' mode"), displayName: _td("Show options to enable 'Do not disturb' mode"),
@ -758,6 +749,12 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null, 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]: { [UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,
default: true, default: true,

View File

@ -29,6 +29,8 @@ import LocalEchoWrapper from "./handlers/LocalEchoWrapper";
import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager"; import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager";
import { SettingLevel } from "./SettingLevel"; import { SettingLevel } from "./SettingLevel";
import SettingsHandler from "./handlers/SettingsHandler"; import SettingsHandler from "./handlers/SettingsHandler";
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
import { Action } from "../dispatcher/actions";
const defaultWatchManager = new WatchManager(); const defaultWatchManager = new WatchManager();
@ -147,7 +149,7 @@ export default class SettingsStore {
* if the change in value is worthwhile enough to react upon. * if the change in value is worthwhile enough to react upon.
* @returns {string} A reference to the watcher that was employed. * @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 setting = SETTINGS[settingName];
const originalSettingName = settingName; const originalSettingName = settingName;
if (!setting) throw new Error(`${settingName} is not a setting`); 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} settingName The setting name to monitor.
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms. * @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. roomId = roomId || null; // the thing wants null specifically to work, so appease it.
if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map()); if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map());
@ -201,8 +203,8 @@ export default class SettingsStore {
const registerWatcher = () => { const registerWatcher = () => {
this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting( this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting(
settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => { settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => {
dis.dispatch({ dis.dispatch<SettingUpdatedPayload>({
action: 'setting_updated', action: Action.SettingUpdated,
settingName, settingName,
roomId: inRoomId, roomId: inRoomId,
level, level,

View File

@ -23,6 +23,8 @@ import { arrayHasDiff } from "../utils/arrays";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { SettingLevel } from "../settings/SettingLevel"; import { SettingLevel } from "../settings/SettingLevel";
import SpaceStore from "./SpaceStore"; import SpaceStore from "./SpaceStore";
import { Action } from "../dispatcher/actions";
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
const MAX_ROOMS = 20; // arbitrary const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up 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<IState> {
protected async onAction(payload: ActionPayload) { protected async onAction(payload: ActionPayload) {
if (!this.matrixClient) return; if (!this.matrixClient) return;
if (payload.action === 'setting_updated') { if (payload.action === Action.SettingUpdated) {
if (payload.settingName === 'breadcrumb_rooms') { const settingUpdatedPayload = payload as SettingUpdatedPayload;
if (settingUpdatedPayload.settingName === 'breadcrumb_rooms') {
await this.updateRooms(); await this.updateRooms();
} else if (payload.settingName === 'breadcrumbs') { } else if (settingUpdatedPayload.settingName === 'breadcrumbs') {
await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) });
} }
} else if (payload.action === 'view_room') { } else if (payload.action === 'view_room') {

View File

@ -37,9 +37,8 @@ import { EnhancedMap, mapDiff } from "../utils/maps";
import { setHasDiff } from "../utils/sets"; import { setHasDiff } from "../utils/sets";
import RoomViewStore from "./RoomViewStore"; import RoomViewStore from "./RoomViewStore";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import { arrayHasDiff } from "../utils/arrays"; import { arrayHasDiff, arrayHasOrderChange } from "../utils/arrays";
import { objectDiff } from "../utils/objects"; import { objectDiff } from "../utils/objects";
import { arrayHasOrderChange } from "../utils/arrays";
import { reorderLexicographically } from "../utils/stringOrderField"; import { reorderLexicographically } from "../utils/stringOrderField";
import { TAG_ORDER } from "../components/views/rooms/RoomList"; import { TAG_ORDER } from "../components/views/rooms/RoomList";
import { shouldShowSpaceSettings } from "../utils/space"; import { shouldShowSpaceSettings } from "../utils/space";
@ -48,6 +47,7 @@ import { _t } from "../languageHandler";
import GenericToast from "../components/views/toasts/GenericToast"; import GenericToast from "../components/views/toasts/GenericToast";
import Modal from "../Modal"; import Modal from "../Modal";
import InfoDialog from "../components/views/dialogs/InfoDialog"; import InfoDialog from "../components/views/dialogs/InfoDialog";
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
type SpaceKey = string | symbol; 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_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_INVITED_SPACES = Symbol("invited-spaces");
export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); 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 // Space Room ID/HOME_SPACE will be emitted when a Space's children change
export interface ISuggestedRoom extends ISpaceSummaryRoom { export interface ISuggestedRoom extends ISpaceSummaryRoom {
@ -69,12 +70,10 @@ export interface ISuggestedRoom extends ISpaceSummaryRoom {
const MAX_SUGGESTED_ROOMS = 20; 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 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 || "HOME_SPACE"}`;
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`;
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
return arr.reduce((result, room: Room) => { return arr.reduce((result, room: Room) => {
@ -102,10 +101,6 @@ const getRoomFn: FetchRoomFn = (room: Room) => {
}; };
export class SpaceStoreClass extends AsyncStoreWithClient<IState> { export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
constructor() {
super(defaultDispatcher, {});
}
// The spaces representing the roots of the various tree-like hierarchies // The spaces representing the roots of the various tree-like hierarchies
private rootSpaces: Room[] = []; private rootSpaces: Room[] = [];
// The list of rooms not present in any currently joined spaces // The list of rooms not present in any currently joined spaces
@ -122,6 +117,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
private _invitedSpaces = new Set<Room>(); private _invitedSpaces = new Set<Room>();
private spaceOrderLocalEchoMap = new Map<string, string>(); private spaceOrderLocalEchoMap = new Map<string, string>();
private _restrictedJoinRuleSupport?: IRoomCapability; private _restrictedJoinRuleSupport?: IRoomCapability;
private _allRoomsInHome: boolean = SettingsStore.getValue("Spaces.allRoomsInHome");
constructor() {
super(defaultDispatcher, {});
SettingsStore.monitorSetting("Spaces.allRoomsInHome", null);
}
public get invitedSpaces(): Room[] { public get invitedSpaces(): Room[] {
return Array.from(this._invitedSpaces); return Array.from(this._invitedSpaces);
@ -139,13 +141,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._suggestedRooms; return this._suggestedRooms;
} }
public get allRoomsInHome(): boolean {
return this._allRoomsInHome;
}
public async setActiveRoomInSpace(space: Room | null): Promise<void> { public async setActiveRoomInSpace(space: Room | null): Promise<void> {
if (space && !space.isSpaceRoom()) return; if (space && !space.isSpaceRoom()) return;
if (space !== this.activeSpace) await this.setActiveSpace(space); if (space !== this.activeSpace) await this.setActiveSpace(space);
if (space) { if (space) {
const notificationState = this.getNotificationState(space.roomId); const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications();
const roomId = notificationState.getFirstRoomWithNotifications();
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: "view_room", action: "view_room",
room_id: roomId, room_id: roomId,
@ -200,7 +205,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// else if the last viewed room in this space is joined then view that // 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 // else view space home or home depending on what is being clicked on
if (space?.getMyMembership() !== "invite" && if (space?.getMyMembership() !== "invite" &&
this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" &&
this.getSpaceFilteredRoomIds(space).has(roomId)
) { ) {
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: "view_room", action: "view_room",
@ -377,7 +383,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => { public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
if (!space && spacesTweakAllRoomsEnabled) { if (!space && this.allRoomsInHome) {
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
} }
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
@ -474,7 +480,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}; };
private showInHomeSpace = (room: Room) => { private showInHomeSpace = (room: Room) => {
if (spacesTweakAllRoomsEnabled) return true; if (this.allRoomsInHome) return true;
if (room.isSpaceRoom()) return false; if (room.isSpaceRoom()) return false;
return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space 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 || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
@ -506,7 +512,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const oldFilteredRooms = this.spaceFilteredRooms; const oldFilteredRooms = this.spaceFilteredRooms;
this.spaceFilteredRooms = new Map(); this.spaceFilteredRooms = new Map();
if (!spacesTweakAllRoomsEnabled) { if (!this.allRoomsInHome) {
// put all room invites in the Home Space // put all room invites in the Home Space
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId))); this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
@ -562,8 +568,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}); });
this.spaceFilteredRooms.forEach((roomIds, s) => { this.spaceFilteredRooms.forEach((roomIds, s) => {
if (this.allRoomsInHome && s === HOME_SPACE) return; // we'll be using the global notification state, skip
// Update NotificationStates // Update NotificationStates
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { this.getNotificationState(s).setRooms(visibleRooms.filter(room => {
if (!roomIds.has(room.roomId)) return false; if (!roomIds.has(room.roomId)) return false;
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
@ -663,7 +671,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// TODO confirm this after implementing parenting behaviour // TODO confirm this after implementing parenting behaviour
if (room.isSpaceRoom()) { if (room.isSpaceRoom()) {
this.onSpaceUpdate(); this.onSpaceUpdate();
} else if (!spacesTweakAllRoomsEnabled) { } else if (!this.allRoomsInHome) {
this.onRoomUpdate(room); this.onRoomUpdate(room);
} }
this.emit(room.roomId); this.emit(room.roomId);
@ -687,7 +695,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (order !== lastOrder) { if (order !== lastOrder) {
this.notifyIfOrderChanged(); 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 // 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 oldTags = lastEv?.getContent()?.tags || {};
const newTags = ev.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {};
@ -698,7 +706,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}; };
private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => {
if (ev.getType() === EventType.Direct) { if (!this.allRoomsInHome && ev.getType() === EventType.Direct) {
const lastContent = lastEvent.getContent(); const lastContent = lastEvent.getContent();
const content = ev.getContent(); const content = ev.getContent();
@ -733,9 +741,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom);
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("RoomState.events", this.onRoomState); this.matrixClient.removeListener("RoomState.events", this.onRoomState);
if (!spacesTweakAllRoomsEnabled) { this.matrixClient.removeListener("accountData", this.onAccountData);
this.matrixClient.removeListener("accountData", this.onAccountData);
}
} }
await this.reset(); await this.reset();
} }
@ -746,9 +752,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom);
this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("Room.accountData", this.onRoomAccountData);
this.matrixClient.on("RoomState.events", this.onRoomState); 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.matrixClient.getCapabilities().then(capabilities => {
this._restrictedJoinRuleSupport = capabilities this._restrictedJoinRuleSupport = capabilities
@ -779,7 +783,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// as it will cause you to end up in the wrong room // as it will cause you to end up in the wrong room
this.setActiveSpace(room, false); this.setActiveSpace(room, false);
} else if ( } else if (
(!spacesTweakAllRoomsEnabled || this.activeSpace) && (!this.allRoomsInHome || this.activeSpace) &&
!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId) !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)
) { ) {
this.switchToRelatedSpace(roomId); this.switchToRelatedSpace(roomId);
@ -791,17 +795,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id); window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id);
break; break;
} }
case "after_leave_room": case "after_leave_room":
if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { if (this._activeSpace && payload.room_id === this._activeSpace.roomId) {
this.setActiveSpace(null, false); this.setActiveSpace(null, false);
} }
break; break;
case Action.SwitchSpace: case Action.SwitchSpace:
if (payload.num === 0) { if (payload.num === 0) {
this.setActiveSpace(null); this.setActiveSpace(null);
} else if (this.spacePanelSpaces.length >= payload.num) { } else if (this.spacePanelSpaces.length >= payload.num) {
this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]); 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<IState> {
export default class SpaceStore { export default class SpaceStore {
public static spacesEnabled = spacesEnabled; public static spacesEnabled = spacesEnabled;
public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled;
private static internalInstance = new SpaceStoreClass(); private static internalInstance = new SpaceStoreClass();

View File

@ -36,6 +36,8 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta
import { VisibilityProvider } from "./filters/VisibilityProvider"; import { VisibilityProvider } from "./filters/VisibilityProvider";
import { SpaceWatcher } from "./SpaceWatcher"; import { SpaceWatcher } from "./SpaceWatcher";
import SpaceStore from "../SpaceStore"; import SpaceStore from "../SpaceStore";
import { Action } from "../../dispatcher/actions";
import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -213,10 +215,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
const logicallyReady = this.matrixClient && this.initialListsGenerated; const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return; if (!logicallyReady) return;
if (payload.action === 'setting_updated') { if (payload.action === Action.SettingUpdated) {
if (this.watchedSettings.includes(payload.settingName)) { const settingUpdatedPayload = payload as SettingUpdatedPayload;
if (this.watchedSettings.includes(settingUpdatedPayload.settingName)) {
// TODO: Remove with https://github.com/vector-im/element-web/issues/14602 // 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 // Log when the setting changes so we know when it was turned on in the rageshake
const enabled = SettingsStore.getValue("advancedRoomListLogging"); const enabled = SettingsStore.getValue("advancedRoomListLogging");
console.warn("Advanced room list logging is enabled? " + enabled); console.warn("Advanced room list logging is enabled? " + enabled);
@ -708,6 +711,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
} }
let promise = Promise.resolve(); let promise = Promise.resolve();
let idx = this.filterConditions.indexOf(filter); let idx = this.filterConditions.indexOf(filter);
let removed = false;
if (idx >= 0) { if (idx >= 0) {
this.filterConditions.splice(idx, 1); this.filterConditions.splice(idx, 1);
@ -718,14 +722,20 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
if (SpaceStore.spacesEnabled) { if (SpaceStore.spacesEnabled) {
promise = this.recalculatePrefiltering(); promise = this.recalculatePrefiltering();
} }
removed = true;
} }
idx = this.prefilterConditions.indexOf(filter); idx = this.prefilterConditions.indexOf(filter);
if (idx >= 0) { if (idx >= 0) {
filter.off(FILTER_CHANGED, this.onPrefilterUpdated); filter.off(FILTER_CHANGED, this.onPrefilterUpdated);
this.prefilterConditions.splice(idx, 1); this.prefilterConditions.splice(idx, 1);
promise = this.recalculatePrefiltering(); promise = this.recalculatePrefiltering();
removed = true;
}
if (removed) {
promise.then(() => this.updateFn.trigger());
} }
promise.then(() => this.updateFn.trigger());
} }
/** /**

View File

@ -18,39 +18,47 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomListStoreClass } from "./RoomListStore"; import { RoomListStoreClass } from "./RoomListStore";
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; 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 * Watches for changes in spaces to manage the filter on the provided RoomListStore
*/ */
export class SpaceWatcher { 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 activeSpace: Room = SpaceStore.instance.activeSpace;
private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome;
constructor(private store: RoomListStoreClass) { constructor(private store: RoomListStoreClass) {
if (!SpaceStore.spacesTweakAllRoomsEnabled) { if (!this.allRoomsInHome || this.activeSpace) {
this.filter = new SpaceFilterCondition();
this.updateFilter(); this.updateFilter();
store.addFilter(this.filter); store.addFilter(this.filter);
} }
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); 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) => {
this.activeSpace = activeSpace; if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop
if (this.filter) { const oldActiveSpace = this.activeSpace;
if (activeSpace || !SpaceStore.spacesTweakAllRoomsEnabled) { const oldAllRoomsInHome = this.allRoomsInHome;
this.updateFilter(); this.activeSpace = activeSpace;
} else { this.allRoomsInHome = allRoomsInHome;
this.store.removeFilter(this.filter);
this.filter = null; if (activeSpace || !allRoomsInHome) {
}
} else if (activeSpace) {
this.filter = new SpaceFilterCondition();
this.updateFilter(); this.updateFilter();
this.store.addFilter(this.filter);
} }
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 = () => { private updateFilter = () => {

View File

@ -18,4 +18,3 @@ limitations under the License.
// SpaceStore reads the SettingsStore which needs the localStorage values set at init time. // 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", "true");
localStorage.setItem("mx_labs_feature_feature_spaces.all_rooms", "true");

View File

@ -16,41 +16,26 @@ limitations under the License.
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { EventType } from "matrix-js-sdk/src/@types/event"; 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 { RoomMember } from "matrix-js-sdk/src/models/room-member";
import "./SpaceStore-setup"; // enable space lab import "./SpaceStore-setup"; // enable space lab
import "../skinned-sdk"; // Must be first for skinning to work import "../skinned-sdk"; // Must be first for skinning to work
import SpaceStore, { import SpaceStore, {
UPDATE_HOME_BEHAVIOUR,
UPDATE_INVITED_SPACES, UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE, UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES, UPDATE_TOP_LEVEL_SPACES,
} from "../../src/stores/SpaceStore"; } from "../../src/stores/SpaceStore";
import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; import * as testUtils from "../utils/test-utils";
import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; import { mkEvent, stubClient } from "../test-utils";
import { EnhancedMap } from "../../src/utils/maps";
import DMRoomMap from "../../src/utils/DMRoomMap"; import DMRoomMap from "../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import defaultDispatcher from "../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../src/dispatcher/dispatcher";
import SettingsStore from "../../src/settings/SettingsStore";
import { SettingLevel } from "../../src/settings/SettingLevel";
jest.useFakeTimers(); jest.useFakeTimers();
const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
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 testUserId = "@test:user";
const getUserIdForRoomId = jest.fn(); const getUserIdForRoomId = jest.fn();
@ -87,45 +72,30 @@ describe("SpaceStore", () => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
let rooms = []; let rooms = [];
const mkRoom = (roomId: string) => testUtils.mkRoom(client, roomId, rooms);
const mkRoom = (roomId: string) => { const mkSpace = (spaceId: string, children: string[] = []) => testUtils.mkSpace(client, spaceId, rooms, children);
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 viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true); const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true);
const run = async () => { const run = async () => {
client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId));
await setupAsyncStoreWithClient(store, client); await testUtils.setupAsyncStoreWithClient(store, client);
jest.runAllTimers(); 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(() => { beforeEach(() => {
jest.runAllTimers(); jest.runAllTimers(); // run async dispatch
client.getVisibleRooms.mockReturnValue(rooms = []); client.getVisibleRooms.mockReturnValue(rooms = []);
}); });
afterEach(async () => { afterEach(async () => {
await resetAsyncStoreWithClient(store); await testUtils.resetAsyncStoreWithClient(store);
}); });
describe("static hierarchy resolution tests", () => { describe("static hierarchy resolution tests", () => {
@ -387,10 +357,16 @@ describe("SpaceStore", () => {
expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); 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(); 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", () => { it("space contains child rooms", () => {
const space = client.getRoom(space1); const space = client.getRoom(space1);
expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy();
@ -488,7 +464,7 @@ describe("SpaceStore", () => {
await run(); await run();
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
const space = mkSpace(space1); 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); emitter.emit("Room", space);
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.spacePanelSpaces).toStrictEqual([space]);
@ -501,7 +477,7 @@ describe("SpaceStore", () => {
expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.spacePanelSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("leave"); 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"); emitter.emit("Room.myMembership", space, "leave", "join");
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
@ -513,7 +489,7 @@ describe("SpaceStore", () => {
expect(store.invitedSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([]);
const space = mkSpace(space1); const space = mkSpace(space1);
space.getMyMembership.mockReturnValue("invite"); space.getMyMembership.mockReturnValue("invite");
const prom = emitPromise(store, UPDATE_INVITED_SPACES); const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES);
emitter.emit("Room", space); emitter.emit("Room", space);
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
@ -528,7 +504,7 @@ describe("SpaceStore", () => {
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("join"); 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"); emitter.emit("Room.myMembership", space, "join", "invite");
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.spacePanelSpaces).toStrictEqual([space]);
@ -543,7 +519,7 @@ describe("SpaceStore", () => {
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("leave"); 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"); emitter.emit("Room.myMembership", space, "leave", "invite");
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
@ -563,7 +539,7 @@ describe("SpaceStore", () => {
const invite = mkRoom(invite1); const invite = mkRoom(invite1);
invite.getMyMembership.mockReturnValue("invite"); invite.getMyMembership.mockReturnValue("invite");
const prom = emitPromise(store, space1); const prom = testUtils.emitPromise(store, space1);
emitter.emit("Room", space); emitter.emit("Room", space);
await prom; await prom;
@ -633,20 +609,30 @@ describe("SpaceStore", () => {
}); });
describe("context switching tests", () => { describe("context switching tests", () => {
const fn = jest.spyOn(defaultDispatcher, "dispatch"); let dispatcherRef;
let currentRoom = null;
beforeEach(async () => { beforeEach(async () => {
[room1, room2, orphan1].forEach(mkRoom); [room1, room2, orphan1].forEach(mkRoom);
mkSpace(space1, [room1, room2]); mkSpace(space1, [room1, room2]);
mkSpace(space2, [room2]); mkSpace(space2, [room2]);
await run(); await run();
dispatcherRef = defaultDispatcher.register(payload => {
if (payload.action === "view_room" || payload.action === "view_home_page") {
currentRoom = payload.room_id || null;
}
});
}); });
afterEach(() => { afterEach(() => {
fn.mockClear();
localStorage.clear(); 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 () => { it("last viewed room in target space is the current viewed and in both spaces", async () => {
await store.setActiveSpace(client.getRoom(space1)); await store.setActiveSpace(client.getRoom(space1));
@ -683,6 +669,14 @@ describe("SpaceStore", () => {
expect(getCurrentRoom()).toBe(space2); 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 () => { it("no last viewed room in target space", async () => {
await store.setActiveSpace(client.getRoom(space1)); await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1); viewRoom(room1);
@ -694,7 +688,7 @@ describe("SpaceStore", () => {
await store.setActiveSpace(client.getRoom(space1)); await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(null); 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(space1, [room1, room2, room3]);
mkSpace(space2, [room1, room2]); mkSpace(space2, [room1, room2]);
client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ const cliRoom2 = client.getRoom(room2);
cliRoom2.currentState.getStateEvents.mockImplementation(testUtils.mockStateEventImplementation([
mkEvent({ mkEvent({
event: true, event: true,
type: EventType.SpaceParent, 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 () => { it("when switching rooms in the all rooms home space don't switch to related space", async () => {
await setShowAllRooms(true);
viewRoom(room2); viewRoom(room2);
await store.setActiveSpace(null, false); await store.setActiveSpace(null, false);
viewRoom(room1); viewRoom(room1);

View File

@ -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);
});
});

View File

@ -15,7 +15,13 @@ limitations under the License.
*/ */
import { MatrixClient } from "matrix-js-sdk/src/client"; 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 { 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 // 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. // 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<any>
// @ts-ignore // @ts-ignore
await store.onNotReady(); await store.onNotReady();
}; };
export const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
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<typeof mkStubRoom>[]) => {
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<typeof mkStubRoom>[],
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));