diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts index 7d26b48676..be27c19346 100644 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -109,7 +109,7 @@ describe("Lazy Loading", () => { } function openMemberlist(): void { - cy.get('.mx_HeaderButtons [aria-label="Room Info"]').click(); + cy.get('.mx_HeaderButtons [aria-label="Room info"]').click(); cy.get(".mx_RoomSummaryCard").within(() => { cy.get(".mx_RoomSummaryCard_icon_people").click(); }); diff --git a/res/css/_common.pcss b/res/css/_common.pcss index db663a8e25..4da58c1d37 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -453,7 +453,7 @@ legend { } @define-mixin customisedCancelButton { - mask: url('$(res)/img/feather-customised/cancel.svg'); + mask: url('$(res)/img/cancel.svg'); mask-repeat: no-repeat; mask-position: center; mask-size: cover; @@ -466,8 +466,8 @@ legend { .mx_Dialog_cancelButton { @mixin customisedCancelButton; - width: 14px; - height: 14px; + width: 18px; + height: 18px; position: absolute; top: 10px; right: 0; diff --git a/res/css/structures/_HeaderButtons.pcss b/res/css/structures/_HeaderButtons.pcss index 96f6f2e9f9..4a3de48376 100644 --- a/res/css/structures/_HeaderButtons.pcss +++ b/res/css/structures/_HeaderButtons.pcss @@ -17,20 +17,3 @@ limitations under the License. .mx_HeaderButtons { display: flex; } - -.mx_RoomHeader_buttons + .mx_HeaderButtons { - /* remove the | separator line for when next to RoomHeaderButtons */ - /* TODO: remove this once when we redo communities and make the right panel similar to the new rooms one */ - &::before { - content: unset; - } -} - -.mx_HeaderButtons::before { - content: ""; - background-color: $header-panel-text-primary-color; - opacity: 0.5; - margin: 6px 8px; - border-radius: 1px; - width: 1px; -} diff --git a/res/css/views/elements/_Tooltip.pcss b/res/css/views/elements/_Tooltip.pcss index 8793cec41c..7c49d2b59a 100644 --- a/res/css/views/elements/_Tooltip.pcss +++ b/res/css/views/elements/_Tooltip.pcss @@ -76,11 +76,6 @@ limitations under the License. border: 0; text-align: center; - &:not(.mx_Tooltip_noMargin) { - margin-left: 6px; - margin-right: 6px; - } - .mx_Tooltip_chevron { display: none; } diff --git a/res/css/views/rooms/_JumpToBottomButton.pcss b/res/css/views/rooms/_JumpToBottomButton.pcss index 3530d36690..4e7f180c21 100644 --- a/res/css/views/rooms/_JumpToBottomButton.pcss +++ b/res/css/views/rooms/_JumpToBottomButton.pcss @@ -68,8 +68,10 @@ limitations under the License. bottom: 0; left: 0; right: 0; - mask-image: url('$(res)/img/feather-customised/chevron-down-thin.svg'); + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); mask-repeat: no-repeat; - mask-size: contain; + mask-size: 20px; + mask-position: center 6px; + transform: rotate(180deg); background: $muted-fg-color; } diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 80e3bfd306..1b325db906 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -19,16 +19,27 @@ limitations under the License. border-bottom: 1px solid $primary-hairline-color; background-color: $background; - .mx_RoomHeader_e2eIcon { + .mx_RoomHeader_icon { height: 12px; width: 12px; - .mx_E2EIcon { - margin: 0; - position: absolute; - height: 12px; - width: 12px; + &.mx_RoomHeader_icon_video { + height: 14px; + width: 14px; + background-color: $secondary-content; + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + mask-size: 100%; } + + &.mx_E2EIcon { + margin: 0; + height: 100%; /* To give the tooltip room to breathe */ + } + } + + .mx_CallDuration { + margin-top: calc(($font-15px - $font-13px) / 2); /* To align with the name */ + font-size: $font-13px; } } @@ -38,7 +49,7 @@ limitations under the License. align-items: center; min-width: 0; margin: 0 20px 0 16px; - padding-top: 8px; + padding-top: 6px; border-bottom: 1px solid $system; .mx_InviteOnlyIcon_large { @@ -77,11 +88,6 @@ limitations under the License. padding-right: 12px; } -.mx_RoomHeader_buttons { - display: flex; - background-color: $background; -} - .mx_RoomHeader_info { display: flex; flex: 1; @@ -93,9 +99,11 @@ limitations under the License. overflow: hidden; color: $primary-content; font-weight: $font-semi-bold; - font-size: $font-18px; + font-size: $font-15px; + min-height: 24px; + align-items: center; border-radius: 6px; - margin: 0 7px; + margin: 0 3px; padding: 1px 4px; display: flex; user-select: none; @@ -112,10 +120,10 @@ limitations under the License. .mx_RoomHeader_chevron { align-self: center; - width: 16px; - height: 16px; + width: 20px; + height: 20px; mask-position: center; - mask-size: contain; + mask-size: 20px; mask-repeat: no-repeat; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); background-color: $tertiary-content; @@ -160,9 +168,6 @@ limitations under the License. line-height: $lineHeight; max-height: calc($lineHeight * $lines); - /* to align baseline of topic with room name */ - margin: 4px 7px 0; - overflow: hidden; -webkit-line-clamp: $lines; /* See: https://drafts.csswg.org/css-overflow-3/#webkit-line-clamp */ -webkit-box-orient: vertical; @@ -177,7 +182,7 @@ limitations under the License. .mx_RoomHeader_avatar { flex: 0; - margin: 0 6px 0 7px; + margin: 0 7px; position: relative; } @@ -206,7 +211,7 @@ limitations under the License. mask-size: contain; } - &:hover { + &:not(.mx_RoomHeader_closeButton):hover { background: rgba($accent, 0.1); &::before { @@ -249,6 +254,37 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/call/video-call.svg'); } +.mx_RoomHeader_layoutButton--freedom::before, +.mx_RoomHeader_freedomIcon::before { + mask-image: url('$(res)/img/element-icons/call/freedom.svg'); +} + +.mx_RoomHeader_layoutButton--spotlight::before, +.mx_RoomHeader_spotlightIcon::before { + mask-image: url('$(res)/img/element-icons/call/spotlight.svg'); +} + +.mx_RoomHeader_closeButton::before { + mask-image: url('$(res)/img/cancel.svg'); + mask-size: 20px; + mask-position: center; +} + +.mx_RoomHeader_minimiseButton::before { + mask-image: url('$(res)/img/element-icons/reduce.svg'); +} + +.mx_RoomHeader_layoutMenu .mx_IconizedContextMenu_icon::before { + content: ''; + width: 16px; + height: 16px; + display: block; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + background: $primary-content; +} + @media only screen and (max-width: 480px) { .mx_RoomHeader_wrapper { padding: 0; diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.pcss b/res/css/views/rooms/_TopUnreadMessagesBar.pcss index fbb7cb0b1e..12daa641db 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.pcss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.pcss @@ -51,11 +51,11 @@ limitations under the License. position: absolute; width: 36px; height: 36px; - mask-image: url('$(res)/img/feather-customised/chevron-down-thin.svg'); + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); mask-repeat: no-repeat; - mask-size: contain; + mask-size: 20px; + mask-position: center; background: $muted-fg-color; - transform: rotate(180deg); } .mx_TopUnreadMessagesBar_markAsRead { diff --git a/res/css/views/voip/_LegacyCallViewHeader.pcss b/res/css/views/voip/_LegacyCallViewHeader.pcss index 3d8d4d2fd9..9849cd1430 100644 --- a/res/css/views/voip/_LegacyCallViewHeader.pcss +++ b/res/css/views/voip/_LegacyCallViewHeader.pcss @@ -25,7 +25,7 @@ limitations under the License. width: 100%; &.mx_LegacyCallViewHeader_pip { - cursor: pointer; + cursor: grab; } } diff --git a/res/img/cancel.svg b/res/img/cancel.svg index e32060025e..2b7083e875 100644 --- a/res/img/cancel.svg +++ b/res/img/cancel.svg @@ -1,10 +1,3 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file + + + diff --git a/res/img/element-icons/call/freedom.svg b/res/img/element-icons/call/freedom.svg new file mode 100644 index 0000000000..0a883b7833 --- /dev/null +++ b/res/img/element-icons/call/freedom.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/call/spotlight.svg b/res/img/element-icons/call/spotlight.svg new file mode 100644 index 0000000000..f9d96a1e85 --- /dev/null +++ b/res/img/element-icons/call/spotlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/reduce.svg b/res/img/element-icons/reduce.svg new file mode 100644 index 0000000000..3179e33a23 --- /dev/null +++ b/res/img/element-icons/reduce.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/feather-customised/cancel.svg b/res/img/feather-customised/cancel.svg deleted file mode 100644 index 6b734e4053..0000000000 --- a/res/img/feather-customised/cancel.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file diff --git a/res/img/feather-customised/chevron-down-thin.svg b/res/img/feather-customised/chevron-down-thin.svg deleted file mode 100644 index 109c83def6..0000000000 --- a/res/img/feather-customised/chevron-down-thin.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 7af7b3e2a4..6425709ea7 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -121,6 +121,7 @@ import { LargeLoader } from './LargeLoader'; import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; import { isVideoRoom } from '../../utils/video-rooms'; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; +import { Call } from "../../models/Call"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -178,6 +179,7 @@ export interface IRoomState { searchHighlights?: string[]; searchInProgress?: boolean; callState?: CallState; + activeCall: Call | null; canPeek: boolean; canSelfRedact: boolean; showApps: boolean; @@ -303,6 +305,8 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} + viewingCall={false} + activeCall={null} /> @@ -353,6 +357,8 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} + viewingCall={false} + activeCall={null} /> @@ -391,6 +397,7 @@ export class RoomView extends React.Component { numUnreadMessages: 0, searchResults: null, callState: null, + activeCall: null, canPeek: false, canSelfRedact: false, showApps: false, @@ -497,13 +504,6 @@ export class RoomView extends React.Component { if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) { // Show chat in right panel when a widget is maximised RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }); - } else if ( - RightPanelStore.instance.isOpen && - RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) - ) { - // hide chat in right panel when the widget is minimized - RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); - RightPanelStore.instance.togglePanel(this.state.roomId); } this.checkWidgets(this.state.room); }; @@ -571,8 +571,22 @@ export class RoomView extends React.Component { mainSplitContentType: room === null ? undefined : this.getMainSplitContentType(room), initialEventId: null, // default to clearing this, will get set later in the method if needed showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId), + activeCall: CallStore.instance.getActiveCall(roomId), }; + if ( + this.state.mainSplitContentType !== MainSplitContentType.Timeline + && newState.mainSplitContentType === MainSplitContentType.Timeline + && RightPanelStore.instance.isOpen + && RightPanelStore.instance.currentCard.phase === RightPanelPhases.Timeline + && RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) + ) { + // We're returning to the main timeline, so hide the right panel timeline + RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); + RightPanelStore.instance.togglePanel(this.state.roomId ?? null); + newState.showRightPanel = false; + } + const initialEventId = RoomViewStore.instance.getInitialEventId(); if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); @@ -701,7 +715,10 @@ export class RoomView extends React.Component { }; private onActiveCalls = () => { - if (this.state.roomId !== undefined && !CallStore.instance.hasActiveCall(this.state.roomId)) { + if (this.state.roomId === undefined) return; + const activeCall = CallStore.instance.getActiveCall(this.state.roomId); + + if (activeCall === null) { // We disconnected from the call, so stop viewing it dis.dispatch({ action: Action.ViewRoom, @@ -710,6 +727,8 @@ export class RoomView extends React.Component { metricsTrigger: undefined, }, true); // Synchronous so that CallView disappears immediately } + + this.setState({ activeCall }); }; private getRoomId = () => { @@ -2404,6 +2423,7 @@ export class RoomView extends React.Component { let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; let onInviteClick = null; + let viewingCall = false; // Simplify the header for other main split types switch (this.state.mainSplitContentType) { @@ -2422,12 +2442,19 @@ export class RoomView extends React.Component { RightPanelPhases.PinnedMessages, RightPanelPhases.NotificationPanel, ]; + if (!isVideoRoom(this.state.room)) { + excludedRightPanelPhaseButtons.push(RightPanelPhases.RoomSummary); + if (this.state.activeCall === null) { + excludedRightPanelPhaseButtons.push(RightPanelPhases.Timeline); + } + } onAppsClick = null; onForgetClick = null; onSearchClick = null; if (this.state.room.canInvite(this.context.credentials.userId)) { onInviteClick = this.onInviteClick; } + viewingCall = true; } return ( @@ -2451,6 +2478,8 @@ export class RoomView extends React.Component { excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} showButtons={!this.viewsLocalRoom} enableRoomOptionsMenu={!this.viewsLocalRoom} + viewingCall={viewingCall} + activeCall={this.state.activeCall} /> diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index b33e3d3747..ecddd435b9 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -493,7 +493,6 @@ export class EmailIdentityAuthEntry extends ? _t("Resent!") : _t("Resend")} alignment={Alignment.Right} - tooltipClassName="mx_Tooltip_noMargin" onHideTooltip={this.state.requested ? () => this.setState({ requested: false }) : undefined} diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index 2741a69936..ab27f4f9d8 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -149,18 +149,24 @@ export default class Tooltip extends React.PureComponent { break; case Alignment.Top: style.top = baseTop - spacing; - style.left = horizontalCenter; - style.transform = "translate(-50%, -100%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))), -100%)`; break; case Alignment.Bottom: style.top = baseTop + parentBox.height + spacing; - style.left = horizontalCenter; - style.transform = "translate(-50%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; break; case Alignment.InnerBottom: style.top = baseTop + parentBox.height - 50; - style.left = horizontalCenter; - style.transform = "translate(-50%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; break; case Alignment.TopRight: style.top = baseTop - spacing; diff --git a/src/components/views/right_panel/HeaderButton.tsx b/src/components/views/right_panel/HeaderButton.tsx index d78dbb867d..3e8aef6586 100644 --- a/src/components/views/right_panel/HeaderButton.tsx +++ b/src/components/views/right_panel/HeaderButton.tsx @@ -23,6 +23,7 @@ import classNames from 'classnames'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { ButtonEvent } from "../elements/AccessibleButton"; +import { Alignment } from "../elements/Tooltip"; interface IProps { // Whether this button is highlighted @@ -54,6 +55,7 @@ export default class HeaderButton extends React.Component { aria-selected={isHighlighted} role="tab" title={title} + alignment={Alignment.Bottom} className={classes} onClick={onClick} />; diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index d950177e06..262b8fc38d 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -282,7 +282,7 @@ export default class RoomHeaderButtons extends HeaderButtons { , diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx index 1a6db4606c..5750febe0e 100644 --- a/src/components/views/rooms/E2EIcon.tsx +++ b/src/components/views/rooms/E2EIcon.tsx @@ -20,7 +20,7 @@ import classNames from 'classnames'; import { _t, _td } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton"; -import Tooltip from "../elements/Tooltip"; +import Tooltip, { Alignment } from "../elements/Tooltip"; import { E2EStatus } from "../../../utils/ShieldUtils"; export enum E2EState { @@ -49,10 +49,20 @@ interface IProps { size?: number; onClick?: () => void; hideTooltip?: boolean; + tooltipAlignment?: Alignment; bordered?: boolean; } -const E2EIcon: React.FC = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => { +const E2EIcon: React.FC = ({ + isUser, + status, + className, + size, + onClick, + hideTooltip, + tooltipAlignment, + bordered, +}) => { const [hover, setHover] = useState(false); const classes = classNames({ @@ -80,7 +90,7 @@ const E2EIcon: React.FC = ({ isUser, status, className, size, onClick, h let tip; if (hover && !hideTooltip) { - tip = ; + tip = ; } if (onClick) { diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 0d01e039c4..f0c55b6988 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -24,7 +24,6 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call"; import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; import type { Room } from "matrix-js-sdk/src/models/room"; import { _t } from '../../../languageHandler'; -import { MatrixClientPeg } from '../../../MatrixClientPeg'; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "../dialogs/UserTab"; @@ -32,7 +31,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; @@ -57,14 +56,17 @@ import SdkConfig from "../../../SdkConfig"; import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import { useWidgets } from "../right_panel/RoomSummaryCard"; import { WidgetType } from "../../../widgets/WidgetType"; -import { useCall } from "../../../hooks/useCall"; +import { useCall, useLayout } from "../../../hooks/useCall"; import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; -import { ElementCall } from "../../../models/Call"; +import { Call, ElementCall, Layout } from "../../../models/Call"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList, + IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { CallDurationFromEvent } from "../voip/CallDuration"; +import { Alignment } from "../elements/Tooltip"; class DisabledWithReason { constructor(public readonly reason: string) { } @@ -107,6 +109,7 @@ const VoiceCallButton: FC = ({ room, busy, setBusy, behavi onClick={onClick} title={_t("Voice call")} tooltip={tooltip ?? _t("Voice call")} + alignment={Alignment.Bottom} disabled={disabled || busy} />; }; @@ -207,6 +210,7 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi onClick={onClick} title={_t("Video call")} tooltip={tooltip ?? _t("Video call")} + alignment={Alignment.Bottom} disabled={disabled || busy} /> { menu } @@ -318,6 +322,72 @@ const CallButtons: FC = ({ room }) => { } }; +interface CallLayoutSelectorProps { + call: ElementCall; +} + +const CallLayoutSelector: FC = ({ 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 = + + + + + ; + } + + return <> + + { menu } + >; +}; + export interface ISearchInfo { searchTerm: string; searchScope: SearchScope; @@ -338,6 +408,8 @@ export interface IProps { excludedRightPanelPhaseButtons?: Array; showButtons?: boolean; enableRoomOptionsMenu?: boolean; + viewingCall: boolean; + activeCall: Call | null; } interface IState { @@ -356,6 +428,7 @@ export default class RoomHeader extends React.Component { static contextType = RoomContext; public context!: React.ContextType; + private readonly client = this.props.room.client; constructor(props: IProps, context: IState) { super(props, context); @@ -367,14 +440,12 @@ export default class RoomHeader extends React.Component { } public componentDidMount() { - const cli = MatrixClientPeg.get(); - cli.on(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); } public componentWillUnmount() { - const cli = MatrixClientPeg.get(); - cli?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + 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); @@ -401,7 +472,7 @@ export default class RoomHeader extends React.Component { this.forceUpdate(); }, 500, { leading: true, trailing: true }); - private onContextMenuOpenClick = (ev: React.MouseEvent) => { + private onContextMenuOpenClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -412,56 +483,98 @@ export default class RoomHeader extends React.Component { this.setState({ contextMenuPosition: undefined }); }; - private renderButtons(): JSX.Element[] { - const buttons: JSX.Element[] = []; + private onHideCallClick = (ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: this.props.room.roomId, + view_call: false, + metricsTrigger: undefined, + }); + }; - if (this.props.inRoom && !this.context.tombstone) { - buttons.push(); + private renderButtons(isVideoRoom: boolean): React.ReactNode { + const startButtons: JSX.Element[] = []; + + if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) { + startButtons.push(); } - if (this.props.onForgetClick) { - const forgetButton = ); + } + + if (!this.props.viewingCall && this.props.onForgetClick) { + startButtons.push(; - buttons.push(forgetButton); + />); } - if (this.props.onAppsClick) { - const appsButton = ; - buttons.push(appsButton); + />); } - if (this.props.onSearchClick && this.props.inRoom) { - const searchButton = ; - buttons.push(searchButton); + />); } - if (this.props.onInviteClick && this.props.inRoom) { - const inviteButton = ; - buttons.push(inviteButton); + />); } - return buttons; + const endButtons: JSX.Element[] = []; + + if (this.props.viewingCall && !isVideoRoom) { + if (this.props.activeCall === null) { + endButtons.push(); + } else { + endButtons.push(); + } + } + + return <> + { startButtons } + + { endButtons } + >; } private renderName(oobName: string) { @@ -480,7 +593,7 @@ export default class RoomHeader extends React.Component { let settingsHint = false; const members = this.props.room ? this.props.room.getJoinedMembers() : undefined; if (members) { - if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { + 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; @@ -505,6 +618,7 @@ export default class RoomHeader extends React.Component { onClick={this.onContextMenuOpenClick} isExpanded={!!this.state.contextMenuPosition} title={_t("Room options")} + alignment={Alignment.Bottom} > { roomName } { this.props.room && } @@ -519,6 +633,57 @@ export default class RoomHeader extends React.Component { } public render() { + const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room); + + let roomAvatar: JSX.Element | null = null; + if (this.props.room) { + roomAvatar = ; + } + + const icon = this.props.viewingCall + ? + : this.props.e2eStatus + ? + // 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() + ? + : null; + + const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null; + + if (this.props.viewingCall && !isVideoRoom) { + return ( + + + { roomAvatar } + { icon } + + { _t("Video call") } + + { this.props.activeCall instanceof ElementCall && ( + + ) } + { /* Empty topic element to fill out space */ } + + { buttons } + + + ); + } + let searchStatus: JSX.Element | null = null; // don't display the search count until the search completes and @@ -543,29 +708,6 @@ export default class RoomHeader extends React.Component { className="mx_RoomHeader_topic" />; - let roomAvatar: JSX.Element | null = null; - if (this.props.room) { - roomAvatar = ; - } - - let buttons: JSX.Element | null = null; - if (this.props.showButtons) { - buttons = - - { this.renderButtons() } - - - ; - } - - const e2eIcon = this.props.e2eStatus ? : undefined; - - const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room); const viewLabs = () => defaultDispatcher.dispatch({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, @@ -581,7 +723,7 @@ export default class RoomHeader extends React.Component { aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined} > { roomAvatar } - { e2eIcon } + { icon } { name } { searchStatus } { topicElement } diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 4e80303aa0..219295d23d 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -89,7 +89,7 @@ export default class RoomTile extends React.PureComponent { selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, - call: CallStore.instance.get(this.props.room.roomId), + call: CallStore.instance.getCall(this.props.room.roomId), // generatePreview() will return nothing if the user has previews disabled messagePreview: "", }; @@ -159,7 +159,7 @@ export default class RoomTile extends React.PureComponent { // Recalculate the call for this room, since it could've changed between // construction and mounting - this.setState({ call: CallStore.instance.get(this.props.room.roomId) }); + this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) }); } public componentWillUnmount() { diff --git a/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx b/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx index 6881be9cb6..e7bc1c4739 100644 --- a/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx +++ b/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx @@ -32,7 +32,7 @@ const LegacyCallViewHeaderControls: React.FC = ({ onExp { onMaximize && } { onPin && { }; return ( - { onStartMoving: this.onStartMoving, onResize: this.onResize, }) } - + ); } } diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 691f422e5b..c6ce0da159 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -24,7 +24,6 @@ import LegacyCallView from "./LegacyCallView"; import { RoomViewStore } from '../../../stores/RoomViewStore'; import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler'; import PersistentApp from "../elements/PersistentApp"; -import SettingsStore from "../../../settings/SettingsStore"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import PictureInPictureDragger from './PictureInPictureDragger'; import dis from '../../../dispatcher/dispatcher'; @@ -35,6 +34,7 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/Activ import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { CallStore } from "../../../stores/CallStore"; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -116,7 +116,6 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall */ export default class PipView extends React.Component { - private settingsWatcherRef: string; private movePersistedElement = createRef<() => void>(); constructor(props: IProps) { @@ -157,7 +156,6 @@ export default class PipView extends React.Component { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); - SettingsStore.unwatchSetting(this.settingsWatcherRef); const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId); if (room) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); @@ -278,6 +276,14 @@ export default class PipView extends React.Component { }); }; + private onViewCall = (): void => + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.persistentRoomId, + view_call: true, + metricsTrigger: undefined, + }); + // Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId public updateShowWidgetInPip( persistentWidgetId = this.state.persistentWidgetId, @@ -323,18 +329,19 @@ export default class PipView extends React.Component { mx_LegacyCallView_large: !pipMode, }); const roomId = this.state.persistentRoomId; - const roomForWidget = MatrixClientPeg.get().getRoom(roomId); + const roomForWidget = MatrixClientPeg.get().getRoom(roomId)!; const viewingCallRoom = this.state.viewedRoomId === roomId; + const isCall = CallStore.instance.getActiveCall(roomId) !== null; - pipContent = ({ onStartMoving, _onResize }) => + pipContent = ({ onStartMoving }) => { onStartMoving(event); this.onStartMoving.bind(this)(); }} pipMode={pipMode} callRooms={[roomForWidget]} - onExpand={!viewingCallRoom && this.onExpand} - onPin={viewingCallRoom && this.onPin} - onMaximize={viewingCallRoom && this.onMaximize} + onExpand={!isCall && !viewingCallRoom ? this.onExpand : undefined} + onPin={!isCall && viewingCallRoom ? this.onPin : undefined} + onMaximize={isCall ? this.onViewCall : viewingCallRoom ? this.onMaximize : undefined} /> ({ threadId: undefined, liveTimeline: undefined, narrow: false, + activeCall: null, }); RoomContext.displayName = "RoomContext"; export default RoomContext; diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index 6a32ee1894..e5bbfe563f 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -17,14 +17,14 @@ limitations under the License. import { useState, useCallback } from "react"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import type { Call, ConnectionState } from "../models/Call"; +import type { Call, ConnectionState, ElementCall, Layout } from "../models/Call"; import { useTypedEventEmitterState } from "./useEventEmitter"; import { CallEvent } from "../models/Call"; import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { useEventEmitter } from "./useEventEmitter"; export const useCall = (roomId: string): Call | null => { - const [call, setCall] = useState(() => CallStore.instance.get(roomId)); + const [call, setCall] = useState(() => CallStore.instance.getCall(roomId)); useEventEmitter(CallStore.instance, CallStoreEvent.Call, (call: Call | null, forRoomId: string) => { if (forRoomId === roomId) setCall(call); }); @@ -44,3 +44,10 @@ export const useParticipants = (call: Call): Set => CallEvent.Participants, useCallback(state => state ?? call.participants, [call]), ); + +export const useLayout = (call: ElementCall): Layout => + useTypedEventEmitterState( + call, + CallEvent.Layout, + useCallback(state => state ?? call.layout, [call]), + ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d95362a2fb..7237e087c5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1082,7 +1082,7 @@ "Show sidebar": "Show sidebar", "More": "More", "Hangup": "Hangup", - "Fill Screen": "Fill Screen", + "Fill screen": "Fill screen", "Pin": "Pin", "Return to call": "Return to call", "%(name)s on hold": "%(name)s on hold", @@ -1894,11 +1894,16 @@ "You do not have permission to start video calls": "You do not have permission to start video calls", "There's no one here to call": "There's no one here to call", "You do not have permission to start voice calls": "You do not have permission to start voice calls", + "Freedom": "Freedom", + "Spotlight": "Spotlight", + "Layout type": "Layout type", "Forget room": "Forget room", "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", "Search": "Search", "Invite": "Invite", + "Close call": "Close call", + "View chat timeline": "View chat timeline", "Room options": "Room options", "(~%(count)s results)|other": "(~%(count)s results)", "(~%(count)s results)|one": "(~%(count)s result)", @@ -2094,7 +2099,7 @@ "If you have permissions, open the menu on any message and select Pin to stick them here.": "If you have permissions, open the menu on any message and select Pin to stick them here.", "Pinned messages": "Pinned messages", "Chat": "Chat", - "Room Info": "Room Info", + "Room info": "Room info", "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", "Maximise": "Maximise", "Unpin this widget to view it in this panel": "Unpin this widget to view it in this panel", diff --git a/src/models/Call.ts b/src/models/Call.ts index 417cf16291..c12c4fdfc9 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -71,15 +71,22 @@ export enum ConnectionState { export const isConnected = (state: ConnectionState): boolean => state === ConnectionState.Connected || state === ConnectionState.Disconnecting; +export enum Layout { + Tile = "tile", + Spotlight = "spotlight", +} + export enum CallEvent { ConnectionState = "connection_state", Participants = "participants", + Layout = "layout", Destroy = "destroy", } interface CallEventHandlerMap { [CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void; [CallEvent.Participants]: (participants: Set, prevParticipants: Set) => void; + [CallEvent.Layout]: (layout: Layout) => void; [CallEvent.Destroy]: () => void; } @@ -110,7 +117,7 @@ export abstract class Call extends TypedEventEmitter { @@ -791,6 +809,8 @@ export class ElementCall extends Call { public setDisconnected() { this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); + this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); super.setDisconnected(); } @@ -812,6 +832,18 @@ export class ElementCall extends Call { super.destroy(); } + /** + * Sets the call's layout. + * @param layout The layout to switch to. + */ + public async setLayout(layout: Layout): Promise { + const action = layout === Layout.Tile + ? ElementWidgetActions.TileLayout + : ElementWidgetActions.SpotlightLayout; + + await this.messaging!.transport.send(action, {}); + } + private get mayTerminate(): boolean { return this.groupCall.getContent()["m.intent"] !== "m.room" && this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client); @@ -869,4 +901,16 @@ export class ElementCall extends Call { await this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); }; + + private onTileLayout = async (ev: CustomEvent) => { + ev.preventDefault(); + this.layout = Layout.Tile; + await this.messaging!.transport.reply(ev.detail, {}); // ack + }; + + private onSpotlightLayout = async (ev: CustomEvent) => { + ev.preventDefault(); + this.layout = Layout.Spotlight; + await this.messaging!.transport.reply(ev.detail, {}); // ack + }; } diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index e8d2a49199..9d8eeb8ff0 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -73,7 +73,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { await Promise.all([ ...uncleanlyDisconnectedRoomIds.map(async uncleanlyDisconnectedRoomId => { logger.log(`Cleaning up call state for room ${uncleanlyDisconnectedRoomId}`); - await this.get(uncleanlyDisconnectedRoomId)?.clean(); + await this.getCall(uncleanlyDisconnectedRoomId)?.clean(); }), SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, []), ]); @@ -152,18 +152,18 @@ export class CallStore extends AsyncStoreWithClient<{}> { * @param {string} roomId The room's ID. * @returns {Call | null} The call. */ - public get(roomId: string): Call | null { + public getCall(roomId: string): Call | null { return this.calls.get(roomId) ?? null; } /** - * Determines whether the given room has an active call. + * Gets the active call associated with the given room, if any. * @param roomId The room's ID. - * @returns Whether the given room has an active call. + * @returns The active call. */ - public hasActiveCall(roomId: string): boolean { - const call = this.get(roomId); - return call !== null && this.activeCalls.has(call); + public getActiveCall(roomId: string): Call | null { + const call = this.getCall(roomId); + return call !== null && this.activeCalls.has(call) ? call : null; } private onRoom = (room: Room) => this.updateRoom(room); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 42443a295d..0a15ce1860 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -365,7 +365,7 @@ export class RoomViewStore extends EventEmitter { viewingCall: payload.view_call ?? ( payload.room_id === this.state.roomId ? this.state.viewingCall - : CallStore.instance.hasActiveCall(payload.room_id) + : CallStore.instance.getActiveCall(payload.room_id) !== null ), }; diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index a0c3a277c9..bf62c38ace 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"U@user:example.comWe're creating a room with @user:example.com"`; +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"U@user:example.comWe're creating a room with @user:example.com"`; -exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"U@user:example.comEnd-to-end encryption isn't enabled Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. U@user:example.comSend your first message to invite @user:example.com to chat!Some of your messages have not been sentRetry"`; +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"U@user:example.comEnd-to-end encryption isn't enabled Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. U@user:example.comSend your first message to invite @user:example.com to chat!Some of your messages have not been sentRetry"`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"U@user:example.comEnd-to-end encryption isn't enabled Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. U@user:example.comSend your first message to invite @user:example.com to chat"`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"U@user:example.comEnd-to-end encryption isn't enabled Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites. U@user:example.comSend your first message to invite @user:example.com to chat"`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"U@user:example.comEncryption enabledMessages in this chat will be end-to-end encrypted.U@user:example.comSend your first message to invite @user:example.com to chat"`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"U@user:example.comEncryption enabledMessages in this chat will be end-to-end encrypted.U@user:example.comSend your first message to invite @user:example.com to chat"`; diff --git a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap index d77fb7ff49..8221ef4b55 100644 --- a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap @@ -4,7 +4,7 @@ exports[` displays Bottom aligned tooltip on mouseover 1`] = ` displays InnerBottom aligned tooltip on mouseover 1`] displays Top aligned tooltip on mouseover 1`] = ` { )); MockedCall.create(room, "1"); - const maybeCall = CallStore.instance.get(room.roomId); + const maybeCall = CallStore.instance.getCall(room.roomId); if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call"); call = maybeCall; @@ -113,7 +113,7 @@ describe("CallEvent", () => { }); it("shows placeholder info if the call isn't loaded yet", () => { - jest.spyOn(CallStore.instance, "get").mockReturnValue(null); + jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null); jest.advanceTimersByTime(90000); renderEvent(); diff --git a/test/components/views/rooms/MessageComposerButtons-test.tsx b/test/components/views/rooms/MessageComposerButtons-test.tsx index 4939663c8e..472b0e7368 100644 --- a/test/components/views/rooms/MessageComposerButtons-test.tsx +++ b/test/components/views/rooms/MessageComposerButtons-test.tsx @@ -268,6 +268,7 @@ function createRoomState(room: Room, narrow: boolean): IRoomState { liveTimeline: undefined, resizing: false, narrow, + activeCall: null, }; } diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 7181f143c3..e75502eff4 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -25,6 +25,8 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { PendingEventOrdering } from "matrix-js-sdk/src/client"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import { ClientWidgetApi, Widget } from "matrix-widget-api"; +import EventEmitter from "events"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; @@ -53,6 +55,10 @@ 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 WidgetUtils from "../../../../src/utils/WidgetUtils"; +import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; +import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler"; describe('RoomHeader (Enzyme)', () => { it('shows the room avatar in a room with only ourselves', () => { @@ -173,13 +179,13 @@ describe('RoomHeader (Enzyme)', () => { it("should render buttons if not passing showButtons (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const wrapper = mountHeader(room); - expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1); + expect(wrapper.find(".mx_RoomHeader_button")).not.toHaveLength(0); }); 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.find(".mx_RoomHeader_buttons")).toHaveLength(0); + expect(wrapper.find(".mx_RoomHeader_button")).toHaveLength(0); }); it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => { @@ -252,6 +258,8 @@ function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial { await Promise.all([CallStore.instance, WidgetStore.instance].map( store => setupAsyncStoreWithClient(store, client), )); + + jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ + [MediaDeviceKindEnum.AudioInput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + [MediaDeviceKindEnum.AudioOutput]: [], + }); }); afterEach(async () => { @@ -419,6 +433,32 @@ describe("RoomHeader (React Testing Library)", () => { const mockLegacyCall = () => { jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall); }; + const withCall = async (fn: (call: ElementCall) => (void | Promise)): Promise => { + 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; + WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); + + await fn(call); + + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); + }; const renderHeader = (props: Partial = {}, roomContext: Partial = {}) => { render( @@ -437,6 +477,8 @@ describe("RoomHeader (React Testing Library)", () => { searchScope: SearchScope.Room, searchCount: 0, }} + viewingCall={false} + activeCall={null} {...props} /> , @@ -724,4 +766,99 @@ describe("RoomHeader (React Testing Library)", () => { 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 => { + await call.connect(); + 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(); + }); }); diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 94eaf66e07..dfaa4405c8 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -77,7 +77,7 @@ describe("RoomTile", () => { setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); MockedCall.create(room, "1"); - call = CallStore.instance.get(room.roomId) as MockedCall; + call = CallStore.instance.getCall(room.roomId) as MockedCall; widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 5cc0f508b3..96b1be95ec 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -91,6 +91,7 @@ describe('', () => { canSelfRedact: false, resizing: false, narrow: false, + activeCall: null, }; describe("createMessageContent", () => { const permalinkCreator = jest.fn() as any; diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx index 24d26e5d52..4d1bf6afc7 100644 --- a/test/components/views/voip/CallView-test.tsx +++ b/test/components/views/voip/CallView-test.tsx @@ -90,7 +90,7 @@ describe("CallLobby", () => { beforeEach(() => { MockedCall.create(room, "1"); - const maybeCall = CallStore.instance.get(room.roomId); + const maybeCall = CallStore.instance.getCall(room.roomId); if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call"); call = maybeCall; @@ -171,8 +171,8 @@ describe("CallLobby", () => { expect(Call.get(room)).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Join" })); - await waitFor(() => expect(CallStore.instance.get(room.roomId)).not.toBeNull()); - const call = CallStore.instance.get(room.roomId)!; + await waitFor(() => expect(CallStore.instance.getCall(room.roomId)).not.toBeNull()); + const call = CallStore.instance.getCall(room.roomId)!; const widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { diff --git a/test/components/views/voip/PipView-test.tsx b/test/components/views/voip/PipView-test.tsx new file mode 100644 index 0000000000..4573525cef --- /dev/null +++ b/test/components/views/voip/PipView-test.tsx @@ -0,0 +1,175 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { mocked, Mocked } from "jest-mock"; +import { screen, render, act, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { Widget, ClientWidgetApi } from "matrix-widget-api"; + +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { + useMockedCalls, + MockedCall, + mkRoomMember, + stubClient, + setupAsyncStoreWithClient, + resetAsyncStoreWithClient, + wrapInMatrixClientContext, +} from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { CallStore } from "../../../../src/stores/CallStore"; +import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore"; +import UnwrappedPipView from "../../../../src/components/views/voip/PipView"; +import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; +import { ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload"; + +const PipView = wrapInMatrixClientContext(UnwrappedPipView); + +describe("PipView", () => { + useMockedCalls(); + Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } }); + jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); + + let client: Mocked; + let room: Room; + let alice: RoomMember; + + beforeEach(async () => { + stubClient(); + client = mocked(MatrixClientPeg.get()); + DMRoomMap.makeShared(); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + alice = mkRoomMember(room.roomId, "@alice:example.org"); + jest.spyOn(room, "getMember").mockImplementation(userId => userId === alice.userId ? alice : null); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map( + store => setupAsyncStoreWithClient(store, client), + )); + }); + + afterEach(async () => { + cleanup(); + await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient)); + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); + jest.restoreAllMocks(); + }); + + const renderPip = () => { render(); }; + + const viewRoom = (roomId: string) => + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, + }, true); + + const withCall = async (fn: () => Promise): Promise => { + MockedCall.create(room, "1"); + const call = CallStore.instance.getCall(room.roomId); + if (!(call instanceof MockedCall)) throw new Error("Failed to create call"); + + const widget = new Widget(call.widget); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as ClientWidgetApi); + + await act(async () => { + await call.connect(); + ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); + }); + + await fn(); + + cleanup(); + call.destroy(); + ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); + }; + + const withWidget = (fn: () => void): void => { + act(() => ActiveWidgetStore.instance.setWidgetPersistence("1", room.roomId, true)); + fn(); + cleanup(); + ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); + }; + + it("hides if there's no content", () => { + renderPip(); + expect(screen.queryByRole("complementary")).toBeNull(); + }); + + it("shows an active call with a maximise button", async () => { + renderPip(); + + await withCall(async () => { + screen.getByRole("complementary"); + screen.getByText(room.roomId); + expect(screen.queryByRole("button", { name: "Pin" })).toBeNull(); + expect(screen.queryByRole("button", { name: /return/i })).toBeNull(); + + // The maximise button should jump to the call + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Fill screen" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }); + }); + + it("shows a persistent widget with pin and maximise buttons when viewing the room", () => { + viewRoom(room.roomId); + renderPip(); + + withWidget(() => { + screen.getByRole("complementary"); + screen.getByText(room.roomId); + screen.getByRole("button", { name: "Pin" }); + screen.getByRole("button", { name: "Fill screen" }); + expect(screen.queryByRole("button", { name: /return/i })).toBeNull(); + }); + }); + + it("shows a persistent widget with a return button when not viewing the room", () => { + viewRoom("!2:example.org"); + renderPip(); + + withWidget(() => { + screen.getByRole("complementary"); + screen.getByText(room.roomId); + expect(screen.queryByRole("button", { name: "Pin" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Fill screen" })).toBeNull(); + screen.getByRole("button", { name: /return/i }); + }); + }); +}); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 83c0456b80..d8a455d0f3 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -15,7 +15,6 @@ limitations under the License. */ import EventEmitter from "events"; -import { isEqual } from "lodash"; import { mocked } from "jest-mock"; import { waitFor } from "@testing-library/react"; import { RoomType } from "matrix-js-sdk/src/@types/event"; @@ -28,7 +27,7 @@ import type { Mocked } from "jest-mock"; import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { ClientWidgetApi } from "matrix-widget-api"; -import type { JitsiCallMemberContent, ElementCallMemberContent } from "../../src/models/Call"; +import { JitsiCallMemberContent, ElementCallMemberContent, Layout } from "../../src/models/Call"; import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../test-utils"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; @@ -404,30 +403,35 @@ describe("JitsiCall", () => { }); it("emits events when connection state changes", async () => { - const events: ConnectionState[] = []; - const onConnectionState = (state: ConnectionState) => events.push(state); + const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); await call.connect(); await call.disconnect(); - expect(events).toEqual([ - ConnectionState.Connecting, - ConnectionState.Connected, - ConnectionState.Disconnecting, - ConnectionState.Disconnected, + expect(onConnectionState.mock.calls).toEqual([ + [ConnectionState.Connecting, ConnectionState.Disconnected], + [ConnectionState.Connected, ConnectionState.Connecting], + [ConnectionState.Disconnecting, ConnectionState.Connected], + [ConnectionState.Disconnected, ConnectionState.Disconnecting], ]); + + call.off(CallEvent.ConnectionState, onConnectionState); }); it("emits events when participants change", async () => { - const events: Set[] = []; - const onParticipants = (participants: Set) => { - if (!isEqual(participants, events[events.length - 1])) events.push(participants); - }; + const onParticipants = jest.fn(); call.on(CallEvent.Participants, onParticipants); await call.connect(); await call.disconnect(); - expect(events).toEqual([new Set([alice]), new Set()]); + expect(onParticipants.mock.calls).toEqual([ + [new Set([alice]), new Set()], + [new Set([alice]), new Set([alice])], + [new Set(), new Set([alice])], + [new Set(), new Set()], + ]); + + call.off(CallEvent.Participants, onParticipants); }); it("switches to spotlight layout when the widget becomes a PiP", async () => { @@ -725,31 +729,80 @@ describe("ElementCall", () => { expect([...call.participants]).toEqual([bob]); }); + it("tracks layout", async () => { + await call.connect(); + expect(call.layout).toBe(Layout.Tile); + + messaging.emit( + `action:${ElementWidgetActions.SpotlightLayout}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + expect(call.layout).toBe(Layout.Spotlight); + + messaging.emit( + `action:${ElementWidgetActions.TileLayout}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + expect(call.layout).toBe(Layout.Tile); + }); + + it("sets layout", async () => { + await call.connect(); + + await call.setLayout(Layout.Spotlight); + expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); + + await call.setLayout(Layout.Tile); + expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); + }); + it("emits events when connection state changes", async () => { - const events: ConnectionState[] = []; - const onConnectionState = (state: ConnectionState) => events.push(state); + const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); await call.connect(); await call.disconnect(); - expect(events).toEqual([ - ConnectionState.Connecting, - ConnectionState.Connected, - ConnectionState.Disconnecting, - ConnectionState.Disconnected, + expect(onConnectionState.mock.calls).toEqual([ + [ConnectionState.Connecting, ConnectionState.Disconnected], + [ConnectionState.Connected, ConnectionState.Connecting], + [ConnectionState.Disconnecting, ConnectionState.Connected], + [ConnectionState.Disconnected, ConnectionState.Disconnecting], ]); + + call.off(CallEvent.ConnectionState, onConnectionState); }); it("emits events when participants change", async () => { - const events: Set[] = []; - const onParticipants = (participants: Set) => { - if (!isEqual(participants, events[events.length - 1])) events.push(participants); - }; + const onParticipants = jest.fn(); call.on(CallEvent.Participants, onParticipants); await call.connect(); await call.disconnect(); - expect(events).toEqual([new Set([alice]), new Set()]); + expect(onParticipants.mock.calls).toEqual([ + [new Set([alice]), new Set()], + [new Set(), new Set()], + [new Set(), new Set([alice])], + ]); + + call.off(CallEvent.Participants, onParticipants); + }); + + it("emits events when layout changes", async () => { + await call.connect(); + const onLayout = jest.fn(); + call.on(CallEvent.Layout, onLayout); + + messaging.emit( + `action:${ElementWidgetActions.SpotlightLayout}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + messaging.emit( + `action:${ElementWidgetActions.TileLayout}`, + new CustomEvent("widgetapirequest", { detail: {} }), + ); + expect(onLayout.mock.calls).toEqual([[Layout.Spotlight], [Layout.Tile]]); + + call.off(CallEvent.Layout, onLayout); }); it("ends the call immediately if we're the last participant to leave", async () => { diff --git a/test/stores/room-list/algorithms/Algorithm-test.ts b/test/stores/room-list/algorithms/Algorithm-test.ts index 41ad06e4b9..c270715926 100644 --- a/test/stores/room-list/algorithms/Algorithm-test.ts +++ b/test/stores/room-list/algorithms/Algorithm-test.ts @@ -81,7 +81,7 @@ describe("Algorithm", () => { setupAsyncStoreWithClient(WidgetMessagingStore.instance, client); MockedCall.create(roomWithCall, "1"); - const call = CallStore.instance.get(roomWithCall.roomId); + const call = CallStore.instance.getCall(roomWithCall.roomId); if (call === null) throw new Error("Failed to create call"); const widget = new Widget(call.widget); diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index 58d15f43e4..763cffeadd 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -77,7 +77,7 @@ describe("IncomingCallEvent", () => { )); MockedCall.create(room, "1"); - const maybeCall = CallStore.instance.get(room.roomId); + const maybeCall = CallStore.instance.getCall(room.roomId); if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call"); call = maybeCall;
Send your first message to invite @user:example.com to chat