From 06dbea62559f6e7e8037aca3b8e5a65c222bc1b1 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 6 Oct 2022 22:27:28 -0400 Subject: [PATCH] New group call experience: Room header and PiP designs (#9351) * Update our cancel icon The cancel icon we're using in the app has drifted out of sync with the ones used in our designs. We also had two identical-looking icons, so this consolidates them into one. I've simultaneously updated our chevron icons, since in the case of the 'jump to unread' timeline button, it became clear that the weight of the new close icon did not match the thinner chevron. * Don't squish bottom/top-aligned tooltips near the edge of the screen * Close the timeline panel when returning to the fullscreen timeline view * Add layout switching capabilities to ElementCall * Bring the room header in line with the group call designs * Bring the PiP header in line with the group call designs * Fix lints * Clarify tooltip CSS calculations * Test PipView * Expand RoomHeader test coverage * Test PipView more --- cypress/e2e/lazy-loading/lazy-loading.spec.ts | 2 +- res/css/_common.pcss | 6 +- res/css/structures/_HeaderButtons.pcss | 17 -- res/css/views/elements/_Tooltip.pcss | 5 - res/css/views/rooms/_JumpToBottomButton.pcss | 6 +- res/css/views/rooms/_RoomHeader.pcss | 80 ++++-- .../views/rooms/_TopUnreadMessagesBar.pcss | 6 +- res/css/views/voip/_LegacyCallViewHeader.pcss | 2 +- res/img/cancel.svg | 13 +- res/img/element-icons/call/freedom.svg | 3 + res/img/element-icons/call/spotlight.svg | 3 + res/img/element-icons/reduce.svg | 4 + res/img/feather-customised/cancel.svg | 10 - .../feather-customised/chevron-down-thin.svg | 3 - src/components/structures/RoomView.tsx | 45 +++- .../auth/InteractiveAuthEntryComponents.tsx | 1 - src/components/views/elements/Tooltip.tsx | 18 +- .../views/right_panel/HeaderButton.tsx | 2 + .../views/right_panel/RoomHeaderButtons.tsx | 2 +- src/components/views/rooms/E2EIcon.tsx | 16 +- src/components/views/rooms/RoomHeader.tsx | 252 ++++++++++++++---- src/components/views/rooms/RoomTile.tsx | 4 +- .../LegacyCallView/LegacyCallViewHeader.tsx | 2 +- .../views/voip/PictureInPictureDragger.tsx | 4 +- src/components/views/voip/PipView.tsx | 23 +- src/contexts/RoomContext.ts | 1 + src/hooks/useCall.ts | 11 +- src/i18n/strings/en_EN.json | 9 +- src/models/Call.ts | 46 +++- src/stores/CallStore.ts | 14 +- src/stores/RoomViewStore.tsx | 2 +- .../__snapshots__/RoomView-test.tsx.snap | 8 +- .../__snapshots__/TooltipTarget-test.tsx.snap | 6 +- .../views/messages/CallEvent-test.tsx | 4 +- .../rooms/MessageComposerButtons-test.tsx | 1 + .../views/rooms/RoomHeader-test.tsx | 141 +++++++++- test/components/views/rooms/RoomTile-test.tsx | 2 +- .../views/rooms/SendMessageComposer-test.tsx | 1 + test/components/views/voip/CallView-test.tsx | 6 +- test/components/views/voip/PipView-test.tsx | 175 ++++++++++++ test/models/Call-test.ts | 105 ++++++-- .../room-list/algorithms/Algorithm-test.ts | 2 +- test/toasts/IncomingCallToast-test.tsx | 2 +- 43 files changed, 845 insertions(+), 220 deletions(-) create mode 100644 res/img/element-icons/call/freedom.svg create mode 100644 res/img/element-icons/call/spotlight.svg create mode 100644 res/img/element-icons/reduce.svg delete mode 100644 res/img/feather-customised/cancel.svg delete mode 100644 res/img/feather-customised/chevron-down-thin.svg create mode 100644 test/components/views/voip/PipView-test.tsx 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.com
We'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.com
We'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.com
  1. End-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.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-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.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-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.com

    Send 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.com
  1. End-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.com

    Send 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.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. U\\"\\"

    @user:example.com

    Send 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.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. U\\"\\"

    @user:example.com

    Send 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`] = `