diff --git a/src/settings/SettingsModal.jsx b/src/settings/SettingsModal.tsx similarity index 91% rename from src/settings/SettingsModal.jsx rename to src/settings/SettingsModal.tsx index 919b955..1e45d55 100644 --- a/src/settings/SettingsModal.jsx +++ b/src/settings/SettingsModal.tsx @@ -15,6 +15,8 @@ limitations under the License. */ import React from "react"; +import { Item } from "@react-stately/collections"; + import { Modal } from "../Modal"; import styles from "./SettingsModal.module.css"; import { TabContainer, TabItem } from "../tabs/Tabs"; @@ -22,7 +24,6 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg"; import { ReactComponent as VideoIcon } from "../icons/Video.svg"; import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg"; import { SelectInput } from "../input/SelectInput"; -import { Item } from "@react-stately/collections"; import { useMediaHandler } from "./useMediaHandler"; import { useSpatialAudio, useShowInspector } from "./useSetting"; import { FieldRow, InputField } from "../input/Input"; @@ -30,7 +31,13 @@ import { Button } from "../button"; import { useDownloadDebugLog } from "./submit-rageshake"; import { Body } from "../typography/Typography"; -export const SettingsModal = (props) => { +interface Props { + setShowInspector: boolean; + showInspector: boolean; + [rest: string]: unknown; +} + +export const SettingsModal = (props: Props) => { const { audioInput, audioInputs, @@ -42,6 +49,7 @@ export const SettingsModal = (props) => { audioOutputs, setAudioOutput, } = useMediaHandler(); + const [spatialAudio, setSpatialAudio] = useSpatialAudio(); const [showInspector, setShowInspector] = useShowInspector(); @@ -91,7 +99,9 @@ export const SettingsModal = (props) => { type="checkbox" checked={spatialAudio} description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)" - onChange={(e) => setSpatialAudio(e.target.checked)} + onChange={(event: React.ChangeEvent) => + setSpatialAudio(event.target.checked) + } /> @@ -133,7 +143,9 @@ export const SettingsModal = (props) => { label="Show Call Inspector" type="checkbox" checked={showInspector} - onChange={(e) => setShowInspector(e.target.checked)} + onChange={(e: React.ChangeEvent) => + setShowInspector(e.target.checked) + } /> diff --git a/src/settings/rageshake.js b/src/settings/rageshake.ts similarity index 81% rename from src/settings/rageshake.js rename to src/settings/rageshake.ts index c9e855e..72b5068 100644 --- a/src/settings/rageshake.js +++ b/src/settings/rageshake.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ /* Copyright 2017 OpenMarket Ltd Copyright 2018 New Vector Ltd @@ -37,19 +38,33 @@ limitations under the License. // actually timestamps. We then purge the remaining logs. We also do this // purge on startup to prevent logs from accumulating. -// the frequency with which we flush to indexeddb import { logger } from "matrix-js-sdk/src/logger"; +import { randomString } from "matrix-js-sdk/src/randomstring"; +// the frequency with which we flush to indexeddb const FLUSH_RATE_MS = 30 * 1000; // the length of log data we keep in indexeddb (and include in the reports) const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB -// A class which monkey-patches the global console and stores log lines. -export class ConsoleLogger { - logs = ""; +type LogFunction = ( + ...args: (Error | DOMException | object | string)[] +) => void; +type LogFunctionName = "log" | "info" | "warn" | "error"; - monkeyPatch(consoleObj) { +// A class which monkey-patches the global console and stores log lines. + +interface LogEntry { + id: string; + lines: string; + index?: number; +} + +export class ConsoleLogger { + private logs = ""; + private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {}; + + public monkeyPatch(consoleObj: Console): void { // Monkey-patch console logging const consoleFunctionsToLevels = { log: "I", @@ -60,6 +75,7 @@ export class ConsoleLogger { Object.keys(consoleFunctionsToLevels).forEach((fnName) => { const level = consoleFunctionsToLevels[fnName]; const originalFn = consoleObj[fnName].bind(consoleObj); + this.originalFunctions[fnName] = originalFn; consoleObj[fnName] = (...args) => { this.log(level, ...args); originalFn(...args); @@ -67,7 +83,17 @@ export class ConsoleLogger { }); } - log(level, ...args) { + public bypassRageshake( + fnName: LogFunctionName, + ...args: (Error | DOMException | object | string)[] + ): void { + this.originalFunctions[fnName](...args); + } + + public log( + level: string, + ...args: (Error | DOMException | object | string)[] + ): void { // We don't know what locale the user may be running so use ISO strings const ts = new Date().toISOString(); @@ -78,21 +104,7 @@ export class ConsoleLogger { } else if (arg instanceof Error) { return arg.message + (arg.stack ? `\n${arg.stack}` : ""); } else if (typeof arg === "object") { - try { - return JSON.stringify(arg); - } catch (e) { - // In development, it can be useful to log complex cyclic - // objects to the console for inspection. This is fine for - // the console, but default `stringify` can't handle that. - // We workaround this by using a special replacer function - // to only log values of the root object and avoid cycles. - return JSON.stringify(arg, (key, value) => { - if (key && typeof value === "object") { - return ""; - } - return value; - }); - } + return JSON.stringify(arg, getCircularReplacer()); } else { return arg; } @@ -116,7 +128,7 @@ export class ConsoleLogger { * @param {boolean} keepLogs True to not delete logs after flushing. * @return {string} \n delimited log lines to flush. */ - flush(keepLogs) { + public flush(keepLogs?: boolean): string { // The ConsoleLogger doesn't care how these end up on disk, it just // flushes them to the caller. if (keepLogs) { @@ -130,24 +142,23 @@ export class ConsoleLogger { // A class which stores log lines in an IndexedDB instance. export class IndexedDBLogStore { - index = 0; - db = null; - flushPromise = null; - flushAgainPromise = null; + private index = 0; + private db: IDBDatabase = null; + private flushPromise: Promise = null; + private flushAgainPromise: Promise = null; + private id: string; - constructor(indexedDB, logger) { - this.indexedDB = indexedDB; - this.logger = logger; - this.id = "instance-" + Math.random() + Date.now(); + constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) { + this.id = "instance-" + randomString(16); } /** * @return {Promise} Resolves when the store is ready. */ - connect() { + public connect(): Promise { const req = this.indexedDB.open("logs"); return new Promise((resolve, reject) => { - req.onsuccess = (event) => { + req.onsuccess = (event: Event) => { // @ts-ignore this.db = event.target.result; // Periodically flush logs to local storage / indexeddb @@ -206,7 +217,7 @@ export class IndexedDBLogStore { * * @return {Promise} Resolved when the logs have been flushed. */ - flush() { + public flush(): Promise { // check if a flush() operation is ongoing if (this.flushPromise) { if (this.flushAgainPromise) { @@ -225,7 +236,7 @@ export class IndexedDBLogStore { } // there is no flush promise or there was but it has finished, so do // a brand new one, destroying the chain which may have been built up. - this.flushPromise = new Promise((resolve, reject) => { + this.flushPromise = new Promise((resolve, reject) => { if (!this.db) { // not connected yet or user rejected access for us to r/w to the db. reject(new Error("No connected database")); @@ -243,6 +254,7 @@ export class IndexedDBLogStore { }; txn.onerror = (event) => { logger.error("Failed to flush logs : ", event); + // @ts-ignore reject(new Error("Failed to write logs: " + event.target.errorCode)); }; objStore.add(this.generateLogEntry(lines)); @@ -264,12 +276,12 @@ export class IndexedDBLogStore { * log ID). The objects have said log ID in an "id" field and "lines" which * is a big string with all the new-line delimited logs. */ - async consume() { + public async consume(): Promise { const db = this.db; // Returns: a string representing the concatenated logs for this ID. // Stops adding log fragments when the size exceeds maxSize - function fetchLogs(id, maxSize) { + function fetchLogs(id: string, maxSize: number): Promise { const objectStore = db .transaction("logs", "readonly") .objectStore("logs"); @@ -280,9 +292,11 @@ export class IndexedDBLogStore { .openCursor(IDBKeyRange.only(id), "prev"); let lines = ""; query.onerror = (event) => { + // @ts-ignore reject(new Error("Query failed: " + event.target.errorCode)); }; query.onsuccess = (event) => { + // @ts-ignore const cursor = event.target.result; if (!cursor) { resolve(lines); @@ -299,12 +313,12 @@ export class IndexedDBLogStore { } // Returns: A sorted array of log IDs. (newest first) - function fetchLogIds() { + function fetchLogIds(): Promise { // To gather all the log IDs, query for all records in logslastmod. const o = db .transaction("logslastmod", "readonly") .objectStore("logslastmod"); - return selectQuery(o, undefined, (cursor) => { + return selectQuery<{ ts: number; id: string }>(o, undefined, (cursor) => { return { id: cursor.value.id, ts: cursor.value.ts, @@ -319,13 +333,14 @@ export class IndexedDBLogStore { }); } - function deleteLogs(id) { - return new Promise((resolve, reject) => { + function deleteLogs(id: number): Promise { + return new Promise((resolve, reject) => { const txn = db.transaction(["logs", "logslastmod"], "readwrite"); const o = txn.objectStore("logs"); // only load the key path, not the data which may be huge const query = o.index("id").openKeyCursor(IDBKeyRange.only(id)); query.onsuccess = (event) => { + // @ts-ignore const cursor = event.target.result; if (!cursor) { return; @@ -340,6 +355,7 @@ export class IndexedDBLogStore { reject( new Error( "Failed to delete logs for " + + // @ts-ignore `'${id}' : ${event.target.errorCode}` ) ); @@ -352,7 +368,7 @@ export class IndexedDBLogStore { const allLogIds = await fetchLogIds(); let removeLogIds = []; - const logs = []; + const logs: LogEntry[] = []; let size = 0; for (let i = 0; i < allLogIds.length; i++) { const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size); @@ -390,7 +406,7 @@ export class IndexedDBLogStore { return logs; } - generateLogEntry(lines) { + private generateLogEntry(lines: string): LogEntry { return { id: this.id, lines: lines, @@ -398,7 +414,7 @@ export class IndexedDBLogStore { }; } - generateLastModifiedTime() { + private generateLastModifiedTime(): { id: string; ts: number } { return { id: this.id, ts: Date.now(), @@ -416,7 +432,11 @@ export class IndexedDBLogStore { * @return {Promise} Resolves to an array of whatever you returned from * resultMapper. */ -function selectQuery(store, keyRange, resultMapper) { +function selectQuery( + store: IDBObjectStore, + keyRange: IDBKeyRange, + resultMapper: (cursor: IDBCursorWithValue) => T +): Promise { const query = store.openCursor(keyRange); return new Promise((resolve, reject) => { const results = []; @@ -437,6 +457,16 @@ function selectQuery(store, keyRange, resultMapper) { }; }); } +declare global { + // eslint-disable-next-line no-var, camelcase + var mx_rage_store: IndexedDBLogStore; + // eslint-disable-next-line no-var, camelcase + var mx_rage_logger: ConsoleLogger; + // eslint-disable-next-line no-var, camelcase + var mx_rage_initPromise: Promise; + // eslint-disable-next-line no-var, camelcase + var mx_rage_initStoragePromise: Promise; +} /** * Configure rage shaking support for sending bug reports. @@ -445,7 +475,7 @@ function selectQuery(store, keyRange, resultMapper) { * be set up immediately for the logs. * @return {Promise} Resolves when set up. */ -export function init(setUpPersistence = true) { +export function init(setUpPersistence = true): Promise { if (global.mx_rage_initPromise) { return global.mx_rage_initPromise; } @@ -465,7 +495,7 @@ export function init(setUpPersistence = true) { * then this no-ops. * @return {Promise} Resolves when complete. */ -export function tryInitStorage() { +export function tryInitStorage(): Promise { if (global.mx_rage_initStoragePromise) { return global.mx_rage_initStoragePromise; } @@ -491,7 +521,7 @@ export function tryInitStorage() { return global.mx_rage_initStoragePromise; } -export function flush() { +export function flush(): Promise { if (!global.mx_rage_store) { return; } @@ -502,7 +532,7 @@ export function flush() { * Clean up old logs. * @return {Promise} Resolves if cleaned logs. */ -export async function cleanup() { +export async function cleanup(): Promise { if (!global.mx_rage_store) { return; } @@ -512,9 +542,9 @@ export async function cleanup() { /** * Get a recent snapshot of the logs, ready for attaching to a bug report * - * @return {Array<{lines: string, id, string}>} list of log data + * @return {LogEntry[]} list of log data */ -export async function getLogsForReport() { +export async function getLogsForReport(): Promise { if (!global.mx_rage_logger) { throw new Error("No console logger, did you forget to call init()?"); } @@ -523,7 +553,7 @@ export async function getLogsForReport() { if (global.mx_rage_store) { // flush most recent logs await global.mx_rage_store.flush(); - return await global.mx_rage_store.consume(); + return global.mx_rage_store.consume(); } else { return [ { @@ -533,3 +563,24 @@ export async function getLogsForReport() { ]; } } + +type StringifyReplacer = ( + this: unknown, + key: string, + value: unknown +) => unknown; + +// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references +// Injects `<$ cycle-trimmed $>` wherever it cuts a cyclical object relationship +const getCircularReplacer = (): StringifyReplacer => { + const seen = new WeakSet(); + return (key: string, value: unknown): unknown => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return "<$ cycle-trimmed $>"; + } + seen.add(value); + } + return value; + }; +}; diff --git a/src/settings/submit-rageshake.js b/src/settings/submit-rageshake.ts similarity index 83% rename from src/settings/submit-rageshake.js rename to src/settings/submit-rageshake.ts index 3c10e57..20b0785 100644 --- a/src/settings/submit-rageshake.js +++ b/src/settings/submit-rageshake.ts @@ -15,14 +15,31 @@ limitations under the License. */ import { useCallback, useContext, useEffect, useState } from "react"; -import { getLogsForReport } from "./rageshake"; import pako from "pako"; +import { MatrixEvent } from "matrix-js-sdk"; +import { OverlayTriggerState } from "@react-stately/overlays"; +import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; + +import { getLogsForReport } from "./rageshake"; import { useClient } from "../ClientContext"; import { InspectorContext } from "../room/GroupCallInspector"; import { useModalTriggerState } from "../Modal"; -export function useSubmitRageshake() { - const { client } = useClient(); +interface RageShakeSubmitOptions { + description: string; + roomId: string; + label: string; + sendLogs: boolean; + rageshakeRequestId: string; +} + +export function useSubmitRageshake(): { + submitRageshake: (opts: RageShakeSubmitOptions) => Promise; + sending: boolean; + sent: boolean; + error: Error; +} { + const client: MatrixClient = useClient().client; const [{ json }] = useContext(InspectorContext); const [{ sending, sent, error }, setState] = useState({ @@ -57,9 +74,12 @@ export function useSubmitRageshake() { opts.description || "User did not supply any additional text." ); body.append("app", "matrix-video-chat"); - body.append("version", import.meta.env.VITE_APP_VERSION || "dev"); + body.append( + "version", + (import.meta.env.VITE_APP_VERSION as string) || "dev" + ); body.append("user_agent", userAgent); - body.append("installed_pwa", false); + body.append("installed_pwa", "false"); body.append("touch_input", touchInput); if (client) { @@ -181,7 +201,11 @@ export function useSubmitRageshake() { if (navigator.storage && navigator.storage.estimate) { try { - const estimate = await navigator.storage.estimate(); + const estimate: { + quota?: number; + usage?: number; + usageDetails?: { [x: string]: unknown }; + } = await navigator.storage.estimate(); body.append("storageManager_quota", String(estimate.quota)); body.append("storageManager_usage", String(estimate.usage)); if (estimate.usageDetails) { @@ -201,7 +225,6 @@ export function useSubmitRageshake() { for (const entry of logs) { // encode as UTF-8 let buf = new TextEncoder().encode(entry.lines); - // compress buf = pako.gzip(buf); @@ -225,7 +248,7 @@ export function useSubmitRageshake() { } await fetch( - import.meta.env.VITE_RAGESHAKE_SUBMIT_URL || + (import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) || "https://element.io/bugreports/submit", { method: "POST", @@ -250,7 +273,7 @@ export function useSubmitRageshake() { }; } -export function useDownloadDebugLog() { +export function useDownloadDebugLog(): () => void { const [{ json }] = useContext(InspectorContext); const downloadDebugLog = useCallback(() => { @@ -271,7 +294,10 @@ export function useDownloadDebugLog() { return downloadDebugLog; } -export function useRageshakeRequest() { +export function useRageshakeRequest(): ( + roomId: string, + rageshakeRequestId: string +) => void { const { client } = useClient(); const sendRageshakeRequest = useCallback( @@ -285,14 +311,27 @@ export function useRageshakeRequest() { return sendRageshakeRequest; } +interface ModalProps { + isOpen: boolean; + onClose: () => void; +} +interface ModalPropsWithId extends ModalProps { + rageshakeRequestId: string; +} -export function useRageshakeRequestModal(roomId) { - const { modalState, modalProps } = useModalTriggerState(); - const { client } = useClient(); - const [rageshakeRequestId, setRageshakeRequestId] = useState(); +export function useRageshakeRequestModal(roomId: string): { + modalState: OverlayTriggerState; + modalProps: ModalPropsWithId; +} { + const { modalState, modalProps } = useModalTriggerState() as { + modalState: OverlayTriggerState; + modalProps: ModalProps; + }; + const client: MatrixClient = useClient().client; + const [rageshakeRequestId, setRageshakeRequestId] = useState(); useEffect(() => { - const onEvent = (event) => { + const onEvent = (event: MatrixEvent) => { const type = event.getType(); if ( @@ -305,10 +344,10 @@ export function useRageshakeRequestModal(roomId) { } }; - client.on("event", onEvent); + client.on(ClientEvent.Event, onEvent); return () => { - client.removeListener("event", onEvent); + client.removeListener(ClientEvent.Event, onEvent); }; }, [modalState.open, roomId, client, modalState]); diff --git a/src/settings/useMediaHandler.jsx b/src/settings/useMediaHandler.tsx similarity index 66% rename from src/settings/useMediaHandler.jsx rename to src/settings/useMediaHandler.tsx index a9c4a76..bd2acd3 100644 --- a/src/settings/useMediaHandler.jsx +++ b/src/settings/useMediaHandler.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ /* Copyright 2022 Matrix.org Foundation C.I.C. @@ -14,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixClient } from "matrix-js-sdk"; +import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler"; import React, { useState, useEffect, @@ -23,9 +26,27 @@ import React, { createContext, } from "react"; -const MediaHandlerContext = createContext(); +export interface MediaHandlerContextInterface { + audioInput: string; + audioInputs: MediaDeviceInfo[]; + setAudioInput: (deviceId: string) => void; + videoInput: string; + videoInputs: MediaDeviceInfo[]; + setVideoInput: (deviceId: string) => void; + audioOutput: string; + audioOutputs: MediaDeviceInfo[]; + setAudioOutput: (deviceId: string) => void; +} -function getMediaPreferences() { +const MediaHandlerContext = + createContext(undefined); + +interface MediaPreferences { + audioInput?: string; + videoInput?: string; + audioOutput?: string; +} +function getMediaPreferences(): MediaPreferences { const mediaPreferences = localStorage.getItem("matrix-media-preferences"); if (mediaPreferences) { @@ -39,8 +60,8 @@ function getMediaPreferences() { } } -function updateMediaPreferences(newPreferences) { - const oldPreferences = getMediaPreferences(newPreferences); +function updateMediaPreferences(newPreferences: MediaPreferences): void { + const oldPreferences = getMediaPreferences(); localStorage.setItem( "matrix-media-preferences", @@ -50,8 +71,11 @@ function updateMediaPreferences(newPreferences) { }) ); } - -export function MediaHandlerProvider({ client, children }) { +interface Props { + client: MatrixClient; + children: JSX.Element[]; +} +export function MediaHandlerProvider({ client, children }: Props): JSX.Element { const [ { audioInput, @@ -72,7 +96,9 @@ export function MediaHandlerProvider({ client, children }) { ); return { + // @ts-ignore, ignore that audioInput is a private members of mediaHandler audioInput: mediaHandler.audioInput, + // @ts-ignore, ignore that videoInput is a private members of mediaHandler videoInput: mediaHandler.videoInput, audioOutput: undefined, audioInputs: [], @@ -84,7 +110,7 @@ export function MediaHandlerProvider({ client, children }) { useEffect(() => { const mediaHandler = client.getMediaHandler(); - function updateDevices() { + function updateDevices(): void { navigator.mediaDevices.enumerateDevices().then((devices) => { const mediaPreferences = getMediaPreferences(); @@ -92,9 +118,10 @@ export function MediaHandlerProvider({ client, children }) { (device) => device.kind === "audioinput" ); const audioConnected = audioInputs.some( + // @ts-ignore (device) => device.deviceId === mediaHandler.audioInput ); - + // @ts-ignore let audioInput = mediaHandler.audioInput; if (!audioConnected && audioInputs.length > 0) { @@ -105,9 +132,11 @@ export function MediaHandlerProvider({ client, children }) { (device) => device.kind === "videoinput" ); const videoConnected = videoInputs.some( + // @ts-ignore (device) => device.deviceId === mediaHandler.videoInput ); + // @ts-ignore let videoInput = mediaHandler.videoInput; if (!videoConnected && videoInputs.length > 0) { @@ -129,7 +158,9 @@ export function MediaHandlerProvider({ client, children }) { } if ( + // @ts-ignore mediaHandler.videoInput !== videoInput || + // @ts-ignore mediaHandler.audioInput !== audioInput ) { mediaHandler.setMediaInputs(audioInput, videoInput); @@ -149,18 +180,21 @@ export function MediaHandlerProvider({ client, children }) { } updateDevices(); - mediaHandler.on("local_streams_changed", updateDevices); + mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices); navigator.mediaDevices.addEventListener("devicechange", updateDevices); return () => { - mediaHandler.removeListener("local_streams_changed", updateDevices); + mediaHandler.removeListener( + MediaHandlerEvent.LocalStreamsChanged, + updateDevices + ); navigator.mediaDevices.removeEventListener("devicechange", updateDevices); mediaHandler.stopAllStreams(); }; }, [client]); - const setAudioInput = useCallback( - (deviceId) => { + const setAudioInput: (deviceId: string) => void = useCallback( + (deviceId: string) => { updateMediaPreferences({ audioInput: deviceId }); setState((prevState) => ({ ...prevState, audioInput: deviceId })); client.getMediaHandler().setAudioInput(deviceId); @@ -168,7 +202,7 @@ export function MediaHandlerProvider({ client, children }) { [client] ); - const setVideoInput = useCallback( + const setVideoInput: (deviceId: string) => void = useCallback( (deviceId) => { updateMediaPreferences({ videoInput: deviceId }); setState((prevState) => ({ ...prevState, videoInput: deviceId })); @@ -177,35 +211,36 @@ export function MediaHandlerProvider({ client, children }) { [client] ); - const setAudioOutput = useCallback((deviceId) => { + const setAudioOutput: (deviceId: string) => void = useCallback((deviceId) => { updateMediaPreferences({ audioOutput: deviceId }); setState((prevState) => ({ ...prevState, audioOutput: deviceId })); }, []); - const context = useMemo( - () => ({ - audioInput, - audioInputs, - setAudioInput, - videoInput, - videoInputs, - setVideoInput, - audioOutput, - audioOutputs, - setAudioOutput, - }), - [ - audioInput, - audioInputs, - setAudioInput, - videoInput, - videoInputs, - setVideoInput, - audioOutput, - audioOutputs, - setAudioOutput, - ] - ); + const context: MediaHandlerContextInterface = + useMemo( + () => ({ + audioInput, + audioInputs, + setAudioInput, + videoInput, + videoInputs, + setVideoInput, + audioOutput, + audioOutputs, + setAudioOutput, + }), + [ + audioInput, + audioInputs, + setAudioInput, + videoInput, + videoInputs, + setVideoInput, + audioOutput, + audioOutputs, + setAudioOutput, + ] + ); return (