diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 6996d33cee..9161942d87 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -210,6 +210,7 @@ @import "./views/elements/_Validation.pcss"; @import "./views/emojipicker/_EmojiPicker.pcss"; @import "./views/location/_LocationPicker.pcss"; +@import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; @import "./views/messages/_DateSeparator.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss"; @@ -264,6 +265,7 @@ @import "./views/rooms/_JumpToBottomButton.pcss"; @import "./views/rooms/_LinkPreviewGroup.pcss"; @import "./views/rooms/_LinkPreviewWidget.pcss"; +@import "./views/rooms/_LiveContentSummary.pcss"; @import "./views/rooms/_MemberInfo.pcss"; @import "./views/rooms/_MemberList.pcss"; @import "./views/rooms/_MessageComposer.pcss"; @@ -285,7 +287,6 @@ @import "./views/rooms/_RoomPreviewCard.pcss"; @import "./views/rooms/_RoomSublist.pcss"; @import "./views/rooms/_RoomTile.pcss"; -@import "./views/rooms/_RoomTileCallSummary.pcss"; @import "./views/rooms/_RoomUpgradeWarningBar.pcss"; @import "./views/rooms/_SearchBar.pcss"; @import "./views/rooms/_SendMessageComposer.pcss"; @@ -347,6 +348,7 @@ @import "./views/user-onboarding/_UserOnboardingTask.pcss"; @import "./views/verification/_VerificationShowSas.pcss"; @import "./views/voip/LegacyCallView/_LegacyCallViewButtons.pcss"; +@import "./views/voip/_CallDuration.pcss"; @import "./views/voip/_CallView.pcss"; @import "./views/voip/_DialPad.pcss"; @import "./views/voip/_DialPadContextMenu.pcss"; diff --git a/res/css/views/elements/_FacePile.pcss b/res/css/views/elements/_FacePile.pcss index dd23eb37e4..7f890a0bb7 100644 --- a/res/css/views/elements/_FacePile.pcss +++ b/res/css/views/elements/_FacePile.pcss @@ -22,6 +22,7 @@ limitations under the License. display: inline-flex; flex-direction: row-reverse; vertical-align: middle; + margin: 0 -1px; /* to cancel out the border on the edges */ /* Overlap the children */ > * + * { diff --git a/res/css/views/messages/_CallEvent.pcss b/res/css/views/messages/_CallEvent.pcss new file mode 100644 index 0000000000..1a11beaa4d --- /dev/null +++ b/res/css/views/messages/_CallEvent.pcss @@ -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; + } +} diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index 88368a4bea..ca9ec513f8 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -523,7 +523,8 @@ limitations under the License. max-width: 100%; } - .mx_LegacyCallEvent_wrapper { + .mx_LegacyCallEvent_wrapper, + .mx_CallEvent_wrapper { justify-content: center; } } diff --git a/res/css/views/rooms/_RoomTileCallSummary.pcss b/res/css/views/rooms/_LiveContentSummary.pcss similarity index 84% rename from res/css/views/rooms/_RoomTileCallSummary.pcss rename to res/css/views/rooms/_LiveContentSummary.pcss index 9c5e99c5ec..c56026a829 100644 --- a/res/css/views/rooms/_RoomTileCallSummary.pcss +++ b/res/css/views/rooms/_LiveContentSummary.pcss @@ -14,21 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomTileCallSummary { - .mx_RoomTileCallSummary_text { +.mx_LiveContentSummary { + color: $secondary-content; + + .mx_LiveContentSummary_text { &::before { display: inline-block; vertical-align: text-bottom; 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: 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; &::before { @@ -37,7 +42,7 @@ limitations under the License. } } - .mx_RoomTileCallSummary_participants::before { + .mx_LiveContentSummary_participants::before { display: inline-block; vertical-align: text-bottom; content: ''; diff --git a/res/css/views/voip/_CallDuration.pcss b/res/css/views/voip/_CallDuration.pcss new file mode 100644 index 0000000000..c8dc07ef67 --- /dev/null +++ b/res/css/views/voip/_CallDuration.pcss @@ -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; +} diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 9e63b6cf54..7036575cd1 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -72,7 +72,7 @@ type IProps = DynamicHtmlElementProps disabled?: boolean; className?: string; triggerOnMouseDown?: boolean; - onClick(e?: ButtonEvent): void | Promise; + onClick: ((e: ButtonEvent) => void | Promise) | null; }; interface IAccessibleButtonProps extends React.InputHTMLAttributes { @@ -106,9 +106,9 @@ export default function AccessibleButton( newProps["disabled"] = true; } else { if (triggerOnMouseDown) { - newProps.onMouseDown = onClick; + newProps.onMouseDown = onClick ?? undefined; } else { - newProps.onClick = onClick; + newProps.onClick = onClick ?? undefined; } // We need to consume enter onKeyDown and space onKeyUp // otherwise we are risking also activating other keyboard focusable elements @@ -124,7 +124,7 @@ export default function AccessibleButton( case KeyBindingAction.Enter: e.stopPropagation(); e.preventDefault(); - return onClick(e); + return onClick?.(e); case KeyBindingAction.Space: e.stopPropagation(); e.preventDefault(); @@ -144,7 +144,7 @@ export default function AccessibleButton( case KeyBindingAction.Space: e.stopPropagation(); e.preventDefault(); - return onClick(e); + return onClick?.(e); default: onKeyUp?.(e); break; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx new file mode 100644 index 0000000000..151adaa9f5 --- /dev/null +++ b/src/components/views/messages/CallEvent.tsx @@ -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; + buttonText: string; + buttonKind: string; + onButtonClick: ((ev: ButtonEvent) => void) | null; +} + +const ActiveCallEvent = forwardRef( + ( + { + 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
+
+ +
+ + { _t("%(name)s started a video call", { name: senderName }) } + + + +
+ + + { buttonText } + +
+
; + }, +); + +interface ActiveLoadedCallEventProps { + mxEvent: MatrixEvent; + call: Call; +} + +const ActiveLoadedCallEvent = forwardRef(({ mxEvent, call }, ref) => { + const connectionState = useConnectionState(call); + const participants = useParticipants(call); + + const connect = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + 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 ; +}); + +interface CallEventProps { + mxEvent: MatrixEvent; +} + +/** + * An event tile representing an active or historical Element call. + */ +export const CallEvent = forwardRef(({ mxEvent }, ref) => { + const noParticipants = useMemo(() => new Set(), []); + 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
+
+ { _t("Video call ended") } + +
+
; + } + + if (call === null) { + // There should be a call, but it hasn't loaded yet + return ; + } + + return ; +}); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 9c601d1ab5..8b88a35670 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -83,6 +83,7 @@ import { ReadReceiptGroup } from './ReadReceiptGroup'; import { useTooltip } from "../../../utils/useTooltip"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; +import { ElementCall } from "../../../models/Call"; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -937,7 +938,7 @@ export class UnwrappedEventTile extends React.Component { public render() { const msgtype = this.props.mxEvent.getContent().msgtype; - const eventType = this.props.mxEvent.getType() as EventType; + const eventType = this.props.mxEvent.getType(); const { hasRenderer, isBubbleMessage, @@ -999,7 +1000,9 @@ export class UnwrappedEventTile extends React.Component { mx_EventTile_sending: !isEditing && isSending, mx_EventTile_highlight: this.shouldHighlight(), 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_lastInSection: this.props.lastInSection, mx_EventTile_contextual: this.props.contextual, @@ -1053,8 +1056,9 @@ export class UnwrappedEventTile extends React.Component { avatarSize = 14; needsSenderProfile = true; } else if ( - (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) || - eventType === EventType.CallInvite + (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) + || eventType === EventType.CallInvite + || ElementCall.CALL_EVENT_TYPE.matches(eventType) ) { // no avatar or sender profile for continuation messages and call tiles avatarSize = 0; diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx new file mode 100644 index 0000000000..95adf54f13 --- /dev/null +++ b/src/components/views/rooms/LiveContentSummary.tsx @@ -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 = ({ type, text, active, participantCount }) => ( + + + { text } + + { participantCount > 0 && <> + { " • " } + + { participantCount } + + } + +); diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 9af01f20d4..717ab5e36f 100644 --- a/src/components/views/rooms/RoomTileCallSummary.tsx +++ b/src/components/views/rooms/RoomTileCallSummary.tsx @@ -15,12 +15,12 @@ limitations under the License. */ import React, { FC } from "react"; -import classNames from "classnames"; import type { Call } from "../../../models/Call"; -import { _t, TranslatedString } from "../../../languageHandler"; +import { _t } from "../../../languageHandler"; import { useConnectionState, useParticipants } from "../../../hooks/useCall"; import { ConnectionState } from "../../../models/Call"; +import { LiveContentSummary, LiveContentType } from "./LiveContentSummary"; interface Props { call: Call; @@ -30,7 +30,7 @@ export const RoomTileCallSummary: FC = ({ call }) => { const connectionState = useConnectionState(call); const participants = useParticipants(call); - let text: TranslatedString; + let text: string; let active: boolean; switch (connectionState) { @@ -49,23 +49,10 @@ export const RoomTileCallSummary: FC = ({ call }) => { break; } - return - - { text } - - { participants.size ? <> - { " · " } - - { participants.size } - - : null } - ; + return ; }; diff --git a/src/components/views/voip/CallDuration.tsx b/src/components/views/voip/CallDuration.tsx new file mode 100644 index 0000000000..38b30038ea --- /dev/null +++ b/src/components/views/voip/CallDuration.tsx @@ -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 = ({ delta }) => { + // Clock desync could lead to a negative duration, so just hide it if that happens + if (delta <= 0) return null; + return
{ formatCallTime(new Date(delta)) }
; +}; + +interface CallDurationFromEventProps { + mxEvent: MatrixEvent; +} + +/** + * A call duration counter that automatically counts up, given the event that + * started the call. + */ +export const CallDurationFromEvent: FC = ({ mxEvent }) => { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + return ; +}; diff --git a/src/createRoom.ts b/src/createRoom.ts index c1bcc122ca..19d92164a0 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -158,9 +158,9 @@ export default async function createRoom(opts: IOpts): Promise { events: { ...DEFAULT_EVENT_POWER_LEVELS, // 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 - "org.matrix.msc3401.call": 200, + [ElementCall.CALL_EVENT_TYPE.name]: 200, }, users: { // Temporarily give ourselves the power to set up a call diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index d1702aac98..9ff402f45d 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -28,6 +28,7 @@ import { TimelineRenderingType } from "../contexts/RoomContext"; import MessageEvent from "../components/views/messages/MessageEvent"; import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; +import { CallEvent } from "../components/views/messages/CallEvent"; import TextualEvent from "../components/views/messages/TextualEvent"; import EncryptionEvent from "../components/views/messages/EncryptionEvent"; 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 { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; +import { ElementCall } from "../models/Call"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps { @@ -74,6 +76,7 @@ const KeyVerificationConclFactory: Factory = (ref, props) => = (ref, props) => ( ); +const CallEventFactory: Factory = (ref, props) => ; const TextualEventFactory: Factory = (ref, props) => ; const VerificationReqFactory: Factory = (ref, props) => ; const HiddenEventFactory: Factory = (ref, props) => ; @@ -113,6 +116,10 @@ const STATE_EVENT_TILE_TYPES = new Map([ [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 for (const evType of ALL_RULE_TYPES) { STATE_EVENT_TILE_TYPES.set(evType, TextualEventFactory); @@ -397,6 +404,15 @@ export function haveRendererForEvent(mxEvent: MatrixEvent, showHiddenEvents: boo return hasText(mxEvent, showHiddenEvents); } else if (handler === STATE_EVENT_TILE_TYPES.get(EventType.RoomCreate)) { 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) { return false; } else { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1a051cf8e1..f8cacb0f70 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1802,6 +1802,8 @@ "Show %(count)s other previews|other": "Show %(count)s other previews", "Show %(count)s other previews|one": "Show %(count)s other 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...|one": "and one other...", "Invite to this room": "Invite to this room", @@ -2001,8 +2003,6 @@ "Video": "Video", "Joining…": "Joining…", "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.", "This room has already been upgraded.": "This room has already been upgraded.", "This room is running room version , which this homeserver has marked as unstable.": "This room is running room version , which this homeserver has marked as unstable.", @@ -2181,6 +2181,8 @@ "%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.", "You cancelled verification.": "You cancelled verification.", "Verification cancelled": "Verification cancelled", + "%(name)s started a video call": "%(name)s started a video call", + "Video call ended": "Video call ended", "Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 02a80f6dda..b92005d611 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -438,6 +438,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, labsGroup: LabGroup.VoiceAndVideo, displayName: _td("New group call experience"), + controller: new ReloadOnChangeController(), default: false, }, "feature_location_share_live": { diff --git a/src/utils/EventRenderingUtils.ts b/src/utils/EventRenderingUtils.ts index ced3874804..3ba4ce5705 100644 --- a/src/utils/EventRenderingUtils.ts +++ b/src/utils/EventRenderingUtils.ts @@ -23,6 +23,7 @@ import SettingsStore from "../settings/SettingsStore"; import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils"; +import { ElementCall } from "../models/Call"; export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: boolean, hideEvent?: boolean): { isInfoMessage: boolean; @@ -61,9 +62,8 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent, showHiddenEvents: bool (eventType === EventType.RoomEncryption) || (factory === JitsiEventFactory) ); - const isLeftAlignedBubbleMessage = ( - !isBubbleMessage && - eventType === EventType.CallInvite + const isLeftAlignedBubbleMessage = !isBubbleMessage && ( + eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType) ); let isInfoMessage = ( !isBubbleMessage && diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx new file mode 100644 index 0000000000..70a9006191 --- /dev/null +++ b/test/components/views/messages/CallEvent-test.tsx @@ -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; + 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(); }; + + 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); + }); +}); diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index f4549026af..1268410ce4 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -18,17 +18,18 @@ import { MatrixWidgetType } from "matrix-widget-api"; import type { Room } from "matrix-js-sdk/src/models/room"; 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 { Call, ElementCall, JitsiCall } from "../../src/models/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 - private constructor(room: Room, id: string) { + private constructor(room: Room, public readonly event: MatrixEvent) { super( { - id, + id: event.getStateKey()!, eventId: "$1:example.org", roomId: room.roomId, type: MatrixWidgetType.Custom, @@ -42,7 +43,9 @@ export class MockedCall extends Call { public static get(room: Room): MockedCall | null { 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) { @@ -52,8 +55,9 @@ export class MockedCall extends Call { type: this.EVENT_TYPE, room: room.roomId, user: "@alice:example.org", - content: { terminated: false }, + content: { "m.type": "m.video", "m.intent": "m.prompt" }, skey: id, + ts: Date.now(), })]); } @@ -78,8 +82,9 @@ export class MockedCall extends Call { type: MockedCall.EVENT_TYPE, room: this.room.roomId, user: "@alice:example.org", - content: { terminated: true }, + content: { ...this.event.getContent(), "m.terminated": "Call ended" }, skey: this.widget.id, + ts: Date.now(), })]); super.destroy();