mirror of
https://github.com/revoltchat/revite.git
synced 2024-12-26 23:42:11 -05:00
Re-write voice context. Working towards #21
This commit is contained in:
parent
0fe154c651
commit
4ec1ff5c59
7 changed files with 195 additions and 325 deletions
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 }) {
|
|||
<Settings>
|
||||
<Locale>
|
||||
<Intermediate>
|
||||
<Client>
|
||||
<Voice>{children}</Voice>
|
||||
</Client>
|
||||
<Client>{children}</Client>
|
||||
</Intermediate>
|
||||
</Locale>
|
||||
</Settings>
|
||||
|
|
161
src/lib/vortex/VoiceState.ts
Normal file
161
src/lib/vortex/VoiceState.ts
Normal 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();
|
|
@ -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<ChannelHeaderProps, "channel">) {
|
|||
)
|
||||
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 (
|
||||
<IconButton onClick={disconnect}>
|
||||
<IconButton onClick={voiceState.disconnect}>
|
||||
<PhoneOff size={22} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
disconnect();
|
||||
connect(channel);
|
||||
onClick={async () => {
|
||||
await voiceState.loadVoice();
|
||||
voiceState.disconnect();
|
||||
voiceState.connect(channel);
|
||||
}}>
|
||||
<PhoneCall size={24} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton>
|
||||
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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 (
|
||||
<VoiceBase>
|
||||
|
@ -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) => {
|
|||
</div>
|
||||
<div className="status">
|
||||
<BarChart size={20} />
|
||||
{status === VoiceStatus.CONNECTED && (
|
||||
{voiceState.status === VoiceStatus.CONNECTED && (
|
||||
<Text id="app.main.channel.voice.connected" />
|
||||
)}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<Button error onClick={disconnect}>
|
||||
<Button error onClick={voiceState.disconnect}>
|
||||
<Text id="app.main.channel.voice.leave" />
|
||||
</Button>
|
||||
{isProducing("audio") ? (
|
||||
<Button onClick={() => stopProducing("audio")}>
|
||||
{voiceState.isProducing("audio") ? (
|
||||
<Button onClick={() => voiceState.stopProducing("audio")}>
|
||||
<Text id="app.main.channel.voice.mute" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => startProducing("audio")}>
|
||||
<Button onClick={() => voiceState.startProducing("audio")}>
|
||||
<Text id="app.main.channel.voice.unmute" />
|
||||
</Button>
|
||||
)}
|
||||
|
@ -136,71 +131,3 @@ export default observer(({ id }: Props) => {
|
|||
</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>
|
||||
)} */
|
||||
|
|
|
@ -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}`)),
|
||||
)
|
||||
}>
|
||||
|
|
Loading…
Reference in a new issue