New group call experience: Call tiles (#9332)

* Add call tiles

* Factor CallDuration out into a reusable component

* Correct the separator character in LiveContentSummary
This commit is contained in:
Robin 2022-09-30 15:26:08 -04:00 committed by GitHub
parent 07a5a1dc6f
commit ff59f68a9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 606 additions and 51 deletions

View file

@ -210,6 +210,7 @@
@import "./views/elements/_Validation.pcss"; @import "./views/elements/_Validation.pcss";
@import "./views/emojipicker/_EmojiPicker.pcss"; @import "./views/emojipicker/_EmojiPicker.pcss";
@import "./views/location/_LocationPicker.pcss"; @import "./views/location/_LocationPicker.pcss";
@import "./views/messages/_CallEvent.pcss";
@import "./views/messages/_CreateEvent.pcss"; @import "./views/messages/_CreateEvent.pcss";
@import "./views/messages/_DateSeparator.pcss"; @import "./views/messages/_DateSeparator.pcss";
@import "./views/messages/_DisambiguatedProfile.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss";
@ -264,6 +265,7 @@
@import "./views/rooms/_JumpToBottomButton.pcss"; @import "./views/rooms/_JumpToBottomButton.pcss";
@import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewGroup.pcss";
@import "./views/rooms/_LinkPreviewWidget.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss";
@import "./views/rooms/_LiveContentSummary.pcss";
@import "./views/rooms/_MemberInfo.pcss"; @import "./views/rooms/_MemberInfo.pcss";
@import "./views/rooms/_MemberList.pcss"; @import "./views/rooms/_MemberList.pcss";
@import "./views/rooms/_MessageComposer.pcss"; @import "./views/rooms/_MessageComposer.pcss";
@ -285,7 +287,6 @@
@import "./views/rooms/_RoomPreviewCard.pcss"; @import "./views/rooms/_RoomPreviewCard.pcss";
@import "./views/rooms/_RoomSublist.pcss"; @import "./views/rooms/_RoomSublist.pcss";
@import "./views/rooms/_RoomTile.pcss"; @import "./views/rooms/_RoomTile.pcss";
@import "./views/rooms/_RoomTileCallSummary.pcss";
@import "./views/rooms/_RoomUpgradeWarningBar.pcss"; @import "./views/rooms/_RoomUpgradeWarningBar.pcss";
@import "./views/rooms/_SearchBar.pcss"; @import "./views/rooms/_SearchBar.pcss";
@import "./views/rooms/_SendMessageComposer.pcss"; @import "./views/rooms/_SendMessageComposer.pcss";
@ -347,6 +348,7 @@
@import "./views/user-onboarding/_UserOnboardingTask.pcss"; @import "./views/user-onboarding/_UserOnboardingTask.pcss";
@import "./views/verification/_VerificationShowSas.pcss"; @import "./views/verification/_VerificationShowSas.pcss";
@import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss"; @import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss";
@import "./views/voip/_CallDuration.pcss";
@import "./views/voip/_CallView.pcss"; @import "./views/voip/_CallView.pcss";
@import "./views/voip/_DialPad.pcss"; @import "./views/voip/_DialPad.pcss";
@import "./views/voip/_DialPadContextMenu.pcss"; @import "./views/voip/_DialPadContextMenu.pcss";

View file

@ -22,6 +22,7 @@ limitations under the License.
display: inline-flex; display: inline-flex;
flex-direction: row-reverse; flex-direction: row-reverse;
vertical-align: middle; vertical-align: middle;
margin: 0 -1px; /* to cancel out the border on the edges */
/* Overlap the children */ /* Overlap the children */
> * + * { > * + * {

View file

@ -0,0 +1,77 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallEvent_wrapper {
display: flex;
width: 100%;
}
.mx_CallEvent {
padding: 12px;
box-sizing: border-box;
min-height: 60px;
max-width: 600px;
width: 100%;
background-color: $system;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-8;
.mx_CallEvent_title {
font-size: $font-15px;
line-height: 24px; /* in px to match the avatar */
}
&.mx_CallEvent_inactive .mx_CallEvent_title::before {
display: inline-block;
vertical-align: middle;
content: '';
background-color: $secondary-content;
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
mask-size: 16px;
width: 16px;
height: 16px;
margin-right: 8px;
}
&.mx_CallEvent_active .mx_CallEvent_title {
font-weight: 600;
}
> .mx_BaseAvatar {
align-self: flex-start;
}
> .mx_CallEvent_infoRows {
flex-grow: 1;
display: flex;
flex-direction: column;
gap: $spacing-4;
}
> .mx_CallDuration {
padding: $spacing-4;
}
> .mx_CallEvent_button {
box-sizing: border-box;
min-width: 120px;
}
}

View file

@ -523,7 +523,8 @@ limitations under the License.
max-width: 100%; max-width: 100%;
} }
.mx_LegacyCallEvent_wrapper { .mx_LegacyCallEvent_wrapper,
.mx_CallEvent_wrapper {
justify-content: center; justify-content: center;
} }
} }

