From eef3e11e62ac490389b08b69d67478862535c5bf Mon Sep 17 00:00:00 2001
From: Paul <paulmakles@gmail.com>
Date: Thu, 24 Jun 2021 10:54:32 +0100
Subject: [PATCH] Load mediasoup client and add voice UI.

---
 src/context/Voice.tsx                    |  42 ++++--
 src/pages/channels/Channel.tsx           |   3 +
 src/pages/channels/voice/VoiceHeader.tsx | 183 +++++++++++++++++++++++
 src/pages/invite/Invite.tsx              |   1 +
 4 files changed, 216 insertions(+), 13 deletions(-)
 create mode 100644 src/pages/channels/voice/VoiceHeader.tsx

diff --git a/src/context/Voice.tsx b/src/context/Voice.tsx
index e78b091..30d6bbb 100644
--- a/src/context/Voice.tsx
+++ b/src/context/Voice.tsx
@@ -1,5 +1,6 @@
 import { createContext } from "preact";
 import { Children } from "../types/Preact";
+import { useForceUpdate } from "./revoltjs/hooks";
 import { AppContext } from "./revoltjs/RevoltClient";
 import type VoiceClient from "../lib/vortex/VoiceClient";
 import type { ProduceType, VoiceUser } from "../lib/vortex/Types";
