mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-29 14:00:48 +08:00
Remove legacy room header and promote beta room header (#105)
* Remove legacy room header and promote beta room header Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Tidy up Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove unused component Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Prune i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
e60d3bd1ee
commit
8a263ac1b0
@ -12,7 +12,6 @@
|
|||||||
@import "./components/views/beacon/_LeftPanelLiveShareWarning.pcss";
|
@import "./components/views/beacon/_LeftPanelLiveShareWarning.pcss";
|
||||||
@import "./components/views/beacon/_LiveTimeRemaining.pcss";
|
@import "./components/views/beacon/_LiveTimeRemaining.pcss";
|
||||||
@import "./components/views/beacon/_OwnBeaconStatus.pcss";
|
@import "./components/views/beacon/_OwnBeaconStatus.pcss";
|
||||||
@import "./components/views/beacon/_RoomLiveShareWarning.pcss";
|
|
||||||
@import "./components/views/beacon/_ShareLatestLocation.pcss";
|
@import "./components/views/beacon/_ShareLatestLocation.pcss";
|
||||||
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
|
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
|
||||||
@import "./components/views/context_menus/_KebabContextMenu.pcss";
|
@import "./components/views/context_menus/_KebabContextMenu.pcss";
|
||||||
|
@ -1,51 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.mx_RoomLiveShareWarning {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: $spacing-12 $spacing-16;
|
|
||||||
|
|
||||||
color: $primary-content;
|
|
||||||
background-color: $system;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomLiveShareWarning_icon {
|
|
||||||
height: 32px;
|
|
||||||
width: 32px;
|
|
||||||
margin-right: $spacing-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomLiveShareWarning_label {
|
|
||||||
flex: 1;
|
|
||||||
font-size: $font-15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomLiveShareWarning_spinner {
|
|
||||||
margin-right: $spacing-16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomLiveShareWarning_closeButton {
|
|
||||||
@mixin ButtonResetDefault;
|
|
||||||
margin-left: $spacing-16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomLiveShareWarning_stopButton {
|
|
||||||
margin-left: $spacing-16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_RoomLiveShareWarning_closeButtonIcon {
|
|
||||||
height: $font-18px;
|
|
||||||
padding: $spacing-4;
|
|
||||||
}
|
|
@ -65,7 +65,6 @@ import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
|
|||||||
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
|
||||||
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
|
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
|
||||||
import AuxPanel from "../views/rooms/AuxPanel";
|
import AuxPanel from "../views/rooms/AuxPanel";
|
||||||
import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
|
|
||||||
import RoomHeader from "../views/rooms/RoomHeader";
|
import RoomHeader from "../views/rooms/RoomHeader";
|
||||||
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
|
||||||
import EffectsOverlay from "../views/elements/EffectsOverlay";
|
import EffectsOverlay from "../views/elements/EffectsOverlay";
|
||||||
@ -313,26 +312,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div className="mx_RoomView mx_RoomView--local">
|
<div className="mx_RoomView mx_RoomView--local">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
|
<RoomHeader room={room} />
|
||||||
<RoomHeader room={room} />
|
|
||||||
) : (
|
|
||||||
<LegacyRoomHeader
|
|
||||||
room={context.room}
|
|
||||||
searchInfo={undefined}
|
|
||||||
inRoom={true}
|
|
||||||
onSearchClick={null}
|
|
||||||
onInviteClick={null}
|
|
||||||
onForgetClick={null}
|
|
||||||
e2eStatus={room.encrypted ? E2EStatus.Normal : undefined}
|
|
||||||
onAppsClick={null}
|
|
||||||
appsShown={false}
|
|
||||||
excludedRightPanelPhaseButtons={[]}
|
|
||||||
showButtons={false}
|
|
||||||
enableRoomOptionsMenu={false}
|
|
||||||
viewingCall={false}
|
|
||||||
activeCall={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<main className="mx_RoomView_body" ref={props.roomView}>
|
<main className="mx_RoomView_body" ref={props.roomView}>
|
||||||
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
|
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
|
||||||
<div className="mx_RoomView_timeline">
|
<div className="mx_RoomView_timeline">
|
||||||
@ -366,26 +346,7 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement
|
|||||||
return (
|
return (
|
||||||
<div className="mx_RoomView mx_RoomView--local">
|
<div className="mx_RoomView mx_RoomView--local">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
|
<RoomHeader room={props.localRoom} />
|
||||||
<RoomHeader room={props.localRoom} />
|
|
||||||
) : (
|
|
||||||
<LegacyRoomHeader
|
|
||||||
room={props.localRoom}
|
|
||||||
searchInfo={undefined}
|
|
||||||
inRoom={true}
|
|
||||||
onSearchClick={null}
|
|
||||||
onInviteClick={null}
|
|
||||||
onForgetClick={null}
|
|
||||||
e2eStatus={props.localRoom.encrypted ? E2EStatus.Normal : undefined}
|
|
||||||
onAppsClick={null}
|
|
||||||
appsShown={false}
|
|
||||||
excludedRightPanelPhaseButtons={[]}
|
|
||||||
showButtons={false}
|
|
||||||
enableRoomOptionsMenu={false}
|
|
||||||
viewingCall={false}
|
|
||||||
activeCall={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="mx_RoomView_body">
|
<div className="mx_RoomView_body">
|
||||||
<LargeLoader text={text} />
|
<LargeLoader text={text} />
|
||||||
</div>
|
</div>
|
||||||
@ -1753,13 +1714,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private onAppsClick = (): void => {
|
|
||||||
dis.dispatch({
|
|
||||||
action: "appsDrawer",
|
|
||||||
show: !this.state.showApps,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private onForgetClick = (): void => {
|
private onForgetClick = (): void => {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: "forget_room",
|
action: "forget_room",
|
||||||
@ -1836,10 +1790,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
dis.fire(Action.ViewRoomDirectory);
|
dis.fire(Action.ViewRoomDirectory);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onSearchClick = (): void => {
|
|
||||||
dis.fire(Action.FocusMessageSearch);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onSearchChange = debounce((e: ChangeEvent): void => {
|
private onSearchChange = debounce((e: ChangeEvent): void => {
|
||||||
const term = (e.target as HTMLInputElement).value;
|
const term = (e.target as HTMLInputElement).value;
|
||||||
this.onSearch(term);
|
this.onSearch(term);
|
||||||
@ -2121,15 +2071,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomHeaderType = SettingsStore.getValue("feature_new_room_decoration_ui") ? "new" : "legacy";
|
|
||||||
|
|
||||||
if (!this.state.room) {
|
if (!this.state.room) {
|
||||||
const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading;
|
const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading;
|
||||||
if (loading) {
|
if (loading) {
|
||||||
// Assume preview loading if we don't have a ready client or a room ID (still resolving the alias)
|
// Assume preview loading if we don't have a ready client or a room ID (still resolving the alias)
|
||||||
const previewLoading = !this.state.matrixClientIsReady || !this.state.roomId || this.state.peekLoading;
|
const previewLoading = !this.state.matrixClientIsReady || !this.state.roomId || this.state.peekLoading;
|
||||||
return (
|
return (
|
||||||
<div className="mx_RoomView" data-room-header={roomHeaderType}>
|
<div className="mx_RoomView">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RoomPreviewBar
|
<RoomPreviewBar
|
||||||
canPreview={false}
|
canPreview={false}
|
||||||
@ -2154,7 +2102,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
// We've got to this room by following a link, possibly a third party invite.
|
// We've got to this room by following a link, possibly a third party invite.
|
||||||
const roomAlias = this.state.roomAlias;
|
const roomAlias = this.state.roomAlias;
|
||||||
return (
|
return (
|
||||||
<div className="mx_RoomView" data-room-header={roomHeaderType}>
|
<div className="mx_RoomView">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RoomPreviewBar
|
<RoomPreviewBar
|
||||||
onJoinClick={this.onJoinButtonClicked}
|
onJoinClick={this.onJoinButtonClicked}
|
||||||
@ -2224,7 +2172,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
|
|
||||||
// We have a regular invite for this room.
|
// We have a regular invite for this room.
|
||||||
return (
|
return (
|
||||||
<div className="mx_RoomView" data-room-header={roomHeaderType}>
|
<div className="mx_RoomView">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RoomPreviewBar
|
<RoomPreviewBar
|
||||||
onJoinClick={this.onJoinButtonClicked}
|
onJoinClick={this.onJoinButtonClicked}
|
||||||
@ -2248,7 +2196,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
([KnownMembership.Knock, KnownMembership.Leave] as Array<string>).includes(myMembership)
|
([KnownMembership.Knock, KnownMembership.Leave] as Array<string>).includes(myMembership)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="mx_RoomView" data-room-header={roomHeaderType}>
|
<div className="mx_RoomView">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RoomPreviewBar
|
<RoomPreviewBar
|
||||||
onJoinClick={this.onJoinButtonClicked}
|
onJoinClick={this.onJoinButtonClicked}
|
||||||
@ -2354,11 +2302,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) {
|
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) {
|
||||||
return (
|
return <div className="mx_RoomView">{previewBar}</div>;
|
||||||
<div className="mx_RoomView" data-room-header={roomHeaderType}>
|
|
||||||
{previewBar}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (hiddenHighlightCount > 0) {
|
} else if (hiddenHighlightCount > 0) {
|
||||||
aux = (
|
aux = (
|
||||||
@ -2587,46 +2531,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
}
|
}
|
||||||
const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName);
|
const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName);
|
||||||
|
|
||||||
let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline];
|
|
||||||
let onAppsClick: (() => void) | null = this.onAppsClick;
|
|
||||||
let onForgetClick: (() => void) | null = this.onForgetClick;
|
|
||||||
let onSearchClick: (() => void) | null = this.onSearchClick;
|
|
||||||
let onInviteClick: (() => void) | null = null;
|
|
||||||
let viewingCall = false;
|
|
||||||
|
|
||||||
// Simplify the header for other main split types
|
|
||||||
switch (mainSplitContentType) {
|
|
||||||
case MainSplitContentType.MaximisedWidget:
|
|
||||||
excludedRightPanelPhaseButtons = [];
|
|
||||||
onAppsClick = null;
|
|
||||||
onForgetClick = null;
|
|
||||||
onSearchClick = null;
|
|
||||||
break;
|
|
||||||
case MainSplitContentType.Call:
|
|
||||||
excludedRightPanelPhaseButtons = [];
|
|
||||||
onAppsClick = null;
|
|
||||||
onForgetClick = null;
|
|
||||||
onSearchClick = null;
|
|
||||||
if (this.state.room.canInvite(this.context.client.getSafeUserId())) {
|
|
||||||
onInviteClick = this.onInviteClick;
|
|
||||||
}
|
|
||||||
viewingCall = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const myMember = this.state.room!.getMember(this.context.client!.getSafeUserId());
|
|
||||||
const showForgetButton =
|
|
||||||
!this.context.client.isGuest() &&
|
|
||||||
(([KnownMembership.Leave, KnownMembership.Ban] as Array<string>).includes(myMembership) ||
|
|
||||||
myMember?.isKicked());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoomContext.Provider value={this.state}>
|
<RoomContext.Provider value={this.state}>
|
||||||
<div
|
<div className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
|
||||||
className={mainClasses}
|
|
||||||
ref={this.roomView}
|
|
||||||
onKeyDown={this.onReactKeyDown}
|
|
||||||
data-room-header={roomHeaderType}
|
|
||||||
>
|
|
||||||
{showChatEffects && this.roomView.current && (
|
{showChatEffects && this.roomView.current && (
|
||||||
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
|
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
|
||||||
)}
|
)}
|
||||||
@ -2644,31 +2551,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||||||
ref={this.roomViewBody}
|
ref={this.roomViewBody}
|
||||||
data-layout={this.state.layout}
|
data-layout={this.state.layout}
|
||||||
>
|
>
|
||||||
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
|
<RoomHeader
|
||||||
<RoomHeader
|
room={this.state.room}
|
||||||
room={this.state.room}
|
additionalButtons={this.state.viewRoomOpts.buttons}
|
||||||
additionalButtons={this.state.viewRoomOpts.buttons}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LegacyRoomHeader
|
|
||||||
room={this.state.room}
|
|
||||||
searchInfo={this.state.search}
|
|
||||||
oobData={this.props.oobData}
|
|
||||||
inRoom={myMembership === KnownMembership.Join}
|
|
||||||
onSearchClick={onSearchClick}
|
|
||||||
onInviteClick={onInviteClick}
|
|
||||||
onForgetClick={showForgetButton ? onForgetClick : null}
|
|
||||||
e2eStatus={this.state.e2eStatus}
|
|
||||||
onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null}
|
|
||||||
appsShown={this.state.showApps}
|
|
||||||
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
|
|
||||||
showButtons={!this.viewsLocalRoom}
|
|
||||||
enableRoomOptionsMenu={!this.viewsLocalRoom}
|
|
||||||
viewingCall={viewingCall}
|
|
||||||
activeCall={this.state.activeCall}
|
|
||||||
additionalButtons={this.state.viewRoomOpts.buttons}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{mainSplitBody}
|
{mainSplitBody}
|
||||||
</div>
|
</div>
|
||||||
</MainSplit>
|
</MainSplit>
|
||||||
|
@ -11,9 +11,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
|||||||
|
|
||||||
import { useRoomContext } from "../../contexts/RoomContext";
|
import { useRoomContext } from "../../contexts/RoomContext";
|
||||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||||
import { E2EStatus } from "../../utils/ShieldUtils";
|
|
||||||
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
import ErrorBoundary from "../views/elements/ErrorBoundary";
|
||||||
import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
|
|
||||||
import RoomHeader from "../views/rooms/RoomHeader";
|
import RoomHeader from "../views/rooms/RoomHeader";
|
||||||
import ScrollPanel from "./ScrollPanel";
|
import ScrollPanel from "./ScrollPanel";
|
||||||
import EventTileBubble from "../views/messages/EventTileBubble";
|
import EventTileBubble from "../views/messages/EventTileBubble";
|
||||||
@ -21,7 +19,6 @@ import NewRoomIntro from "../views/rooms/NewRoomIntro";
|
|||||||
import { UnwrappedEventTile } from "../views/rooms/EventTile";
|
import { UnwrappedEventTile } from "../views/rooms/EventTile";
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
import SdkConfig from "../../SdkConfig";
|
import SdkConfig from "../../SdkConfig";
|
||||||
import SettingsStore from "../../settings/SettingsStore";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
roomView: RefObject<HTMLElement>;
|
roomView: RefObject<HTMLElement>;
|
||||||
@ -41,24 +38,7 @@ export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resize
|
|||||||
return (
|
return (
|
||||||
<div className="mx_RoomView mx_RoomView--local">
|
<div className="mx_RoomView mx_RoomView--local">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
|
<RoomHeader room={context.room!} />
|
||||||
<RoomHeader room={context.room!} />
|
|
||||||
) : (
|
|
||||||
<LegacyRoomHeader
|
|
||||||
room={context.room}
|
|
||||||
inRoom={true}
|
|
||||||
onInviteClick={null}
|
|
||||||
onForgetClick={null}
|
|
||||||
e2eStatus={E2EStatus.Normal}
|
|
||||||
onAppsClick={null}
|
|
||||||
appsShown={false}
|
|
||||||
excludedRightPanelPhaseButtons={[]}
|
|
||||||
showButtons={false}
|
|
||||||
enableRoomOptionsMenu={false}
|
|
||||||
viewingCall={false}
|
|
||||||
activeCall={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<main className="mx_RoomView_body" ref={roomView}>
|
<main className="mx_RoomView_body" ref={roomView}>
|
||||||
<div className="mx_RoomView_timeline">
|
<div className="mx_RoomView_timeline">
|
||||||
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={resizeNotifier}>
|
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={resizeNotifier}>
|
||||||
|
@ -1,148 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Room } from "matrix-js-sdk/src/matrix";
|
|
||||||
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
|
||||||
import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore";
|
|
||||||
import { useOwnLiveBeacons } from "../../../utils/beacon";
|
|
||||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
|
||||||
import Spinner from "../elements/Spinner";
|
|
||||||
import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon";
|
|
||||||
import LiveTimeRemaining from "./LiveTimeRemaining";
|
|
||||||
import dispatcher from "../../../dispatcher/dispatcher";
|
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
|
||||||
import { Action } from "../../../dispatcher/actions";
|
|
||||||
|
|
||||||
const getLabel = (hasLocationPublishError: boolean, hasStopSharingError: boolean): string => {
|
|
||||||
if (hasLocationPublishError) {
|
|
||||||
return _t("location_sharing|error_sharing_live_location_try_again");
|
|
||||||
}
|
|
||||||
if (hasStopSharingError) {
|
|
||||||
return _t("location_sharing|error_stopping_live_location_try_again");
|
|
||||||
}
|
|
||||||
return _t("location_sharing|live_location_active");
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RoomLiveShareWarningInnerProps {
|
|
||||||
liveBeaconIds: string[];
|
|
||||||
roomId: Room["roomId"];
|
|
||||||
}
|
|
||||||
const RoomLiveShareWarningInner: React.FC<RoomLiveShareWarningInnerProps> = ({ liveBeaconIds, roomId }) => {
|
|
||||||
const {
|
|
||||||
onStopSharing,
|
|
||||||
onResetLocationPublishError,
|
|
||||||
beacon,
|
|
||||||
stoppingInProgress,
|
|
||||||
hasStopSharingError,
|
|
||||||
hasLocationPublishError,
|
|
||||||
} = useOwnLiveBeacons(liveBeaconIds);
|
|
||||||
|
|
||||||
if (!beacon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasError = hasStopSharingError || hasLocationPublishError;
|
|
||||||
|
|
||||||
// eat events from buttons so navigate to tile
|
|
||||||
// is not triggered
|
|
||||||
const stopPropagationWrapper =
|
|
||||||
(callback: () => void) =>
|
|
||||||
(e?: ButtonEvent): void => {
|
|
||||||
e?.stopPropagation();
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onButtonClick = (): void => {
|
|
||||||
if (hasLocationPublishError) {
|
|
||||||
onResetLocationPublishError();
|
|
||||||
} else {
|
|
||||||
onStopSharing();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClick = (): void => {
|
|
||||||
dispatcher.dispatch<ViewRoomPayload>({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: beacon.roomId,
|
|
||||||
metricsTrigger: undefined,
|
|
||||||
event_id: beacon.beaconInfoId,
|
|
||||||
scroll_into_view: true,
|
|
||||||
highlighted: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx_RoomLiveShareWarning" onClick={onClick}>
|
|
||||||
<StyledLiveBeaconIcon className="mx_RoomLiveShareWarning_icon" withError={hasError} />
|
|
||||||
|
|
||||||
<span className="mx_RoomLiveShareWarning_label">
|
|
||||||
{getLabel(hasLocationPublishError, hasStopSharingError)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{stoppingInProgress && (
|
|
||||||
<span className="mx_RoomLiveShareWarning_spinner">
|
|
||||||
<Spinner h={16} w={16} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!stoppingInProgress && !hasError && <LiveTimeRemaining beacon={beacon} />}
|
|
||||||
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_RoomLiveShareWarning_stopButton"
|
|
||||||
data-testid="room-live-share-primary-button"
|
|
||||||
onClick={stopPropagationWrapper(onButtonClick)}
|
|
||||||
kind="danger"
|
|
||||||
element="button"
|
|
||||||
disabled={stoppingInProgress}
|
|
||||||
>
|
|
||||||
{hasError ? _t("action|retry") : _t("action|stop")}
|
|
||||||
</AccessibleButton>
|
|
||||||
{hasLocationPublishError && (
|
|
||||||
<AccessibleButton
|
|
||||||
data-testid="room-live-share-wire-error-close-button"
|
|
||||||
title={_t("location_sharing|stop_and_close")}
|
|
||||||
element="button"
|
|
||||||
className="mx_RoomLiveShareWarning_closeButton"
|
|
||||||
onClick={stopPropagationWrapper(onStopSharing)}
|
|
||||||
>
|
|
||||||
<CloseIcon className="mx_RoomLiveShareWarning_closeButtonIcon" />
|
|
||||||
</AccessibleButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
roomId: Room["roomId"];
|
|
||||||
}
|
|
||||||
const RoomLiveShareWarning: React.FC<Props> = ({ roomId }) => {
|
|
||||||
// do we have an active geolocation.watchPosition
|
|
||||||
const isMonitoringLiveLocation = useEventEmitterState(
|
|
||||||
OwnBeaconStore.instance,
|
|
||||||
OwnBeaconStoreEvent.MonitoringLivePosition,
|
|
||||||
() => OwnBeaconStore.instance.isMonitoringLiveLocation,
|
|
||||||
);
|
|
||||||
|
|
||||||
const liveBeaconIds = useEventEmitterState(OwnBeaconStore.instance, OwnBeaconStoreEvent.LivenessChange, () =>
|
|
||||||
OwnBeaconStore.instance.getLiveBeaconIds(roomId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMonitoringLiveLocation || !liveBeaconIds.length) {
|
|
||||||
// This logic is entangled with the RoomCallBanner-test's. The tests need updating if this logic changes.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// split into outer/inner to avoid watching various parts of live beacon state
|
|
||||||
// when there are none
|
|
||||||
return <RoomLiveShareWarningInner liveBeaconIds={liveBeaconIds} roomId={roomId} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomLiveShareWarning;
|
|
@ -1,389 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useContext } from "react";
|
|
||||||
import { Room, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
|
||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
|
||||||
|
|
||||||
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
|
|
||||||
import IconizedContextMenu, {
|
|
||||||
IconizedContextMenuCheckbox,
|
|
||||||
IconizedContextMenuOption,
|
|
||||||
IconizedContextMenuOptionList,
|
|
||||||
} from "./IconizedContextMenu";
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
|
||||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
|
||||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
|
||||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
|
||||||
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
|
|
||||||
import { RoomNotifState } from "../../../RoomNotifs";
|
|
||||||
import Modal from "../../../Modal";
|
|
||||||
import ExportDialog from "../dialogs/ExportDialog";
|
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
|
||||||
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
|
|
||||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
|
||||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
|
||||||
import { Action } from "../../../dispatcher/actions";
|
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
|
||||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
|
||||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
|
||||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
|
||||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
|
||||||
import { UIComponent } from "../../../settings/UIFeature";
|
|
||||||
import { DeveloperToolsOption } from "./DeveloperToolsOption";
|
|
||||||
import { tagRoom } from "../../../utils/room/tagRoom";
|
|
||||||
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
|
|
||||||
import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
|
|
||||||
|
|
||||||
interface IProps extends IContextMenuProps {
|
|
||||||
room: Room;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Room context menu accessible via the room header.
|
|
||||||
* @deprecated will be removed as part of `feature_new_room_decoration_ui`
|
|
||||||
*/
|
|
||||||
const RoomContextMenu: React.FC<IProps> = ({ room, onFinished, ...props }) => {
|
|
||||||
const cli = useContext(MatrixClientContext);
|
|
||||||
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () =>
|
|
||||||
RoomListStore.instance.getTagsForRoom(room),
|
|
||||||
);
|
|
||||||
|
|
||||||
let leaveOption: JSX.Element | undefined;
|
|
||||||
if (roomTags.includes(DefaultTagID.Archived)) {
|
|
||||||
const onForgetRoomClick = (ev: ButtonEvent): void => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
dis.dispatch({
|
|
||||||
action: "forget_room",
|
|
||||||
room_id: room.roomId,
|
|
||||||
});
|
|
||||||
onFinished();
|
|
||||||
};
|
|
||||||
|
|
||||||
leaveOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
iconClassName="mx_RoomTile_iconSignOut"
|
|
||||||
label={_t("room|context_menu|forget")}
|
|
||||||
className="mx_IconizedContextMenu_option_red"
|
|
||||||
onClick={onForgetRoomClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const onLeaveRoomClick = (ev: ButtonEvent): void => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
dis.dispatch({
|
|
||||||
action: "leave_room",
|
|
||||||
room_id: room.roomId,
|
|
||||||
});
|
|
||||||
onFinished();
|
|
||||||
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomHeaderContextMenuLeaveItem", ev);
|
|
||||||
};
|
|
||||||
|
|
||||||
leaveOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={onLeaveRoomClick}
|
|
||||||
label={_t("action|leave")}
|
|
||||||
className="mx_IconizedContextMenu_option_red"
|
|
||||||
iconClassName="mx_RoomTile_iconSignOut"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
|
||||||
const isVideoRoom = calcIsVideoRoom(room);
|
|
||||||
const canInvite = useEventEmitterState(cli, RoomMemberEvent.PowerLevel, () => room.canInvite(cli.getUserId()!));
|
|
||||||
let inviteOption: JSX.Element | undefined;
|
|
||||||
if (canInvite && !isDm && shouldShowComponent(UIComponent.InviteUsers)) {
|
|
||||||
const onInviteClick = (ev: ButtonEvent): void => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
dis.dispatch({
|
|
||||||
action: "view_invite",
|
|
||||||
roomId: room.roomId,
|
|
||||||
});
|
|
||||||
onFinished();
|
|
||||||
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomHeaderContextMenuInviteItem", ev);
|
|
||||||
};
|
|
||||||
|
|
||||||
inviteOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={onInviteClick}
|
|
||||||
label={_t("action|invite")}
|
|
||||||
iconClassName="mx_RoomTile_iconInvite"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let favouriteOption: JSX.Element | undefined;
|
|
||||||
let lowPriorityOption: JSX.Element | undefined;
|
|
||||||
let notificationOption: JSX.Element | undefined;
|
|
||||||
if (room.getMyMembership() === KnownMembership.Join) {
|
|
||||||
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
|
|
||||||
favouriteOption = (
|
|
||||||
<IconizedContextMenuCheckbox
|
|
||||||
onClick={(e) => {
|
|
||||||
onTagRoom(e, DefaultTagID.Favourite);
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomHeaderContextMenuFavouriteToggle", e);
|
|
||||||
}}
|
|
||||||
active={isFavorite}
|
|
||||||
label={isFavorite ? _t("room|context_menu|unfavourite") : _t("room|context_menu|favourite")}
|
|
||||||
iconClassName="mx_RoomTile_iconStar"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLowPriority = roomTags.includes(DefaultTagID.LowPriority);
|
|
||||||
lowPriorityOption = (
|
|
||||||
<IconizedContextMenuCheckbox
|
|
||||||
onClick={(e) => onTagRoom(e, DefaultTagID.LowPriority)}
|
|
||||||
active={isLowPriority}
|
|
||||||
label={_t("common|low_priority")}
|
|
||||||
iconClassName="mx_RoomTile_iconArrowDown"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const echoChamber = EchoChamber.forRoom(room);
|
|
||||||
let notificationLabel: string | undefined;
|
|
||||||
let iconClassName: string | undefined;
|
|
||||||
switch (echoChamber.notificationVolume) {
|
|
||||||
case RoomNotifState.AllMessages:
|
|
||||||
notificationLabel = _t("notifications|default");
|
|
||||||
iconClassName = "mx_RoomTile_iconNotificationsDefault";
|
|
||||||
break;
|
|
||||||
case RoomNotifState.AllMessagesLoud:
|
|
||||||
notificationLabel = _t("notifications|all_messages");
|
|
||||||
iconClassName = "mx_RoomTile_iconNotificationsAllMessages";
|
|
||||||
break;
|
|
||||||
case RoomNotifState.MentionsOnly:
|
|
||||||
notificationLabel = _t("room|context_menu|mentions_only");
|
|
||||||
iconClassName = "mx_RoomTile_iconNotificationsMentionsKeywords";
|
|
||||||
break;
|
|
||||||
case RoomNotifState.Mute:
|
|
||||||
notificationLabel = _t("common|mute");
|
|
||||||
iconClassName = "mx_RoomTile_iconNotificationsNone";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
dis.dispatch({
|
|
||||||
action: "open_room_settings",
|
|
||||||
room_id: room.roomId,
|
|
||||||
initial_tab_id: RoomSettingsTab.Notifications,
|
|
||||||
});
|
|
||||||
onFinished();
|
|
||||||
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomHeaderContextMenuNotificationsItem", ev);
|
|
||||||
}}
|
|
||||||
label={_t("notifications|enable_prompt_toast_title")}
|
|
||||||
iconClassName={iconClassName}
|
|
||||||
>
|
|
||||||
<span className="mx_IconizedContextMenu_sublabel">{notificationLabel}</span>
|
|
||||||
</IconizedContextMenuOption>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let peopleOption: JSX.Element | undefined;
|
|
||||||
let copyLinkOption: JSX.Element | undefined;
|
|
||||||
if (!isDm) {
|
|
||||||
peopleOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
ensureViewingRoom(ev);
|
|
||||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.RoomMemberList }, false);
|
|
||||||
onFinished();
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomHeaderContextMenuPeopleItem", ev);
|
|
||||||
}}
|
|
||||||
label={_t("common|people")}
|
|
||||||
iconClassName="mx_RoomTile_iconPeople"
|
|
||||||
>
|
|
||||||
<span className="mx_IconizedContextMenu_sublabel">{room.getJoinedMemberCount()}</span>
|
|
||||||
</IconizedContextMenuOption>
|
|
||||||
);
|
|
||||||
|
|
||||||
copyLinkOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
dis.dispatch({
|
|
||||||
action: "copy_room",
|
|
||||||
room_id: room.roomId,
|
|
||||||
});
|
|
||||||
onFinished();
|
|
||||||
}}
|
|
||||||
label={_t("room|context_menu|copy_link")}
|
|
||||||
iconClassName="mx_RoomTile_iconCopyLink"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let filesOption: JSX.Element | undefined;
|
|
||||||
if (!isVideoRoom) {
|
|
||||||
filesOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
ensureViewingRoom(ev);
|
|
||||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.FilePanel }, false);
|
|
||||||
onFinished();
|
|
||||||
}}
|
|
||||||
label={_t("right_panel|files_button")}
|
|
||||||
iconClassName="mx_RoomTile_iconFiles"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pinCount = usePinnedEvents(room).length;
|
|
||||||
|
|
||||||
let pinsOption: JSX.Element | undefined;
|
|
||||||
if (!isVideoRoom) {
|
|
||||||
pinsOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
ensureViewingRoom(ev);
|
|
||||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.PinnedMessages }, false);
|
|
||||||
onFinished();
|
|
||||||
}}
|
|
||||||
label={_t("right_panel|pinned_messages_button")}
|
|
||||||
iconClassName="mx_RoomTile_iconPins"
|
|
||||||
>
|
|
||||||
{pinCount > 0 && <span className="mx_IconizedContextMenu_sublabel">{pinCount}</span>}
|
|
||||||
</IconizedContextMenuOption>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let widgetsOption: JSX.Element | undefined;
|
|
||||||
if (!isVideoRoom) {
|
|
||||||
widgetsOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
ensureViewingRoom(ev);
|
|
||||||
RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }, false);
|
|
||||||
onFinished();
|
|
||||||
}}
|
|
||||||
label={_t("right_panel|widgets_section")}
|
|
||||||
iconClassName="mx_RoomTile_iconWidgets"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let exportChatOption: JSX.Element | undefined;
|
|
||||||
if (!isVideoRoom) {
|
|
||||||
exportChatOption = (
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
Modal.createDialog(ExportDialog, { room });
|
|
||||||
onFinished();
|
|
||||||
}}
|
|
||||||
label={_t("right_panel|export_chat_button")}
|
|
||||||
iconClassName="mx_RoomTile_iconExport"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTagRoom = (ev: ButtonEvent, tagId: TagID): void => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
tagRoom(room, tagId);
|
|
||||||
|
|
||||||
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
|
|
||||||
switch (action) {
|
|
||||||
case KeyBindingAction.Enter:
|
|
||||||
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
|
||||||
onFinished();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureViewingRoom = (ev: ButtonEvent): void => {
|
|
||||||
if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId) return;
|
|
||||||
dis.dispatch<ViewRoomPayload>(
|
|
||||||
{
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: room.roomId,
|
|
||||||
metricsTrigger: "RoomList",
|
|
||||||
metricsViaKeyboard: ev.type !== "click",
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconizedContextMenu {...props} onFinished={onFinished} className="mx_RoomTile_contextMenu" compact>
|
|
||||||
<IconizedContextMenuOptionList>
|
|
||||||
{inviteOption}
|
|
||||||
{notificationOption}
|
|
||||||
{favouriteOption}
|
|
||||||
{peopleOption}
|
|
||||||
{filesOption}
|
|
||||||
{pinsOption}
|
|
||||||
{widgetsOption}
|
|
||||||
{lowPriorityOption}
|
|
||||||
{copyLinkOption}
|
|
||||||
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
onClick={(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
|
|
||||||
dis.dispatch({
|
|
||||||
action: "open_room_settings",
|
|
||||||
room_id: room.roomId,
|
|
||||||
});
|
|
||||||
onFinished();
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomHeaderContextMenuSettingsItem", ev);
|
|
||||||
}}
|
|
||||||
label={_t("common|settings")}
|
|
||||||
iconClassName="mx_RoomTile_iconSettings"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{exportChatOption}
|
|
||||||
|
|
||||||
{SettingsStore.getValue("developerMode") && (
|
|
||||||
<DeveloperToolsOption onFinished={onFinished} roomId={room.roomId} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{leaveOption}
|
|
||||||
</IconizedContextMenuOptionList>
|
|
||||||
</IconizedContextMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoomContextMenu;
|
|
@ -1,318 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2019-2023 The Matrix.org Foundation C.I.C.
|
|
||||||
Copyright 2018 New Vector Ltd
|
|
||||||
Copyright 2017 Vector Creations Ltd
|
|
||||||
Copyright 2017 New Vector Ltd
|
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { NotificationCountType, Room, RoomEvent, ThreadEvent } from "matrix-js-sdk/src/matrix";
|
|
||||||
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import HeaderButton from "./HeaderButton";
|
|
||||||
import HeaderButtons, { HeaderKind } from "./HeaderButtons";
|
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
|
||||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
|
||||||
import { showThreadPanel } from "../../../dispatcher/dispatch-actions/threads";
|
|
||||||
import {
|
|
||||||
RoomNotificationStateStore,
|
|
||||||
UPDATE_STATUS_INDICATOR,
|
|
||||||
} from "../../../stores/notifications/RoomNotificationStateStore";
|
|
||||||
import { NotificationLevel } from "../../../stores/notifications/NotificationLevel";
|
|
||||||
import { SummarizedNotificationState } from "../../../stores/notifications/SummarizedNotificationState";
|
|
||||||
import PosthogTrackers from "../../../PosthogTrackers";
|
|
||||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
|
||||||
import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread";
|
|
||||||
import { usePinnedEvents, useReadPinnedEvents } from "../../../hooks/usePinnedEvents";
|
|
||||||
|
|
||||||
const ROOM_INFO_PHASES = [
|
|
||||||
RightPanelPhases.RoomSummary,
|
|
||||||
RightPanelPhases.Widget,
|
|
||||||
RightPanelPhases.FilePanel,
|
|
||||||
RightPanelPhases.RoomMemberList,
|
|
||||||
RightPanelPhases.RoomMemberInfo,
|
|
||||||
RightPanelPhases.EncryptionPanel,
|
|
||||||
RightPanelPhases.Room3pidMemberInfo,
|
|
||||||
];
|
|
||||||
|
|
||||||
interface IUnreadIndicatorProps {
|
|
||||||
color?: NotificationLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UnreadIndicator: React.FC<IUnreadIndicatorProps> = ({ color }) => {
|
|
||||||
if (color === NotificationLevel.None) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const classes = classNames({
|
|
||||||
mx_Indicator: true,
|
|
||||||
mx_LegacyRoomHeader_button_unreadIndicator: true,
|
|
||||||
mx_Indicator_activity: color === NotificationLevel.Activity,
|
|
||||||
mx_Indicator_notification: color === NotificationLevel.Notification,
|
|
||||||
mx_Indicator_highlight: color === NotificationLevel.Highlight,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="mx_LegacyRoomHeader_button_unreadIndicator_bg" />
|
|
||||||
<div className={classes} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IHeaderButtonProps {
|
|
||||||
room: Room;
|
|
||||||
isHighlighted: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PinnedMessagesHeaderButton: React.FC<IHeaderButtonProps> = ({ room, isHighlighted, onClick }) => {
|
|
||||||
const pinnedEvents = usePinnedEvents(room);
|
|
||||||
const readPinnedEvents = useReadPinnedEvents(room);
|
|
||||||
if (!pinnedEvents?.length) return null;
|
|
||||||
|
|
||||||
let unreadIndicator;
|
|
||||||
if (pinnedEvents.some((id) => !readPinnedEvents.has(id))) {
|
|
||||||
unreadIndicator = <UnreadIndicator />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HeaderButton
|
|
||||||
name="pinnedMessagesButton"
|
|
||||||
title={_t("right_panel|pinned_messages|title")}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
isUnread={!!unreadIndicator}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{unreadIndicator}
|
|
||||||
</HeaderButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TimelineCardHeaderButton: React.FC<IHeaderButtonProps> = ({ room, isHighlighted, onClick }) => {
|
|
||||||
let unreadIndicator;
|
|
||||||
const color = RoomNotificationStateStore.instance.getRoomState(room).level;
|
|
||||||
switch (color) {
|
|
||||||
case NotificationLevel.Activity:
|
|
||||||
case NotificationLevel.Notification:
|
|
||||||
case NotificationLevel.Highlight:
|
|
||||||
unreadIndicator = <UnreadIndicator color={color} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HeaderButton
|
|
||||||
name="timelineCardButton"
|
|
||||||
title={_t("right_panel|video_room_chat|title")}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{unreadIndicator}
|
|
||||||
</HeaderButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IProps {
|
|
||||||
room?: Room;
|
|
||||||
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated will be removed as part of 'feature_new_room_decoration_ui'
|
|
||||||
*/
|
|
||||||
export default class LegacyRoomHeaderButtons extends HeaderButtons<IProps> {
|
|
||||||
private static readonly THREAD_PHASES = [RightPanelPhases.ThreadPanel, RightPanelPhases.ThreadView];
|
|
||||||
private globalNotificationState: SummarizedNotificationState;
|
|
||||||
|
|
||||||
public constructor(props: IProps) {
|
|
||||||
super(props, HeaderKind.Room);
|
|
||||||
this.globalNotificationState = RoomNotificationStateStore.instance.globalState;
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidMount(): void {
|
|
||||||
super.componentDidMount();
|
|
||||||
// Notification badge may change if the notification counts from the
|
|
||||||
// server change, if a new thread is created or updated, or if a
|
|
||||||
// receipt is sent in the thread.
|
|
||||||
this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
|
|
||||||
this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate);
|
|
||||||
this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate);
|
|
||||||
this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate);
|
|
||||||
this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
|
|
||||||
this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate);
|
|
||||||
this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate);
|
|
||||||
this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate);
|
|
||||||
this.onNotificationUpdate();
|
|
||||||
RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
|
||||||
super.componentWillUnmount();
|
|
||||||
this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
|
|
||||||
this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate);
|
|
||||||
this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate);
|
|
||||||
this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate);
|
|
||||||
this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
|
|
||||||
this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate);
|
|
||||||
this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate);
|
|
||||||
this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate);
|
|
||||||
RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onNotificationUpdate = (): void => {
|
|
||||||
// console.log
|
|
||||||
// XXX: why don't we read from this.state.threadNotificationLevel in the render methods?
|
|
||||||
this.setState({
|
|
||||||
threadNotificationLevel: this.notificationLevel,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private get notificationLevel(): NotificationLevel {
|
|
||||||
switch (this.props.room?.threadsAggregateNotificationType) {
|
|
||||||
case NotificationCountType.Highlight:
|
|
||||||
return NotificationLevel.Highlight;
|
|
||||||
case NotificationCountType.Total:
|
|
||||||
return NotificationLevel.Notification;
|
|
||||||
}
|
|
||||||
// We don't have any notified messages, but we might have unread messages. Let's
|
|
||||||
// find out.
|
|
||||||
for (const thread of this.props.room!.getThreads()) {
|
|
||||||
// If the current thread has unread messages, we're done.
|
|
||||||
if (doesRoomOrThreadHaveUnreadMessages(thread)) {
|
|
||||||
return NotificationLevel.Activity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Otherwise, no notification color.
|
|
||||||
return NotificationLevel.None;
|
|
||||||
}
|
|
||||||
|
|
||||||
private onUpdateStatus = (notificationState: SummarizedNotificationState): void => {
|
|
||||||
// XXX: why don't we read from this.state.globalNotificationCount in the render methods?
|
|
||||||
this.globalNotificationState = notificationState;
|
|
||||||
this.setState({
|
|
||||||
globalNotificationLevel: notificationState.level,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
protected onAction(payload: ActionPayload): void {}
|
|
||||||
|
|
||||||
private onRoomSummaryClicked = (): void => {
|
|
||||||
// use roomPanelPhase rather than this.state.phase as it remembers the latest one if we close
|
|
||||||
const currentPhase = RightPanelStore.instance.currentCard.phase;
|
|
||||||
if (currentPhase && ROOM_INFO_PHASES.includes(currentPhase)) {
|
|
||||||
if (this.state.phase === currentPhase) {
|
|
||||||
RightPanelStore.instance.showOrHidePhase(currentPhase);
|
|
||||||
} else {
|
|
||||||
RightPanelStore.instance.showOrHidePhase(currentPhase, RightPanelStore.instance.currentCard.state);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// This toggles for us, if needed
|
|
||||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.RoomSummary);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onNotificationsClicked = (): void => {
|
|
||||||
// This toggles for us, if needed
|
|
||||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.NotificationPanel);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onPinnedMessagesClicked = (): void => {
|
|
||||||
// This toggles for us, if needed
|
|
||||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.PinnedMessages);
|
|
||||||
};
|
|
||||||
private onTimelineCardClicked = (): void => {
|
|
||||||
RightPanelStore.instance.showOrHidePhase(RightPanelPhases.Timeline);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onThreadsPanelClicked = (ev: ButtonEvent): void => {
|
|
||||||
if (this.state.phase && LegacyRoomHeaderButtons.THREAD_PHASES.includes(this.state.phase)) {
|
|
||||||
RightPanelStore.instance.togglePanel(this.props.room?.roomId ?? null);
|
|
||||||
} else {
|
|
||||||
showThreadPanel();
|
|
||||||
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", ev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public renderButtons(): JSX.Element {
|
|
||||||
if (!this.props.room) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rightPanelPhaseButtons: Map<RightPanelPhases, any> = new Map();
|
|
||||||
|
|
||||||
rightPanelPhaseButtons.set(
|
|
||||||
RightPanelPhases.PinnedMessages,
|
|
||||||
<PinnedMessagesHeaderButton
|
|
||||||
key="pinnedMessagesButton"
|
|
||||||
room={this.props.room}
|
|
||||||
isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
|
|
||||||
onClick={this.onPinnedMessagesClicked}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
rightPanelPhaseButtons.set(
|
|
||||||
RightPanelPhases.Timeline,
|
|
||||||
<TimelineCardHeaderButton
|
|
||||||
key="timelineButton"
|
|
||||||
room={this.props.room}
|
|
||||||
isHighlighted={this.isPhase(RightPanelPhases.Timeline)}
|
|
||||||
onClick={this.onTimelineCardClicked}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
rightPanelPhaseButtons.set(
|
|
||||||
RightPanelPhases.ThreadPanel,
|
|
||||||
<HeaderButton
|
|
||||||
key={RightPanelPhases.ThreadPanel}
|
|
||||||
name="threadsButton"
|
|
||||||
data-testid="threadsButton"
|
|
||||||
title={_t("common|threads")}
|
|
||||||
onClick={this.onThreadsPanelClicked}
|
|
||||||
isHighlighted={this.isPhase(LegacyRoomHeaderButtons.THREAD_PHASES)}
|
|
||||||
isUnread={this.state.threadNotificationLevel > NotificationLevel.None}
|
|
||||||
>
|
|
||||||
<UnreadIndicator color={this.state.threadNotificationLevel} />
|
|
||||||
</HeaderButton>,
|
|
||||||
);
|
|
||||||
if (this.state.notificationsEnabled) {
|
|
||||||
rightPanelPhaseButtons.set(
|
|
||||||
RightPanelPhases.NotificationPanel,
|
|
||||||
<HeaderButton
|
|
||||||
key="notifsButton"
|
|
||||||
name="notifsButton"
|
|
||||||
title={_t("notifications|enable_prompt_toast_title")}
|
|
||||||
isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)}
|
|
||||||
onClick={this.onNotificationsClicked}
|
|
||||||
isUnread={this.globalNotificationState.level === NotificationLevel.Highlight}
|
|
||||||
>
|
|
||||||
{this.globalNotificationState.level === NotificationLevel.Highlight ? (
|
|
||||||
<UnreadIndicator color={this.globalNotificationState.level} />
|
|
||||||
) : null}
|
|
||||||
</HeaderButton>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
rightPanelPhaseButtons.set(
|
|
||||||
RightPanelPhases.RoomSummary,
|
|
||||||
<HeaderButton
|
|
||||||
key="roomSummaryButton"
|
|
||||||
name="roomSummaryButton"
|
|
||||||
title={_t("right_panel|room_summary_card|title")}
|
|
||||||
isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
|
|
||||||
onClick={this.onRoomSummaryClicked}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Array.from(rightPanelPhaseButtons.keys()).map((phase) =>
|
|
||||||
this.props.excludedRightPanelPhaseButtons?.includes(phase)
|
|
||||||
? null
|
|
||||||
: rightPanelPhaseButtons.get(phase),
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,818 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
|
|
||||||
Copyright 2015, 2016 OpenMarket Ltd
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { FC, useState, useMemo, useCallback } from "react";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { throttle } from "lodash";
|
|
||||||
import { RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
|
||||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
|
||||||
import { IconButton, Tooltip } from "@vector-im/compound-web";
|
|
||||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
|
||||||
|
|
||||||
import type { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
|
||||||
import { _t } from "../../../languageHandler";
|
|
||||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
|
||||||
import { Action } from "../../../dispatcher/actions";
|
|
||||||
import { UserTab } from "../dialogs/UserTab";
|
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
|
||||||
import RoomHeaderButtons from "../right_panel/LegacyRoomHeaderButtons";
|
|
||||||
import E2EIcon from "./E2EIcon";
|
|
||||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
|
||||||
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
|
|
||||||
import RoomTopic from "../elements/RoomTopic";
|
|
||||||
import RoomName from "../elements/RoomName";
|
|
||||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
|
||||||
import { IOOBData } from "../../../stores/ThreepidInviteStore";
|
|
||||||
import { RoomKnocksBar } from "./RoomKnocksBar";
|
|
||||||
import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
|
||||||
import RoomContextMenu from "../context_menus/RoomContextMenu";
|
|
||||||
import { contextMenuBelow } from "./RoomTile";
|
|
||||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
|
||||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
|
||||||
import RoomLiveShareWarning from "../beacon/RoomLiveShareWarning";
|
|
||||||
import { BetaPill } from "../beta/BetaCard";
|
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
|
||||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
|
||||||
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
|
|
||||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
|
|
||||||
import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings";
|
|
||||||
import SdkConfig from "../../../SdkConfig";
|
|
||||||
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
|
||||||
import { useWidgets } from "../../../utils/WidgetUtils";
|
|
||||||
import { WidgetType } from "../../../widgets/WidgetType";
|
|
||||||
import { useCall, useLayout } from "../../../hooks/useCall";
|
|
||||||
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
|
|
||||||
import { Call, ElementCall, Layout } from "../../../models/Call";
|
|
||||||
import IconizedContextMenu, {
|
|
||||||
IconizedContextMenuOption,
|
|
||||||
IconizedContextMenuOptionList,
|
|
||||||
IconizedContextMenuRadio,
|
|
||||||
} from "../context_menus/IconizedContextMenu";
|
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
|
||||||
import { SessionDuration } from "../voip/CallDuration";
|
|
||||||
import RoomCallBanner from "../beacon/RoomCallBanner";
|
|
||||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
|
|
||||||
import { UIComponent } from "../../../settings/UIFeature";
|
|
||||||
import { SearchInfo } from "../../../Searching";
|
|
||||||
|
|
||||||
class DisabledWithReason {
|
|
||||||
public constructor(public readonly reason: string) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VoiceCallButtonProps {
|
|
||||||
room: Room;
|
|
||||||
busy: boolean;
|
|
||||||
setBusy: (value: boolean) => void;
|
|
||||||
behavior: DisabledWithReason | "legacy_or_jitsi";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi
|
|
||||||
* widgets.
|
|
||||||
*/
|
|
||||||
const VoiceCallButton: FC<VoiceCallButtonProps> = ({ room, busy, setBusy, behavior }) => {
|
|
||||||
const { onClick, tooltip, disabled } = useMemo(() => {
|
|
||||||
if (behavior instanceof DisabledWithReason) {
|
|
||||||
return {
|
|
||||||
onClick: () => {},
|
|
||||||
tooltip: behavior.reason,
|
|
||||||
disabled: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// behavior === "legacy_or_jitsi"
|
|
||||||
return {
|
|
||||||
onClick: async (ev: ButtonEvent): Promise<void> => {
|
|
||||||
ev.preventDefault();
|
|
||||||
setBusy(true);
|
|
||||||
await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice);
|
|
||||||
setBusy(false);
|
|
||||||
},
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [behavior, room, setBusy]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_voiceCallButton"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={_t("voip|voice_call")}
|
|
||||||
title={tooltip ?? _t("voip|voice_call")}
|
|
||||||
placement="bottom"
|
|
||||||
disabled={disabled || busy}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface VideoCallButtonProps {
|
|
||||||
room: Room;
|
|
||||||
busy: boolean;
|
|
||||||
setBusy: (value: boolean) => void;
|
|
||||||
behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element" | "legacy_or_element";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button for starting video calls, supporting both legacy 1:1 calls, Jitsi
|
|
||||||
* widgets, and native group calls. If multiple calling options are available,
|
|
||||||
* this shows a menu to pick between them.
|
|
||||||
*/
|
|
||||||
const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavior }) => {
|
|
||||||
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
|
|
||||||
|
|
||||||
const startLegacyCall = useCallback(async (): Promise<void> => {
|
|
||||||
setBusy(true);
|
|
||||||
await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video);
|
|
||||||
setBusy(false);
|
|
||||||
}, [setBusy, room]);
|
|
||||||
|
|
||||||
const startElementCall = useCallback(
|
|
||||||
(skipLobby: boolean) => {
|
|
||||||
setBusy(true);
|
|
||||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: room.roomId,
|
|
||||||
view_call: true,
|
|
||||||
skipLobby: skipLobby,
|
|
||||||
metricsTrigger: undefined,
|
|
||||||
});
|
|
||||||
setBusy(false);
|
|
||||||
},
|
|
||||||
[setBusy, room],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { onClick, tooltip, disabled } = useMemo(() => {
|
|
||||||
if (behavior instanceof DisabledWithReason) {
|
|
||||||
return {
|
|
||||||
onClick: () => {},
|
|
||||||
tooltip: behavior.reason,
|
|
||||||
disabled: true,
|
|
||||||
};
|
|
||||||
} else if (behavior === "legacy_or_jitsi") {
|
|
||||||
return {
|
|
||||||
onClick: async (ev: ButtonEvent): Promise<void> => {
|
|
||||||
ev.preventDefault();
|
|
||||||
await startLegacyCall();
|
|
||||||
},
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
} else if (behavior === "element") {
|
|
||||||
return {
|
|
||||||
onClick: async (ev: ButtonEvent): Promise<void> => {
|
|
||||||
ev.preventDefault();
|
|
||||||
startElementCall("shiftKey" in ev ? ev.shiftKey : false);
|
|
||||||
},
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// behavior === "jitsi_or_element" | "legacy_or_element"
|
|
||||||
return {
|
|
||||||
onClick: async (ev: ButtonEvent): Promise<void> => {
|
|
||||||
ev.preventDefault();
|
|
||||||
openMenu();
|
|
||||||
},
|
|
||||||
disabled: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [behavior, startLegacyCall, startElementCall, openMenu]);
|
|
||||||
|
|
||||||
const onJitsiClick = useCallback(
|
|
||||||
async (ev: ButtonEvent): Promise<void> => {
|
|
||||||
ev.preventDefault();
|
|
||||||
closeMenu();
|
|
||||||
await startLegacyCall();
|
|
||||||
},
|
|
||||||
[closeMenu, startLegacyCall],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onElementClick = useCallback(
|
|
||||||
(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
closeMenu();
|
|
||||||
startElementCall("shiftKey" in ev ? ev.shiftKey : false);
|
|
||||||
},
|
|
||||||
[closeMenu, startElementCall],
|
|
||||||
);
|
|
||||||
|
|
||||||
let menu: JSX.Element | null = null;
|
|
||||||
if (menuOpen) {
|
|
||||||
const buttonRect = buttonRef.current!.getBoundingClientRect();
|
|
||||||
const brand = SdkConfig.get("element_call").brand;
|
|
||||||
menu = (
|
|
||||||
<IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
|
|
||||||
<IconizedContextMenuOptionList>
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
label={
|
|
||||||
behavior == "legacy_or_element"
|
|
||||||
? _t("room|header|video_call_button_legacy")
|
|
||||||
: _t("room|header|video_call_button_jitsi")
|
|
||||||
}
|
|
||||||
onClick={onJitsiClick}
|
|
||||||
/>
|
|
||||||
<IconizedContextMenuOption
|
|
||||||
label={_t("room|header|video_call_button_ec", { brand })}
|
|
||||||
onClick={onElementClick}
|
|
||||||
/>
|
|
||||||
</IconizedContextMenuOptionList>
|
|
||||||
</IconizedContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AccessibleButton
|
|
||||||
ref={buttonRef}
|
|
||||||
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_videoCallButton"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={_t("voip|video_call")}
|
|
||||||
title={tooltip ?? _t("voip|video_call")}
|
|
||||||
placement="bottom"
|
|
||||||
disabled={disabled || busy}
|
|
||||||
/>
|
|
||||||
{menu}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CallButtonsProps {
|
|
||||||
room: Room;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The header buttons for placing calls have become stupidly complex, so here
|
|
||||||
// they are as a separate component
|
|
||||||
const CallButtons: FC<CallButtonsProps> = ({ room }) => {
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const showButtons = useSettingValue<boolean>("showCallButtonsInComposer");
|
|
||||||
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
|
|
||||||
const isVideoRoom = useMemo(() => calcIsVideoRoom(room), [room]);
|
|
||||||
const useElementCallExclusively = useMemo(() => {
|
|
||||||
return SdkConfig.get("element_call").use_exclusively;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const hasLegacyCall = useEventEmitterState(
|
|
||||||
LegacyCallHandler.instance,
|
|
||||||
LegacyCallHandlerEvent.CallsChanged,
|
|
||||||
useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const widgets = useWidgets(room);
|
|
||||||
const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]);
|
|
||||||
|
|
||||||
const hasGroupCall = useCall(room.roomId) !== null;
|
|
||||||
|
|
||||||
const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState(
|
|
||||||
room,
|
|
||||||
RoomStateEvent.Update,
|
|
||||||
useCallback(
|
|
||||||
() => [
|
|
||||||
getJoinedNonFunctionalMembers(room),
|
|
||||||
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
|
|
||||||
room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client),
|
|
||||||
],
|
|
||||||
[room],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => (
|
|
||||||
<VoiceCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />
|
|
||||||
);
|
|
||||||
const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => (
|
|
||||||
<VideoCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isVideoRoom || !showButtons) {
|
|
||||||
return null;
|
|
||||||
} else if (groupCallsEnabled && useElementCallExclusively) {
|
|
||||||
if (hasGroupCall) {
|
|
||||||
return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")));
|
|
||||||
} else if (mayCreateElementCalls) {
|
|
||||||
return makeVideoCallButton("element");
|
|
||||||
} else {
|
|
||||||
return makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call")));
|
|
||||||
}
|
|
||||||
} else if (hasLegacyCall || hasJitsiWidget) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))}
|
|
||||||
{makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_ongoing_call")))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (functionalMembers.length <= 1) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_no_one_here")))}
|
|
||||||
{makeVideoCallButton(new DisabledWithReason(_t("voip|disabled_no_one_here")))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (functionalMembers.length === 2) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{makeVoiceCallButton("legacy_or_jitsi")}
|
|
||||||
{makeVideoCallButton(groupCallsEnabled ? "legacy_or_element" : "legacy_or_jitsi")}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (mayEditWidgets) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{makeVoiceCallButton("legacy_or_jitsi")}
|
|
||||||
{makeVideoCallButton(
|
|
||||||
groupCallsEnabled && mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi",
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const videoCallBehavior =
|
|
||||||
groupCallsEnabled && mayCreateElementCalls
|
|
||||||
? "element"
|
|
||||||
: new DisabledWithReason(_t("voip|disabled_no_perms_start_video_call"));
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{makeVoiceCallButton(new DisabledWithReason(_t("voip|disabled_no_perms_start_voice_call")))}
|
|
||||||
{makeVideoCallButton(videoCallBehavior)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CallLayoutSelectorProps {
|
|
||||||
call: ElementCall;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CallLayoutSelector: FC<CallLayoutSelectorProps> = ({ call }) => {
|
|
||||||
const layout = useLayout(call);
|
|
||||||
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
|
|
||||||
|
|
||||||
const onClick = useCallback(
|
|
||||||
(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
openMenu();
|
|
||||||
},
|
|
||||||
[openMenu],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onFreedomClick = useCallback(
|
|
||||||
(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
closeMenu();
|
|
||||||
call.setLayout(Layout.Tile);
|
|
||||||
},
|
|
||||||
[closeMenu, call],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSpotlightClick = useCallback(
|
|
||||||
(ev: ButtonEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
closeMenu();
|
|
||||||
call.setLayout(Layout.Spotlight);
|
|
||||||
},
|
|
||||||
[closeMenu, call],
|
|
||||||
);
|
|
||||||
|
|
||||||
let menu: JSX.Element | null = null;
|
|
||||||
if (menuOpen) {
|
|
||||||
const buttonRect = buttonRef.current!.getBoundingClientRect();
|
|
||||||
menu = (
|
|
||||||
<IconizedContextMenu
|
|
||||||
className="mx_LegacyRoomHeader_layoutMenu"
|
|
||||||
{...aboveLeftOf(buttonRect)}
|
|
||||||
onFinished={closeMenu}
|
|
||||||
>
|
|
||||||
<IconizedContextMenuOptionList>
|
|
||||||
<IconizedContextMenuRadio
|
|
||||||
iconClassName="mx_LegacyRoomHeader_freedomIcon"
|
|
||||||
label={_t("room|header|video_call_ec_layout_freedom")}
|
|
||||||
active={layout === Layout.Tile}
|
|
||||||
onClick={onFreedomClick}
|
|
||||||
/>
|
|
||||||
<IconizedContextMenuRadio
|
|
||||||
iconClassName="mx_LegacyRoomHeader_spotlightIcon"
|
|
||||||
label={_t("room|header|video_call_ec_layout_spotlight")}
|
|
||||||
active={layout === Layout.Spotlight}
|
|
||||||
onClick={onSpotlightClick}
|
|
||||||
/>
|
|
||||||
</IconizedContextMenuOptionList>
|
|
||||||
</IconizedContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AccessibleButton
|
|
||||||
ref={buttonRef}
|
|
||||||
className={classNames("mx_LegacyRoomHeader_button", {
|
|
||||||
"mx_LegacyRoomHeader_layoutButton--freedom": layout === Layout.Tile,
|
|
||||||
"mx_LegacyRoomHeader_layoutButton--spotlight": layout === Layout.Spotlight,
|
|
||||||
})}
|
|
||||||
onClick={onClick}
|
|
||||||
title={_t("room|header|video_call_ec_change_layout")}
|
|
||||||
placement="bottom"
|
|
||||||
key="layout"
|
|
||||||
/>
|
|
||||||
{menu}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IProps {
|
|
||||||
room: Room;
|
|
||||||
oobData?: IOOBData;
|
|
||||||
inRoom: boolean;
|
|
||||||
onSearchClick: (() => void) | null;
|
|
||||||
onInviteClick: (() => void) | null;
|
|
||||||
onForgetClick: (() => void) | null;
|
|
||||||
onAppsClick: (() => void) | null;
|
|
||||||
e2eStatus: E2EStatus;
|
|
||||||
appsShown: boolean;
|
|
||||||
searchInfo?: SearchInfo;
|
|
||||||
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
|
|
||||||
showButtons?: boolean;
|
|
||||||
enableRoomOptionsMenu?: boolean;
|
|
||||||
viewingCall: boolean;
|
|
||||||
activeCall: Call | null;
|
|
||||||
additionalButtons?: ViewRoomOpts["buttons"];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IState {
|
|
||||||
contextMenuPosition?: DOMRect;
|
|
||||||
rightPanelOpen: boolean;
|
|
||||||
featureAskToJoin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated use `src/components/views/rooms/RoomHeader.tsx` instead
|
|
||||||
*/
|
|
||||||
export default class RoomHeader extends React.Component<IProps, IState> {
|
|
||||||
public static defaultProps: Partial<IProps> = {
|
|
||||||
inRoom: false,
|
|
||||||
excludedRightPanelPhaseButtons: [],
|
|
||||||
showButtons: true,
|
|
||||||
enableRoomOptionsMenu: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static contextType = RoomContext;
|
|
||||||
public declare context: React.ContextType<typeof RoomContext>;
|
|
||||||
private readonly client = this.props.room.client;
|
|
||||||
private readonly featureAskToJoinWatcher: string;
|
|
||||||
|
|
||||||
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
|
|
||||||
super(props, context);
|
|
||||||
const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room);
|
|
||||||
notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate);
|
|
||||||
this.state = {
|
|
||||||
rightPanelOpen: RightPanelStore.instance.isOpen,
|
|
||||||
featureAskToJoin: SettingsStore.getValue("feature_ask_to_join"),
|
|
||||||
};
|
|
||||||
this.featureAskToJoinWatcher = SettingsStore.watchSetting(
|
|
||||||
"feature_ask_to_join",
|
|
||||||
null,
|
|
||||||
(_settingName, _roomId, _atLevel, _newValAtLevel, featureAskToJoin) => {
|
|
||||||
this.setState({ featureAskToJoin });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidMount(): void {
|
|
||||||
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
|
|
||||||
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
|
||||||
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
|
||||||
const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room);
|
|
||||||
notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate);
|
|
||||||
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
|
|
||||||
SettingsStore.unwatchSetting(this.featureAskToJoinWatcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onRightPanelStoreUpdate = (): void => {
|
|
||||||
this.setState({ rightPanelOpen: RightPanelStore.instance.isOpen });
|
|
||||||
};
|
|
||||||
|
|
||||||
private onRoomStateEvents = (event: MatrixEvent): void => {
|
|
||||||
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// redisplay the room name, topic, etc.
|
|
||||||
this.rateLimitedUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
private onNotificationUpdate = (): void => {
|
|
||||||
this.forceUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
private rateLimitedUpdate = throttle(
|
|
||||||
() => {
|
|
||||||
this.forceUpdate();
|
|
||||||
},
|
|
||||||
500,
|
|
||||||
{ leading: true, trailing: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
private onContextMenuOpenClick = (ev: ButtonEvent): void => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
const target = ev.target as HTMLButtonElement;
|
|
||||||
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
|
|
||||||
};
|
|
||||||
|
|
||||||
private onContextMenuCloseClick = (): void => {
|
|
||||||
this.setState({ contextMenuPosition: undefined });
|
|
||||||
};
|
|
||||||
|
|
||||||
private onHideCallClick = (ev: ButtonEvent): void => {
|
|
||||||
ev.preventDefault();
|
|
||||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: this.props.room.roomId,
|
|
||||||
view_call: false,
|
|
||||||
metricsTrigger: undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private renderButtons(isVideoRoom: boolean): React.ReactNode {
|
|
||||||
const startButtons: JSX.Element[] = [];
|
|
||||||
|
|
||||||
if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) {
|
|
||||||
startButtons.push(<CallButtons key="calls" room={this.props.room} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) {
|
|
||||||
startButtons.push(<CallLayoutSelector key="layout" call={this.props.activeCall} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.props.viewingCall && this.props.onForgetClick) {
|
|
||||||
startButtons.push(
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_forgetButton"
|
|
||||||
onClick={this.props.onForgetClick}
|
|
||||||
title={_t("room|header|forget_room_button")}
|
|
||||||
placement="bottom"
|
|
||||||
key="forget"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.props.viewingCall && this.props.onAppsClick) {
|
|
||||||
startButtons.push(
|
|
||||||
<AccessibleButton
|
|
||||||
className={classNames("mx_LegacyRoomHeader_button mx_LegacyRoomHeader_appsButton", {
|
|
||||||
mx_LegacyRoomHeader_appsButton_highlight: this.props.appsShown,
|
|
||||||
})}
|
|
||||||
onClick={this.props.onAppsClick}
|
|
||||||
title={
|
|
||||||
this.props.appsShown
|
|
||||||
? _t("room|header|hide_widgets_button")
|
|
||||||
: _t("room|header|show_widgets_button")
|
|
||||||
}
|
|
||||||
aria-checked={this.props.appsShown}
|
|
||||||
placement="bottom"
|
|
||||||
key="apps"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) {
|
|
||||||
startButtons.push(
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_searchButton"
|
|
||||||
onClick={this.props.onSearchClick}
|
|
||||||
title={_t("action|search")}
|
|
||||||
placement="bottom"
|
|
||||||
key="search"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) {
|
|
||||||
startButtons.push(
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_inviteButton"
|
|
||||||
onClick={this.props.onInviteClick}
|
|
||||||
title={_t("action|invite")}
|
|
||||||
placement="bottom"
|
|
||||||
key="invite"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const endButtons: JSX.Element[] = [];
|
|
||||||
|
|
||||||
if (this.props.viewingCall && !isVideoRoom) {
|
|
||||||
if (this.props.activeCall === null) {
|
|
||||||
endButtons.push(
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_closeButton"
|
|
||||||
onClick={this.onHideCallClick}
|
|
||||||
title={_t("room|header|close_call_button")}
|
|
||||||
key="close"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
endButtons.push(
|
|
||||||
<AccessibleButton
|
|
||||||
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_minimiseButton"
|
|
||||||
onClick={this.onHideCallClick}
|
|
||||||
title={_t("room|header|video_room_view_chat_button")}
|
|
||||||
placement="bottom"
|
|
||||||
key="minimise"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{this.props.additionalButtons?.map((props) => {
|
|
||||||
const label = props.label();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={label} key={props.id}>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
props.onClick();
|
|
||||||
this.forceUpdate();
|
|
||||||
}}
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
{typeof props.icon === "function" ? props.icon() : props.icon}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{startButtons}
|
|
||||||
<RoomHeaderButtons
|
|
||||||
room={this.props.room}
|
|
||||||
excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons}
|
|
||||||
/>
|
|
||||||
{endButtons}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderName(oobName: string): JSX.Element {
|
|
||||||
let contextMenu: JSX.Element | null = null;
|
|
||||||
if (this.state.contextMenuPosition && this.props.room) {
|
|
||||||
contextMenu = (
|
|
||||||
<RoomContextMenu
|
|
||||||
{...contextMenuBelow(this.state.contextMenuPosition)}
|
|
||||||
room={this.props.room}
|
|
||||||
onFinished={this.onContextMenuCloseClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
|
|
||||||
let settingsHint = false;
|
|
||||||
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
|
|
||||||
if (members) {
|
|
||||||
if (members.length === 1 && members[0].userId === this.client.credentials.userId) {
|
|
||||||
const nameEvent = this.props.room.currentState.getStateEvents("m.room.name", "");
|
|
||||||
if (!nameEvent || !nameEvent.getContent().name) {
|
|
||||||
settingsHint = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textClasses = classNames("mx_LegacyRoomHeader_nametext", {
|
|
||||||
mx_LegacyRoomHeader_settingsHint: settingsHint,
|
|
||||||
});
|
|
||||||
const roomName = (
|
|
||||||
<RoomName room={this.props.room}>
|
|
||||||
{(name) => {
|
|
||||||
const roomName = name || oobName;
|
|
||||||
return (
|
|
||||||
<div dir="auto" className={textClasses} title={roomName} role="heading" aria-level={1}>
|
|
||||||
{roomName}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</RoomName>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this.props.enableRoomOptionsMenu && shouldShowComponent(UIComponent.RoomOptionsMenu)) {
|
|
||||||
return (
|
|
||||||
<ContextMenuTooltipButton
|
|
||||||
className="mx_LegacyRoomHeader_name"
|
|
||||||
onClick={this.onContextMenuOpenClick}
|
|
||||||
isExpanded={!!this.state.contextMenuPosition}
|
|
||||||
title={_t("room|context_menu|title")}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
{roomName}
|
|
||||||
{this.props.room && <div className="mx_LegacyRoomHeader_chevron" />}
|
|
||||||
{contextMenu}
|
|
||||||
</ContextMenuTooltipButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="mx_LegacyRoomHeader_name mx_LegacyRoomHeader_name--textonly">{roomName}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
const isVideoRoom = calcIsVideoRoom(this.props.room);
|
|
||||||
|
|
||||||
let roomAvatar: JSX.Element | null = null;
|
|
||||||
if (this.props.room) {
|
|
||||||
roomAvatar = (
|
|
||||||
<DecoratedRoomAvatar
|
|
||||||
room={this.props.room}
|
|
||||||
size="24px"
|
|
||||||
oobData={this.props.oobData}
|
|
||||||
viewAvatarOnClick={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = this.props.viewingCall ? (
|
|
||||||
<div className="mx_LegacyRoomHeader_icon mx_LegacyRoomHeader_icon_video" />
|
|
||||||
) : this.props.e2eStatus ? (
|
|
||||||
<E2EIcon className="mx_LegacyRoomHeader_icon" status={this.props.e2eStatus} tooltipPlacement="bottom" />
|
|
||||||
) : // If we're expecting an E2EE status to come in, but it hasn't
|
|
||||||
// yet been loaded, insert a blank div to reserve space
|
|
||||||
this.client.isRoomEncrypted(this.props.room.roomId) && this.client.isCryptoEnabled() ? (
|
|
||||||
<div className="mx_LegacyRoomHeader_icon" />
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null;
|
|
||||||
|
|
||||||
let oobName = _t("common|unnamed_room");
|
|
||||||
if (this.props.oobData && this.props.oobData.name) {
|
|
||||||
oobName = this.props.oobData.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = this.renderName(oobName);
|
|
||||||
|
|
||||||
if (this.props.viewingCall && !isVideoRoom) {
|
|
||||||
return (
|
|
||||||
<header className="mx_LegacyRoomHeader light-panel">
|
|
||||||
<div
|
|
||||||
className="mx_LegacyRoomHeader_wrapper"
|
|
||||||
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
|
|
||||||
>
|
|
||||||
<div className="mx_LegacyRoomHeader_avatar">{roomAvatar}</div>
|
|
||||||
{icon}
|
|
||||||
{name}
|
|
||||||
{this.props.activeCall instanceof ElementCall && (
|
|
||||||
<SessionDuration session={this.props.activeCall?.session} />
|
|
||||||
)}
|
|
||||||
{/* Empty topic element to fill out space */}
|
|
||||||
<div className="mx_LegacyRoomHeader_topic" />
|
|
||||||
{buttons}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchStatus: JSX.Element | null = null;
|
|
||||||
|
|
||||||
// don't display the search count until the search completes and
|
|
||||||
// gives us a valid (possibly zero) searchCount.
|
|
||||||
if (typeof this.props.searchInfo?.count === "number") {
|
|
||||||
searchStatus = (
|
|
||||||
<div className="mx_LegacyRoomHeader_searchStatus">
|
|
||||||
|
|
||||||
{_t("room|search|result_count", { count: this.props.searchInfo.count })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const topicElement = <RoomTopic room={this.props.room} className="mx_LegacyRoomHeader_topic" />;
|
|
||||||
|
|
||||||
const viewLabs = (): void =>
|
|
||||||
defaultDispatcher.dispatch({
|
|
||||||
action: Action.ViewUserSettings,
|
|
||||||
initialTabId: UserTab.Labs,
|
|
||||||
});
|
|
||||||
const betaPill = isVideoRoom ? (
|
|
||||||
<BetaPill onClick={viewLabs} tooltipTitle={_t("labs|video_rooms_beta")} />
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="mx_LegacyRoomHeader light-panel">
|
|
||||||
<div
|
|
||||||
className="mx_LegacyRoomHeader_wrapper"
|
|
||||||
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
|
|
||||||
>
|
|
||||||
<div className="mx_LegacyRoomHeader_avatar">{roomAvatar}</div>
|
|
||||||
{icon}
|
|
||||||
{name}
|
|
||||||
{searchStatus}
|
|
||||||
{topicElement}
|
|
||||||
{betaPill}
|
|
||||||
{buttons}
|
|
||||||
</div>
|
|
||||||
{!isVideoRoom && <RoomCallBanner roomId={this.props.room.roomId} />}
|
|
||||||
<RoomLiveShareWarning roomId={this.props.room.roomId} />
|
|
||||||
{this.state.featureAskToJoin && <RoomKnocksBar room={this.props.room} />}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import type { RoomMember } from "matrix-js-sdk/src/matrix";
|
import type { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
import { Call, ConnectionState, ElementCall, Layout, CallEvent } from "../models/Call";
|
import { Call, ConnectionState, CallEvent } from "../models/Call";
|
||||||
import { useTypedEventEmitterState, useEventEmitter } from "./useEventEmitter";
|
import { useTypedEventEmitterState, useEventEmitter } from "./useEventEmitter";
|
||||||
import { CallStore, CallStoreEvent } from "../stores/CallStore";
|
import { CallStore, CallStoreEvent } from "../stores/CallStore";
|
||||||
import SdkConfig, { DEFAULTS } from "../SdkConfig";
|
import SdkConfig, { DEFAULTS } from "../SdkConfig";
|
||||||
@ -81,10 +81,3 @@ export const useJoinCallButtonDisabledTooltip = (call: Call | null): string | nu
|
|||||||
if (isFull) return _t("voip|join_button_tooltip_call_full");
|
if (isFull) return _t("voip|join_button_tooltip_call_full");
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useLayout = (call: ElementCall): Layout =>
|
|
||||||
useTypedEventEmitterState(
|
|
||||||
call,
|
|
||||||
CallEvent.Layout,
|
|
||||||
useCallback((state) => state ?? call.layout, [call]),
|
|
||||||
);
|
|
||||||
|
@ -1461,9 +1461,6 @@
|
|||||||
"location_share_live_description": "Temporary implementation. Locations persist in room history.",
|
"location_share_live_description": "Temporary implementation. Locations persist in room history.",
|
||||||
"mjolnir": "New ways to ignore people",
|
"mjolnir": "New ways to ignore people",
|
||||||
"msc3531_hide_messages_pending_moderation": "Let moderators hide messages pending moderation.",
|
"msc3531_hide_messages_pending_moderation": "Let moderators hide messages pending moderation.",
|
||||||
"new_room_decoration_ui": "New room header",
|
|
||||||
"new_room_decoration_ui_beta_caption": "A new look for your rooms with a simpler, cleaner and more accessible room header.",
|
|
||||||
"new_room_decoration_ui_beta_title": "Room header",
|
|
||||||
"notification_settings": "New Notification Settings",
|
"notification_settings": "New Notification Settings",
|
||||||
"notification_settings_beta_caption": "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.",
|
"notification_settings_beta_caption": "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.",
|
||||||
"notification_settings_beta_title": "Notification Settings",
|
"notification_settings_beta_title": "Notification Settings",
|
||||||
@ -1558,9 +1555,7 @@
|
|||||||
"error_send_description": "%(brand)s could not send your location. Please try again later.",
|
"error_send_description": "%(brand)s could not send your location. Please try again later.",
|
||||||
"error_send_title": "We couldn't send your location",
|
"error_send_title": "We couldn't send your location",
|
||||||
"error_sharing_live_location": "An error occurred whilst sharing your live location",
|
"error_sharing_live_location": "An error occurred whilst sharing your live location",
|
||||||
"error_sharing_live_location_try_again": "An error occurred whilst sharing your live location, please try again",
|
|
||||||
"error_stopping_live_location": "An error occurred while stopping your live location",
|
"error_stopping_live_location": "An error occurred while stopping your live location",
|
||||||
"error_stopping_live_location_try_again": "An error occurred while stopping your live location, please try again",
|
|
||||||
"expand_map": "Expand map",
|
"expand_map": "Expand map",
|
||||||
"failed_generic": "Failed to fetch your location. Please try again later.",
|
"failed_generic": "Failed to fetch your location. Please try again later.",
|
||||||
"failed_load_map": "Unable to load map",
|
"failed_load_map": "Unable to load map",
|
||||||
@ -1590,7 +1585,6 @@
|
|||||||
"share_type_own": "My current location",
|
"share_type_own": "My current location",
|
||||||
"share_type_pin": "Drop a Pin",
|
"share_type_pin": "Drop a Pin",
|
||||||
"share_type_prompt": "What location type do you want to share?",
|
"share_type_prompt": "What location type do you want to share?",
|
||||||
"stop_and_close": "Stop and close",
|
|
||||||
"toggle_attribution": "Toggle attribution"
|
"toggle_attribution": "Toggle attribution"
|
||||||
},
|
},
|
||||||
"member_list": {
|
"member_list": {
|
||||||
@ -1838,7 +1832,6 @@
|
|||||||
"right_panel": {
|
"right_panel": {
|
||||||
"add_integrations": "Add extensions",
|
"add_integrations": "Add extensions",
|
||||||
"add_topic": "Add topic",
|
"add_topic": "Add topic",
|
||||||
"export_chat_button": "Export chat",
|
|
||||||
"extensions_empty_description": "Select “%(addIntegrations)s” to browse and add extensions to this room",
|
"extensions_empty_description": "Select “%(addIntegrations)s” to browse and add extensions to this room",
|
||||||
"extensions_empty_title": "Boost productivity with more tools, widgets and bots",
|
"extensions_empty_title": "Boost productivity with more tools, widgets and bots",
|
||||||
"files_button": "Files",
|
"files_button": "Files",
|
||||||
@ -1861,7 +1854,6 @@
|
|||||||
"title": "All new pinned messages"
|
"title": "All new pinned messages"
|
||||||
},
|
},
|
||||||
"reply_thread": "Reply to a <link>thread message</link>",
|
"reply_thread": "Reply to a <link>thread message</link>",
|
||||||
"title": "Pinned messages",
|
|
||||||
"unpin_all": {
|
"unpin_all": {
|
||||||
"button": "Unpin all messages",
|
"button": "Unpin all messages",
|
||||||
"content": "Make sure that you really want to remove all pinned messages. This action can’t be undone.",
|
"content": "Make sure that you really want to remove all pinned messages. This action can’t be undone.",
|
||||||
@ -1903,8 +1895,7 @@
|
|||||||
},
|
},
|
||||||
"video_room_chat": {
|
"video_room_chat": {
|
||||||
"title": "Chat"
|
"title": "Chat"
|
||||||
},
|
}
|
||||||
"widgets_section": "Widgets"
|
|
||||||
},
|
},
|
||||||
"room": {
|
"room": {
|
||||||
"3pid_invite_email_not_found_account": "This invite was sent to %(email)s which is not associated with your account",
|
"3pid_invite_email_not_found_account": "This invite was sent to %(email)s which is not associated with your account",
|
||||||
@ -1925,7 +1916,6 @@
|
|||||||
"low_priority": "Low Priority",
|
"low_priority": "Low Priority",
|
||||||
"mark_read": "Mark as read",
|
"mark_read": "Mark as read",
|
||||||
"mark_unread": "Mark as unread",
|
"mark_unread": "Mark as unread",
|
||||||
"mentions_only": "Mentions only",
|
|
||||||
"notifications_default": "Match default setting",
|
"notifications_default": "Match default setting",
|
||||||
"notifications_mute": "Mute room",
|
"notifications_mute": "Mute room",
|
||||||
"title": "Room options",
|
"title": "Room options",
|
||||||
@ -1968,22 +1958,11 @@
|
|||||||
"forget_room": "Forget this room",
|
"forget_room": "Forget this room",
|
||||||
"forget_space": "Forget this space",
|
"forget_space": "Forget this space",
|
||||||
"header": {
|
"header": {
|
||||||
"close_call_button": "Close call",
|
|
||||||
"forget_room_button": "Forget room",
|
|
||||||
"hide_widgets_button": "Hide Widgets",
|
|
||||||
"n_people_asking_to_join": {
|
"n_people_asking_to_join": {
|
||||||
"one": "Asking to join",
|
"one": "Asking to join",
|
||||||
"other": "%(count)s people asking to join"
|
"other": "%(count)s people asking to join"
|
||||||
},
|
},
|
||||||
"room_is_public": "This room is public",
|
"room_is_public": "This room is public"
|
||||||
"show_widgets_button": "Show Widgets",
|
|
||||||
"video_call_button_ec": "Video call (%(brand)s)",
|
|
||||||
"video_call_button_jitsi": "Video call (Jitsi)",
|
|
||||||
"video_call_button_legacy": "Legacy video call",
|
|
||||||
"video_call_ec_change_layout": "Change layout",
|
|
||||||
"video_call_ec_layout_freedom": "Freedom",
|
|
||||||
"video_call_ec_layout_spotlight": "Spotlight",
|
|
||||||
"video_room_view_chat_button": "View chat timeline"
|
|
||||||
},
|
},
|
||||||
"header_avatar_open_settings_label": "Open room settings",
|
"header_avatar_open_settings_label": "Open room settings",
|
||||||
"header_face_pile_tooltip": "People",
|
"header_face_pile_tooltip": "People",
|
||||||
@ -2077,10 +2056,6 @@
|
|||||||
"search": {
|
"search": {
|
||||||
"all_rooms_button": "Search all rooms",
|
"all_rooms_button": "Search all rooms",
|
||||||
"placeholder": "Search messages…",
|
"placeholder": "Search messages…",
|
||||||
"result_count": {
|
|
||||||
"one": "(~%(count)s result)",
|
|
||||||
"other": "(~%(count)s results)"
|
|
||||||
},
|
|
||||||
"summary": {
|
"summary": {
|
||||||
"one": "1 result found for “<query/>”",
|
"one": "1 result found for “<query/>”",
|
||||||
"other": "%(count)s results found for “<query/>”"
|
"other": "%(count)s results found for “<query/>”"
|
||||||
|
@ -578,18 +578,6 @@ export const SETTINGS: { [setting: string]: ISetting } = {
|
|||||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
|
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
|
||||||
supportedLevelsAreOrdered: true,
|
supportedLevelsAreOrdered: true,
|
||||||
},
|
},
|
||||||
"feature_new_room_decoration_ui": {
|
|
||||||
isFeature: true,
|
|
||||||
labsGroup: LabGroup.Rooms,
|
|
||||||
displayName: _td("labs|new_room_decoration_ui"),
|
|
||||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
|
||||||
default: true,
|
|
||||||
controller: new ReloadOnChangeController(),
|
|
||||||
betaInfo: {
|
|
||||||
title: _td("labs|new_room_decoration_ui_beta_title"),
|
|
||||||
caption: () => <p>{_t("labs|new_room_decoration_ui_beta_caption")}</p>,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"feature_notifications": {
|
"feature_notifications": {
|
||||||
isFeature: true,
|
isFeature: true,
|
||||||
labsGroup: LabGroup.Messaging,
|
labsGroup: LabGroup.Messaging,
|
||||||
|
@ -1100,7 +1100,6 @@ exports[`RoomView should show error view if failed to look up room alias 1`] = `
|
|||||||
<DocumentFragment>
|
<DocumentFragment>
|
||||||
<div
|
<div
|
||||||
class="mx_RoomView"
|
class="mx_RoomView"
|
||||||
data-room-header="new"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mx_RoomPreviewBar mx_RoomPreviewBar_RoomNotFound mx_RoomPreviewBar_dialog"
|
class="mx_RoomPreviewBar mx_RoomPreviewBar_RoomNotFound mx_RoomPreviewBar_dialog"
|
||||||
|
@ -1,420 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Room, Beacon, BeaconEvent, getBeaconInfoIdentifier, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
import { act, fireEvent, getByTestId, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
|
|
||||||
import RoomLiveShareWarning from "../../../../src/components/views/beacon/RoomLiveShareWarning";
|
|
||||||
import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../../src/stores/OwnBeaconStore";
|
|
||||||
import {
|
|
||||||
advanceDateAndTime,
|
|
||||||
flushPromisesWithFakeTimers,
|
|
||||||
getMockClientWithEventEmitter,
|
|
||||||
makeBeaconInfoEvent,
|
|
||||||
mockGeolocation,
|
|
||||||
resetAsyncStoreWithClient,
|
|
||||||
setupAsyncStoreWithClient,
|
|
||||||
} from "../../../test-utils";
|
|
||||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
|
||||||
import { Action } from "../../../../src/dispatcher/actions";
|
|
||||||
|
|
||||||
jest.useFakeTimers();
|
|
||||||
describe("<RoomLiveShareWarning />", () => {
|
|
||||||
const aliceId = "@alice:server.org";
|
|
||||||
const room1Id = "$room1:server.org";
|
|
||||||
const room2Id = "$room2:server.org";
|
|
||||||
const room3Id = "$room3:server.org";
|
|
||||||
const mockClient = getMockClientWithEventEmitter({
|
|
||||||
getVisibleRooms: jest.fn().mockReturnValue([]),
|
|
||||||
getUserId: jest.fn().mockReturnValue(aliceId),
|
|
||||||
getSafeUserId: jest.fn().mockReturnValue(aliceId),
|
|
||||||
unstable_setLiveBeacon: jest.fn().mockResolvedValue({ event_id: "1" }),
|
|
||||||
sendEvent: jest.fn(),
|
|
||||||
isGuest: jest.fn().mockReturnValue(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 14.03.2022 16:15
|
|
||||||
const now = 1647270879403;
|
|
||||||
const MINUTE_MS = 60000;
|
|
||||||
const HOUR_MS = 3600000;
|
|
||||||
// mock the date so events are stable for snapshots etc
|
|
||||||
jest.spyOn(global.Date, "now").mockReturnValue(now);
|
|
||||||
const room1Beacon1 = makeBeaconInfoEvent(
|
|
||||||
aliceId,
|
|
||||||
room1Id,
|
|
||||||
{
|
|
||||||
isLive: true,
|
|
||||||
timeout: HOUR_MS,
|
|
||||||
},
|
|
||||||
"$0",
|
|
||||||
);
|
|
||||||
const room2Beacon1 = makeBeaconInfoEvent(aliceId, room2Id, { isLive: true, timeout: HOUR_MS }, "$1");
|
|
||||||
const room2Beacon2 = makeBeaconInfoEvent(aliceId, room2Id, { isLive: true, timeout: HOUR_MS * 12 }, "$2");
|
|
||||||
const room3Beacon1 = makeBeaconInfoEvent(aliceId, room3Id, { isLive: true, timeout: HOUR_MS }, "$3");
|
|
||||||
|
|
||||||
// make fresh rooms every time
|
|
||||||
// as we update room state
|
|
||||||
const makeRoomsWithStateEvents = (stateEvents: MatrixEvent[] = []): [Room, Room] => {
|
|
||||||
const room1 = new Room(room1Id, mockClient, aliceId);
|
|
||||||
const room2 = new Room(room2Id, mockClient, aliceId);
|
|
||||||
|
|
||||||
room1.currentState.setStateEvents(stateEvents);
|
|
||||||
room2.currentState.setStateEvents(stateEvents);
|
|
||||||
mockClient.getVisibleRooms.mockReturnValue([room1, room2]);
|
|
||||||
|
|
||||||
return [room1, room2];
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeOwnBeaconStore = async () => {
|
|
||||||
const store = OwnBeaconStore.instance;
|
|
||||||
|
|
||||||
await setupAsyncStoreWithClient(store, mockClient);
|
|
||||||
return store;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
roomId: room1Id,
|
|
||||||
};
|
|
||||||
const getComponent = (props = {}) => {
|
|
||||||
return render(<RoomLiveShareWarning {...defaultProps} {...props} />);
|
|
||||||
};
|
|
||||||
|
|
||||||
const localStorageSpy = jest.spyOn(localStorage.__proto__, "getItem").mockReturnValue(undefined);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockGeolocation();
|
|
||||||
jest.spyOn(global.Date, "now").mockReturnValue(now);
|
|
||||||
mockClient.unstable_setLiveBeacon.mockReset().mockResolvedValue({ event_id: "1" });
|
|
||||||
|
|
||||||
// assume all beacons were created on this device
|
|
||||||
localStorageSpy.mockReturnValue(
|
|
||||||
JSON.stringify([room1Beacon1.getId(), room2Beacon1.getId(), room2Beacon2.getId(), room3Beacon1.getId()]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
jest.spyOn(OwnBeaconStore.instance, "beaconHasLocationPublishError").mockRestore();
|
|
||||||
await resetAsyncStoreWithClient(OwnBeaconStore.instance);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
jest.spyOn(global.Date, "now").mockRestore();
|
|
||||||
localStorageSpy.mockRestore();
|
|
||||||
jest.spyOn(defaultDispatcher, "dispatch").mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders nothing when user has no live beacons at all", async () => {
|
|
||||||
await makeOwnBeaconStore();
|
|
||||||
const { asFragment } = getComponent();
|
|
||||||
expect(asFragment()).toMatchInlineSnapshot(`<DocumentFragment />`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders nothing when user has no live beacons in room", async () => {
|
|
||||||
await act(async () => {
|
|
||||||
await makeRoomsWithStateEvents([room2Beacon1]);
|
|
||||||
await makeOwnBeaconStore();
|
|
||||||
});
|
|
||||||
const { asFragment } = getComponent({ roomId: room1Id });
|
|
||||||
expect(asFragment()).toMatchInlineSnapshot(`<DocumentFragment />`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render when geolocation is not working", async () => {
|
|
||||||
jest.spyOn(logger, "error").mockImplementation(() => {});
|
|
||||||
// @ts-ignore
|
|
||||||
navigator.geolocation = undefined;
|
|
||||||
await act(async () => {
|
|
||||||
await makeRoomsWithStateEvents([room1Beacon1, room2Beacon1, room2Beacon2]);
|
|
||||||
await makeOwnBeaconStore();
|
|
||||||
});
|
|
||||||
const { asFragment } = getComponent({ roomId: room1Id });
|
|
||||||
|
|
||||||
expect(asFragment()).toMatchInlineSnapshot(`<DocumentFragment />`);
|
|
||||||
});
|
|
||||||
describe("when user has live beacons and geolocation is available", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
await makeRoomsWithStateEvents([room1Beacon1, room2Beacon1, room2Beacon2]);
|
|
||||||
await makeOwnBeaconStore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders correctly with one live beacon in room", () => {
|
|
||||||
const { asFragment } = getComponent({ roomId: room1Id });
|
|
||||||
// beacons have generated ids that break snapshots
|
|
||||||
// assert on html
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders correctly with two live beacons in room", () => {
|
|
||||||
const { asFragment, container } = getComponent({ roomId: room2Id });
|
|
||||||
// beacons have generated ids that break snapshots
|
|
||||||
// assert on html
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
// later expiry displayed
|
|
||||||
expect(container).toHaveTextContent("12h left");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes itself when user stops having live beacons", async () => {
|
|
||||||
const { container } = getComponent({ roomId: room1Id });
|
|
||||||
// started out rendered
|
|
||||||
expect(container.firstChild).toBeTruthy();
|
|
||||||
|
|
||||||
// time travel until room1Beacon1 is expired
|
|
||||||
act(() => {
|
|
||||||
advanceDateAndTime(HOUR_MS + 1);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
mockClient.emit(BeaconEvent.LivenessChange, false, new Beacon(room1Beacon1));
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(container.firstChild).toBeFalsy());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes itself when user stops monitoring live position", async () => {
|
|
||||||
const { container } = getComponent({ roomId: room1Id });
|
|
||||||
// started out rendered
|
|
||||||
expect(container.firstChild).toBeTruthy();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
// cheat to clear this
|
|
||||||
// @ts-ignore
|
|
||||||
OwnBeaconStore.instance.clearPositionWatch = undefined;
|
|
||||||
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(container.firstChild).toBeFalsy());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders when user adds a live beacon", async () => {
|
|
||||||
const { container } = getComponent({ roomId: room3Id });
|
|
||||||
// started out not rendered
|
|
||||||
expect(container.firstChild).toBeFalsy();
|
|
||||||
act(() => {
|
|
||||||
mockClient.emit(BeaconEvent.New, room3Beacon1, new Beacon(room3Beacon1));
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(container.firstChild).toBeTruthy());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates beacon time left periodically", () => {
|
|
||||||
const { container } = getComponent({ roomId: room1Id });
|
|
||||||
expect(container).toHaveTextContent("1h left");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
advanceDateAndTime(MINUTE_MS * 25);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container).toHaveTextContent("35m left");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates beacon time left when beacon updates", () => {
|
|
||||||
const { container } = getComponent({ roomId: room1Id });
|
|
||||||
expect(container).toHaveTextContent("1h left");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const beacon = OwnBeaconStore.instance.getBeaconById(getBeaconInfoIdentifier(room1Beacon1));
|
|
||||||
const room1Beacon1Update = makeBeaconInfoEvent(
|
|
||||||
aliceId,
|
|
||||||
room1Id,
|
|
||||||
{
|
|
||||||
isLive: true,
|
|
||||||
timeout: 3 * HOUR_MS,
|
|
||||||
},
|
|
||||||
"$0",
|
|
||||||
);
|
|
||||||
beacon?.update(room1Beacon1Update);
|
|
||||||
});
|
|
||||||
|
|
||||||
// update to expiry of new beacon
|
|
||||||
expect(container).toHaveTextContent("3h left");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears expiry time interval on unmount", () => {
|
|
||||||
const clearIntervalSpy = jest.spyOn(global, "clearInterval");
|
|
||||||
const { container, unmount } = getComponent({ roomId: room1Id });
|
|
||||||
expect(container).toHaveTextContent("1h left");
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("navigates to beacon tile on click", () => {
|
|
||||||
const dispatcherSpy = jest.spyOn(defaultDispatcher, "dispatch");
|
|
||||||
const { container } = getComponent({ roomId: room1Id });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
fireEvent.click(container.firstChild! as Node);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
event_id: room1Beacon1.getId(),
|
|
||||||
room_id: room1Id,
|
|
||||||
highlighted: true,
|
|
||||||
scroll_into_view: true,
|
|
||||||
metricsTrigger: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("stopping beacons", () => {
|
|
||||||
it("stops beacon on stop sharing click", async () => {
|
|
||||||
const { container } = getComponent({ roomId: room2Id });
|
|
||||||
|
|
||||||
const btn = getByTestId(container, "room-live-share-primary-button");
|
|
||||||
|
|
||||||
fireEvent.click(btn);
|
|
||||||
|
|
||||||
expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalled();
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("spinner")).toBeInTheDocument());
|
|
||||||
|
|
||||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays error when stop sharing fails", async () => {
|
|
||||||
const { container, asFragment } = getComponent({ roomId: room1Id });
|
|
||||||
const btn = getByTestId(container, "room-live-share-primary-button");
|
|
||||||
|
|
||||||
// fail first time
|
|
||||||
mockClient.unstable_setLiveBeacon
|
|
||||||
.mockRejectedValueOnce(new Error("oups"))
|
|
||||||
.mockResolvedValue({ event_id: "1" });
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(btn);
|
|
||||||
await flushPromisesWithFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
fireEvent.click(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockClient.unstable_setLiveBeacon).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays again with correct state after stopping a beacon", () => {
|
|
||||||
// make sure the loading state is reset correctly after removing a beacon
|
|
||||||
const { container } = getComponent({ roomId: room1Id });
|
|
||||||
const btn = getByTestId(container, "room-live-share-primary-button");
|
|
||||||
|
|
||||||
// stop the beacon
|
|
||||||
act(() => {
|
|
||||||
fireEvent.click(btn);
|
|
||||||
});
|
|
||||||
// time travel until room1Beacon1 is expired
|
|
||||||
act(() => {
|
|
||||||
advanceDateAndTime(HOUR_MS + 1);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
mockClient.emit(BeaconEvent.LivenessChange, false, new Beacon(room1Beacon1));
|
|
||||||
});
|
|
||||||
|
|
||||||
const newLiveBeacon = makeBeaconInfoEvent(aliceId, room1Id, { isLive: true });
|
|
||||||
act(() => {
|
|
||||||
mockClient.emit(BeaconEvent.New, newLiveBeacon, new Beacon(newLiveBeacon));
|
|
||||||
});
|
|
||||||
|
|
||||||
// button not disabled and expiry time shown
|
|
||||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("with location publish errors", () => {
|
|
||||||
it("displays location publish error when mounted with location publish errors", async () => {
|
|
||||||
const locationPublishErrorSpy = jest
|
|
||||||
.spyOn(OwnBeaconStore.instance, "beaconHasLocationPublishError")
|
|
||||||
.mockReturnValue(true);
|
|
||||||
const { asFragment } = getComponent({ roomId: room2Id });
|
|
||||||
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
expect(locationPublishErrorSpy).toHaveBeenCalledWith(getBeaconInfoIdentifier(room2Beacon1), 0, [
|
|
||||||
getBeaconInfoIdentifier(room2Beacon1),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
|
||||||
"displays location publish error when locationPublishError event is emitted" +
|
|
||||||
" and beacons have errors",
|
|
||||||
async () => {
|
|
||||||
const locationPublishErrorSpy = jest
|
|
||||||
.spyOn(OwnBeaconStore.instance, "beaconHasLocationPublishError")
|
|
||||||
.mockReturnValue(false);
|
|
||||||
const { container } = getComponent({ roomId: room2Id });
|
|
||||||
|
|
||||||
// update mock and emit event
|
|
||||||
act(() => {
|
|
||||||
locationPublishErrorSpy.mockReturnValue(true);
|
|
||||||
OwnBeaconStore.instance.emit(
|
|
||||||
OwnBeaconStoreEvent.LocationPublishError,
|
|
||||||
getBeaconInfoIdentifier(room2Beacon1),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// renders wire error ui
|
|
||||||
expect(container).toHaveTextContent(
|
|
||||||
"An error occurred whilst sharing your live location, please try again",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("room-live-share-wire-error-close-button")).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("stops displaying wire error when errors are cleared", async () => {
|
|
||||||
const locationPublishErrorSpy = jest
|
|
||||||
.spyOn(OwnBeaconStore.instance, "beaconHasLocationPublishError")
|
|
||||||
.mockReturnValue(true);
|
|
||||||
const { container } = getComponent({ roomId: room2Id });
|
|
||||||
|
|
||||||
// update mock and emit event
|
|
||||||
act(() => {
|
|
||||||
locationPublishErrorSpy.mockReturnValue(false);
|
|
||||||
OwnBeaconStore.instance.emit(
|
|
||||||
OwnBeaconStoreEvent.LocationPublishError,
|
|
||||||
getBeaconInfoIdentifier(room2Beacon1),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// renders error-free ui
|
|
||||||
expect(container).toHaveTextContent("You are sharing your live location");
|
|
||||||
expect(screen.queryByTestId("room-live-share-wire-error-close-button")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicking retry button resets location publish errors", async () => {
|
|
||||||
jest.spyOn(OwnBeaconStore.instance, "beaconHasLocationPublishError").mockReturnValue(true);
|
|
||||||
const resetErrorSpy = jest.spyOn(OwnBeaconStore.instance, "resetLocationPublishError");
|
|
||||||
|
|
||||||
const { container } = getComponent({ roomId: room2Id });
|
|
||||||
const btn = getByTestId(container, "room-live-share-primary-button");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
fireEvent.click(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resetErrorSpy).toHaveBeenCalledWith(getBeaconInfoIdentifier(room2Beacon1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicking close button stops beacons", async () => {
|
|
||||||
jest.spyOn(OwnBeaconStore.instance, "beaconHasLocationPublishError").mockReturnValue(true);
|
|
||||||
const stopBeaconSpy = jest.spyOn(OwnBeaconStore.instance, "stopBeacon");
|
|
||||||
|
|
||||||
const { container } = getComponent({ roomId: room2Id });
|
|
||||||
const btn = getByTestId(container, "room-live-share-wire-error-close-button");
|
|
||||||
act(() => {
|
|
||||||
fireEvent.click(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(stopBeaconSpy).toHaveBeenCalledWith(getBeaconInfoIdentifier(room2Beacon1));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,133 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with one live beacon in room 1`] = `
|
|
||||||
<DocumentFragment>
|
|
||||||
<div
|
|
||||||
class="mx_RoomLiveShareWarning"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="mx_RoomLiveShareWarning_label"
|
|
||||||
>
|
|
||||||
You are sharing your live location
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="mx_LiveTimeRemaining"
|
|
||||||
data-testid="room-live-share-expiry"
|
|
||||||
>
|
|
||||||
1h left
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="mx_AccessibleButton mx_RoomLiveShareWarning_stopButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
|
|
||||||
data-testid="room-live-share-primary-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</DocumentFragment>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available renders correctly with two live beacons in room 1`] = `
|
|
||||||
<DocumentFragment>
|
|
||||||
<div
|
|
||||||
class="mx_RoomLiveShareWarning"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="mx_RoomLiveShareWarning_label"
|
|
||||||
>
|
|
||||||
You are sharing your live location
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="mx_LiveTimeRemaining"
|
|
||||||
data-testid="room-live-share-expiry"
|
|
||||||
>
|
|
||||||
12h left
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="mx_AccessibleButton mx_RoomLiveShareWarning_stopButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
|
|
||||||
data-testid="room-live-share-primary-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</DocumentFragment>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available stopping beacons displays error when stop sharing fails 1`] = `
|
|
||||||
<DocumentFragment>
|
|
||||||
<div
|
|
||||||
class="mx_RoomLiveShareWarning"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon mx_StyledLiveBeaconIcon_error"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="mx_RoomLiveShareWarning_label"
|
|
||||||
>
|
|
||||||
An error occurred while stopping your live location, please try again
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="mx_AccessibleButton mx_RoomLiveShareWarning_stopButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
|
|
||||||
data-testid="room-live-share-primary-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</DocumentFragment>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`<RoomLiveShareWarning /> when user has live beacons and geolocation is available with location publish errors displays location publish error when mounted with location publish errors 1`] = `
|
|
||||||
<DocumentFragment>
|
|
||||||
<div
|
|
||||||
class="mx_RoomLiveShareWarning"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_StyledLiveBeaconIcon mx_RoomLiveShareWarning_icon mx_StyledLiveBeaconIcon_error"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="mx_RoomLiveShareWarning_label"
|
|
||||||
>
|
|
||||||
An error occurred whilst sharing your live location, please try again
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="mx_AccessibleButton mx_RoomLiveShareWarning_stopButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
|
|
||||||
data-testid="room-live-share-primary-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="Stop and close"
|
|
||||||
class="mx_AccessibleButton mx_RoomLiveShareWarning_closeButton"
|
|
||||||
data-testid="room-live-share-wire-error-close-button"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="mx_RoomLiveShareWarning_closeButtonIcon"
|
|
||||||
fill="currentColor"
|
|
||||||
height="1em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</DocumentFragment>
|
|
||||||
`;
|
|
@ -1,109 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2023 Mikhail Aheichyk
|
|
||||||
Copyright 2023 Nordeck IT + Consulting GmbH.
|
|
||||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import React, { ComponentProps } from "react";
|
|
||||||
import { mocked } from "jest-mock";
|
|
||||||
import { MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
|
|
||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
|
||||||
|
|
||||||
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
|
|
||||||
import RoomContextMenu from "../../../../src/components/views/context_menus/RoomContextMenu";
|
|
||||||
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
|
|
||||||
import { stubClient } from "../../../test-utils";
|
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
|
||||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
|
||||||
import { EchoChamber } from "../../../../src/stores/local-echo/EchoChamber";
|
|
||||||
import { RoomNotifState } from "../../../../src/RoomNotifs";
|
|
||||||
|
|
||||||
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
|
|
||||||
shouldShowComponent: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("RoomContextMenu", () => {
|
|
||||||
const ROOM_ID = "!123:matrix.org";
|
|
||||||
|
|
||||||
let room: Room;
|
|
||||||
let mockClient: MatrixClient;
|
|
||||||
|
|
||||||
let onFinished: () => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
stubClient();
|
|
||||||
mockClient = mocked(MatrixClientPeg.safeGet());
|
|
||||||
|
|
||||||
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
|
|
||||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dmRoomMap = {
|
|
||||||
getUserIdForRoomId: jest.fn(),
|
|
||||||
} as unknown as DMRoomMap;
|
|
||||||
DMRoomMap.setShared(dmRoomMap);
|
|
||||||
|
|
||||||
onFinished = jest.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderComponent(props: Partial<ComponentProps<typeof RoomContextMenu>> = {}) {
|
|
||||||
render(
|
|
||||||
<MatrixClientContext.Provider value={mockClient}>
|
|
||||||
<RoomContextMenu room={room} onFinished={onFinished} {...props} />
|
|
||||||
</MatrixClientContext.Provider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("does not render invite menu item when UIComponent customisations disable invite", () => {
|
|
||||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
|
||||||
mocked(shouldShowComponent).mockReturnValue(false);
|
|
||||||
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
expect(screen.queryByRole("menuitem", { name: "Invite" })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders invite menu item when UIComponent customisations enable invite", () => {
|
|
||||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
|
||||||
mocked(shouldShowComponent).mockReturnValue(true);
|
|
||||||
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
expect(screen.getByRole("menuitem", { name: "Invite" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("when developer mode is disabled, it should not render the developer tools option", () => {
|
|
||||||
renderComponent();
|
|
||||||
expect(screen.queryByText("Developer tools")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when developer mode is enabled", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "developerMode");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the developer tools option", () => {
|
|
||||||
renderComponent();
|
|
||||||
expect(screen.getByText("Developer tools")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render notification option for joined rooms", () => {
|
|
||||||
const chamber = EchoChamber.forRoom(room);
|
|
||||||
chamber.notificationVolume = RoomNotifState.Mute;
|
|
||||||
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("menuitem", { name: "Notifications" }).querySelector(".mx_IconizedContextMenu_sublabel"),
|
|
||||||
).toHaveTextContent("Mute");
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,187 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { render, waitFor } from "@testing-library/react";
|
|
||||||
import {
|
|
||||||
MatrixEvent,
|
|
||||||
MsgType,
|
|
||||||
RelationType,
|
|
||||||
NotificationCountType,
|
|
||||||
Room,
|
|
||||||
MatrixClient,
|
|
||||||
PendingEventOrdering,
|
|
||||||
ReceiptType,
|
|
||||||
} from "matrix-js-sdk/src/matrix";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import LegacyRoomHeaderButtons from "../../../../src/components/views/right_panel/LegacyRoomHeaderButtons";
|
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
|
||||||
import { mkEvent, stubClient } from "../../../test-utils";
|
|
||||||
import { mkThread } from "../../../test-utils/threads";
|
|
||||||
|
|
||||||
describe("LegacyRoomHeaderButtons-test.tsx", function () {
|
|
||||||
const ROOM_ID = "!roomId:example.org";
|
|
||||||
let room: Room;
|
|
||||||
let client: MatrixClient;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
stubClient();
|
|
||||||
client = MatrixClientPeg.safeGet();
|
|
||||||
client.supportsThreads = () => true;
|
|
||||||
room = new Room(ROOM_ID, client, client.getUserId() ?? "", {
|
|
||||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getComponent(room?: Room) {
|
|
||||||
return render(<LegacyRoomHeaderButtons room={room} excludedRightPanelPhaseButtons={[]} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getThreadButton(container: HTMLElement) {
|
|
||||||
return container.querySelector(".mx_RightPanel_threadsButton");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIndicatorOfType(container: HTMLElement, type: "highlight" | "notification" | "activity") {
|
|
||||||
return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")!.className.includes(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should render", () => {
|
|
||||||
const { asFragment } = getComponent(room);
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the thread button", () => {
|
|
||||||
const { container } = getComponent(room);
|
|
||||||
expect(getThreadButton(container)).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("room wide notification does not change the thread button", () => {
|
|
||||||
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
|
|
||||||
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
|
|
||||||
|
|
||||||
const { container } = getComponent(room);
|
|
||||||
|
|
||||||
expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("thread notification does change the thread button", async () => {
|
|
||||||
const { container } = getComponent(room);
|
|
||||||
expect(getThreadButton(container)!.className.includes("mx_LegacyRoomHeader_button--unread")).toBeFalsy();
|
|
||||||
|
|
||||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(getThreadButton(container)!.className.includes("mx_LegacyRoomHeader_button--unread")).toBeTruthy();
|
|
||||||
expect(isIndicatorOfType(container, "notification")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
|
|
||||||
await waitFor(() => expect(isIndicatorOfType(container, "highlight")).toBe(true));
|
|
||||||
|
|
||||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 0);
|
|
||||||
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 0);
|
|
||||||
|
|
||||||
await waitFor(() => expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("thread activity does change the thread button", async () => {
|
|
||||||
const { container } = getComponent(room);
|
|
||||||
|
|
||||||
// Thread activity should appear on the icon.
|
|
||||||
const { rootEvent, events } = mkThread({
|
|
||||||
room,
|
|
||||||
client,
|
|
||||||
authorId: client.getUserId()!,
|
|
||||||
participantUserIds: ["@alice:example.org"],
|
|
||||||
length: 5,
|
|
||||||
});
|
|
||||||
// We need some receipt, otherwise we treat this thread as
|
|
||||||
// "older than all threaded receipts" and consider it read.
|
|
||||||
let receipt = new MatrixEvent({
|
|
||||||
type: "m.receipt",
|
|
||||||
room_id: room.roomId,
|
|
||||||
content: {
|
|
||||||
[events[1].getId()!]: {
|
|
||||||
// Receipt for the first event in the thread
|
|
||||||
[ReceiptType.Read]: {
|
|
||||||
[client.getUserId()!]: { ts: 1, thread_id: rootEvent.getId() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
room.addReceipt(receipt);
|
|
||||||
await waitFor(() => expect(isIndicatorOfType(container, "activity")).toBe(true));
|
|
||||||
|
|
||||||
// Sending the last event should clear the notification.
|
|
||||||
let event = mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: "m.room.message",
|
|
||||||
user: client.getUserId()!,
|
|
||||||
room: room.roomId,
|
|
||||||
content: {
|
|
||||||
"msgtype": MsgType.Text,
|
|
||||||
"body": "Test",
|
|
||||||
"m.relates_to": {
|
|
||||||
event_id: rootEvent.getId(),
|
|
||||||
rel_type: RelationType.Thread,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
room.addLiveEvents([event]);
|
|
||||||
await waitFor(() => expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull());
|
|
||||||
|
|
||||||
// Mark it as unread again.
|
|
||||||
event = mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: "m.room.message",
|
|
||||||
user: "@alice:example.org",
|
|
||||||
room: room.roomId,
|
|
||||||
content: {
|
|
||||||
"msgtype": MsgType.Text,
|
|
||||||
"body": "Test",
|
|
||||||
"m.relates_to": {
|
|
||||||
event_id: rootEvent.getId(),
|
|
||||||
rel_type: RelationType.Thread,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
room.addLiveEvents([event]);
|
|
||||||
await waitFor(() => expect(isIndicatorOfType(container, "activity")).toBe(true));
|
|
||||||
|
|
||||||
// Sending a read receipt on an earlier event shouldn't do anything.
|
|
||||||
receipt = new MatrixEvent({
|
|
||||||
type: "m.receipt",
|
|
||||||
room_id: room.roomId,
|
|
||||||
content: {
|
|
||||||
[events.at(-1)!.getId()!]: {
|
|
||||||
[ReceiptType.Read]: {
|
|
||||||
[client.getUserId()!]: { ts: 1, thread_id: rootEvent.getId() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
room.addReceipt(receipt);
|
|
||||||
await waitFor(() => expect(isIndicatorOfType(container, "activity")).toBe(true));
|
|
||||||
|
|
||||||
// Sending a receipt on the latest event should clear the notification.
|
|
||||||
receipt = new MatrixEvent({
|
|
||||||
type: "m.receipt",
|
|
||||||
room_id: room.roomId,
|
|
||||||
content: {
|
|
||||||
[event.getId()!]: {
|
|
||||||
[ReceiptType.Read]: {
|
|
||||||
[client.getUserId()!]: { ts: 1, thread_id: rootEvent.getId() },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
room.addReceipt(receipt);
|
|
||||||
await waitFor(() => expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull());
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,28 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`LegacyRoomHeaderButtons-test.tsx should render 1`] = `
|
|
||||||
<DocumentFragment>
|
|
||||||
<div
|
|
||||||
aria-current="false"
|
|
||||||
aria-label="Chat"
|
|
||||||
class="mx_AccessibleButton mx_LegacyRoomHeader_button mx_RightPanel_timelineCardButton"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
aria-current="false"
|
|
||||||
aria-label="Threads"
|
|
||||||
class="mx_AccessibleButton mx_LegacyRoomHeader_button mx_RightPanel_threadsButton"
|
|
||||||
data-testid="threadsButton"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
aria-current="false"
|
|
||||||
aria-label="Room info"
|
|
||||||
class="mx_AccessibleButton mx_LegacyRoomHeader_button mx_RightPanel_roomSummaryButton"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
/>
|
|
||||||
</DocumentFragment>
|
|
||||||
`;
|
|
@ -1,917 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
|
||||||
Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { render, screen, act, fireEvent, waitFor, getByRole, RenderResult } from "@testing-library/react";
|
|
||||||
import { mocked, Mocked } from "jest-mock";
|
|
||||||
import {
|
|
||||||
EventType,
|
|
||||||
RoomType,
|
|
||||||
Room,
|
|
||||||
RoomStateEvent,
|
|
||||||
PendingEventOrdering,
|
|
||||||
ISearchResults,
|
|
||||||
IContent,
|
|
||||||
} from "matrix-js-sdk/src/matrix";
|
|
||||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
|
||||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
|
||||||
import { ClientWidgetApi, Widget } from "matrix-widget-api";
|
|
||||||
import EventEmitter from "events";
|
|
||||||
import { setupJestCanvasMock } from "jest-canvas-mock";
|
|
||||||
import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
|
|
||||||
import { MatrixRTCSession, MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc";
|
|
||||||
|
|
||||||
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix";
|
|
||||||
import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
|
||||||
import {
|
|
||||||
stubClient,
|
|
||||||
mkRoomMember,
|
|
||||||
setupAsyncStoreWithClient,
|
|
||||||
resetAsyncStoreWithClient,
|
|
||||||
mockPlatformPeg,
|
|
||||||
mkEvent,
|
|
||||||
filterConsole,
|
|
||||||
} from "../../../test-utils";
|
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
|
||||||
import DMRoomMap from "../../../../src/utils/DMRoomMap";
|
|
||||||
import RoomHeader, { IProps as RoomHeaderProps } from "../../../../src/components/views/rooms/LegacyRoomHeader";
|
|
||||||
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
|
|
||||||
import { IRoomState } from "../../../../src/components/structures/RoomView";
|
|
||||||
import RoomContext from "../../../../src/contexts/RoomContext";
|
|
||||||
import SdkConfig from "../../../../src/SdkConfig";
|
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
|
||||||
import { ElementCall, JitsiCall } from "../../../../src/models/Call";
|
|
||||||
import { CallStore } from "../../../../src/stores/CallStore";
|
|
||||||
import LegacyCallHandler from "../../../../src/LegacyCallHandler";
|
|
||||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
|
||||||
import { Action } from "../../../../src/dispatcher/actions";
|
|
||||||
import WidgetStore from "../../../../src/stores/WidgetStore";
|
|
||||||
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
|
|
||||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler";
|
|
||||||
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
|
|
||||||
import { UIComponent } from "../../../../src/settings/UIFeature";
|
|
||||||
import WidgetUtils from "../../../../src/utils/WidgetUtils";
|
|
||||||
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
|
|
||||||
import { SearchScope } from "../../../../src/Searching";
|
|
||||||
|
|
||||||
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
|
|
||||||
shouldShowComponent: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("LegacyRoomHeader", () => {
|
|
||||||
let client: Mocked<MatrixClient>;
|
|
||||||
let room: Room;
|
|
||||||
let alice: RoomMember;
|
|
||||||
let bob: RoomMember;
|
|
||||||
let carol: RoomMember;
|
|
||||||
|
|
||||||
filterConsole(
|
|
||||||
"Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.",
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// some of our tests rely on the jest canvas mock, and `afterEach` will have reset the mock, so we need to
|
|
||||||
// restore it.
|
|
||||||
setupJestCanvasMock();
|
|
||||||
|
|
||||||
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
|
|
||||||
|
|
||||||
stubClient();
|
|
||||||
client = mocked(MatrixClientPeg.safeGet());
|
|
||||||
client.getUserId.mockReturnValue("@alice:example.org");
|
|
||||||
|
|
||||||
room = new Room("!1:example.org", client, "@alice:example.org", {
|
|
||||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
|
||||||
});
|
|
||||||
room.currentState.setStateEvents([mkCreationEvent(room.roomId, "@alice:example.org")]);
|
|
||||||
|
|
||||||
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
|
|
||||||
client.getRooms.mockReturnValue([room]);
|
|
||||||
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
|
|
||||||
client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
|
|
||||||
if (roomId !== room.roomId) throw new Error("Unknown room");
|
|
||||||
const event = mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: eventType,
|
|
||||||
room: roomId,
|
|
||||||
user: alice.userId,
|
|
||||||
skey: stateKey,
|
|
||||||
content: content as IContent,
|
|
||||||
});
|
|
||||||
room.addLiveEvents([event]);
|
|
||||||
return { event_id: event.getId()! };
|
|
||||||
});
|
|
||||||
|
|
||||||
alice = mkRoomMember(room.roomId, "@alice:example.org");
|
|
||||||
bob = mkRoomMember(room.roomId, "@bob:example.org");
|
|
||||||
carol = mkRoomMember(room.roomId, "@carol:example.org");
|
|
||||||
|
|
||||||
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
|
|
||||||
client.getRooms.mockReturnValue([room]);
|
|
||||||
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
[CallStore.instance, WidgetStore.instance].map((store) => setupAsyncStoreWithClient(store, client)),
|
|
||||||
);
|
|
||||||
|
|
||||||
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
|
|
||||||
[MediaDeviceKindEnum.AudioInput]: [],
|
|
||||||
[MediaDeviceKindEnum.VideoInput]: [],
|
|
||||||
[MediaDeviceKindEnum.AudioOutput]: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
DMRoomMap.makeShared(client);
|
|
||||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(carol.userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient));
|
|
||||||
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
SdkConfig.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRoomType = (type: string) => {
|
|
||||||
jest.spyOn(room, "getType").mockReturnValue(type);
|
|
||||||
};
|
|
||||||
const mockRoomMembers = (members: RoomMember[]) => {
|
|
||||||
jest.spyOn(room, "getJoinedMembers").mockReturnValue(members);
|
|
||||||
jest.spyOn(room, "getMember").mockImplementation(
|
|
||||||
(userId) => members.find((member) => member.userId === userId) ?? null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const mockEnabledSettings = (settings: string[]) => {
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settings.includes(settingName));
|
|
||||||
};
|
|
||||||
const mockEventPowerLevels = (events: { [eventType: string]: number }) => {
|
|
||||||
room.currentState.setStateEvents([
|
|
||||||
mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: EventType.RoomPowerLevels,
|
|
||||||
room: room.roomId,
|
|
||||||
user: alice.userId,
|
|
||||||
skey: "",
|
|
||||||
content: { events, state_default: 0 },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
const mockLegacyCall = () => {
|
|
||||||
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall);
|
|
||||||
};
|
|
||||||
const withCall = async (fn: (call: ElementCall) => void | Promise<void>): Promise<void> => {
|
|
||||||
await ElementCall.create(room);
|
|
||||||
const call = CallStore.instance.getCall(room.roomId);
|
|
||||||
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
|
|
||||||
|
|
||||||
const widget = new Widget(call.widget);
|
|
||||||
|
|
||||||
const eventEmitter = new EventEmitter();
|
|
||||||
const messaging = {
|
|
||||||
on: eventEmitter.on.bind(eventEmitter),
|
|
||||||
off: eventEmitter.off.bind(eventEmitter),
|
|
||||||
once: eventEmitter.once.bind(eventEmitter),
|
|
||||||
emit: eventEmitter.emit.bind(eventEmitter),
|
|
||||||
stop: jest.fn(),
|
|
||||||
transport: {
|
|
||||||
send: jest.fn(),
|
|
||||||
reply: jest.fn(),
|
|
||||||
},
|
|
||||||
} as unknown as Mocked<ClientWidgetApi>;
|
|
||||||
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
|
|
||||||
|
|
||||||
await fn(call);
|
|
||||||
|
|
||||||
call.destroy();
|
|
||||||
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderHeader = (props: Partial<RoomHeaderProps> = {}, roomContext: Partial<IRoomState> = {}) => {
|
|
||||||
render(
|
|
||||||
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
|
|
||||||
<RoomHeader
|
|
||||||
room={room}
|
|
||||||
inRoom={true}
|
|
||||||
onSearchClick={() => {}}
|
|
||||||
onInviteClick={null}
|
|
||||||
onForgetClick={() => {}}
|
|
||||||
onAppsClick={() => {}}
|
|
||||||
e2eStatus={E2EStatus.Normal}
|
|
||||||
appsShown={true}
|
|
||||||
searchInfo={{
|
|
||||||
searchId: Math.random(),
|
|
||||||
promise: new Promise<ISearchResults>(() => {}),
|
|
||||||
term: "",
|
|
||||||
scope: SearchScope.Room,
|
|
||||||
count: 0,
|
|
||||||
}}
|
|
||||||
viewingCall={false}
|
|
||||||
activeCall={null}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</RoomContext.Provider>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
it("hides call buttons in video rooms", () => {
|
|
||||||
mockRoomType(RoomType.UnstableCall);
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_video_rooms", "feature_element_call_video_rooms"]);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.queryByRole("button", { name: /call/i })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides call buttons if showCallButtonsInComposer is disabled", () => {
|
|
||||||
mockEnabledSettings([]);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.queryByRole("button", { name: /call/i })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
|
||||||
"hides the voice call button and disables the video call button if configured to use Element Call exclusively " +
|
|
||||||
"and there's an ongoing call",
|
|
||||||
async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
SdkConfig.put({
|
|
||||||
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
|
|
||||||
});
|
|
||||||
await ElementCall.create(room);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"hides the voice call button and starts an Element call when the video call button is pressed if configured to " +
|
|
||||||
"use Element Call exclusively",
|
|
||||||
async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
SdkConfig.put({
|
|
||||||
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
|
|
||||||
|
|
||||||
const dispatcherSpy = jest.fn();
|
|
||||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: room.roomId,
|
|
||||||
skipLobby: false,
|
|
||||||
view_call: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"hides the voice call button and disables the video call button if configured to use Element Call exclusively " +
|
|
||||||
"and the user lacks permission",
|
|
||||||
() => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
SdkConfig.put({
|
|
||||||
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
|
|
||||||
});
|
|
||||||
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 });
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("disables call buttons in the new group call experience if there's an ongoing Element call", async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
await ElementCall.create(room);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables call buttons in the new group call experience if there's an ongoing legacy 1:1 call", () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
mockLegacyCall();
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables call buttons in the new group call experience if there's an existing Jitsi widget", async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
await JitsiCall.create(room);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables call buttons in the new group call experience if there's no other members", () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
|
||||||
"starts a legacy 1:1 call when call buttons are pressed in the new group call experience if there's 1 other " +
|
|
||||||
"member",
|
|
||||||
async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
mockRoomMembers([alice, bob]);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
|
|
||||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
|
|
||||||
|
|
||||||
placeCallSpy.mockClear();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: "Legacy video call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"creates a Jitsi widget when call buttons are pressed in the new group call experience if the user lacks " +
|
|
||||||
"permission to start Element calls",
|
|
||||||
async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
mockRoomMembers([alice, bob, carol]);
|
|
||||||
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 });
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
|
|
||||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
|
|
||||||
|
|
||||||
placeCallSpy.mockClear();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"creates a Jitsi widget when the voice call button is pressed and shows a menu when the video call button is " +
|
|
||||||
"pressed in the new group call experience",
|
|
||||||
async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
mockRoomMembers([alice, bob, carol]);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
|
|
||||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
|
|
||||||
|
|
||||||
// First try creating a Jitsi widget from the menu
|
|
||||||
placeCallSpy.mockClear();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /jitsi/i }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
|
|
||||||
|
|
||||||
// Then try starting an Element call from the menu
|
|
||||||
const dispatcherSpy = jest.fn();
|
|
||||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /element/i }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: room.roomId,
|
|
||||||
skipLobby: false,
|
|
||||||
view_call: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it(
|
|
||||||
"disables the voice call button and starts an Element call when the video call button is pressed in the new " +
|
|
||||||
"group call experience if the user lacks permission to edit widgets",
|
|
||||||
async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
mockRoomMembers([alice, bob, carol]);
|
|
||||||
mockEventPowerLevels({ "im.vector.modular.widgets": 100 });
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
|
|
||||||
const dispatcherSpy = jest.fn();
|
|
||||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: room.roomId,
|
|
||||||
skipLobby: false,
|
|
||||||
view_call: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("disables call buttons in the new group call experience if the user lacks permission", () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
|
|
||||||
mockRoomMembers([alice, bob, carol]);
|
|
||||||
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100, "im.vector.modular.widgets": 100 });
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables call buttons if there's an ongoing legacy 1:1 call", () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer"]);
|
|
||||||
mockLegacyCall();
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables call buttons if there's an existing Jitsi widget", async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer"]);
|
|
||||||
await JitsiCall.create(room);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables call buttons if there's no other members", () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer"]);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts a legacy 1:1 call when call buttons are pressed if there's 1 other member", async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer"]);
|
|
||||||
mockRoomMembers([alice, bob]);
|
|
||||||
mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
|
|
||||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
|
|
||||||
|
|
||||||
placeCallSpy.mockClear();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a Jitsi widget when call buttons are pressed", async () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer"]);
|
|
||||||
mockRoomMembers([alice, bob, carol]);
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
|
|
||||||
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
|
|
||||||
|
|
||||||
placeCallSpy.mockClear();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
|
|
||||||
await act(() => Promise.resolve()); // Allow effects to settle
|
|
||||||
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables call buttons if the user lacks permission", () => {
|
|
||||||
mockEnabledSettings(["showCallButtonsInComposer"]);
|
|
||||||
mockRoomMembers([alice, bob, carol]);
|
|
||||||
mockEventPowerLevels({ "im.vector.modular.widgets": 100 });
|
|
||||||
|
|
||||||
renderHeader();
|
|
||||||
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a close button when viewing a call lobby that returns to the timeline when pressed", async () => {
|
|
||||||
mockEnabledSettings(["feature_group_calls"]);
|
|
||||||
|
|
||||||
renderHeader({ viewingCall: true });
|
|
||||||
|
|
||||||
const dispatcherSpy = jest.fn();
|
|
||||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: room.roomId,
|
|
||||||
view_call: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a reduce button when viewing a call that returns to the timeline when pressed", async () => {
|
|
||||||
mockEnabledSettings(["feature_group_calls"]);
|
|
||||||
|
|
||||||
await withCall(async (call) => {
|
|
||||||
renderHeader({ viewingCall: true, activeCall: call });
|
|
||||||
|
|
||||||
const dispatcherSpy = jest.fn();
|
|
||||||
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /timeline/i }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(dispatcherSpy).toHaveBeenCalledWith({
|
|
||||||
action: Action.ViewRoom,
|
|
||||||
room_id: room.roomId,
|
|
||||||
view_call: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
defaultDispatcher.unregister(dispatcherRef);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a layout button when viewing a call that shows a menu when pressed", async () => {
|
|
||||||
mockEnabledSettings(["feature_group_calls"]);
|
|
||||||
|
|
||||||
await withCall(async (call) => {
|
|
||||||
// We set the call to skip lobby because otherwise the connection will wait until
|
|
||||||
// the user clicks the "join" button, inside the widget lobby which is hard to mock.
|
|
||||||
call.widget.data = { ...call.widget.data, skipLobby: true };
|
|
||||||
// The connect method will wait until the session actually connected. Otherwise it will timeout.
|
|
||||||
// Emitting SessionStarted will trigger the connect method to resolve.
|
|
||||||
setTimeout(
|
|
||||||
() =>
|
|
||||||
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, {
|
|
||||||
room,
|
|
||||||
} as MatrixRTCSession),
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
await call.start();
|
|
||||||
|
|
||||||
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!;
|
|
||||||
renderHeader({ viewingCall: true, activeCall: call });
|
|
||||||
|
|
||||||
// Should start with Freedom selected
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
|
|
||||||
screen.getByRole("menuitemradio", { name: "Freedom", checked: true });
|
|
||||||
|
|
||||||
// Clicking Spotlight should tell the widget to switch and close the menu
|
|
||||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Spotlight" }));
|
|
||||||
expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
|
|
||||||
expect(screen.queryByRole("menu")).toBeNull();
|
|
||||||
|
|
||||||
// When the widget responds and the user reopens the menu, they should see Spotlight selected
|
|
||||||
act(() => {
|
|
||||||
messaging.emit(
|
|
||||||
`action:${ElementWidgetActions.SpotlightLayout}`,
|
|
||||||
new CustomEvent("widgetapirequest", { detail: { data: {} } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
|
|
||||||
screen.getByRole("menuitemradio", { name: "Spotlight", checked: true });
|
|
||||||
|
|
||||||
// Now try switching back to Freedom
|
|
||||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Freedom" }));
|
|
||||||
expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
|
|
||||||
expect(screen.queryByRole("menu")).toBeNull();
|
|
||||||
|
|
||||||
// When the widget responds and the user reopens the menu, they should see Freedom selected
|
|
||||||
act(() => {
|
|
||||||
messaging.emit(
|
|
||||||
`action:${ElementWidgetActions.TileLayout}`,
|
|
||||||
new CustomEvent("widgetapirequest", { detail: { data: {} } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
|
|
||||||
screen.getByRole("menuitemradio", { name: "Freedom", checked: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an invite button in video rooms", () => {
|
|
||||||
mockEnabledSettings(["feature_video_rooms", "feature_element_call_video_rooms"]);
|
|
||||||
mockRoomType(RoomType.UnstableCall);
|
|
||||||
|
|
||||||
const onInviteClick = jest.fn();
|
|
||||||
renderHeader({ onInviteClick, viewingCall: true });
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /invite/i }));
|
|
||||||
expect(onInviteClick).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the invite button in non-video rooms when viewing a call", () => {
|
|
||||||
renderHeader({ onInviteClick: () => {}, viewingCall: true });
|
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: /invite/i })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the room avatar in a room with only ourselves", () => {
|
|
||||||
// When we render a non-DM room with 1 person in it
|
|
||||||
const room = createRoom({ name: "X Room", isDm: false, userIds: [] });
|
|
||||||
const rendered = mountHeader(room);
|
|
||||||
|
|
||||||
// Then the room's avatar is the initial of its name
|
|
||||||
const initial = rendered.container.querySelector(".mx_BaseAvatar");
|
|
||||||
expect(initial).toHaveTextContent("X");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the room avatar in a room with 2 people", () => {
|
|
||||||
// When we render a non-DM room with 2 people in it
|
|
||||||
const room = createRoom({ name: "Y Room", isDm: false, userIds: ["other"] });
|
|
||||||
const rendered = mountHeader(room);
|
|
||||||
|
|
||||||
// Then the room's avatar is the initial of its name
|
|
||||||
const initial = rendered.container.querySelector(".mx_BaseAvatar");
|
|
||||||
expect(initial).toHaveTextContent("Y");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the room avatar in a room with >2 people", () => {
|
|
||||||
// When we render a non-DM room with 3 people in it
|
|
||||||
const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] });
|
|
||||||
const rendered = mountHeader(room);
|
|
||||||
|
|
||||||
// Then the room's avatar is the initial of its name
|
|
||||||
const initial = rendered.container.querySelector(".mx_BaseAvatar");
|
|
||||||
expect(initial).toHaveTextContent("Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the room avatar in a DM with only ourselves", () => {
|
|
||||||
// When we render a non-DM room with 1 person in it
|
|
||||||
const room = createRoom({ name: "Z Room", isDm: true, userIds: [] });
|
|
||||||
const rendered = mountHeader(room);
|
|
||||||
|
|
||||||
// Then the room's avatar is the initial of its name
|
|
||||||
const initial = rendered.container.querySelector(".mx_BaseAvatar");
|
|
||||||
expect(initial).toHaveTextContent("Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the user avatar in a DM with 2 people", () => {
|
|
||||||
// Note: this is the interesting case - this is the ONLY
|
|
||||||
// time we should use the user's avatar.
|
|
||||||
|
|
||||||
// When we render a DM room with only 2 people in it
|
|
||||||
const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] });
|
|
||||||
const rendered = mountHeader(room);
|
|
||||||
|
|
||||||
// Then we use the other user's avatar as our room's image avatar
|
|
||||||
const image = rendered.container.querySelector(".mx_BaseAvatar img");
|
|
||||||
expect(image).toHaveAttribute("src", "http://this.is.a.url/example.org/other");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the room avatar in a DM with >2 people", () => {
|
|
||||||
// When we render a DM room with 3 people in it
|
|
||||||
const room = createRoom({
|
|
||||||
name: "Z Room",
|
|
||||||
isDm: true,
|
|
||||||
userIds: ["other1", "other2"],
|
|
||||||
});
|
|
||||||
const rendered = mountHeader(room);
|
|
||||||
|
|
||||||
// Then the room's avatar is the initial of its name
|
|
||||||
const initial = rendered.container.querySelector(".mx_BaseAvatar");
|
|
||||||
expect(initial).toHaveTextContent("Z");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders call buttons normally", () => {
|
|
||||||
const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] });
|
|
||||||
const wrapper = mountHeader(room);
|
|
||||||
|
|
||||||
expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeDefined();
|
|
||||||
expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides call buttons when the room is tombstoned", () => {
|
|
||||||
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
|
|
||||||
const wrapper = mountHeader(
|
|
||||||
room,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
tombstone: mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: "m.room.tombstone",
|
|
||||||
room: room.roomId,
|
|
||||||
user: "@user1:server",
|
|
||||||
skey: "",
|
|
||||||
content: {},
|
|
||||||
ts: Date.now(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeFalsy();
|
|
||||||
expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render buttons if not passing showButtons (default true)", () => {
|
|
||||||
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
|
|
||||||
const wrapper = mountHeader(room);
|
|
||||||
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_button")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not render buttons if passing showButtons = false", () => {
|
|
||||||
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
|
|
||||||
const wrapper = mountHeader(room, { showButtons: false });
|
|
||||||
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_button")).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should render the room options context menu if not passing enableRoomOptionsMenu (default true) and UIComponent customisations room options enabled", () => {
|
|
||||||
mocked(shouldShowComponent).mockReturnValue(true);
|
|
||||||
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
|
|
||||||
const wrapper = mountHeader(room);
|
|
||||||
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
|
|
||||||
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[false, true],
|
|
||||||
[true, false],
|
|
||||||
])(
|
|
||||||
"should not render the room options context menu if passing enableRoomOptionsMenu = %s and UIComponent customisations room options enable = %s",
|
|
||||||
(enableRoomOptionsMenu, showRoomOptionsMenu) => {
|
|
||||||
mocked(shouldShowComponent).mockReturnValue(showRoomOptionsMenu);
|
|
||||||
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
|
|
||||||
const wrapper = mountHeader(room, { enableRoomOptionsMenu });
|
|
||||||
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeFalsy();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("renders additionalButtons", async () => {
|
|
||||||
const additionalButtons: ViewRoomOpts["buttons"] = [
|
|
||||||
{
|
|
||||||
icon: () => <>test-icon</>,
|
|
||||||
id: "test-id",
|
|
||||||
label: () => "test-label",
|
|
||||||
onClick: () => {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
renderHeader({ additionalButtons });
|
|
||||||
expect(screen.getByRole("button", { name: "test-icon" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onClick-callback on additionalButtons", () => {
|
|
||||||
const callback = jest.fn();
|
|
||||||
const additionalButtons: ViewRoomOpts["buttons"] = [
|
|
||||||
{
|
|
||||||
icon: () => <>test-icon</>,
|
|
||||||
id: "test-id",
|
|
||||||
label: () => "test-label",
|
|
||||||
onClick: callback,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
renderHeader({ additionalButtons });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "test-icon" }));
|
|
||||||
expect(callback).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IRoomCreationInfo {
|
|
||||||
name: string;
|
|
||||||
isDm: boolean;
|
|
||||||
userIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRoom(info: IRoomCreationInfo) {
|
|
||||||
stubClient();
|
|
||||||
const client: MatrixClient = MatrixClientPeg.safeGet();
|
|
||||||
|
|
||||||
const roomId = "!1234567890:domain";
|
|
||||||
const userId = client.getUserId()!;
|
|
||||||
if (info.isDm) {
|
|
||||||
client.getAccountData = (eventType) => {
|
|
||||||
if (eventType === "m.direct") {
|
|
||||||
return mkDirectEvent(roomId, userId, info.userIds);
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
DMRoomMap.makeShared(client).start();
|
|
||||||
|
|
||||||
const room = new Room(roomId, client, userId, {
|
|
||||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
|
||||||
});
|
|
||||||
|
|
||||||
const otherJoinEvents: MatrixEvent[] = [];
|
|
||||||
for (const otherUserId of info.userIds) {
|
|
||||||
otherJoinEvents.push(mkJoinEvent(roomId, otherUserId));
|
|
||||||
}
|
|
||||||
|
|
||||||
room.currentState.setStateEvents([
|
|
||||||
mkCreationEvent(roomId, userId),
|
|
||||||
mkNameEvent(roomId, userId, info.name),
|
|
||||||
mkJoinEvent(roomId, userId),
|
|
||||||
...otherJoinEvents,
|
|
||||||
]);
|
|
||||||
room.recalculate();
|
|
||||||
|
|
||||||
return room;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial<IRoomState>): RenderResult {
|
|
||||||
const props: RoomHeaderProps = {
|
|
||||||
room,
|
|
||||||
inRoom: true,
|
|
||||||
onSearchClick: () => {},
|
|
||||||
onInviteClick: null,
|
|
||||||
onForgetClick: () => {},
|
|
||||||
onAppsClick: () => {},
|
|
||||||
e2eStatus: E2EStatus.Normal,
|
|
||||||
appsShown: true,
|
|
||||||
searchInfo: {
|
|
||||||
searchId: Math.random(),
|
|
||||||
promise: new Promise<ISearchResults>(() => {}),
|
|
||||||
term: "",
|
|
||||||
scope: SearchScope.Room,
|
|
||||||
count: 0,
|
|
||||||
},
|
|
||||||
viewingCall: false,
|
|
||||||
activeCall: null,
|
|
||||||
...propsOverride,
|
|
||||||
};
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
|
|
||||||
<RoomHeader {...props} />
|
|
||||||
</RoomContext.Provider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mkCreationEvent(roomId: string, userId: string): MatrixEvent {
|
|
||||||
return mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: "m.room.create",
|
|
||||||
room: roomId,
|
|
||||||
user: userId,
|
|
||||||
content: {
|
|
||||||
creator: userId,
|
|
||||||
room_version: "5",
|
|
||||||
predecessor: {
|
|
||||||
room_id: "!prevroom",
|
|
||||||
event_id: "$someevent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mkNameEvent(roomId: string, userId: string, name: string): MatrixEvent {
|
|
||||||
return mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: "m.room.name",
|
|
||||||
room: roomId,
|
|
||||||
user: userId,
|
|
||||||
content: { name },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mkJoinEvent(roomId: string, userId: string) {
|
|
||||||
const ret = mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: "m.room.member",
|
|
||||||
room: roomId,
|
|
||||||
user: userId,
|
|
||||||
content: {
|
|
||||||
membership: KnownMembership.Join,
|
|
||||||
avatar_url: "mxc://example.org/" + userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
ret.event.state_key = userId;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mkDirectEvent(roomId: string, userId: string, otherUsers: string[]): MatrixEvent {
|
|
||||||
const content: Record<string, string[]> = {};
|
|
||||||
for (const otherUserId of otherUsers) {
|
|
||||||
content[otherUserId] = [roomId];
|
|
||||||
}
|
|
||||||
return mkEvent({
|
|
||||||
event: true,
|
|
||||||
type: "m.direct",
|
|
||||||
room: roomId,
|
|
||||||
user: userId,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
|
@ -129,61 +129,6 @@ exports[`<LabsUserSettingsTab /> renders settings marked as beta as beta cards 1
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="mx_BetaCard"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_BetaCard_columns"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_BetaCard_columns_description"
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
class="mx_BetaCard_title"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Room header
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="mx_BetaCard_betaPill"
|
|
||||||
>
|
|
||||||
Beta
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
class="mx_BetaCard_caption"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
A new look for your rooms with a simpler, cleaner and more accessible room header.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_BetaCard_buttons"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
Join the beta
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_BetaCard_refreshWarning"
|
|
||||||
>
|
|
||||||
Joining the beta will reload BrandedClient.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mx_BetaCard_columns_image_wrapper"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
class="mx_BetaCard_columns_image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
Loading…
Reference in New Issue
Block a user