View file

@ -14,21 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_RoomTileCallSummary { .mx_LiveContentSummary {
.mx_RoomTileCallSummary_text { color: $secondary-content;
.mx_LiveContentSummary_text {
&::before { &::before {
display: inline-block; display: inline-block;
vertical-align: text-bottom; vertical-align: text-bottom;
content: ''; content: '';
background-color: $secondary-content; background-color: $secondary-content;
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
mask-size: 16px; mask-size: 16px;
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-right: 4px; margin-right: 4px;
} }
&.mx_RoomTileCallSummary_text_active { &.mx_LiveContentSummary_text_video::before {
mask-image: url('$(res)/img/element-icons/call/video-call.svg');
}
&.mx_LiveContentSummary_text_active {
color: $accent; color: $accent;
&::before { &::before {
@ -37,7 +42,7 @@ limitations under the License.
} }
} }
.mx_RoomTileCallSummary_participants::before { .mx_LiveContentSummary_participants::before {
display: inline-block; display: inline-block;
vertical-align: text-bottom; vertical-align: text-bottom;
content: ''; content: '';

View file

@ -0,0 +1,20 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_CallDuration {
color: $secondary-content;
font-size: $font-12px;
}

View file

@ -72,7 +72,7 @@ type IProps<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T>
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
triggerOnMouseDown?: boolean; triggerOnMouseDown?: boolean;
onClick(e?: ButtonEvent): void | Promise<void>; onClick: ((e: ButtonEvent) => void | Promise<void>) | null;
}; };
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> { interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
@ -106,9 +106,9 @@ export default function AccessibleButton<T extends keyof JSX.IntrinsicElements>(
newProps["disabled"] = true; newProps["disabled"] = true;
} else { } else {
if (triggerOnMouseDown) { if (triggerOnMouseDown) {
newProps.onMouseDown = onClick; newProps.onMouseDown = onClick ?? undefined;
} else { } else {
newProps.onClick = onClick; newProps.onClick = onClick ?? undefined;
} }
// We need to consume enter onKeyDown and space onKeyUp // We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements // otherwise we are risking also activating other keyboard focusable elements
@ -124,7 +124,7 @@ export default function AccessibleButton<T extends keyof JSX.IntrinsicElements>(
case KeyBindingAction.Enter: case KeyBindingAction.Enter:
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
return onClick(e); return onClick?.(e);
case KeyBindingAction.Space: case KeyBindingAction.Space:
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -144,7 +144,7 @@ export default function AccessibleButton<T extends keyof JSX.IntrinsicElements>(
case KeyBindingAction.Space: case KeyBindingAction.Space:
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
return onClick(e); return onClick?.(e);
default: default:
onKeyUp?.(e); onKeyUp?.(e);
break; break;

View file

@ -0,0 +1,176 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef, useCallback, useContext, useMemo } from "react";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Call, ConnectionState } from "../../../models/Call";
import { _t } from "../../../languageHandler";
import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import type { ButtonEvent } from "../elements/AccessibleButton";
import MemberAvatar from "../avatars/MemberAvatar";
import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary";
import FacePile from "../elements/FacePile";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration";
const MAX_FACES = 8;
interface ActiveCallEventProps {
mxEvent: MatrixEvent;
participants: Set<RoomMember>;
buttonText: string;
buttonKind: string;
onButtonClick: ((ev: ButtonEvent) => void) | null;
}
const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
(
{
mxEvent,
participants,
buttonText,
buttonKind,
onButtonClick,
},
ref,
) => {
const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]);
const facePileMembers = useMemo(() => [...participants].slice(0, MAX_FACES), [participants]);
const facePileOverflow = participants.size > facePileMembers.length;
return <div className="mx_CallEvent_wrapper" ref={ref}>
<div className="mx_CallEvent mx_CallEvent_active">
<MemberAvatar
member={mxEvent.sender}
fallbackUserId={mxEvent.getSender()}
viewUserOnClick
width={24}
height={24}
/>
<div className="mx_CallEvent_infoRows">
<span className="mx_CallEvent_title">
{ _t("%(name)s started a video call", { name: senderName }) }
</span>
<LiveContentSummary
type={LiveContentType.Video}
text={_t("Video call")}
active={false}
participantCount={participants.size}
/>
<FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
</div>
<CallDurationFromEvent mxEvent={mxEvent} />
<AccessibleButton
className="mx_CallEvent_button"
kind={buttonKind}
disabled={onButtonClick === null}
onClick={onButtonClick}
>
{ buttonText }
</AccessibleButton>
</div>
</div>;
},
);
interface ActiveLoadedCallEventProps {
mxEvent: MatrixEvent;
call: Call;
}
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
const connectionState = useConnectionState(call);
const participants = useParticipants(call);
const connect = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: mxEvent.getRoomId()!,
view_call: true,
metricsTrigger: undefined,
});
}, [mxEvent]);
const disconnect = useCallback((ev: ButtonEvent) => {
ev.preventDefault();
call.disconnect();
}, [call]);
const [buttonText, buttonKind, onButtonClick] = useMemo(() => {
switch (connectionState) {
case ConnectionState.Disconnected: return [_t("Join"), "primary", connect];
case ConnectionState.Connecting: return [_t("Join"), "primary", null];
case ConnectionState.Connected: return [_t("Leave"), "danger", disconnect];
case ConnectionState.Disconnecting: return [_t("Leave"), "danger", null];
}
}, [connectionState, connect, disconnect]);
return <ActiveCallEvent
ref={ref}
mxEvent={mxEvent}
participants={participants}
buttonText={buttonText}
buttonKind={buttonKind}
onButtonClick={onButtonClick}
/>;
});
interface CallEventProps {
mxEvent: MatrixEvent;
}
/**
* An event tile representing an active or historical Element call.
*/
export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
const noParticipants = useMemo(() => new Set<RoomMember>(), []);
const client = useContext(MatrixClientContext);
const call = useCall(mxEvent.getRoomId()!);
const latestEvent = client.getRoom(mxEvent.getRoomId())!.currentState
.getStateEvents(mxEvent.getType(), mxEvent.getStateKey()!);
if ("m.terminated" in latestEvent.getContent()) {
// The call is terminated
return <div className="mx_CallEvent_wrapper" ref={ref}>
<div className="mx_CallEvent mx_CallEvent_inactive">
<span className="mx_CallEvent_title">{ _t("Video call ended") }</span>
<CallDuration delta={latestEvent.getTs() - mxEvent.getTs()} />
</div>
</div>;
}
if (call === null) {
// There should be a call, but it hasn't loaded yet
return <ActiveCallEvent
ref={ref}
mxEvent={mxEvent}
participants={noParticipants}
buttonText={_t("Join")}
buttonKind="primary"
onButtonClick={null}
/>;
}
return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call} ref={ref} />;
});

