diff --git a/src/context/Voice.tsx b/src/context/Voice.tsx index e78b09181084d83425b4f0d8da48e762f953c46b..30d6bbbebcf935113fc14a32a3c9a228bd024443 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 70a7d02bce0c19bce9327def21c715950ead934c..6732f54ecd3eb3ed5356d6aaa39b6ea7b9ca3648 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 0000000000000000000000000000000000000000..b99b0f0b1f8f3208c9d39d53a50387836e35f2ad --- /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 831895d6536e3d990220b6f6fae234091e975731..080f3cbd80a3cab09a93706a8b2f8954cbadf142 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}>