Re-write voice context. Working towards #21

This commit is contained in:
Paul 2021-08-07 16:15:55 +01:00
parent 0fe154c651
commit 4ec1ff5c59
7 changed files with 195 additions and 325 deletions

View file

@ -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<Channel>;
disconnect: () => void;
isProducing: (type: ProduceType) => boolean;
startProducing: (type: ProduceType) => Promise<void>;
stopProducing: (type: ProduceType) => Promise<void> | undefined;
}
export interface VoiceState {
roomId?: string;
status: VoiceStatus;
participants?: Readonly<Map<string, VoiceUser>>;
}
// They should be present from first render. - insert's words
export const VoiceContext = createContext<VoiceState>(null!);
export const VoiceOperationsContext = createContext<VoiceOperations>(null!);
type Props = {
children: Children;
};
export default function Voice({ children }: Props) {
const [client, setClient] = useState<VoiceClient | undefined>(undefined);
const [state, setState] = useState<VoiceState>({
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 (
<VoiceContext.Provider value={state}>
<VoiceOperationsContext.Provider value={operations}>
{children}
</VoiceOperationsContext.Provider>
</VoiceContext.Provider>
);
}

View file

@ -6,7 +6,6 @@ import { Children } from "../types/Preact";
import Locale from "./Locale"; import Locale from "./Locale";
import Settings from "./Settings"; import Settings from "./Settings";
import Theme from "./Theme"; import Theme from "./Theme";
import Voice from "./Voice";
import Intermediate from "./intermediate/Intermediate"; import Intermediate from "./intermediate/Intermediate";
import Client from "./revoltjs/RevoltClient"; import Client from "./revoltjs/RevoltClient";
@ -18,9 +17,7 @@ export default function Context({ children }: { children: Children }) {
<Settings> <Settings>
<Locale> <Locale>
<Intermediate> <Intermediate>
<Client> <Client>{children}</Client>
<Voice>{children}</Voice>
</Client>
</Intermediate> </Intermediate>
</Locale> </Locale>
</Settings> </Settings>

View file

@ -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<string>;
participants: Map<string, VoiceUser>;
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();

View file

@ -8,13 +8,8 @@ import {
} from "@styled-icons/boxicons-solid"; } from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom"; 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 { useIntermediate } from "../../../context/intermediate/Intermediate";
import UpdateIndicator from "../../../components/common/UpdateIndicator"; import UpdateIndicator from "../../../components/common/UpdateIndicator";
@ -74,27 +69,27 @@ function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) {
) )
return null; return null;
const voice = useContext(VoiceContext); if (voiceState.status >= VoiceStatus.READY) {
const { connect, disconnect } = useContext(VoiceOperationsContext); if (voiceState.roomId === channel._id) {
if (voice.status >= VoiceStatus.READY) {
if (voice.roomId === channel._id) {
return ( return (
<IconButton onClick={disconnect}> <IconButton onClick={voiceState.disconnect}>
<PhoneOff size={22} /> <PhoneOff size={22} />
</IconButton> </IconButton>
); );
} }
return ( return (
<IconButton <IconButton
onClick={() => { onClick={async () => {
disconnect(); await voiceState.loadVoice();
connect(channel); voiceState.disconnect();
voiceState.connect(channel);
}}> }}>
<PhoneCall size={24} /> <PhoneCall size={24} />
</IconButton> </IconButton>
); );
} }
return ( return (
<IconButton> <IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" /> <PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />

View file

@ -72,7 +72,11 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
const subs = [ const subs = [
internalSubscribe("MessageRenderer", "edit_last", editLast), internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing), internalSubscribe(
"MessageRenderer",
"edit_message",
setEditing as (...args: unknown[]) => void,
),
]; ];
return () => subs.forEach((unsub) => unsub()); return () => subs.forEach((unsub) => unsub());

View file

@ -3,13 +3,10 @@ import { observer } from "mobx-react-lite";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n"; 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 { useClient } from "../../../context/revoltjs/RevoltClient";
import UserIcon from "../../../components/common/user/UserIcon"; import UserIcon from "../../../components/common/user/UserIcon";
@ -68,19 +65,16 @@ const VoiceBase = styled.div`
`; `;
export default observer(({ id }: Props) => { export default observer(({ id }: Props) => {
const { status, participants, roomId } = useContext(VoiceContext); if (voiceState.roomId !== id) return null;
if (roomId !== id) return null;
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const client = useClient(); const client = useClient();
const self = client.users.get(client.user!._id); const self = client.users.get(client.user!._id);
//const ctx = useForceUpdate(); const keys = Array.from(voiceState.participants.keys());
//const self = useSelf(ctx); const users = useMemo(() => {
const keys = participants ? Array.from(participants.keys()) : undefined; return keys.map((key) => client.users.get(key));
const users = keys?.map((key) => client.users.get(key)); // eslint-disable-next-line
}, [keys]);
return ( return (
<VoiceBase> <VoiceBase>
@ -95,7 +89,8 @@ export default observer(({ id }: Props) => {
target={user} target={user}
status={false} status={false}
voice={ voice={
participants!.get(id)?.audio voiceState.participants!.get(id)
?.audio
? undefined ? undefined
: "muted" : "muted"
} }
@ -115,20 +110,20 @@ export default observer(({ id }: Props) => {
</div> </div>
<div className="status"> <div className="status">
<BarChart size={20} /> <BarChart size={20} />
{status === VoiceStatus.CONNECTED && ( {voiceState.status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" /> <Text id="app.main.channel.voice.connected" />
)} )}
</div> </div>
<div className="actions"> <div className="actions">
<Button error onClick={disconnect}> <Button error onClick={voiceState.disconnect}>
<Text id="app.main.channel.voice.leave" /> <Text id="app.main.channel.voice.leave" />
</Button> </Button>
{isProducing("audio") ? ( {voiceState.isProducing("audio") ? (
<Button onClick={() => stopProducing("audio")}> <Button onClick={() => voiceState.stopProducing("audio")}>
<Text id="app.main.channel.voice.mute" /> <Text id="app.main.channel.voice.mute" />
</Button> </Button>
) : ( ) : (
<Button onClick={() => startProducing("audio")}> <Button onClick={() => voiceState.startProducing("audio")}>
<Text id="app.main.channel.voice.unmute" /> <Text id="app.main.channel.voice.unmute" />
</Button> </Button>
)} )}
@ -136,71 +131,3 @@ export default observer(({ id }: Props) => {
</VoiceBase> </VoiceBase>
); );
}); });
/**{voice.roomId === id && (
<div className={styles.rtc}>
<div className={styles.participants}>
{participants.length !== 0 ? participants.map((user, index) => {
const id = participantIds[index];
return (
<div key={id}>
<UserIcon
size={80}
user={user}
status={false}
voice={
voice.participants.get(id)
?.audio
? undefined
: "muted"
}
/>
</div>
);
}) : self !== undefined && (
<div key={self._id} className={styles.disconnected}>
<UserIcon
size={80}
user={self}
status={false}
/>
</div>
)}
</div>
<div className={styles.status}>
<BarChart size={20} />
{ voice.status === VoiceStatus.CONNECTED && <Text id="app.main.channel.voice.connected" /> }
</div>
<div className={styles.actions}>
<Button
style="error"
onClick={() =>
voice.operations.disconnect()
}
>
<Text id="app.main.channel.voice.leave" />
</Button>
{voice.operations.isProducing("audio") ? (
<Button
onClick={() =>
voice.operations.stopProducing(
"audio"
)
}
>
<Text id="app.main.channel.voice.mute" />
</Button>
) : (
<Button
onClick={() =>
voice.operations.startProducing(
"audio"
)
}
>
<Text id="app.main.channel.voice.unmute" />
</Button>
)}
</div>
</div>
)} */

View file

@ -12,8 +12,8 @@ import { Text } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { stopPropagation } from "../../lib/stopPropagation"; import { stopPropagation } from "../../lib/stopPropagation";
import { voiceState } from "../../lib/vortex/VoiceState";
import { VoiceOperationsContext } from "../../context/Voice";
import { useIntermediate } from "../../context/intermediate/Intermediate"; import { useIntermediate } from "../../context/intermediate/Intermediate";
import UserIcon from "../../components/common/user/UserIcon"; import UserIcon from "../../components/common/user/UserIcon";
@ -29,7 +29,6 @@ interface Props {
export const Friend = observer(({ user }: Props) => { export const Friend = observer(({ user }: Props) => {
const history = useHistory(); const history = useHistory();
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const { connect } = useContext(VoiceOperationsContext);
const actions: Children[] = []; const actions: Children[] = [];
let subtext: Children = null; let subtext: Children = null;
@ -46,7 +45,7 @@ export const Friend = observer(({ user }: Props) => {
ev, ev,
user user
.openDM() .openDM()
.then(connect) .then(voiceState.connect)
.then((x) => history.push(`/channel/${x._id}`)), .then((x) => history.push(`/channel/${x._id}`)),
) )
}> }>