View file

@ -83,6 +83,7 @@ import { ReadReceiptGroup } from './ReadReceiptGroup';
import { useTooltip } from "../../../utils/useTooltip"; import { useTooltip } from "../../../utils/useTooltip";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
import { ElementCall } from "../../../models/Call";
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@ -937,7 +938,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
public render() { public render() {
const msgtype = this.props.mxEvent.getContent().msgtype; const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType; const eventType = this.props.mxEvent.getType();
const { const {
hasRenderer, hasRenderer,
isBubbleMessage, isBubbleMessage,
@ -999,7 +1000,9 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
mx_EventTile_sending: !isEditing && isSending, mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_highlight: this.shouldHighlight(), mx_EventTile_highlight: this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu, mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu,
mx_EventTile_continuation: isContinuation || eventType === EventType.CallInvite, mx_EventTile_continuation: isContinuation
|| eventType === EventType.CallInvite
|| ElementCall.CALL_EVENT_TYPE.matches(eventType),
mx_EventTile_last: this.props.last, mx_EventTile_last: this.props.last,
mx_EventTile_lastInSection: this.props.lastInSection, mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual, mx_EventTile_contextual: this.props.contextual,
@ -1053,8 +1056,9 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
avatarSize = 14; avatarSize = 14;
needsSenderProfile = true; needsSenderProfile = true;
} else if ( } else if (
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) || (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File)
eventType === EventType.CallInvite || eventType === EventType.CallInvite
|| ElementCall.CALL_EVENT_TYPE.matches(eventType)
) { ) {
// no avatar or sender profile for continuation messages and call tiles // no avatar or sender profile for continuation messages and call tiles
avatarSize = 0; avatarSize = 0;

View file

@ -0,0 +1,57 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
export enum LiveContentType {
Video,
// More coming soon
}
interface Props {
type: LiveContentType;
text: string;
active: boolean;
participantCount: number;
}
/**
* Summary line used to call out live, interactive content such as calls.
*/
export const LiveContentSummary: FC<Props> = ({ type, text, active, participantCount }) => (
<span className="mx_LiveContentSummary">
<span
className={classNames("mx_LiveContentSummary_text", {
"mx_LiveContentSummary_text_video": type === LiveContentType.Video,
"mx_LiveContentSummary_text_active": active,
})}
>
{ text }
</span>
{ participantCount > 0 && <>
{ " • " }
<span
className="mx_LiveContentSummary_participants"
aria-label={_t("%(count)s participants", { count: participantCount })}
>
{ participantCount }
</span>
</> }
</span>
);

View file

@ -15,12 +15,12 @@ limitations under the License.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import classNames from "classnames";
import type { Call } from "../../../models/Call"; import type { Call } from "../../../models/Call";
import { _t, TranslatedString } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { useConnectionState, useParticipants } from "../../../hooks/useCall"; import { useConnectionState, useParticipants } from "../../../hooks/useCall";
import { ConnectionState } from "../../../models/Call"; import { ConnectionState } from "../../../models/Call";
import { LiveContentSummary, LiveContentType } from "./LiveContentSummary";
interface Props { interface Props {
call: Call; call: Call;
@ -30,7 +30,7 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
const connectionState = useConnectionState(call); const connectionState = useConnectionState(call);
const participants = useParticipants(call); const participants = useParticipants(call);
let text: TranslatedString; let text: string;
let active: boolean; let active: boolean;
switch (connectionState) { switch (connectionState) {
@ -49,23 +49,10 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
break; break;
} }
return <span className="mx_RoomTileCallSummary"> return <LiveContentSummary
<span type={LiveContentType.Video}
className={classNames( text={text}
"mx_RoomTileCallSummary_text", active={active}
{ "mx_RoomTileCallSummary_text_active": active }, participantCount={participants.size}
)} />;
>
{ text }
</span>
{ participants.size ? <>
{ " · " }
<span
className="mx_RoomTileCallSummary_participants"
aria-label={_t("%(count)s participants", { count: participants.size })}
>
{ participants.size }
</span>
</> : null }
</span>;
}; };

View file

@ -0,0 +1,51 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useState, useEffect } from "react";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { formatCallTime } from "../../../DateUtils";
interface CallDurationProps {
delta: number;
}
/**
* A call duration counter.
*/
export const CallDuration: FC<CallDurationProps> = ({ delta }) => {
// Clock desync could lead to a negative duration, so just hide it if that happens
if (delta <= 0) return null;
return <div className="mx_CallDuration">{ formatCallTime(new Date(delta)) }</div>;
};
interface CallDurationFromEventProps {
mxEvent: MatrixEvent;
}
/**
* A call duration counter that automatically counts up, given the event that
* started the call.
*/
export const CallDurationFromEvent: FC<CallDurationFromEventProps> = ({ mxEvent }) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
return <CallDuration delta={now - mxEvent.getTs()} />;
};

View file

@ -158,9 +158,9 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
events: { events: {
...DEFAULT_EVENT_POWER_LEVELS, ...DEFAULT_EVENT_POWER_LEVELS,
// Allow all users to send call membership updates // Allow all users to send call membership updates
"org.matrix.msc3401.call.member": 0, [ElementCall.MEMBER_EVENT_TYPE.name]: 0,
// Make calls immutable, even to admins // Make calls immutable, even to admins
"org.matrix.msc3401.call": 200, [ElementCall.CALL_EVENT_TYPE.name]: 200,
}, },
users: { users: {
// Temporarily give ourselves the power to set up a call // Temporarily give ourselves the power to set up a call

View file

@ -28,6 +28,7 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
import MessageEvent from "../components/views/messages/MessageEvent"; import MessageEvent from "../components/views/messages/MessageEvent";
import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion"; import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import { CallEvent } from "../components/views/messages/CallEvent";
import TextualEvent from "../components/views/messages/TextualEvent"; import TextualEvent from "../components/views/messages/TextualEvent";
import EncryptionEvent from "../components/views/messages/EncryptionEvent"; import EncryptionEvent from "../components/views/messages/EncryptionEvent";
import RoomCreate from "../components/views/messages/RoomCreate"; import RoomCreate from "../components/views/messages/RoomCreate";
@ -44,6 +45,7 @@ import HiddenBody from "../components/views/messages/HiddenBody";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile";
import { ElementCall } from "../models/Call";
// Subset of EventTile's IProps plus some mixins // Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps { export interface EventTileTypeProps {
@ -74,6 +76,7 @@ const KeyVerificationConclFactory: Factory = (ref, props) => <MKeyVerificationCo
const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => ( const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => (
<LegacyCallEvent ref={ref} {...props} /> <LegacyCallEvent ref={ref} {...props} />
); );
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />; const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />;
const VerificationReqFactory: Factory = (ref, props) => <MKeyVerificationRequest ref={ref} {...props} />; const VerificationReqFactory: Factory = (ref, props) => <MKeyVerificationRequest ref={ref} {...props} />;
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />; const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
@ -113,6 +116,10 @@ const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
[EventType.RoomGuestAccess, TextualEventFactory], [EventType.RoomGuestAccess, TextualEventFactory],
]); ]);
for (const evType of ElementCall.CALL_EVENT_TYPE.names) {
STATE_EVENT_TILE_TYPES.set(evType, CallEventFactory);
}
// Add all the Mjolnir stuff to the renderer too // Add all the Mjolnir stuff to the renderer too
for (const evType of ALL_RULE_TYPES) { for (const evType of ALL_RULE_TYPES) {
STATE_EVENT_TILE_TYPES.set(evType, TextualEventFactory); STATE_EVENT_TILE_TYPES.set(evType, TextualEventFactory);
@ -397,6 +404,15 @@ export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents: boo
return hasText(mxEvent, showHiddenEvents); return hasText(mxEvent, showHiddenEvents);
} else if (handler === STATE_EVENT_TILE_TYPES.get(EventType.RoomCreate)) { } else if (handler === STATE_EVENT_TILE_TYPES.get(EventType.RoomCreate)) {
return Boolean(mxEvent.getContent()['predecessor']); return Boolean(mxEvent.getContent()['predecessor']);
} else if (ElementCall.CALL_EVENT_TYPE.names.some(eventType => handler === STATE_EVENT_TILE_TYPES.get(eventType))) {
const intent = mxEvent.getContent()['m.intent'];
const prevContent = mxEvent.getPrevContent();
// If the call became unterminated or previously had invalid contents,
// then this event marks the start of the call
const newlyStarted = 'm.terminated' in prevContent
|| !('m.intent' in prevContent) || !('m.type' in prevContent);
// Only interested in events that mark the start of a non-room call
return typeof intent === 'string' && intent !== 'm.room' && newlyStarted;
} else if (handler === JSONEventFactory) { } else if (handler === JSONEventFactory) {
return false; return false;
} else { } else {

View file

@ -1802,6 +1802,8 @@
"Show %(count)s other previews|other": "Show %(count)s other previews", "Show %(count)s other previews|other": "Show %(count)s other previews",
"Show %(count)s other previews|one": "Show %(count)s other preview", "Show %(count)s other previews|one": "Show %(count)s other preview",
"Close preview": "Close preview", "Close preview": "Close preview",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"and %(count)s others...|other": "and %(count)s others...", "and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...", "and %(count)s others...|one": "and one other...",
"Invite to this room": "Invite to this room", "Invite to this room": "Invite to this room",
@ -2001,8 +2003,6 @@
"Video": "Video", "Video": "Video",
"Joining…": "Joining…", "Joining…": "Joining…",
"Joined": "Joined", "Joined": "Joined",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.",
"This room has already been upgraded.": "This room has already been upgraded.", "This room has already been upgraded.": "This room has already been upgraded.",
"This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.": "This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.", "This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.": "This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.",
@ -2181,6 +2181,8 @@
"%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.", "%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.",
"You cancelled verification.": "You cancelled verification.", "You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled", "Verification cancelled": "Verification cancelled",
"%(name)s started a video call": "%(name)s started a video call",
"Video call ended": "Video call ended",
"Sunday": "Sunday", "Sunday": "Sunday",
"Monday": "Monday", "Monday": "Monday",
"Tuesday": "Tuesday", "Tuesday": "Tuesday",

View file

@ -438,6 +438,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
labsGroup: LabGroup.VoiceAndVideo, labsGroup: LabGroup.VoiceAndVideo,
displayName: _td("New group call experience"), displayName: _td("New group call experience"),
controller: new ReloadOnChangeController(),
default: false, default: false,
}, },
"feature_location_share_live": { "feature_location_share_live": {

View file

@ -23,6 +23,7 @@ import SettingsStore from "../settings/SettingsStore";
import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory";
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils";
import { ElementCall } from "../models/Call";
export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): { export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): {
isInfoMessage: boolean; isInfoMessage: boolean;
@ -61,9 +62,8 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: bool
(eventType === EventType.RoomEncryption) || (eventType === EventType.RoomEncryption) ||
(factory === JitsiEventFactory) (factory === JitsiEventFactory)
); );
const isLeftAlignedBubbleMessage = ( const isLeftAlignedBubbleMessage = !isBubbleMessage && (
!isBubbleMessage && eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType)
eventType === EventType.CallInvite
); );
let isInfoMessage = ( let isInfoMessage = (
!isBubbleMessage && !isBubbleMessage &&

View file

@ -0,0 +1,150 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, screen, act, cleanup, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { mocked, Mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import {
useMockedCalls,
MockedCall,
stubClient,
mkRoomMember,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
wrapInMatrixClientContext,
} from "../../../test-utils";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { CallEvent as UnwrappedCallEvent } from "../../../../src/components/views/messages/CallEvent";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { CallStore } from "../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../src/models/Call";
const CallEvent = wrapInMatrixClientContext(UnwrappedCallEvent);
describe("CallEvent", () => {
useMockedCalls();
Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } });
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let call: MockedCall;
let widget: Widget;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
stubClient();
client = mocked(MatrixClientPeg.get());
client.getUserId.mockReturnValue("@alice:example.org");
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
jest.spyOn(room, "getMember").mockImplementation(
userId => [alice, bob].find(member => member.userId === userId) ?? null,
);
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(
store => setupAsyncStoreWithClient(store, client),
));
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.get(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(async () => {
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
});
const renderEvent = () => { render(<CallEvent mxEvent={call.event} />); };
it("shows a message and duration if the call was ended", () => {
jest.advanceTimersByTime(90000);
call.destroy();
renderEvent();
screen.getByText("Video call ended");
screen.getByText("1m 30s");
});
it("shows placeholder info if the call isn't loaded yet", () => {
jest.spyOn(CallStore.instance, "get").mockReturnValue(null);
jest.advanceTimersByTime(90000);
renderEvent();
screen.getByText("@alice:example.org started a video call");
expect(screen.getByRole("button", { name: "Join" })).toHaveAttribute("aria-disabled", "true");
});
it("shows call details and connection controls if the call is loaded", async () => {
jest.advanceTimersByTime(90000);
call.participants = new Set([alice, bob]);
renderEvent();
screen.getByText("@alice:example.org started a video call");
screen.getByLabelText("2 participants");
screen.getByText("1m 30s");
// Test that the join button works
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}));
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.connect());
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));
await waitFor(() => screen.getByRole("button", { name: "Join" }));
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
});