@@ -40,7 +41,7 @@ type Props = {
 
 export default function Voice({ children }: Props) {
     const revoltClient = useContext(AppContext);
-    const [client,] = useState<VoiceClient | undefined>(undefined);
+    const [client, setClient] = useState<VoiceClient | undefined>(undefined);
     const [state, setState] = useState<VoiceState>({
         status: VoiceStatus.LOADING,
         participants: new Map()
@@ -55,11 +56,21 @@ export default function Voice({ children }: Props) {
     }
 
     useEffect(() => {
-        if (!client?.supported()) {
-            setStatus(VoiceStatus.UNAVAILABLE);
-        } else {
-            setStatus(VoiceStatus.READY);
-        }
+        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);
+            })
     }, []);
 
     const isConnecting = useRef(false);
@@ -83,7 +94,7 @@ export default function Voice({ children }: Props) {
                     }
 
                     // ! FIXME: use configuration to check if voso is enabled
-                    //await client.connect("wss://voso.revolt.chat/ws");
+                    // await client.connect("wss://voso.revolt.chat/ws");
                     await client.connect("wss://voso.revolt.chat/ws", channelId);
 
                     setStatus(VoiceStatus.AUTHENTICATING);
@@ -120,8 +131,8 @@ export default function Voice({ children }: Props) {
             startProducing: async (type: ProduceType) => {
                 switch (type) {
                     case "audio": {
-                        if (client?.audioProducer !== undefined) return;
-                        if (navigator.mediaDevices === undefined) return;
+                        if (client?.audioProducer !== undefined) return console.log('No audio producer.'); // ! FIXME: let the user know
+                        if (navigator.mediaDevices === undefined) return console.log('No media devices.'); // ! FIXME: let the user know
                         const mediaStream = await navigator.mediaDevices.getUserMedia(
                             {
                                 audio: true
@@ -142,27 +153,32 @@ export default function Voice({ children }: Props) {
         }
     }, [ client ]);
 
+    const { forceUpdate } = useForceUpdate();
     useEffect(() => {
         if (!client?.supported()) return;
 
-        /* client.on("startProduce", forceUpdate);
+        // ! FIXME: message for fatal:
+        // ! get rid of these force updates
+        // ! handle it through state or smth
+
+        client.on("startProduce",  forceUpdate);
         client.on("stopProduce", forceUpdate);
 
         client.on("userJoined", forceUpdate);
         client.on("userLeft", forceUpdate);
         client.on("userStartProduce", forceUpdate);
         client.on("userStopProduce", forceUpdate);
-        client.on("close", forceUpdate); */
+        client.on("close", forceUpdate);
 
         return () => {
-            /* client.removeListener("startProduce", forceUpdate);
+            client.removeListener("startProduce", forceUpdate);
             client.removeListener("stopProduce", forceUpdate);
 
             client.removeListener("userJoined", forceUpdate);
             client.removeListener("userLeft", forceUpdate);
             client.removeListener("userStartProduce", forceUpdate);
             client.removeListener("userStopProduce", forceUpdate);
-            client.removeListener("close", forceUpdate); */
+            client.removeListener("close", forceUpdate);
         };
     }, [ client, state ]);
 
diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx
index 70a7d02..6732f54 100644
--- a/src/pages/channels/Channel.tsx
+++ b/src/pages/channels/Channel.tsx
@@ -11,6 +11,7 @@ import MemberSidebar from "../../components/navigation/right/MemberSidebar";
 import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
 import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
 import { Channel } from "revolt.js";
+import VoiceHeader from "./voice/VoiceHeader";
 
 const ChannelMain = styled.div`
     flex-grow: 1;
@@ -48,6 +49,7 @@ function TextChannel({ channel }: { channel: Channel }) {
         <ChannelHeader channel={channel} toggleSidebar={() => setMembers(!showMembers)} />
         <ChannelMain>
             <ChannelContent>
+                <VoiceHeader id={id} />
                 <MessageArea id={id} />
                 <TypingIndicator id={id} />
                 <JumpToBottom id={id} />
@@ -61,6 +63,7 @@ function TextChannel({ channel }: { channel: Channel }) {
 function VoiceChannel({ channel }: { channel: Channel }) {
     return <>
         <ChannelHeader channel={channel} />
+        <VoiceHeader id={channel._id} />
     </>;
 }
 
diff --git a/src/pages/channels/voice/VoiceHeader.tsx b/src/pages/channels/voice/VoiceHeader.tsx
new file mode 100644
index 0000000..b99b0f0
--- /dev/null
+++ b/src/pages/channels/voice/VoiceHeader.tsx
@@ -0,0 +1,183 @@
+import { Text } from "preact-i18n";
+import styled from "styled-components";
+import { useContext } from "preact/hooks";
+import { BarChart } from "@styled-icons/bootstrap";
+import Button from "../../../components/ui/Button";
+import UserIcon from "../../../components/common/user/UserIcon";
+import { useForceUpdate, useSelf, useUsers } from "../../../context/revoltjs/hooks";
+import { VoiceContext, VoiceOperationsContext, VoiceStatus } from "../../../context/Voice";
+
+interface Props {
+    id: string
+}
+
+const VoiceBase = styled.div`
+    padding: 20px;
+    background: var(--secondary-background);
+
+    .status {
+        position: absolute;
+        color: var(--success);
+        background: var(--primary-background);
+        display: flex;
+        align-items: center;
+        padding: 10px;
+        font-size: 14px;
+        font-weight: 600;
+        border-radius: 7px;
+        flex: 1 0;
+        user-select: none;
+
+        svg {
+            margin-right: 4px;
+            cursor: help;
+        }
+    }
+
+    display: flex;
+    flex-direction: column;
+
+    .participants {
+        margin: 20px 0;
+        justify-content: center;
+        pointer-events: none;
+        user-select: none;
+        display: flex;
+        gap: 16px;
+
+        .disconnected {
+            opacity: 0.5;
+        }
+    }
+
+    .actions {
+        display: flex;
+        justify-content: center;
+        gap: 10px;
+    }
+`;
+
+export default function VoiceHeader({ id }: Props) {
+    const { status, participants, roomId } = useContext(VoiceContext);
+    if (roomId !== id) return null;
+
+    const { isProducing, startProducing, stopProducing, disconnect } = useContext(VoiceOperationsContext);
+
+    const ctx = useForceUpdate();
+    const self = useSelf(ctx);
+    const keys = participants ? Array.from(participants.keys()) : undefined;
+    const users = keys ? useUsers(keys, ctx) : undefined;
+    
+    return (
+        <VoiceBase>
+            <div className="participants">
+                { users && users.length !== 0 ? users.map((user, index) => {
+                    const id = keys![index];
+                    return (
+                        <div key={id}>
+                            <UserIcon
+                                size={80}
+                                target={user}
+                                status={false}
+                                voice={ participants!.get(id)?.audio ? undefined : "muted" }
+                            />
+                        </div>
+                    );
+                }) : self !== undefined && (
+                    <div key={self._id} className="disconnected">
+                        <UserIcon
+                            size={80}
+                            target={self}
+                            status={false} />
+                    </div>
+                )}
+            </div>
+            <div className="status">
+                <BarChart size={20} strokeWidth={2} />
+                { status === VoiceStatus.CONNECTED && <Text id="app.main.channel.voice.connected" /> }
+            </div>
+            <div className="actions">
+                <Button error onClick={disconnect}>
+                    <Text id="app.main.channel.voice.leave" />
+                </Button>
+                { isProducing("audio") ? (
+                    <Button onClick={() => stopProducing("audio")}>
+                        <Text id="app.main.channel.voice.mute" />
+                    </Button>
+                ) : (
+                    <Button onClick={() => startProducing("audio")}>
+                        <Text id="app.main.channel.voice.unmute" />
+                    </Button>
+                )}
+            </div>
+        </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} strokeWidth={2} />
+                                { 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>
+                    )} */
diff --git a/src/pages/invite/Invite.tsx b/src/pages/invite/Invite.tsx
index 831895d..080f3cb 100644
--- a/src/pages/invite/Invite.tsx
+++ b/src/pages/invite/Invite.tsx
@@ -40,6 +40,7 @@ export default function Invite() {
         )
     }
 
+    // ! FIXME: add i18n translations
     return (
         <div className={styles.invite} style={{ backgroundImage: invite.server_banner ? `url('${client.generateFileURL(invite.server_banner)}')` : undefined }}>
             <div className={styles.leave}>
-- 
GitLab