diff --git a/package.json b/package.json index a1a8deb8591c5d5c879b076f95bc71c3e3633102..e95866ea9a7db99245f73ea4c5baadcec7db5367 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@styled-icons/simple-icons": "^10.33.0", "@traptitech/markdown-it-katex": "^3.4.3", "@traptitech/markdown-it-spoiler": "^1.1.6", + "@types/lodash.defaultsdeep": "^4.6.6", "@types/lodash.isequal": "^4.5.5", "@types/markdown-it": "^12.0.2", "@types/node": "^15.12.4", @@ -55,11 +56,13 @@ "highlight.js": "^11.0.1", "idb": "^6.1.2", "localforage": "^1.9.0", + "lodash.defaultsdeep": "^4.6.1", "lodash.isequal": "^4.5.0", "markdown-it": "^12.0.6", "markdown-it-emoji": "^2.0.0", "markdown-it-sub": "^1.0.0", "markdown-it-sup": "^1.0.0", + "mediasoup-client": "^3.6.33", "preact-context-menu": "^0.1.5", "preact-i18n": "^2.4.0-preactx", "prettier": "^2.3.1", diff --git a/src/context/Locale.tsx b/src/context/Locale.tsx index 202a4cb69ce040a67abfc6ca8e68da6ee2810817..95fd9b79decf227a1a3e9f107afc193b00754c5e 100644 --- a/src/context/Locale.tsx +++ b/src/context/Locale.tsx @@ -1,4 +1,5 @@ import { IntlProvider } from "preact-i18n"; +import defaultsDeep from "lodash.defaultsdeep"; import { connectState } from "../redux/connector"; import { useEffect, useState } from "preact/hooks"; import definition from "../../external/lang/en.json"; @@ -148,7 +149,7 @@ function Locale({ children, locale }: Props) { } dayjs.locale(dayjs_locale.default); - setDefinition(defn); + setDefinition(defaultsDeep(defn, definition)); } ); }, [locale, lang]); diff --git a/src/context/Voice.tsx b/src/context/Voice.tsx new file mode 100644 index 0000000000000000000000000000000000000000..935bda52b0cced88488db21748eba9a2dcc00e89 --- /dev/null +++ b/src/context/Voice.tsx @@ -0,0 +1,184 @@ +import { createContext } from "preact"; +import { Children } from "../types/Preact"; +import VoiceClient from "../lib/vortex/VoiceClient"; +import { AppContext } from "./revoltjs/RevoltClient"; +import { ProduceType, VoiceUser } from "../lib/vortex/Types"; +import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; + +export enum VoiceStatus { + LOADING = 0, + UNAVAILABLE, + ERRORED, + READY = 3, + CONNECTING = 4, + AUTHENTICATING, + RTC_CONNECTING, + CONNECTED + // RECONNECTING +} + +export interface VoiceOperations { + connect: (channelId: string) => Promise<void>; + disconnect: () => void; + isProducing: (type: ProduceType) => boolean; + startProducing: (type: ProduceType) => Promise<void>; + stopProducing: (type: ProduceType) => Promise<void>; +} + +export interface VoiceState { + roomId?: string; + status: VoiceStatus; + participants?: Readonly<Map<string, VoiceUser>>; +} + +export interface VoiceOperations { + connect: (channelId: string) => Promise<void>; + disconnect: () => void; + isProducing: (type: ProduceType) => boolean; + startProducing: (type: ProduceType) => Promise<void>; + stopProducing: (type: ProduceType) => Promise<void>; +} + +export const VoiceContext = createContext<VoiceState>(undefined as any); +export const VoiceOperationsContext = createContext<VoiceOperations>(undefined as any); + +type Props = { + children: Children; +}; + +export default function Voice({ children }: Props) { + const revoltClient = useContext(AppContext); + const [client,] = useState(new VoiceClient()); + const [state, setState] = useState<VoiceState>({ + status: VoiceStatus.LOADING, + participants: new Map() + }); + + function setStatus(status: VoiceStatus, roomId?: string) { + setState({ + status, + roomId: roomId ?? client.roomId, + participants: client.participants ?? new Map(), + }); + } + + useEffect(() => { + if (!client.supported()) { + setStatus(VoiceStatus.UNAVAILABLE); + } else { + setStatus(VoiceStatus.READY); + } + }, []); + + const isConnecting = useRef(false); + const operations: VoiceOperations = useMemo(() => { + return { + connect: async channelId => { + if (!client.supported()) + throw new Error("RTC is unavailable"); + + isConnecting.current = true; + setStatus(VoiceStatus.CONNECTING, channelId); + + try { + const call = await revoltClient.channels.joinCall( + channelId + ); + + if (!isConnecting.current) { + setStatus(VoiceStatus.READY); + return; + } + + // ! 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", channelId); + + setStatus(VoiceStatus.AUTHENTICATING); + + await client.authenticate(call.token); + setStatus(VoiceStatus.RTC_CONNECTING); + + await client.initializeTransports(); + } catch (error) { + console.error(error); + setStatus(VoiceStatus.READY); + } + + setStatus(VoiceStatus.CONNECTED); + isConnecting.current = false; + }, + 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; + if (navigator.mediaDevices === undefined) return; + const mediaStream = await navigator.mediaDevices.getUserMedia( + { + audio: true + } + ); + + await client.startProduce( + mediaStream.getAudioTracks()[0], + "audio" + ); + return; + } + } + }, + stopProducing: (type: ProduceType) => { + return client.stopProduce(type); + } + } + }, [ client ]); + + useEffect(() => { + if (!client.supported()) return; + + /* 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); */ + + return () => { + /* 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, state ]); + + return ( + <VoiceContext.Provider value={state}> + <VoiceOperationsContext.Provider value={operations}> + { children } + </VoiceOperationsContext.Provider> + </VoiceContext.Provider> + ); +} diff --git a/src/context/index.tsx b/src/context/index.tsx index 84967625f82e3c0e035f5e6cb2470d3042895212..f600e5b9be67f496a46a21260a18ca8f0fda8ad4 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -1,24 +1,27 @@ import State from "../redux/State"; import { Children } from "../types/Preact"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter as Router } from "react-router-dom"; import Intermediate from './intermediate/Intermediate'; -import ClientContext from './revoltjs/RevoltClient'; +import Client from './revoltjs/RevoltClient'; +import Voice from "./Voice"; import Locale from "./Locale"; import Theme from "./Theme"; export default function Context({ children }: { children: Children }) { return ( - <BrowserRouter> + <Router> <State> <Locale> <Intermediate> - <ClientContext> - <Theme>{children}</Theme> - </ClientContext> + <Client> + <Voice> + <Theme>{children}</Theme> + </Voice> + </Client> </Intermediate> </Locale> </State> - </BrowserRouter> + </Router> ); } diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index 4de9d9362669a9990c584e9a63091e06fabcd858..85bec3b7a1677ae456d3a59695a55742824904af 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -38,11 +38,10 @@ export const OperationsContext = createContext<ClientOperations>(undefined as an type Props = WithDispatcher & { auth: AuthState; - sync: SyncOptions; children: Children; }; -function Context({ auth, sync, children, dispatcher }: Props) { +function Context({ auth, children, dispatcher }: Props) { const { openScreen } = useIntermediate(); const [status, setStatus] = useState(ClientStatus.INIT); const [client, setClient] = useState<Client>(undefined as unknown as Client); diff --git a/src/lib/vortex/Signaling.ts b/src/lib/vortex/Signaling.ts new file mode 100644 index 0000000000000000000000000000000000000000..b54a6110174688a3b1956f01c019776115b1a598 --- /dev/null +++ b/src/lib/vortex/Signaling.ts @@ -0,0 +1,188 @@ +import EventEmitter from "eventemitter3"; +import { + RtpCapabilities, + RtpParameters +} from "mediasoup-client/lib/RtpParameters"; +import { DtlsParameters } from "mediasoup-client/lib/Transport"; + +import { + AuthenticationResult, + Room, + TransportInitDataTuple, + WSCommandType, + WSErrorCode, + ProduceType, + ConsumerData +} from "./Types"; + +interface SignalingEvents { + open: (event: Event) => void; + close: (event: CloseEvent) => void; + error: (event: Event) => void; + data: (data: any) => void; +} + +export default class Signaling extends EventEmitter<SignalingEvents> { + ws?: WebSocket; + index: number; + pending: Map<number, (data: unknown) => void>; + + constructor() { + super(); + this.index = 0; + this.pending = new Map(); + } + + connected(): boolean { + return ( + this.ws !== undefined && + this.ws.readyState !== WebSocket.CLOSING && + this.ws.readyState !== WebSocket.CLOSED + ); + } + + connect(address: string): Promise<void> { + this.disconnect(); + this.ws = new WebSocket(address); + this.ws.onopen = e => this.emit("open", e); + this.ws.onclose = e => this.emit("close", e); + this.ws.onerror = e => this.emit("error", e); + this.ws.onmessage = e => this.parseData(e); + + let finished = false; + return new Promise((resolve, reject) => { + this.once("open", () => { + if (finished) return; + finished = true; + resolve(); + }); + + this.once("error", () => { + if (finished) return; + finished = true; + reject(); + }); + }); + } + + disconnect() { + if ( + this.ws !== undefined && + this.ws.readyState !== WebSocket.CLOSED && + this.ws.readyState !== WebSocket.CLOSING + ) + this.ws.close(1000); + } + + private parseData(event: MessageEvent) { + if (typeof event.data !== "string") return; + const json = JSON.parse(event.data); + const entry = this.pending.get(json.id); + if (entry === undefined) { + this.emit("data", json); + return; + } + + entry(json); + } + + sendRequest(type: string, data?: any): Promise<any> { + if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN) + return Promise.reject({ error: WSErrorCode.NotConnected }); + + const ws = this.ws; + return new Promise((resolve, reject) => { + if (this.index >= 2 ** 32) this.index = 0; + while (this.pending.has(this.index)) this.index++; + const onClose = (e: CloseEvent) => { + reject({ + error: e.code, + message: e.reason + }); + }; + + const finishedFn = (data: any) => { + this.removeListener("close", onClose); + if (data.error) + reject({ + error: data.error, + message: data.message, + data: data.data + }); + resolve(data.data); + }; + + this.pending.set(this.index, finishedFn); + this.once("close", onClose); + const json = { + id: this.index, + type: type, + data + }; + ws.send(JSON.stringify(json) + "\n"); + this.index++; + }); + } + + authenticate(token: string, roomId: string): Promise<AuthenticationResult> { + return this.sendRequest(WSCommandType.Authenticate, { token, roomId }); + } + + async roomInfo(): Promise<Room> { + const room = await this.sendRequest(WSCommandType.RoomInfo); + return { + id: room.id, + videoAllowed: room.videoAllowed, + users: new Map(Object.entries(room.users)) + }; + } + + initializeTransports( + rtpCapabilities: RtpCapabilities + ): Promise<TransportInitDataTuple> { + return this.sendRequest(WSCommandType.InitializeTransports, { + mode: "SplitWebRTC", + rtpCapabilities + }); + } + + connectTransport( + id: string, + dtlsParameters: DtlsParameters + ): Promise<void> { + return this.sendRequest(WSCommandType.ConnectTransport, { + id, + dtlsParameters + }); + } + + async startProduce( + type: ProduceType, + rtpParameters: RtpParameters + ): Promise<string> { + let result = await this.sendRequest(WSCommandType.StartProduce, { + type, + rtpParameters + }); + return result.producerId; + } + + stopProduce(type: ProduceType): Promise<void> { + return this.sendRequest(WSCommandType.StopProduce, { type }); + } + + startConsume(userId: string, type: ProduceType): Promise<ConsumerData> { + return this.sendRequest(WSCommandType.StartConsume, { type, userId }); + } + + stopConsume(consumerId: string): Promise<void> { + return this.sendRequest(WSCommandType.StopConsume, { id: consumerId }); + } + + setConsumerPause(consumerId: string, paused: boolean): Promise<void> { + return this.sendRequest(WSCommandType.SetConsumerPause, { + id: consumerId, + paused + }); + } +} diff --git a/src/lib/vortex/Types.ts b/src/lib/vortex/Types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ceaf4e912f4acb45efca74a371b7646e68c7593a --- /dev/null +++ b/src/lib/vortex/Types.ts @@ -0,0 +1,111 @@ +import { Consumer } from "mediasoup-client/lib/Consumer"; +import { + MediaKind, + RtpCapabilities, + RtpParameters +} from "mediasoup-client/lib/RtpParameters"; +import { SctpParameters } from "mediasoup-client/lib/SctpParameters"; +import { + DtlsParameters, + IceCandidate, + IceParameters +} from "mediasoup-client/lib/Transport"; + +export enum WSEventType { + UserJoined = "UserJoined", + UserLeft = "UserLeft", + + UserStartProduce = "UserStartProduce", + UserStopProduce = "UserStopProduce" +} + +export enum WSCommandType { + Authenticate = "Authenticate", + RoomInfo = "RoomInfo", + + InitializeTransports = "InitializeTransports", + ConnectTransport = "ConnectTransport", + + StartProduce = "StartProduce", + StopProduce = "StopProduce", + + StartConsume = "StartConsume", + StopConsume = "StopConsume", + SetConsumerPause = "SetConsumerPause" +} + +export enum WSErrorCode { + NotConnected = 0, + NotFound = 404, + + TransportConnectionFailure = 601, + + ProducerFailure = 611, + ProducerNotFound = 614, + + ConsumerFailure = 621, + ConsumerNotFound = 624 +} + +export enum WSCloseCode { + // Sent when the received data is not a string, or is unparseable + InvalidData = 1003, + Unauthorized = 4001, + RoomClosed = 4004, + // Sent when a client tries to send an opcode in the wrong state + InvalidState = 1002, + ServerError = 1011 +} + +export interface VoiceError { + error: WSErrorCode | WSCloseCode; + message: string; +} + +export type ProduceType = "audio"; //| "video" | "saudio" | "svideo"; + +export interface AuthenticationResult { + userId: string; + roomId: string; + rtpCapabilities: RtpCapabilities; +} + +export interface Room { + id: string; + videoAllowed: boolean; + users: Map<string, VoiceUser>; +} + +export interface VoiceUser { + audio?: boolean; + //video?: boolean, + //saudio?: boolean, + //svideo?: boolean, +} + +export interface ConsumerList { + audio?: Consumer; + //video?: Consumer, + //saudio?: Consumer, + //svideo?: Consumer, +} + +export interface TransportInitData { + id: string; + iceParameters: IceParameters; + iceCandidates: IceCandidate[]; + dtlsParameters: DtlsParameters; + sctpParameters: SctpParameters | undefined; +} + +export interface TransportInitDataTuple { + sendTransport: TransportInitData; + recvTransport: TransportInitData; +} + +export interface ConsumerData { + id: string; + producerId: string; + kind: MediaKind; + rtpParameters: RtpParameters; +} diff --git a/src/lib/vortex/VoiceClient.ts b/src/lib/vortex/VoiceClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..903295bb3d44f8ef83ade8e6a27e55879c53ab3e --- /dev/null +++ b/src/lib/vortex/VoiceClient.ts @@ -0,0 +1,331 @@ +import EventEmitter from "eventemitter3"; + +import * as mediasoupClient from "mediasoup-client"; +import { + Device, + Producer, + Transport, + UnsupportedError +} from "mediasoup-client/lib/types"; + +import { + ProduceType, + WSEventType, + VoiceError, + VoiceUser, + ConsumerList, + WSErrorCode +} from "./Types"; +import Signaling from "./Signaling"; + +interface VoiceEvents { + ready: () => void; + error: (error: Error) => void; + close: (error?: VoiceError) => void; + + startProduce: (type: ProduceType) => void; + stopProduce: (type: ProduceType) => void; + + userJoined: (userId: string) => void; + userLeft: (userId: string) => void; + + userStartProduce: (userId: string, type: ProduceType) => void; + userStopProduce: (userId: string, type: ProduceType) => void; +} + +export default class VoiceClient extends EventEmitter<VoiceEvents> { + private _supported: boolean; + + device?: Device; + signaling: Signaling; + + sendTransport?: Transport; + recvTransport?: Transport; + + userId?: string; + roomId?: string; + participants: Map<string, VoiceUser>; + consumers: Map<string, ConsumerList>; + + audioProducer?: Producer; + constructor() { + super(); + this._supported = mediasoupClient.detectDevice() !== undefined; + this.signaling = new Signaling(); + + this.participants = new Map(); + this.consumers = new Map(); + + this.signaling.on( + "data", + json => { + const data = json.data; + switch (json.type) { + case WSEventType.UserJoined: { + this.participants.set(data.id, {}); + this.emit("userJoined", data.id); + break; + } + case WSEventType.UserLeft: { + this.participants.delete(data.id); + this.emit("userLeft", data.id); + + if (this.recvTransport) this.stopConsume(data.id); + break; + } + case WSEventType.UserStartProduce: { + const user = this.participants.get(data.id); + if (user === undefined) return; + switch (data.type) { + case "audio": + user.audio = true; + break; + default: + throw new Error( + `Invalid produce type ${data.type}` + ); + } + + if (this.recvTransport) + this.startConsume(data.id, data.type); + this.emit("userStartProduce", data.id, data.type); + break; + } + case WSEventType.UserStopProduce: { + const user = this.participants.get(data.id); + if (user === undefined) return; + switch (data.type) { + case "audio": + user.audio = false; + break; + default: + throw new Error( + `Invalid produce type ${data.type}` + ); + } + + if (this.recvTransport) + this.stopConsume(data.id, data.type); + this.emit("userStopProduce", data.id, data.type); + break; + } + } + }, + this + ); + + this.signaling.on( + "error", + error => { + this.emit("error", new Error("Signaling error")); + }, + this + ); + + this.signaling.on( + "close", + error => { + this.disconnect( + { + error: error.code, + message: error.reason + }, + true + ); + }, + this + ); + } + + supported() { + return this._supported; + } + throwIfUnsupported() { + if (!this._supported) throw new UnsupportedError("RTC not supported"); + } + + connect(address: string, roomId: string) { + this.throwIfUnsupported(); + this.device = new Device(); + this.roomId = roomId; + return this.signaling.connect(address); + } + + disconnect(error?: VoiceError, ignoreDisconnected?: boolean) { + if (!this.signaling.connected() && !ignoreDisconnected) return; + this.signaling.disconnect(); + this.participants = new Map(); + this.consumers = new Map(); + this.userId = undefined; + this.roomId = undefined; + + this.audioProducer = undefined; + + if (this.sendTransport) this.sendTransport.close(); + if (this.recvTransport) this.recvTransport.close(); + this.sendTransport = undefined; + this.recvTransport = undefined; + + this.emit("close", error); + } + + async authenticate(token: string) { + this.throwIfUnsupported(); + if (this.device === undefined || this.roomId === undefined) + throw new ReferenceError("Voice Client is in an invalid state"); + const result = await this.signaling.authenticate(token, this.roomId); + let [room] = await Promise.all([ + this.signaling.roomInfo(), + this.device.load({ routerRtpCapabilities: result.rtpCapabilities }) + ]); + + this.userId = result.userId; + this.participants = room.users; + } + + async initializeTransports() { + this.throwIfUnsupported(); + if (this.device === undefined) + throw new ReferenceError("Voice Client is in an invalid state"); + const initData = await this.signaling.initializeTransports( + this.device.rtpCapabilities + ); + + this.sendTransport = this.device.createSendTransport( + initData.sendTransport + ); + this.recvTransport = this.device.createRecvTransport( + initData.recvTransport + ); + + const connectTransport = (transport: Transport) => { + transport.on("connect", ({ dtlsParameters }, callback, errback) => { + this.signaling + .connectTransport(transport.id, dtlsParameters) + .then(callback) + .catch(errback); + }); + }; + + connectTransport(this.sendTransport); + connectTransport(this.recvTransport); + + this.sendTransport.on("produce", (parameters, callback, errback) => { + const type = parameters.appData.type; + if ( + parameters.kind === "audio" && + type !== "audio" && + type !== "saudio" + ) + return errback(); + if ( + parameters.kind === "video" && + type !== "video" && + type !== "svideo" + ) + return errback(); + this.signaling + .startProduce(type, parameters.rtpParameters) + .then(id => callback({ id })) + .catch(errback); + }); + + this.emit("ready"); + for (let user of this.participants) { + if (user[1].audio && user[0] !== this.userId) + this.startConsume(user[0], "audio"); + } + } + + private async startConsume(userId: string, type: ProduceType) { + if (this.recvTransport === undefined) + throw new Error("Receive transport undefined"); + const consumers = this.consumers.get(userId) || {}; + const consumerParams = await this.signaling.startConsume(userId, type); + const consumer = await this.recvTransport.consume(consumerParams); + switch (type) { + case "audio": + consumers.audio = consumer; + } + + const mediaStream = new MediaStream([consumer.track]); + const audio = new Audio(); + audio.srcObject = mediaStream; + await this.signaling.setConsumerPause(consumer.id, false); + audio.play(); + this.consumers.set(userId, consumers); + } + + private async stopConsume(userId: string, type?: ProduceType) { + const consumers = this.consumers.get(userId); + if (consumers === undefined) return; + if (type === undefined) { + if (consumers.audio !== undefined) consumers.audio.close(); + this.consumers.delete(userId); + } else { + switch (type) { + case "audio": { + if (consumers.audio !== undefined) { + consumers.audio.close(); + this.signaling.stopConsume(consumers.audio.id); + } + consumers.audio = undefined; + break; + } + } + + this.consumers.set(userId, consumers); + } + } + + async startProduce(track: MediaStreamTrack, type: ProduceType) { + if (this.sendTransport === undefined) + throw new Error("Send transport undefined"); + const producer = await this.sendTransport.produce({ + track, + appData: { type } + }); + + switch (type) { + case "audio": + this.audioProducer = producer; + break; + } + + const participant = this.participants.get(this.userId || ""); + if (participant !== undefined) { + participant[type] = true; + this.participants.set(this.userId || "", participant); + } + + this.emit("startProduce", type); + } + + async stopProduce(type: ProduceType) { + let producer; + switch (type) { + case "audio": + producer = this.audioProducer; + this.audioProducer = undefined; + break; + } + + if (producer !== undefined) { + producer.close(); + this.emit("stopProduce", type); + } + + const participant = this.participants.get(this.userId || ""); + if (participant !== undefined) { + participant[type] = false; + this.participants.set(this.userId || "", participant); + } + + try { + await this.signaling.stopProduce(type); + } catch (error) { + if (error.error === WSErrorCode.ProducerNotFound) return; + else throw error; + } + } +} diff --git a/src/pages/channels/ChannelHeader.tsx b/src/pages/channels/ChannelHeader.tsx index 929ac9fba214ccabef142b6d61afde96142369ab..24a23f3f899665838d62669129930cd447ec59ce 100644 --- a/src/pages/channels/ChannelHeader.tsx +++ b/src/pages/channels/ChannelHeader.tsx @@ -1,19 +1,17 @@ import styled from "styled-components"; import { Channel, User } from "revolt.js"; import { useContext } from "preact/hooks"; -import { useHistory } from "react-router-dom"; import Header from "../../components/ui/Header"; -import IconButton from "../../components/ui/IconButton"; +import HeaderActions from "./actions/HeaderActions"; import Markdown from "../../components/markdown/Markdown"; import { getChannelName } from "../../context/revoltjs/util"; import UserStatus from "../../components/common/user/UserStatus"; import { AppContext } from "../../context/revoltjs/RevoltClient"; -import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; +import { Save, AtSign, Users, Hash } from "@styled-icons/feather"; import { useStatusColour } from "../../components/common/user/UserIcon"; import { useIntermediate } from "../../context/intermediate/Intermediate"; -import { Save, AtSign, Users, Hash, UserPlus, Settings, Sidebar as SidebarIcon } from "@styled-icons/feather"; -interface Props { +export interface ChannelHeaderProps { channel: Channel, toggleSidebar?: () => void } @@ -51,10 +49,9 @@ const Info = styled.div` } `; -export default function ChannelHeader({ channel, toggleSidebar }: Props) { +export default function ChannelHeader({ channel, toggleSidebar }: ChannelHeaderProps) { const { openScreen } = useIntermediate(); const client = useContext(AppContext); - const history = useHistory(); const name = getChannelName(client, channel); let icon, recipient; @@ -105,32 +102,7 @@ export default function ChannelHeader({ channel, toggleSidebar }: Props) { </> )} </Info> - <> - { channel.channel_type === "Group" && ( - <> - <IconButton onClick={() => - openScreen({ - id: "user_picker", - omit: channel.recipients, - callback: async users => { - for (const user of users) { - await client.channels.addMember(channel._id, user); - } - } - })}> - <UserPlus size={22} /> - </IconButton> - <IconButton onClick={() => history.push(`/channel/${channel._id}/settings`)}> - <Settings size={22} /> - </IconButton> - </> - ) } - { channel.channel_type === "Group" && !isTouchscreenDevice && ( - <IconButton onClick={toggleSidebar}> - <SidebarIcon size={22} /> - </IconButton> - ) } - </> + <HeaderActions channel={channel} toggleSidebar={toggleSidebar} /> </Header> ) } diff --git a/src/pages/channels/actions/HeaderActions.tsx b/src/pages/channels/actions/HeaderActions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6916361bc4d1868b172211ca727c7c7213e7b4b0 --- /dev/null +++ b/src/pages/channels/actions/HeaderActions.tsx @@ -0,0 +1,78 @@ +import { useContext } from "preact/hooks"; +import { useHistory } from "react-router-dom"; +import { ChannelHeaderProps } from "../ChannelHeader"; +import IconButton from "../../../components/ui/IconButton"; +import { AppContext } from "../../../context/revoltjs/RevoltClient"; +import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; +import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import { VoiceContext, VoiceOperationsContext, VoiceStatus } from "../../../context/Voice"; +import { UserPlus, Settings, Sidebar as SidebarIcon, PhoneCall, PhoneOff } from "@styled-icons/feather"; + +export default function HeaderActions({ channel, toggleSidebar }: ChannelHeaderProps) { + const { openScreen } = useIntermediate(); + const client = useContext(AppContext); + const history = useHistory(); + + return ( + <> + { channel.channel_type === "Group" && ( + <> + <IconButton onClick={() => + openScreen({ + id: "user_picker", + omit: channel.recipients, + callback: async users => { + for (const user of users) { + await client.channels.addMember(channel._id, user); + } + } + })}> + <UserPlus size={22} /> + </IconButton> + <IconButton onClick={() => history.push(`/channel/${channel._id}/settings`)}> + <Settings size={22} /> + </IconButton> + </> + ) } + <VoiceActions channel={channel} /> + { channel.channel_type === "Group" && !isTouchscreenDevice && ( + <IconButton onClick={toggleSidebar}> + <SidebarIcon size={22} /> + </IconButton> + ) } + </> + ) +} + +function VoiceActions({ channel }: Pick<ChannelHeaderProps, 'channel'>) { + if (channel.channel_type === 'SavedMessages' || + channel.channel_type === 'TextChannel') return null; + + const voice = useContext(VoiceContext); + const { connect, disconnect } = useContext(VoiceOperationsContext); + + if (voice.status >= VoiceStatus.READY) { + if (voice.roomId === channel._id) { + return ( + <IconButton onClick={disconnect}> + <PhoneOff size={22} /> + </IconButton> + ) + } else { + return ( + <IconButton onClick={() => { + disconnect(); + connect(channel._id); + }}> + <PhoneCall size={22} /> + </IconButton> + ) + } + } else { + return ( + <IconButton> + <PhoneCall size={22} /** ! FIXME: TEMP */ color="red" /> + </IconButton> + ) + } +} diff --git a/yarn.lock b/yarn.lock index 2ec6929987d32d56ae185068be00ee18410e12fe..2284252bc97da826c81599141878ad8a9d6ace49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1134,11 +1134,21 @@ resolved "https://registry.yarnpkg.com/@traptitech/markdown-it-spoiler/-/markdown-it-spoiler-1.1.6.tgz#973e92045699551e2c9fb39bbd673ee48bc90b83" integrity sha512-tH/Fk1WMsnSuLpuRsXw8iHtdivoCEI5V08hQ7doVm6WmzAnBf/cUzyH9+GbOldPq9Hwv9v9tuy5t/MxmdNAGXg== +"@types/debug@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" + integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== + "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/events@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" + integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== + "@types/highlight.js@^9.7.0": version "9.12.4" resolved "https://registry.yarnpkg.com/@types/highlight.js/-/highlight.js-9.12.4.tgz#8c3496bd1b50cc04aeefd691140aa571d4dbfa34" @@ -1167,6 +1177,13 @@ resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.1.tgz#4d26a9efe3aa2caf829234ec5a39580fc88b6001" integrity sha512-pQv3Sygwxxh6jYQzXaiyWDAHevJqWtqDUv6t11Sa9CPGiXny66II7Pl6PR8QO5OVysD6HYOkHMeBgIjLnk9SkQ== +"@types/lodash.defaultsdeep@^4.6.6": + version "4.6.6" + resolved "https://registry.yarnpkg.com/@types/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.6.tgz#d2e87c07ec8d0361e4b79aa000815732b210be04" + integrity sha512-k3bXTg1/54Obm6uFEtSwvDm2vCyK9jSROv0V9X3gFFNPu7eKmvqqadPSXx0SkVVixSilR30BxhFlnIj8OavXOA== + dependencies: + "@types/lodash" "*" + "@types/lodash.isequal@^4.5.5": version "4.5.5" resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.5.tgz#4fed1b1b00bef79e305de0352d797e9bb816c8ff" @@ -1504,6 +1521,11 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +awaitqueue@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/awaitqueue/-/awaitqueue-2.3.3.tgz#35e6568970fcac3de1644a2c28abc1074045b570" + integrity sha512-RbzQg6VtPUtyErm55iuQLTrBJ2uihy5BKBOEkyBwv67xm5Fn2o/j+Bz+a5BmfSoe2oZ5dcz9Z3fExS8pL+LLhw== + axios@^0.19.2: version "0.19.2" resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" @@ -1584,6 +1606,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +bowser@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" + integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2156,11 +2183,21 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" +event-target-shim@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + exponential-backoff@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.0.tgz#9409c7e579131f8bd4b32d7d8094a911040f2e68" @@ -2171,6 +2208,14 @@ extend@3.0.2: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== +fake-mediastreamtrack@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/fake-mediastreamtrack/-/fake-mediastreamtrack-1.1.6.tgz#2cbdfae201b9771cb8a6b988120ce0edf25eb6ca" + integrity sha512-lcoO5oPsW57istAsnjvQxNjBEahi18OdUhWfmEewwfPfzNZnji5OXuodQM+VnUPi/1HnQRJ6gBUjbt1TNXrkjQ== + dependencies: + event-target-shim "^5.0.1" + uuid "^8.1.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -2356,6 +2401,13 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== +h264-profile-level-id@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/h264-profile-level-id/-/h264-profile-level-id-1.0.1.tgz#92033c190766c846e57c6a97e4c1d922943a9cce" + integrity sha512-D3Rln/jKNjKDW5ZTJTK3niSoOGE+pFqPvRHHVgQN3G7umcn/zWGPUo8Q8VpDj16x3hKz++zVviRNRmXu5cpN+Q== + dependencies: + debug "^4.1.1" + has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" @@ -2811,6 +2863,22 @@ mdurl@^1.0.1: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4= +mediasoup-client@^3.6.33: + version "3.6.33" + resolved "https://registry.yarnpkg.com/mediasoup-client/-/mediasoup-client-3.6.33.tgz#4ec4f63cc9425c9adcf3ff9e1aec2eb465d2841b" + integrity sha512-qy+TB/TU3lgNTBZ1LthdD89iRjOvv3Rg3meQ4+hssWJfbFQEzsvZ707sjzjhQ1I0iF2Q9zlEaWlggPRt6f9j5Q== + dependencies: + "@types/debug" "^4.1.5" + "@types/events" "^3.0.0" + awaitqueue "^2.3.3" + bowser "^2.11.0" + debug "^4.3.1" + events "^3.3.0" + fake-mediastreamtrack "^1.1.6" + h264-profile-level-id "^1.0.1" + sdp-transform "^2.14.1" + supports-color "^8.1.1" + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -3407,6 +3475,11 @@ sass@^1.35.1: dependencies: chokidar ">=3.0.0 <4.0.0" +sdp-transform@^2.14.1: + version "2.14.1" + resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.14.1.tgz#2bb443583d478dee217df4caa284c46b870d5827" + integrity sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw== + select@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" @@ -3650,6 +3723,13 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + table@^6.0.9: version "6.7.1" resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" @@ -3854,6 +3934,11 @@ use-resize-observer@^7.0.0: dependencies: resize-observer-polyfill "^1.5.1" +uuid@^8.1.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache@^2.0.3: version "2.3.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"