diff --git a/res/css/_components.scss b/res/css/_components.scss index f88cc728c86..a8463cf62bb 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -275,6 +275,7 @@ @import "./views/rooms/_ThreadSummary.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; @import "./views/rooms/_VoiceRecordComposerTile.scss"; +@import "./views/rooms/_VideoRoomSummary.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; @import "./views/settings/_AvatarSetting.scss"; @import "./views/settings/_CrossSigningPanel.scss"; diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index ce8fd062d37..2e8d6a479d6 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -75,40 +75,6 @@ limitations under the License. .mx_RoomTile_subtitle { line-height: $font-18px; color: $secondary-content; - - .mx_RoomTile_videoIndicator { - &::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_RoomTile_videoIndicator_active { - color: $accent; - - &::before { - background-color: $accent; - } - } - } - - .mx_RoomTile_videoParticipants::before { - display: inline-block; - vertical-align: text-bottom; - content: ''; - background-color: $secondary-content; - mask-image: url('$(res)/img/element-icons/group-members.svg'); - mask-size: 16px; - width: 16px; - height: 16px; - margin-right: 2px; - } } .mx_RoomTile_titleWithSubtitle { diff --git a/res/css/views/rooms/_VideoRoomSummary.scss b/res/css/views/rooms/_VideoRoomSummary.scss new file mode 100644 index 00000000000..b3e9af3f651 --- /dev/null +++ b/res/css/views/rooms/_VideoRoomSummary.scss @@ -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. +*/ + +.mx_VideoRoomSummary { + .mx_VideoRoomSummary_indicator { + &::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_VideoRoomSummary_indicator_active { + color: $accent; + + &::before { + background-color: $accent; + } + } + } + + .mx_VideoRoomSummary_participants::before { + display: inline-block; + vertical-align: text-bottom; + content: ''; + background-color: $secondary-content; + mask-image: url('$(res)/img/element-icons/group-members.svg'); + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 2px; + } +} diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index c7be65779f7..2a4f79617aa 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -19,8 +19,6 @@ import React, { createRef } from "react"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; @@ -50,19 +48,12 @@ import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; -import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../../../stores/VideoChannelStore"; -import { getConnectedMembers } from "../../../utils/VideoChannelUtils"; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { RoomViewStore } from "../../../stores/RoomViewStore"; - -enum VideoStatus { - Disconnected, - Connecting, - Connected, -} +import VideoRoomSummary from "./VideoRoomSummary"; interface IProps { room: Room; @@ -78,11 +69,6 @@ interface IState { notificationsMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect; messagePreview?: string; - videoStatus: VideoStatus; - // Active video channel members, according to room state - videoMembers: Set; - // Active video channel members, according to Jitsi - jitsiParticipants: IJitsiParticipant[]; } const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; @@ -105,26 +91,12 @@ export default class RoomTile extends React.PureComponent { constructor(props: IProps) { super(props); - let videoStatus; - if (VideoChannelStore.instance.roomId === this.props.room.roomId) { - if (VideoChannelStore.instance.connected) { - videoStatus = VideoStatus.Connected; - } else { - videoStatus = VideoStatus.Connecting; - } - } else { - videoStatus = VideoStatus.Disconnected; - } - this.state = { selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, // generatePreview() will return nothing if the user has previews disabled messagePreview: "", - videoStatus, - videoMembers: getConnectedMembers(this.props.room, videoStatus === VideoStatus.Connected), - jitsiParticipants: VideoChannelStore.instance.participants, }; this.generatePreview(); @@ -169,9 +141,6 @@ export default class RoomTile extends React.PureComponent { MessagePreviewStore.getPreviewChangedEventName(this.props.room), this.onRoomPreviewChanged, ); - prevProps.room?.currentState?.off(RoomStateEvent.Events, this.updateVideoMembers); - this.props.room?.currentState?.on(RoomStateEvent.Events, this.updateVideoMembers); - this.updateVideoStatus(); prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate); this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate); } @@ -192,14 +161,6 @@ export default class RoomTile extends React.PureComponent { this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate); this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate); - this.props.room.currentState.on(RoomStateEvent.Events, this.updateVideoMembers); - - VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.onConnectVideo); - VideoChannelStore.instance.on(VideoChannelEvent.StartConnect, this.onStartConnectVideo); - VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.onDisconnectVideo); - if (VideoChannelStore.instance.roomId === this.props.room.roomId) { - VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants); - } } public componentWillUnmount() { @@ -209,14 +170,9 @@ export default class RoomTile extends React.PureComponent { this.onRoomPreviewChanged, ); this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate); - this.props.room.currentState.off(RoomStateEvent.Events, this.updateVideoMembers); defaultDispatcher.unregister(this.dispatcherRef); this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate); this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate); - - VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.onConnectVideo); - VideoChannelStore.instance.off(VideoChannelEvent.StartConnect, this.onStartConnectVideo); - VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.onDisconnectVideo); } private onAction = (payload: ActionPayload) => { @@ -591,54 +547,6 @@ export default class RoomTile extends React.PureComponent { ); } - private updateVideoMembers = () => { - this.setState(state => ({ - videoMembers: getConnectedMembers(this.props.room, state.videoStatus === VideoStatus.Connected), - })); - }; - - private updateVideoStatus = () => { - if (VideoChannelStore.instance.roomId === this.props.room?.roomId) { - if (VideoChannelStore.instance.connected) { - this.onConnectVideo(this.props.room?.roomId); - } else { - this.onStartConnectVideo(this.props.room?.roomId); - } - } else { - this.onDisconnectVideo(this.props.room?.roomId); - } - }; - - private onConnectVideo = (roomId: string) => { - if (roomId === this.props.room?.roomId) { - this.setState({ - videoStatus: VideoStatus.Connected, - videoMembers: getConnectedMembers(this.props.room, true), - }); - VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants); - } - }; - - private onStartConnectVideo = (roomId: string) => { - if (roomId === this.props.room?.roomId) { - this.setState({ videoStatus: VideoStatus.Connecting }); - } - }; - - private onDisconnectVideo = (roomId: string) => { - if (roomId === this.props.room?.roomId) { - this.setState({ - videoStatus: VideoStatus.Disconnected, - videoMembers: getConnectedMembers(this.props.room, false), - }); - VideoChannelStore.instance.off(VideoChannelEvent.Participants, this.updateJitsiParticipants); - } - }; - - private updateJitsiParticipants = (roomId: string, participants: IJitsiParticipant[]) => { - this.setState({ jitsiParticipants: participants }); - }; - public render(): React.ReactElement { const classes = classNames({ 'mx_RoomTile': true, @@ -667,46 +575,9 @@ export default class RoomTile extends React.PureComponent { let subtitle; if (this.isVideoRoom) { - let videoText: string; - let videoActive: boolean; - let participantCount: number; - - switch (this.state.videoStatus) { - case VideoStatus.Disconnected: - videoText = _t("Video"); - videoActive = false; - participantCount = this.state.videoMembers.size; - break; - case VideoStatus.Connecting: - videoText = _t("Joining…"); - videoActive = true; - participantCount = this.state.videoMembers.size; - break; - case VideoStatus.Connected: - videoText = _t("Joined"); - videoActive = true; - participantCount = this.state.jitsiParticipants.length; - } - subtitle = (
- - { videoText } - - { participantCount ? <> - { " · " } - - { participantCount } - - : null } +
); } else if (this.showMessagePreview && this.state.messagePreview) { diff --git a/src/components/views/rooms/VideoRoomSummary.tsx b/src/components/views/rooms/VideoRoomSummary.tsx new file mode 100644 index 00000000000..e83203af146 --- /dev/null +++ b/src/components/views/rooms/VideoRoomSummary.tsx @@ -0,0 +1,81 @@ +/* +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 { Room } from "matrix-js-sdk/src/models/room"; + +import { _t, TranslatedString } from "../../../languageHandler"; +import { + ConnectionState, + useConnectionState, + useConnectedMembers, + useJitsiParticipants, +} from "../../../utils/VideoChannelUtils"; + +interface IProps { + room: Room; +} + +const VideoRoomSummary: FC = ({ room }) => { + const connectionState = useConnectionState(room); + const videoMembers = useConnectedMembers(room, connectionState === ConnectionState.Connected); + const jitsiParticipants = useJitsiParticipants(room); + + let indicator: TranslatedString; + let active: boolean; + let participantCount: number; + + switch (connectionState) { + case ConnectionState.Disconnected: + indicator = _t("Video"); + active = false; + participantCount = videoMembers.size; + break; + case ConnectionState.Connecting: + indicator = _t("Joining…"); + active = true; + participantCount = videoMembers.size; + break; + case ConnectionState.Connected: + indicator = _t("Joined"); + active = true; + participantCount = jitsiParticipants.length; + break; + } + + return + + { indicator } + + { participantCount ? <> + { " · " } + + { participantCount } + + : null } + ; +}; + +export default VideoRoomSummary; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b87d30d8ece..af9d020df94 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1896,11 +1896,6 @@ "Favourite": "Favourite", "Low Priority": "Low Priority", "Copy room link": "Copy room link", - "Video": "Video", - "Joining…": "Joining…", - "Joined": "Joined", - "%(count)s participants|other": "%(count)s participants", - "%(count)s participants|one": "1 participant", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|one": "1 unread mention.", "%(count)s unread messages.|other": "%(count)s unread messages.", @@ -1927,6 +1922,11 @@ "Open thread": "Open thread", "Jump to first unread message.": "Jump to first unread message.", "Mark all as read": "Mark all as read", + "Video": "Video", + "Joining…": "Joining…", + "Joined": "Joined", + "%(count)s participants|other": "%(count)s participants", + "%(count)s participants|one": "1 participant", "Unable to access your microphone": "Unable to access your microphone", "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.", "No microphone found": "No microphone found", diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts index 8d7b32a94a6..1645686ff4c 100644 --- a/src/stores/VideoChannelStore.ts +++ b/src/stores/VideoChannelStore.ts @@ -15,6 +15,7 @@ limitations under the License. */ import EventEmitter from "events"; +import { logger } from "matrix-js-sdk/src/logger"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api"; @@ -24,7 +25,7 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { ElementWidgetActions } from "./widgets/ElementWidgetActions"; import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore"; -import { getVideoChannel, addOurDevice, removeOurDevice } from "../utils/VideoChannelUtils"; +import { STUCK_DEVICE_TIMEOUT_MS, getVideoChannel, addOurDevice, removeOurDevice } from "../utils/VideoChannelUtils"; import { timeout } from "../utils/promise"; import WidgetUtils from "../utils/WidgetUtils"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; @@ -80,6 +81,7 @@ export default class VideoChannelStore extends AsyncStoreWithClient { } private activeChannel: ClientWidgetApi; + private resendDevicesTimer: number; // This is persisted to settings so we can detect unclean disconnects public get roomId(): string | null { return SettingsStore.getValue("videoChannelRoomId"); } @@ -235,6 +237,11 @@ export default class VideoChannelStore extends AsyncStoreWithClient { // Tell others that we're connected, by adding our device to room state await addOurDevice(this.room); + // Re-add this device every so often so our video member event doesn't become stale + this.resendDevicesTimer = setInterval(async () => { + logger.log(`Resending video member event for ${this.roomId}`); + await addOurDevice(this.room); + }, (STUCK_DEVICE_TIMEOUT_MS * 3) / 4); }; public disconnect = async () => { @@ -257,6 +264,7 @@ export default class VideoChannelStore extends AsyncStoreWithClient { this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); room.off(RoomEvent.MyMembership, this.onMyMembership); window.removeEventListener("beforeunload", this.setDisconnected); + clearInterval(this.resendDevicesTimer); this.activeChannel = null; this.roomId = null; diff --git a/src/utils/VideoChannelUtils.ts b/src/utils/VideoChannelUtils.ts index 498ccf63258..d7461c9e90e 100644 --- a/src/utils/VideoChannelUtils.ts +++ b/src/utils/VideoChannelUtils.ts @@ -14,28 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useState } from "react"; +import { useState, useMemo, useEffect } from "react"; import { throttle } from "lodash"; import { Optional } from "matrix-events-sdk"; +import { logger } from "matrix-js-sdk/src/logger"; import { IMyDevice } from "matrix-js-sdk/src/client"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import { useTypedEventEmitter } from "../hooks/useEventEmitter"; +import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter"; import WidgetStore, { IApp } from "../stores/WidgetStore"; import { WidgetType } from "../widgets/WidgetType"; import WidgetUtils from "./WidgetUtils"; - -const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; +import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../stores/VideoChannelStore"; interface IVideoChannelMemberContent { // Connected device IDs devices: string[]; + // Time at which this state event should be considered stale + expires_ts: number; } export const VIDEO_CHANNEL_MEMBER = "io.element.video.member"; +export const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour + +export enum ConnectionState { + Disconnected = "disconnected", + Connecting = "connecting", + Connected = "connected", +} export const getVideoChannel = (roomId: string): IApp => { const apps = WidgetStore.instance.getApps(roomId); @@ -46,36 +55,77 @@ export const addVideoChannel = async (roomId: string, roomName: string) => { await WidgetUtils.addJitsiWidget(roomId, CallType.Video, "Video channel", true, roomName); }; -export const getConnectedMembers = (room: Room, connectedLocalEcho: boolean): Set => { +// Gets the members connected to a given video room, along with a timestamp +// indicating when this data should be considered stale +const getConnectedMembers = (room: Room, connectedLocalEcho: boolean): [Set, number] => { const members = new Set(); + const now = Date.now(); + let allExpireAt = Infinity; for (const e of room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER)) { const member = room.getMember(e.getStateKey()); - let devices = e.getContent()?.devices ?? []; + const content = e.getContent(); + let devices = Array.isArray(content.devices) ? content.devices : []; + const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity; + + // Ignore events with a timeout that's way off in the future + const inTheFuture = (expiresAt - ((STUCK_DEVICE_TIMEOUT_MS * 5) / 4)) > now; + const expired = expiresAt <= now || inTheFuture; // Apply local echo for the disconnected case if (!connectedLocalEcho && member?.userId === room.client.getUserId()) { devices = devices.filter(d => d !== room.client.getDeviceId()); } - // Must have a device connected and still be joined to the room - if (devices.length && member?.membership === "join") members.add(member); + // Must have a device connected, be unexpired, and still be joined to the room + if (devices.length && !expired && member?.membership === "join") { + members.add(member); + if (expiresAt < allExpireAt) allExpireAt = expiresAt; + } } // Apply local echo for the connected case if (connectedLocalEcho) members.add(room.getMember(room.client.getUserId())); - return members; + return [members, allExpireAt]; }; export const useConnectedMembers = ( room: Room, connectedLocalEcho: boolean, throttleMs = 100, ): Set => { - const [members, setMembers] = useState>(getConnectedMembers(room, connectedLocalEcho)); - useTypedEventEmitter(room.currentState, RoomStateEvent.Update, throttle(() => { - setMembers(getConnectedMembers(room, connectedLocalEcho)); - }, throttleMs, { leading: true, trailing: true })); + const [[members, expiresAt], setState] = useState(() => getConnectedMembers(room, connectedLocalEcho)); + const updateState = useMemo(() => throttle(() => { + setState(getConnectedMembers(room, connectedLocalEcho)); + }, throttleMs, { leading: true, trailing: true }), [setState, room, connectedLocalEcho, throttleMs]); + + useTypedEventEmitter(room.currentState, RoomStateEvent.Update, updateState); + useEffect(() => { + if (expiresAt < Infinity) { + const timer = setTimeout(() => { + logger.log(`Refreshing video members for ${room.roomId}`); + updateState(); + }, expiresAt - Date.now()); + return () => clearTimeout(timer); + } + }, [expiresAt, updateState, room.roomId]); + return members; }; +export const useJitsiParticipants = (room: Room): IJitsiParticipant[] => { + const store = VideoChannelStore.instance; + const [participants, setParticipants] = useState(() => + store.connected && store.roomId === room.roomId ? store.participants : [], + ); + + useEventEmitter(store, VideoChannelEvent.Disconnect, (roomId: string) => { + if (roomId === room.roomId) setParticipants([]); + }); + useEventEmitter(store, VideoChannelEvent.Participants, (roomId: string, participants: IJitsiParticipant[]) => { + if (roomId === room.roomId) setParticipants(participants); + }); + + return participants; +}; + const updateDevices = async (room: Optional, fn: (devices: string[] | null) => string[]) => { if (room?.getMyMembership() !== "join") return; @@ -84,9 +134,12 @@ const updateDevices = async (room: Optional, fn: (devices: string[] | null const newDevices = fn(devices); if (newDevices) { - await room.client.sendStateEvent( - room.roomId, VIDEO_CHANNEL_MEMBER, { devices: newDevices }, room.client.getUserId(), - ); + const content: IVideoChannelMemberContent = { + devices: newDevices, + expires_ts: Date.now() + STUCK_DEVICE_TIMEOUT_MS, + }; + + await room.client.sendStateEvent(room.roomId, VIDEO_CHANNEL_MEMBER, content, room.client.getUserId()); } }; @@ -110,7 +163,7 @@ export const removeOurDevice = async (room: Room) => { * @param {boolean} connectedLocalEcho Local echo of whether this device is connected */ export const fixStuckDevices = async (room: Room, connectedLocalEcho: boolean) => { - const now = new Date().valueOf(); + const now = Date.now(); const { devices: myDevices } = await room.client.getDevices(); const deviceMap = new Map(myDevices.map(d => [d.device_id, d])); @@ -126,3 +179,26 @@ export const fixStuckDevices = async (room: Room, connectedLocalEcho: boolean) = return newDevices.length === devices.length ? null : newDevices; }); }; + +export const useConnectionState = (room: Room): ConnectionState => { + const store = VideoChannelStore.instance; + const [state, setState] = useState(() => + store.roomId === room.roomId + ? store.connected + ? ConnectionState.Connected + : ConnectionState.Connecting + : ConnectionState.Disconnected, + ); + + useEventEmitter(store, VideoChannelEvent.Disconnect, (roomId: string) => { + if (roomId === room.roomId) setState(ConnectionState.Disconnected); + }); + useEventEmitter(store, VideoChannelEvent.StartConnect, (roomId: string) => { + if (roomId === room.roomId) setState(ConnectionState.Connecting); + }); + useEventEmitter(store, VideoChannelEvent.Connect, (roomId: string) => { + if (roomId === room.roomId) setState(ConnectionState.Connected); + }); + + return state; +}; diff --git a/test/components/structures/VideoRoomView-test.tsx b/test/components/structures/VideoRoomView-test.tsx index 02d82bb27ca..f14a8d80196 100644 --- a/test/components/structures/VideoRoomView-test.tsx +++ b/test/components/structures/VideoRoomView-test.tsx @@ -95,7 +95,10 @@ describe("VideoRoomView", () => { // All devices should have been removed expect(cli.sendStateEvent).toHaveBeenLastCalledWith( - "!1:example.org", VIDEO_CHANNEL_MEMBER, { devices: [] }, cli.getUserId(), + "!1:example.org", + VIDEO_CHANNEL_MEMBER, + { devices: [], expires_ts: expect.any(Number) }, + cli.getUserId(), ); }); diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx index 7b4f712c6a9..d5cb86443a5 100644 --- a/test/components/views/rooms/RoomTile-test.tsx +++ b/test/components/views/rooms/RoomTile-test.tsx @@ -30,6 +30,7 @@ import { stubVideoChannelStore, StubVideoChannelStore, } from "../../../test-utils"; +import { STUCK_DEVICE_TIMEOUT_MS } from "../../../../src/utils/VideoChannelUtils"; import RoomTile from "../../../../src/components/views/rooms/RoomTile"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { DefaultTagID } from "../../../../src/stores/room-list/models"; @@ -38,6 +39,18 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import PlatformPeg from "../../../../src/PlatformPeg"; import BasePlatform from "../../../../src/BasePlatform"; +const mockGetMember = (room: Room, getMembership: (userId: string) => string = () => "join") => { + mocked(room).getMember.mockImplementation(userId => ({ + userId, + membership: getMembership(userId), + name: userId, + rawDisplayName: userId, + roomId: "!1:example.org", + getAvatarUrl: () => {}, + getMxcAvatarUrl: () => {}, + }) as unknown as RoomMember); +}; + describe("RoomTile", () => { jest.spyOn(PlatformPeg, 'get') .mockReturnValue({ overrideBrowserShortcuts: () => false } as unknown as BasePlatform); @@ -59,7 +72,10 @@ describe("RoomTile", () => { DMRoomMap.makeShared(); }); - afterEach(() => jest.clearAllMocks()); + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); describe("video rooms", () => { let room: Room; @@ -68,31 +84,34 @@ describe("RoomTile", () => { mocked(room.isElementVideoRoom).mockReturnValue(true); }); + const mountTile = () => mount( + , + ); + it("tracks connection state", () => { - const tile = mount( - , - ); - expect(tile.find(".mx_RoomTile_videoIndicator").text()).toEqual("Video"); + const tile = mountTile(); + expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Video"); act(() => { store.startConnect("!1:example.org"); }); tile.update(); - expect(tile.find(".mx_RoomTile_videoIndicator").text()).toEqual("Joining…"); + expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Joining…"); act(() => { store.connect("!1:example.org"); }); tile.update(); - expect(tile.find(".mx_RoomTile_videoIndicator").text()).toEqual("Joined"); + expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Joined"); act(() => { store.disconnect(); }); tile.update(); - expect(tile.find(".mx_RoomTile_videoIndicator").text()).toEqual("Video"); + expect(tile.find(".mx_VideoRoomSummary_indicator").text()).toEqual("Video"); }); it("displays connected members", () => { + mockGetMember(room, userId => userId === "@chris:example.org" ? "leave" : "join"); mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([ // A user connected from 2 devices mkVideoChannelMember("@alice:example.org", ["device 1", "device 2"]), @@ -102,56 +121,41 @@ describe("RoomTile", () => { mkVideoChannelMember("@chris:example.org", ["device 1"]), ])); - mocked(room).getMember.mockImplementation(userId => ({ - userId, - membership: userId === "@chris:example.org" ? "leave" : "join", - name: userId, - rawDisplayName: userId, - roomId: "!1:example.org", - getAvatarUrl: () => {}, - getMxcAvatarUrl: () => {}, - }) as unknown as RoomMember); - - const tile = mount( - , - ); + const tile = mountTile(); // Only Alice should display as connected - expect(tile.find(".mx_RoomTile_videoParticipants").text()).toEqual("1"); + expect(tile.find(".mx_VideoRoomSummary_participants").text()).toEqual("1"); }); it("reflects local echo in connected members", () => { + mockGetMember(room); mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([ // Make the remote echo claim that we're connected, while leaving the store disconnected mkVideoChannelMember(cli.getUserId(), [cli.getDeviceId()]), ])); - mocked(room).getMember.mockImplementation(userId => ({ - userId, - membership: "join", - name: userId, - rawDisplayName: userId, - roomId: "!1:example.org", - getAvatarUrl: () => {}, - getMxcAvatarUrl: () => {}, - }) as unknown as RoomMember); - - const tile = mount( - , - ); + const tile = mountTile(); // Because of our local echo, we should still appear as disconnected - expect(tile.find(".mx_RoomTile_videoParticipants").exists()).toEqual(false); + expect(tile.find(".mx_VideoRoomSummary_participants").exists()).toEqual(false); + }); + + it("doesn't count members whose device data has expired", () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + + mockGetMember(room); + mocked(room.currentState).getStateEvents.mockImplementation(mockStateEventImplementation([ + mkVideoChannelMember("@alice:example.org", ["device 1"], STUCK_DEVICE_TIMEOUT_MS), + ])); + + const tile = mountTile(); + + expect(tile.find(".mx_VideoRoomSummary_participants").text()).toEqual("1"); + // Expire Alice's device data + act(() => { jest.advanceTimersByTime(STUCK_DEVICE_TIMEOUT_MS); }); + tile.update(); + expect(tile.find(".mx_VideoRoomSummary_participants").exists()).toEqual(false); }); }); }); diff --git a/test/stores/VideoChannelStore-test.ts b/test/stores/VideoChannelStore-test.ts index acc44689b5c..b924d01d84c 100644 --- a/test/stores/VideoChannelStore-test.ts +++ b/test/stores/VideoChannelStore-test.ts @@ -16,12 +16,14 @@ limitations under the License. import { mocked } from "jest-mock"; import { Widget, ClientWidgetApi, MatrixWidgetType, IWidgetApiRequest } from "matrix-widget-api"; +import { MatrixClient } from "matrix-js-sdk/src/client"; import { stubClient, setupAsyncStoreWithClient, mkRoom } from "../test-utils"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import WidgetStore, { IApp } from "../../src/stores/WidgetStore"; import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore"; import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions"; +import { VIDEO_CHANNEL_MEMBER, STUCK_DEVICE_TIMEOUT_MS } from "../../src/utils/VideoChannelUtils"; import VideoChannelStore, { VideoChannelEvent } from "../../src/stores/VideoChannelStore"; describe("VideoChannelStore", () => { @@ -46,9 +48,10 @@ describe("VideoChannelStore", () => { let onMock: (action: string, listener: (ev: CustomEvent) => void) => void; let onceMock: (action: string, listener: (ev: CustomEvent) => void) => void; let messaging: ClientWidgetApi; + let cli: MatrixClient; beforeEach(() => { stubClient(); - const cli = MatrixClientPeg.get(); + cli = MatrixClientPeg.get(); setupAsyncStoreWithClient(WidgetMessagingStore.instance, cli); setupAsyncStoreWithClient(store, cli); mocked(cli).getRoom.mockReturnValue(mkRoom(cli, "!1:example.org")); @@ -72,6 +75,8 @@ describe("VideoChannelStore", () => { } as unknown as ClientWidgetApi; }); + afterEach(() => jest.useRealTimers()); + const widgetReady = () => { // Tell the WidgetStore that the widget is ready const [, ready] = mocked(onceMock).mock.calls.find(([action]) => @@ -109,6 +114,9 @@ describe("VideoChannelStore", () => { }; it("connects and disconnects", async () => { + jest.useFakeTimers(); + jest.setSystemTime(0); + WidgetMessagingStore.instance.storeMessaging(widget, "!1:example.org", messaging); widgetReady(); expect(store.roomId).toBeFalsy(); @@ -120,12 +128,40 @@ describe("VideoChannelStore", () => { expect(store.roomId).toEqual("!1:example.org"); expect(store.connected).toEqual(true); + // Our device should now appear as connected + expect(cli.sendStateEvent).toHaveBeenLastCalledWith( + "!1:example.org", + VIDEO_CHANNEL_MEMBER, + { devices: [cli.getDeviceId()], expires_ts: expect.any(Number) }, + cli.getUserId(), + ); + mocked(cli).sendStateEvent.mockClear(); + + // Our devices should be resent within the timeout period to prevent + // the data from becoming stale + jest.advanceTimersByTime(STUCK_DEVICE_TIMEOUT_MS); + expect(cli.sendStateEvent).toHaveBeenLastCalledWith( + "!1:example.org", + VIDEO_CHANNEL_MEMBER, + { devices: [cli.getDeviceId()], expires_ts: expect.any(Number) }, + cli.getUserId(), + ); + mocked(cli).sendStateEvent.mockClear(); + const disconnectPromise = store.disconnect(); await confirmDisconnect(); await expect(disconnectPromise).resolves.toBeUndefined(); expect(store.roomId).toBeFalsy(); expect(store.connected).toEqual(false); WidgetMessagingStore.instance.stopMessaging(widget, "!1:example.org"); + + // Our device should now be marked as disconnected + expect(cli.sendStateEvent).toHaveBeenLastCalledWith( + "!1:example.org", + VIDEO_CHANNEL_MEMBER, + { devices: [], expires_ts: expect.any(Number) }, + cli.getUserId(), + ); }); it("waits for messaging when connecting", async () => { diff --git a/test/test-utils/video.ts b/test/test-utils/video.ts index ffc3ac2883a..f165c283985 100644 --- a/test/test-utils/video.ts +++ b/test/test-utils/video.ts @@ -18,7 +18,7 @@ import { EventEmitter } from "events"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { mkEvent } from "./test-utils"; -import { VIDEO_CHANNEL_MEMBER } from "../../src/utils/VideoChannelUtils"; +import { VIDEO_CHANNEL_MEMBER, STUCK_DEVICE_TIMEOUT_MS } from "../../src/utils/VideoChannelUtils"; import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../../src/stores/VideoChannelStore"; export class StubVideoChannelStore extends EventEmitter { @@ -52,11 +52,14 @@ export const stubVideoChannelStore = (): StubVideoChannelStore => { return store; }; -export const mkVideoChannelMember = (userId: string, devices: string[]): MatrixEvent => mkEvent({ +export const mkVideoChannelMember = (userId: string, devices: string[], expiresAt?: number): MatrixEvent => mkEvent({ event: true, type: VIDEO_CHANNEL_MEMBER, room: "!1:example.org", user: userId, skey: userId, - content: { devices }, + content: { + devices, + expires_ts: expiresAt == null ? Date.now() + STUCK_DEVICE_TIMEOUT_MS : expiresAt, + }, });