mirror of https://github.com/vector-im/riot-web
				
				
				
			Expire video member events after 1 hour (#8776)
* Expire video member events after 1 hour * Iterate based on feedback * Validate the types of video member events better * Even more parenthesespull/28788/head^2
							parent
							
								
									12dd5a7ef0
								
							
						
					
					
						commit
						d9396b0b54
					
				|  | @ -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"; | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
| } | ||||
|  | @ -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<RoomMember>; | ||||
|     // 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<IProps, IState> { | |||
|     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<IProps, IState> { | |||
|                 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<IProps, IState> { | |||
|         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<IProps, IState> { | |||
|             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<IProps, IState> { | |||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     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<IProps, IState> { | |||
| 
 | ||||
|         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 = ( | ||||
|                 <div className="mx_RoomTile_subtitle"> | ||||
|                     <span | ||||
|                         className={classNames({ | ||||
|                             "mx_RoomTile_videoIndicator": true, | ||||
|                             "mx_RoomTile_videoIndicator_active": videoActive, | ||||
|                         })} | ||||
|                     > | ||||
|                         { videoText } | ||||
|                     </span> | ||||
|                     { participantCount ? <> | ||||
|                         { " · " } | ||||
|                         <span | ||||
|                             className="mx_RoomTile_videoParticipants" | ||||
|                             aria-label={_t("%(count)s participants", { count: participantCount })} | ||||
|                         > | ||||
|                             { participantCount } | ||||
|                         </span> | ||||
|                     </> : null } | ||||
|                     <VideoRoomSummary room={this.props.room} /> | ||||
|                 </div> | ||||
|             ); | ||||
|         } else if (this.showMessagePreview && this.state.messagePreview) { | ||||
|  |  | |||
|  | @ -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<IProps> = ({ 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 <span className="mx_VideoRoomSummary"> | ||||
|         <span | ||||
|             className={classNames( | ||||
|                 "mx_VideoRoomSummary_indicator", | ||||
|                 { "mx_VideoRoomSummary_indicator_active": active }, | ||||
|             )} | ||||
|         > | ||||
|             { indicator } | ||||
|         </span> | ||||
|         { participantCount ? <> | ||||
|             { " · " } | ||||
|             <span | ||||
|                 className="mx_VideoRoomSummary_participants" | ||||
|                 aria-label={_t("%(count)s participants", { count: participantCount })} | ||||
|             > | ||||
|                 { participantCount } | ||||
|             </span> | ||||
|         </> : null } | ||||
|     </span>; | ||||
| }; | ||||
| 
 | ||||
| export default VideoRoomSummary; | ||||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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<null> { | |||
|     } | ||||
| 
 | ||||
|     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<null> { | |||
| 
 | ||||
|         // 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<null> { | |||
|         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; | ||||
|  |  | |||
|  | @ -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<RoomMember> => { | ||||
| // 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<RoomMember>, number] => { | ||||
|     const members = new Set<RoomMember>(); | ||||
|     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<IVideoChannelMemberContent>()?.devices ?? []; | ||||
|         const content = e.getContent<IVideoChannelMemberContent>(); | ||||
|         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<RoomMember> => { | ||||
|     const [members, setMembers] = useState<Set<RoomMember>>(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<Room>, fn: (devices: string[] | null) => string[]) => { | ||||
|     if (room?.getMyMembership() !== "join") return; | ||||
| 
 | ||||
|  | @ -84,9 +134,12 @@ const updateDevices = async (room: Optional<Room>, 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<string, IMyDevice>(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; | ||||
| }; | ||||
|  |  | |||
|  | @ -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(), | ||||
|         ); | ||||
|     }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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( | ||||
|             <RoomTile | ||||
|                 room={room} | ||||
|                 showMessagePreview={false} | ||||
|                 isMinimized={false} | ||||
|                 tag={DefaultTagID.Untagged} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|         it("tracks connection state", () => { | ||||
|             const tile = mount( | ||||
|                 <RoomTile | ||||
|                     room={room} | ||||
|                     showMessagePreview={false} | ||||
|                     isMinimized={false} | ||||
|                     tag={DefaultTagID.Untagged} | ||||
|                 />, | ||||
|             ); | ||||
|             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( | ||||
|                 <RoomTile | ||||
|                     room={room} | ||||
|                     showMessagePreview={false} | ||||
|                     isMinimized={false} | ||||
|                     tag={DefaultTagID.Untagged} | ||||
|                 />, | ||||
|             ); | ||||
|             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( | ||||
|                 <RoomTile | ||||
|                     room={room} | ||||
|                     showMessagePreview={false} | ||||
|                     isMinimized={false} | ||||
|                     tag={DefaultTagID.Untagged} | ||||
|                 />, | ||||
|             ); | ||||
|             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); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -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<IWidgetApiRequest>) => void) => void; | ||||
|     let onceMock: (action: string, listener: (ev: CustomEvent<IWidgetApiRequest>) => 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 () => { | ||||
|  |  | |||
|  | @ -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, | ||||
|     }, | ||||
| }); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Robin
						Robin