From 4ec1ff5c595f5ac4a02a76e1c966473bed706652 Mon Sep 17 00:00:00 2001 From: Paul Date: Sat, 7 Aug 2021 16:15:55 +0100 Subject: [PATCH] Re-write voice context. Working towards #21 --- src/context/Voice.tsx | 213 ------------------ src/context/index.tsx | 5 +- src/lib/vortex/VoiceState.ts | 161 +++++++++++++ src/pages/channels/actions/HeaderActions.tsx | 25 +- .../channels/messaging/MessageRenderer.tsx | 6 +- src/pages/channels/voice/VoiceHeader.tsx | 105 ++------- src/pages/friends/Friend.tsx | 5 +- 7 files changed, 195 insertions(+), 325 deletions(-) delete mode 100644 src/context/Voice.tsx create mode 100644 src/lib/vortex/VoiceState.ts diff --git a/src/context/Voice.tsx b/src/context/Voice.tsx deleted file mode 100644 index 7ec5e230..00000000 --- a/src/context/Voice.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { Channel } from "revolt.js/dist/maps/Channels"; - -import { createContext } from "preact"; -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "preact/hooks"; - -import type { ProduceType, VoiceUser } from "../lib/vortex/Types"; -import type VoiceClient from "../lib/vortex/VoiceClient"; - -import { Children } from "../types/Preact"; -import { SoundContext } from "./Settings"; - -export enum VoiceStatus { - LOADING = 0, - UNAVAILABLE, - ERRORED, - READY = 3, - CONNECTING = 4, - AUTHENTICATING, - RTC_CONNECTING, - CONNECTED, - // RECONNECTING -} - -export interface VoiceOperations { - connect: (channel: Channel) => Promise; - disconnect: () => void; - isProducing: (type: ProduceType) => boolean; - startProducing: (type: ProduceType) => Promise; - stopProducing: (type: ProduceType) => Promise | undefined; -} - -export interface VoiceState { - roomId?: string; - status: VoiceStatus; - participants?: Readonly>; -} - -// They should be present from first render. - insert's words -export const VoiceContext = createContext(null!); -export const VoiceOperationsContext = createContext(null!); - -type Props = { - children: Children; -}; - -export default function Voice({ children }: Props) { - const [client, setClient] = useState(undefined); - const [state, setState] = useState({ - status: VoiceStatus.LOADING, - participants: new Map(), - }); - - const setStatus = useCallback( - (status: VoiceStatus, roomId?: string) => { - setState({ - status, - roomId: roomId ?? client?.roomId, - participants: client?.participants ?? new Map(), - }); - }, - // eslint-disable-next-line - [], - ); - - useEffect(() => { - import("../lib/vortex/VoiceClient") - .then(({ default: VoiceClient }) => { - const client = new VoiceClient(); - setClient(client); - - if (!client?.supported()) { - setStatus(VoiceStatus.UNAVAILABLE); - } else { - setStatus(VoiceStatus.READY); - } - }) - .catch((err) => { - console.error("Failed to load voice library!", err); - setStatus(VoiceStatus.UNAVAILABLE); - }); - // eslint-disable-next-line - }, []); - - const isConnecting = useRef(false); - const operations: VoiceOperations = useMemo(() => { - return { - connect: async (channel) => { - if (!client?.supported()) throw new Error("RTC is unavailable"); - - isConnecting.current = true; - setStatus(VoiceStatus.CONNECTING, channel._id); - - try { - const call = await channel.joinCall(); - - if (!isConnecting.current) { - setStatus(VoiceStatus.READY); - return channel; - } - - // ! TODO: use configuration to check if voso is enabled - // await client.connect("wss://voso.revolt.chat/ws"); - await client.connect( - "wss://voso.revolt.chat/ws", - channel._id, - ); - - setStatus(VoiceStatus.AUTHENTICATING); - - await client.authenticate(call.token); - setStatus(VoiceStatus.RTC_CONNECTING); - - await client.initializeTransports(); - } catch (error) { - console.error(error); - setStatus(VoiceStatus.READY); - return channel; - } - - setStatus(VoiceStatus.CONNECTED); - isConnecting.current = false; - return channel; - }, - disconnect: () => { - if (!client?.supported()) throw new Error("RTC is unavailable"); - - // if (status <= VoiceStatus.READY) return; - // this will not update in this context - - isConnecting.current = false; - client.disconnect(); - setStatus(VoiceStatus.READY); - }, - isProducing: (type: ProduceType) => { - switch (type) { - case "audio": - return client?.audioProducer !== undefined; - } - }, - startProducing: async (type: ProduceType) => { - switch (type) { - case "audio": { - if (client?.audioProducer !== undefined) - return console.log("No audio producer."); // ! TODO: let the user know - if (navigator.mediaDevices === undefined) - return console.log("No media devices."); // ! TODO: let the user know - const mediaStream = - await navigator.mediaDevices.getUserMedia({ - audio: true, - }); - - await client?.startProduce( - mediaStream.getAudioTracks()[0], - "audio", - ); - return; - } - } - }, - stopProducing: (type: ProduceType) => { - return client?.stopProduce(type); - }, - }; - // eslint-disable-next-line - }, [client]); - - const playSound = useContext(SoundContext); - - useEffect(() => { - if (!client?.supported()) return; - - // ! TODO: message for fatal: - // ! get rid of these force updates - // ! handle it through state or smth - - function stateUpdate() { - setStatus(state.status); - } - - client.on("startProduce", stateUpdate); - client.on("stopProduce", stateUpdate); - client.on("userJoined", stateUpdate); - client.on("userLeft", stateUpdate); - client.on("userStartProduce", stateUpdate); - client.on("userStopProduce", stateUpdate); - client.on("close", stateUpdate); - - return () => { - client.removeListener("startProduce", stateUpdate); - client.removeListener("stopProduce", stateUpdate); - client.removeListener("userJoined", stateUpdate); - client.removeListener("userLeft", stateUpdate); - client.removeListener("userStartProduce", stateUpdate); - client.removeListener("userStopProduce", stateUpdate); - client.removeListener("close", stateUpdate); - }; - }, [client, state, playSound, setStatus]); - - return ( - - - {children} - - - ); -} diff --git a/src/context/index.tsx b/src/context/index.tsx index f7a3ccb6..60f3694c 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -6,7 +6,6 @@ import { Children } from "../types/Preact"; import Locale from "./Locale"; import Settings from "./Settings"; import Theme from "./Theme"; -import Voice from "./Voice"; import Intermediate from "./intermediate/Intermediate"; import Client from "./revoltjs/RevoltClient"; @@ -18,9 +17,7 @@ export default function Context({ children }: { children: Children }) { - - {children} - + {children} diff --git a/src/lib/vortex/VoiceState.ts b/src/lib/vortex/VoiceState.ts new file mode 100644 index 00000000..d7fd6c9f --- /dev/null +++ b/src/lib/vortex/VoiceState.ts @@ -0,0 +1,161 @@ +import { action, makeAutoObservable, runInAction } from "mobx"; +import { Channel } from "revolt.js/dist/maps/Channels"; +import { Nullable, toNullable } from "revolt.js/dist/util/null"; + +import type { ProduceType, VoiceUser } from "./Types"; +import type VoiceClient from "./VoiceClient"; + +export enum VoiceStatus { + LOADING = 0, + UNAVAILABLE, + ERRORED, + READY = 3, + CONNECTING = 4, + UNLOADED = 5, + AUTHENTICATING, + RTC_CONNECTING, + CONNECTED, + // RECONNECTING +} + +// This is an example of how to implement MobX state. +// * Note for better implementation: +// * MobX state should be implemented on the VoiceClient itself. +class VoiceStateReference { + client?: VoiceClient; + connecting?: boolean; + + status: VoiceStatus; + roomId: Nullable; + participants: Map; + + constructor() { + this.roomId = null; + this.status = VoiceStatus.UNLOADED; + this.participants = new Map(); + + makeAutoObservable(this, { + client: false, + connecting: false, + }); + } + + // This takes information from the voice + // client and applies it to the state here. + @action syncState() { + if (!this.client) return; + this.roomId = toNullable(this.client.roomId); + this.participants.clear(); + this.client.participants.forEach((v, k) => this.participants.set(k, v)); + } + + // This imports and constructs the voice client. + @action async loadVoice() { + if (this.status !== VoiceStatus.UNLOADED) return; + this.status = VoiceStatus.LOADING; + + try { + const { default: VoiceClient } = await import("./VoiceClient"); + const client = new VoiceClient(); + + runInAction(() => { + if (!client.supported()) { + this.status = VoiceStatus.UNAVAILABLE; + } else { + this.status = VoiceStatus.READY; + this.client = client; + } + }); + } catch (err) { + console.error("Failed to load voice library!", err); + runInAction(() => { + this.status = VoiceStatus.UNAVAILABLE; + }); + } + } + + // Connect to a voice channel. + @action async connect(channel: Channel) { + if (!this.client?.supported()) throw new Error("RTC is unavailable"); + + this.connecting = true; + this.status = VoiceStatus.CONNECTING; + + try { + const call = await channel.joinCall(); + + await this.client.connect("wss://voso.revolt.chat/ws", channel._id); + + runInAction(() => { + this.status = VoiceStatus.AUTHENTICATING; + }); + + await this.client.authenticate(call.token); + + runInAction(() => { + this.status = VoiceStatus.RTC_CONNECTING; + }); + + await this.client.initializeTransports(); + } catch (err) { + console.error(err); + + runInAction(() => { + this.status = VoiceStatus.READY; + }); + + return channel; + } + + runInAction(() => { + this.status = VoiceStatus.CONNECTED; + this.connecting = false; + }); + + return channel; + } + + // Disconnect from current channel. + @action disconnect() { + if (!this.client?.supported()) throw new Error("RTC is unavailable"); + + this.connecting = false; + this.status = VoiceStatus.READY; + + this.client.disconnect(); + } + + isProducing(type: ProduceType) { + switch (type) { + case "audio": + return this.client?.audioProducer !== undefined; + } + } + + async startProducing(type: ProduceType) { + switch (type) { + case "audio": { + if (this.client?.audioProducer !== undefined) + return console.log("No audio producer."); // ! TODO: let the user know + + if (navigator.mediaDevices === undefined) + return console.log("No media devices."); // ! TODO: let the user know + + const mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + + await this.client?.startProduce( + mediaStream.getAudioTracks()[0], + "audio", + ); + } + } + } + + stopProducing(type: ProduceType) { + this.client?.stopProduce(type); + } +} + +export const voiceState = new VoiceStateReference(); diff --git a/src/pages/channels/actions/HeaderActions.tsx b/src/pages/channels/actions/HeaderActions.tsx index 29e9ba4a..fa304598 100644 --- a/src/pages/channels/actions/HeaderActions.tsx +++ b/src/pages/channels/actions/HeaderActions.tsx @@ -8,13 +8,8 @@ import { } from "@styled-icons/boxicons-solid"; import { useHistory } from "react-router-dom"; -import { useContext } from "preact/hooks"; +import { voiceState, VoiceStatus } from "../../../lib/vortex/VoiceState"; -import { - VoiceContext, - VoiceOperationsContext, - VoiceStatus, -} from "../../../context/Voice"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import UpdateIndicator from "../../../components/common/UpdateIndicator"; @@ -74,27 +69,27 @@ function VoiceActions({ channel }: Pick) { ) return null; - const voice = useContext(VoiceContext); - const { connect, disconnect } = useContext(VoiceOperationsContext); - - if (voice.status >= VoiceStatus.READY) { - if (voice.roomId === channel._id) { + if (voiceState.status >= VoiceStatus.READY) { + if (voiceState.roomId === channel._id) { return ( - + ); } + return ( { - disconnect(); - connect(channel); + onClick={async () => { + await voiceState.loadVoice(); + voiceState.disconnect(); + voiceState.connect(channel); }}> ); } + return ( diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx index 0e7a6fb4..fc470c96 100644 --- a/src/pages/channels/messaging/MessageRenderer.tsx +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -72,7 +72,11 @@ function MessageRenderer({ id, state, queue, highlight }: Props) { const subs = [ internalSubscribe("MessageRenderer", "edit_last", editLast), - internalSubscribe("MessageRenderer", "edit_message", setEditing), + internalSubscribe( + "MessageRenderer", + "edit_message", + setEditing as (...args: unknown[]) => void, + ), ]; return () => subs.forEach((unsub) => unsub()); diff --git a/src/pages/channels/voice/VoiceHeader.tsx b/src/pages/channels/voice/VoiceHeader.tsx index e957e7e4..2b310eef 100644 --- a/src/pages/channels/voice/VoiceHeader.tsx +++ b/src/pages/channels/voice/VoiceHeader.tsx @@ -3,13 +3,10 @@ import { observer } from "mobx-react-lite"; import styled from "styled-components"; import { Text } from "preact-i18n"; -import { useContext } from "preact/hooks"; +import { useMemo } from "preact/hooks"; + +import { voiceState, VoiceStatus } from "../../../lib/vortex/VoiceState"; -import { - VoiceContext, - VoiceOperationsContext, - VoiceStatus, -} from "../../../context/Voice"; import { useClient } from "../../../context/revoltjs/RevoltClient"; import UserIcon from "../../../components/common/user/UserIcon"; @@ -68,19 +65,16 @@ const VoiceBase = styled.div` `; export default observer(({ id }: Props) => { - const { status, participants, roomId } = useContext(VoiceContext); - if (roomId !== id) return null; - - const { isProducing, startProducing, stopProducing, disconnect } = - useContext(VoiceOperationsContext); + if (voiceState.roomId !== id) return null; const client = useClient(); const self = client.users.get(client.user!._id); - //const ctx = useForceUpdate(); - //const self = useSelf(ctx); - const keys = participants ? Array.from(participants.keys()) : undefined; - const users = keys?.map((key) => client.users.get(key)); + const keys = Array.from(voiceState.participants.keys()); + const users = useMemo(() => { + return keys.map((key) => client.users.get(key)); + // eslint-disable-next-line + }, [keys]); return ( @@ -95,7 +89,8 @@ export default observer(({ id }: Props) => { target={user} status={false} voice={ - participants!.get(id)?.audio + voiceState.participants!.get(id) + ?.audio ? undefined : "muted" } @@ -115,20 +110,20 @@ export default observer(({ id }: Props) => {
- {status === VoiceStatus.CONNECTED && ( + {voiceState.status === VoiceStatus.CONNECTED && ( )}
- - {isProducing("audio") ? ( - ) : ( - )} @@ -136,71 +131,3 @@ export default observer(({ id }: Props) => { ); }); - -/**{voice.roomId === id && ( -
-
- {participants.length !== 0 ? participants.map((user, index) => { - const id = participantIds[index]; - return ( -
- -
- ); - }) : self !== undefined && ( -
- -
- )} -
-
- - { voice.status === VoiceStatus.CONNECTED && } -
-
- - {voice.operations.isProducing("audio") ? ( - - ) : ( - - )} -
-
- )} */ diff --git a/src/pages/friends/Friend.tsx b/src/pages/friends/Friend.tsx index 765ac03b..c52b10d4 100644 --- a/src/pages/friends/Friend.tsx +++ b/src/pages/friends/Friend.tsx @@ -12,8 +12,8 @@ import { Text } from "preact-i18n"; import { useContext } from "preact/hooks"; import { stopPropagation } from "../../lib/stopPropagation"; +import { voiceState } from "../../lib/vortex/VoiceState"; -import { VoiceOperationsContext } from "../../context/Voice"; import { useIntermediate } from "../../context/intermediate/Intermediate"; import UserIcon from "../../components/common/user/UserIcon"; @@ -29,7 +29,6 @@ interface Props { export const Friend = observer(({ user }: Props) => { const history = useHistory(); const { openScreen } = useIntermediate(); - const { connect } = useContext(VoiceOperationsContext); const actions: Children[] = []; let subtext: Children = null; @@ -46,7 +45,7 @@ export const Friend = observer(({ user }: Props) => { ev, user .openDM() - .then(connect) + .then(voiceState.connect) .then((x) => history.push(`/channel/${x._id}`)), ) }>