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
This commit is contained in:
Robin
2022-10-07 02:27:28 +00:00
committed by GitHub
parent 9a3ae2398e
commit 06dbea6255
43 changed files with 845 additions and 220 deletions
+37 -8
View File
@@ -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}
/>
<main className="mx_RoomView_body" ref={props.roomView}>
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
@@ -353,6 +357,8 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
<div className="mx_RoomView_body">
<LargeLoader text={text} />
@@ -391,6 +397,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
numUnreadMessages: 0,
searchResults: null,
callState: null,
activeCall: null,
canPeek: false,
canSelfRedact: false,
showApps: false,
@@ -497,13 +504,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
};
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<ViewRoomPayload>({
action: Action.ViewRoom,
@@ -710,6 +727,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
metricsTrigger: undefined,
}, true); // Synchronous so that CallView disappears immediately
}
this.setState({ activeCall });
};
private getRoomId = () => {
@@ -2404,6 +2423,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
showButtons={!this.viewsLocalRoom}
enableRoomOptionsMenu={!this.viewsLocalRoom}
viewingCall={viewingCall}
activeCall={this.state.activeCall}
/>
<MainSplit panel={rightPanel} resizeNotifier={this.props.resizeNotifier}>
<div className={mainSplitContentClasses} ref={this.roomViewBody} data-layout={this.state.layout}>
@@ -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}
+12 -6
View File
@@ -149,18 +149,24 @@ export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
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;
@@ -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<IProps> {
aria-selected={isHighlighted}
role="tab"
title={title}
alignment={Alignment.Bottom}
className={classes}
onClick={onClick}
/>;
@@ -282,7 +282,7 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
<HeaderButton
key="roomSummaryButton"
name="roomSummaryButton"
title={_t('Room Info')}
title={_t('Room info')}
isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
onClick={this.onRoomSummaryClicked}
/>,
+13 -3
View File
@@ -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<IProps> = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => {
const E2EIcon: React.FC<IProps> = ({
isUser,
status,
className,
size,
onClick,
hideTooltip,
tooltipAlignment,
bordered,
}) => {
const [hover, setHover] = useState(false);
const classes = classNames({
@@ -80,7 +90,7 @@ const E2EIcon: React.FC<IProps> = ({ isUser, status, className, size, onClick, h
let tip;
if (hover && !hideTooltip) {
tip = <Tooltip label={e2eTitle ? _t(e2eTitle) : ""} />;
tip = <Tooltip label={e2eTitle ? _t(e2eTitle) : ""} alignment={tooltipAlignment} />;
}
if (onClick) {
+197 -55
View File
@@ -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<VoiceCallButtonProps> = ({ 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<VideoCallButtonProps> = ({ 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<CallButtonsProps> = ({ room }) => {
}
};
interface CallLayoutSelectorProps {
call: ElementCall;
}
const CallLayoutSelector: FC<CallLayoutSelectorProps> = ({ call }) => {
const layout = useLayout(call);
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
const onClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
openMenu();
}, [openMenu]);
const onFreedomClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Tile);
}, [closeMenu, call]);
const onSpotlightClick = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Spotlight);
}, [closeMenu, call]);
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
menu = <IconizedContextMenu
className="mx_RoomHeader_layoutMenu"
{...aboveLeftOf(buttonRect)}
onFinished={closeMenu}
>
<IconizedContextMenuOptionList>
<IconizedContextMenuRadio
iconClassName="mx_RoomHeader_freedomIcon"
label={_t("Freedom")}
active={layout === Layout.Tile}
onClick={onFreedomClick}
/>
<IconizedContextMenuRadio
iconClassName="mx_RoomHeader_spotlightIcon"
label={_t("Spotlight")}
active={layout === Layout.Spotlight}
onClick={onSpotlightClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return <>
<AccessibleTooltipButton
inputRef={buttonRef}
className={classNames("mx_RoomHeader_button", {
"mx_RoomHeader_layoutButton--freedom": layout === Layout.Tile,
"mx_RoomHeader_layoutButton--spotlight": layout === Layout.Spotlight,
})}
onClick={onClick}
title={_t("Layout type")}
alignment={Alignment.Bottom}
key="layout"
/>
{ menu }
</>;
};
export interface ISearchInfo {
searchTerm: string;
searchScope: SearchScope;
@@ -338,6 +408,8 @@ export interface IProps {
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
showButtons?: boolean;
enableRoomOptionsMenu?: boolean;
viewingCall: boolean;
activeCall: Call | null;
}
interface IState {
@@ -356,6 +428,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
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<IProps, IState> {
}
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<IProps, IState> {
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<IProps, IState> {
this.setState({ contextMenuPosition: undefined });
};
private renderButtons(): JSX.Element[] {
const buttons: JSX.Element[] = [];
private onHideCallClick = (ev: ButtonEvent) => {
ev.preventDefault();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
view_call: false,
metricsTrigger: undefined,
});
};
if (this.props.inRoom && !this.context.tombstone) {
buttons.push(<CallButtons key="calls" room={this.props.room} />);
private renderButtons(isVideoRoom: boolean): React.ReactNode {
const startButtons: JSX.Element[] = [];
if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) {
startButtons.push(<CallButtons key="calls" room={this.props.room} />);
}
if (this.props.onForgetClick) {
const forgetButton = <AccessibleTooltipButton
if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) {
startButtons.push(<CallLayoutSelector call={this.props.activeCall} />);
}
if (!this.props.viewingCall && this.props.onForgetClick) {
startButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")}
alignment={Alignment.Bottom}
key="forget"
/>;
buttons.push(forgetButton);
/>);
}
if (this.props.onAppsClick) {
const appsButton = <AccessibleTooltipButton
if (!this.props.viewingCall && this.props.onAppsClick) {
startButtons.push(<AccessibleTooltipButton
className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
mx_RoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")}
alignment={Alignment.Bottom}
key="apps"
/>;
buttons.push(appsButton);
/>);
}
if (this.props.onSearchClick && this.props.inRoom) {
const searchButton = <AccessibleTooltipButton
if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) {
startButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")}
alignment={Alignment.Bottom}
key="search"
/>;
buttons.push(searchButton);
/>);
}
if (this.props.onInviteClick && this.props.inRoom) {
const inviteButton = <AccessibleTooltipButton
if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) {
startButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_inviteButton"
onClick={this.props.onInviteClick}
title={_t("Invite")}
alignment={Alignment.Bottom}
key="invite"
/>;
buttons.push(inviteButton);
/>);
}
return buttons;
const endButtons: JSX.Element[] = [];
if (this.props.viewingCall && !isVideoRoom) {
if (this.props.activeCall === null) {
endButtons.push(<AccessibleButton
className="mx_RoomHeader_button mx_RoomHeader_closeButton"
onClick={this.onHideCallClick}
title={_t("Close call")}
key="close"
/>);
} else {
endButtons.push(<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_minimiseButton"
onClick={this.onHideCallClick}
title={_t("View chat timeline")}
alignment={Alignment.Bottom}
key="minimise"
/>);
}
}
return <>
{ startButtons }
<RoomHeaderButtons
room={this.props.room}
excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons}
/>
{ endButtons }
</>;
}
private renderName(oobName: string) {
@@ -480,7 +593,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
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<IProps, IState> {
onClick={this.onContextMenuOpenClick}
isExpanded={!!this.state.contextMenuPosition}
title={_t("Room options")}
alignment={Alignment.Bottom}
>
{ roomName }
{ this.props.room && <div className="mx_RoomHeader_chevron" /> }
@@ -519,6 +633,57 @@ export default class RoomHeader extends React.Component<IProps, IState> {
}
public render() {
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
let roomAvatar: JSX.Element | null = null;
if (this.props.room) {
roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>;
}
const icon = this.props.viewingCall
? <div className="mx_RoomHeader_icon mx_RoomHeader_icon_video" />
: this.props.e2eStatus
? <E2EIcon
className="mx_RoomHeader_icon"
status={this.props.e2eStatus}
tooltipAlignment={Alignment.Bottom}
/>
// If we're expecting an E2EE status to come in, but it hasn't
// yet been loaded, insert a blank div to reserve space
: this.client.isRoomEncrypted(this.props.room.roomId) && this.client.isCryptoEnabled()
? <div className="mx_RoomHeader_icon" />
: null;
const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null;
if (this.props.viewingCall && !isVideoRoom) {
return (
<header className="mx_RoomHeader light-panel">
<div
className="mx_RoomHeader_wrapper"
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
{ icon }
<div className="mx_RoomHeader_name mx_RoomHeader_name--textonly mx_RoomHeader_name--small">
{ _t("Video call") }
</div>
{ this.props.activeCall instanceof ElementCall && (
<CallDurationFromEvent mxEvent={this.props.activeCall.groupCall} />
) }
{ /* Empty topic element to fill out space */ }
<div className="mx_RoomHeader_topic" />
{ buttons }
</div>
</header>
);
}
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<IProps, IState> {
className="mx_RoomHeader_topic"
/>;
let roomAvatar: JSX.Element | null = null;
if (this.props.room) {
roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>;
}
let buttons: JSX.Element | null = null;
if (this.props.showButtons) {
buttons = <React.Fragment>
<div className="mx_RoomHeader_buttons">
{ this.renderButtons() }
</div>
<RoomHeaderButtons room={this.props.room} excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons} />
</React.Fragment>;
}
const e2eIcon = this.props.e2eStatus ? <E2EIcon status={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<IProps, IState> {
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_RoomHeader_avatar">{ roomAvatar }</div>
<div className="mx_RoomHeader_e2eIcon">{ e2eIcon }</div>
{ icon }
{ name }
{ searchStatus }
{ topicElement }
+2 -2
View File
@@ -89,7 +89,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
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<IProps, IState> {
// 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() {
@@ -32,7 +32,7 @@ const LegacyCallViewHeaderControls: React.FC<LegacyCallControlsProps> = ({ onExp
{ onMaximize && <AccessibleTooltipButton
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_fullscreen"
onClick={onMaximize}
title={_t("Fill Screen")}
title={_t("Fill screen")}
/> }
{ onPin && <AccessibleTooltipButton
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_pin"
@@ -201,7 +201,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
};
return (
<div
<aside
className={this.props.className}
style={style}
ref={this.callViewWrapper}
@@ -211,7 +211,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
onStartMoving: this.onStartMoving,
onResize: this.onResize,
}) }
</div>
</aside>
);
}
}
+15 -8
View File
@@ -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<IProps, IState> {
private settingsWatcherRef: string;
private movePersistedElement = createRef<() => void>();
constructor(props: IProps) {
@@ -157,7 +156,6 @@ export default class PipView extends React.Component<IProps, IState> {
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<IProps, IState> {
});
};
private onViewCall = (): void =>
dis.dispatch<ViewRoomPayload>({
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<IProps, IState> {
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 }) =>
<div className={pipViewClasses}>
<LegacyCallViewHeader
onPipMouseDown={(event) => { 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}
/>
<PersistentApp
persistentWidgetId={this.state.persistentWidgetId}
+1
View File
@@ -65,6 +65,7 @@ const RoomContext = createContext<IRoomState>({
threadId: undefined,
liveTimeline: undefined,
narrow: false,
activeCall: null,
});
RoomContext.displayName = "RoomContext";
export default RoomContext;
+9 -2
View File
@@ -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<RoomMember> =>
CallEvent.Participants,
useCallback(state => state ?? call.participants, [call]),
);
export const useLayout = (call: ElementCall): Layout =>
useTypedEventEmitterState(
call,
CallEvent.Layout,
useCallback(state => state ?? call.layout, [call]),
);
+7 -2
View File
@@ -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 <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
"Pinned messages": "Pinned messages",
"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",
+45 -1
View File
@@ -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<RoomMember>, prevParticipants: Set<RoomMember>) => void;
[CallEvent.Layout]: (layout: Layout) => void;
[CallEvent.Destroy]: () => void;
}
@@ -110,7 +117,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
return this.widget.roomId;
}
private _connectionState: ConnectionState = ConnectionState.Disconnected;
private _connectionState = ConnectionState.Disconnected;
public get connectionState(): ConnectionState {
return this._connectionState;
}
@@ -604,6 +611,15 @@ export class ElementCall extends Call {
private participantsExpirationTimer: number | null = null;
private terminationTimer: number | null = null;
private _layout = Layout.Tile;
public get layout(): Layout {
return this._layout;
}
protected set layout(value: Layout) {
this._layout = value;
this.emit(CallEvent.Layout, value);
}
private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
// Splice together the Element Call URL for this call
const url = new URL(SdkConfig.get("element_call").url);
@@ -779,6 +795,8 @@ export class ElementCall extends Call {
}
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
}
protected async performDisconnection(): Promise<void> {
@@ -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<void> {
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<IWidgetApiRequest>) => {
ev.preventDefault();
this.layout = Layout.Tile;
await this.messaging!.transport.reply(ev.detail, {}); // ack
};
private onSpotlightLayout = async (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.layout = Layout.Spotlight;
await this.messaging!.transport.reply(ev.detail, {}); // ack
};
}
+7 -7
View File
@@ -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);
+1 -1
View File
@@ -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
),
};