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