+ { _t("Reply to an ongoing thread or use “%(replyInThread)s” " + + "when hovering over a message to start a new one.", { + replyInThread: _t("Reply in thread"), + }) } +
++ { /* Always display that paragraph to prevent layout shift when hiding the button */ } + { (filterOption === ThreadFilterType.My) + ? + : <> > + } +
+ >; + } else { + body = <> +{ _t("Threads help keep your conversations on-topic and easy to track.") }
++ { _t('Tip: Use "Reply in thread" when hovering over a message.', {}, { + b: sub => { sub }, + }) } +
+ >; + } + return ; }; @@ -214,6 +240,12 @@ const ThreadPanel: React.FC{ _t("Keep discussions organised with threads.") }
+{ _t("Threads help keep conversations on-topic and easy to track. Learn more.", {}, { + a: (sub) => + { sub } + , + }) }
+ >, + disclaimer: () => + SdkConfig.get().bug_report_endpoint_url && <> +{ _t("Use \"Reply in thread\" when hovering over a message.") }
+{ _t("To leave, return to this page and use the “Leave the beta” button.") }
+ >, + feedbackLabel: "thread-feedback", + feedbackSubheading: _td("Thank you for trying the beta, " + + "please go into as much detail as you can so we can improve it."), + image: require("../../res/img/betas/threads.png"), + requiresRefresh: true, + }, + }, "feature_custom_status": { isFeature: true, @@ -237,10 +261,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { default: false, controller: new CustomStatusController(), }, - "feature_voice_rooms": { + "feature_video_rooms": { isFeature: true, labsGroup: LabGroup.Rooms, - displayName: _td("Voice & video rooms (under active development)"), + displayName: _td("Video rooms (under active development)"), supportedLevels: LEVELS_FEATURE, default: false, // Reload to ensure that the left panel etc. get remounted diff --git a/src/stores/VideoChannelStore.ts b/src/stores/VideoChannelStore.ts new file mode 100644 index 0000000000..6bd1b621e4 --- /dev/null +++ b/src/stores/VideoChannelStore.ts @@ -0,0 +1,164 @@ +/* +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 { EventEmitter } from "events"; +import { logger } from "matrix-js-sdk/src/logger"; +import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api"; + +import { MatrixClientPeg } from "../MatrixClientPeg"; +import { ElementWidgetActions } from "./widgets/ElementWidgetActions"; +import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore"; +import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore"; +import { + VIDEO_CHANNEL, + VIDEO_CHANNEL_MEMBER, + IVideoChannelMemberContent, + getVideoChannel, +} from "../utils/VideoChannelUtils"; +import WidgetUtils from "../utils/WidgetUtils"; + +export enum VideoChannelEvent { + Connect = "connect", + Disconnect = "disconnect", + Participants = "participants", +} + +export interface IJitsiParticipant { + avatarURL: string; + displayName: string; + formattedDisplayName: string; + participantId: string; +} + +/* + * Holds information about the currently active video channel. + */ +export default class VideoChannelStore extends EventEmitter { + private static _instance: VideoChannelStore; + + public static get instance(): VideoChannelStore { + if (!VideoChannelStore._instance) { + VideoChannelStore._instance = new VideoChannelStore(); + } + return VideoChannelStore._instance; + } + + private readonly cli = MatrixClientPeg.get(); + private activeChannel: ClientWidgetApi; + private _roomId: string; + private _participants: IJitsiParticipant[]; + + public get roomId(): string { + return this._roomId; + } + + public get participants(): IJitsiParticipant[] { + return this._participants; + } + + public start = () => { + ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate); + }; + + public stop = () => { + ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Update, this.onActiveWidgetUpdate); + }; + + private setConnected = async (roomId: string) => { + const jitsi = getVideoChannel(roomId); + if (!jitsi) throw new Error(`No video channel in room ${roomId}`); + + const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(jitsi)); + if (!messaging) throw new Error(`Failed to bind video channel in room ${roomId}`); + + this.activeChannel = messaging; + this._roomId = roomId; + this._participants = []; + + this.activeChannel.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.activeChannel.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + + this.emit(VideoChannelEvent.Connect); + + // Tell others that we're connected, by adding our device to room state + await this.updateDevices(devices => Array.from(new Set(devices).add(this.cli.getDeviceId()))); + }; + + private setDisconnected = async () => { + this.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants); + + this.activeChannel = null; + this._participants = null; + + try { + // Tell others that we're disconnected, by removing our device from room state + await this.updateDevices(devices => { + const devicesSet = new Set(devices); + devicesSet.delete(this.cli.getDeviceId()); + return Array.from(devicesSet); + }); + } finally { + // Save this for last, since updateDevices needs the room ID + this._roomId = null; + this.emit(VideoChannelEvent.Disconnect); + } + }; + + private ack = (ev: CustomEvent