View file

@ -18,17 +18,18 @@ import { MatrixWidgetType } from "matrix-widget-api";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { mkEvent } from "./test-utils"; import { mkEvent } from "./test-utils";
import { Call, ElementCall, JitsiCall } from "../../src/models/Call"; import { Call, ElementCall, JitsiCall } from "../../src/models/Call";
export class MockedCall extends Call { export class MockedCall extends Call {
private static EVENT_TYPE = "org.example.mocked_call"; public static readonly EVENT_TYPE = "org.example.mocked_call";
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private constructor(room: Room, id: string) { private constructor(room: Room, public readonly event: MatrixEvent) {
super( super(
{ {
id, id: event.getStateKey()!,
eventId: "$1:example.org", eventId: "$1:example.org",
roomId: room.roomId, roomId: room.roomId,
type: MatrixWidgetType.Custom, type: MatrixWidgetType.Custom,
@ -42,7 +43,9 @@ export class MockedCall extends Call {
public static get(room: Room): MockedCall | null { public static get(room: Room): MockedCall | null {
const [event] = room.currentState.getStateEvents(this.EVENT_TYPE); const [event] = room.currentState.getStateEvents(this.EVENT_TYPE);
return event?.getContent().terminated ?? true ? null : new MockedCall(room, event.getStateKey()!); return (event === undefined || "m.terminated" in event.getContent())
? null
: new MockedCall(room, event);
} }
public static create(room: Room, id: string) { public static create(room: Room, id: string) {
@ -52,8 +55,9 @@ export class MockedCall extends Call {
type: this.EVENT_TYPE, type: this.EVENT_TYPE,
room: room.roomId, room: room.roomId,
user: "@alice:example.org", user: "@alice:example.org",
content: { terminated: false }, content: { "m.type": "m.video", "m.intent": "m.prompt" },
skey: id, skey: id,
ts: Date.now(),
})]); })]);
} }
@ -78,8 +82,9 @@ export class MockedCall extends Call {
type: MockedCall.EVENT_TYPE, type: MockedCall.EVENT_TYPE,
room: this.room.roomId, room: this.room.roomId,
user: "@alice:example.org", user: "@alice:example.org",
content: { terminated: true }, content: { ...this.event.getContent(), "m.terminated": "Call ended" },
skey: this.widget.id, skey: this.widget.id,
ts: Date.now(),
})]); })]);
super.destroy(); super.destroy();