Remove voice

pull/1049/head
Declan Chidlow 2024-08-28 10:32:00 +08:00
parent a67cb74484
commit a016e1d980
15 changed files with 8 additions and 1419 deletions

View File

@ -37,14 +37,10 @@ export default observer(
const isServerChannel =
server ||
(target &&
(target.channel_type === "TextChannel" ||
target.channel_type === "VoiceChannel"));
(target.channel_type === "TextChannel"));
if (typeof iconURL === "undefined") {
if (isServerChannel) {
if (target?.channel_type === "VoiceChannel") {
return <VolumeFull size={size} />;
}
return <Hash size={size} />;
}
}

View File

@ -1,8 +1,6 @@
import { VolumeMute, MicrophoneOff } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom";
import { User, API } from "revolt.js";
import styled, { css } from "styled-components/macro";
import { useApplicationState } from "../../../mobx/State";
@ -11,11 +9,9 @@ import fallback from "../assets/user.png";
import { useClient } from "../../../controllers/client/ClientController";
import IconBase, { IconBaseProps } from "../IconBase";
type VoiceStatus = "muted" | "deaf";
interface Props extends IconBaseProps<User> {
status?: boolean;
override?: string;
voice?: VoiceStatus;
masquerade?: API.Masquerade;
showServerIdentity?: boolean;
}
@ -34,22 +30,6 @@ export function useStatusColour(user?: User) {
: theme.getVariable("status-invisible");
}
const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
width: 10px;
height: 10px;
border-radius: var(--border-radius-half);
display: flex;
align-items: center;
justify-content: center;
${(props) =>
(props.status === "muted" || props.status === "deaf") &&
css`
background: var(--error);
`}
`;
export default observer(
(
props: Props &
@ -131,18 +111,6 @@ export default observer(
fill={useStatusColour(target)}
/>
)}
{props.voice && (
<foreignObject x="22" y="22" width="10" height="10">
<VoiceIndicator status={props.voice}>
{(props.voice === "deaf" && (
<VolumeMute size={6} />
)) ||
(props.voice === "muted" && (
<MicrophoneOff size={6} />
))}
</VoiceIndicator>
</foreignObject>
)}
</IconBase>
);
},

View File

@ -22,7 +22,6 @@ export default function CreateChannel({
title={<Text id="app.context_menu.create_channel" />}
schema={{
name: "text",
type: "radio",
}}
data={{
name: {
@ -30,32 +29,10 @@ export default function CreateChannel({
<Text id="app.main.servers.channel_name" />
) as React.ReactChild,
},
type: {
field: (
<Text id="app.main.servers.channel_type" />
) as React.ReactChild,
choices: [
{
name: (
<Text id="app.main.servers.text_channel" />
) as React.ReactChild,
value: "Text",
},
{
name: (
<Text id="app.main.servers.voice_channel" />
) as React.ReactChild,
value: "Voice",
},
],
},
}}
defaults={{
type: "Text",
}}
callback={async ({ name, type }) => {
callback={async ({ name }) => {
const channel = await target.createChannel({
type: type as "Text" | "Voice",
type: "Text",
name,
});

View File

@ -1,5 +1,5 @@
import { ChevronRight, Trash } from "@styled-icons/boxicons-regular";
import { Cog, UserVoice } from "@styled-icons/boxicons-solid";
import { Cog } from "@styled-icons/boxicons-solid";
import { isFirefox } from "react-device-detect";
import { useHistory } from "react-router-dom";
import { Channel, Message, Server, User, API, Permission, UserPermission, Member } from "revolt.js";
@ -163,8 +163,7 @@ export default function ContextMenus() {
case "mark_as_read":
{
if (
data.channel.channel_type === "SavedMessages" ||
data.channel.channel_type === "VoiceChannel"
data.channel.channel_type === "SavedMessages"
)
return;
@ -574,8 +573,7 @@ export default function ContextMenus() {
const user = uid ? client.users.get(uid) : undefined;
const serverChannel =
targetChannel &&
(targetChannel.channel_type === "TextChannel" ||
targetChannel.channel_type === "VoiceChannel")
(targetChannel.channel_type === "TextChannel")
? targetChannel
: undefined;
@ -961,7 +959,7 @@ export default function ContextMenus() {
pushDivider();
if (channel) {
if (channel.channel_type !== "VoiceChannel") {
if (channel.channel_type) {
generateAction(
{
action: "open_notification_options",
@ -998,7 +996,6 @@ export default function ContextMenus() {
});
break;
case "TextChannel":
case "VoiceChannel":
if (
channelPermissions &
Permission.InviteOthers
@ -1275,7 +1272,6 @@ export default function ContextMenus() {
<MenuItem
data={{ action: "set_status" }}
disabled={!isOnline}>
<UserVoice size={18} />
<Text id={`app.context_menu.custom_status`} />
{client.user!.status?.text && (
<IconButton>
@ -1293,4 +1289,4 @@ export default function ContextMenus() {
<CMNotifications />
</>
);
}
}

View File

@ -1,192 +0,0 @@
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;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
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,
data,
};
ws.send(`${JSON.stringify(json)}\n`);
this.index++;
});
}
/* eslint-enable @typescript-eslint/no-explicit-any */
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> {
const 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,
});
}
}

View File

@ -1,111 +0,0 @@
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;
}

View File

@ -1,340 +0,0 @@
import EventEmitter from "eventemitter3";
import * as mediasoupClient from "mediasoup-client";
import { types } from "mediasoup-client";
import { Device, Producer, Transport } from "mediasoup-client/lib/types";
import { useApplicationState } from "../../mobx/State";
import Signaling from "./Signaling";
import {
ProduceType,
WSEventType,
VoiceError,
VoiceUser,
ConsumerList,
WSErrorCode,
} from "./Types";
const UnsupportedError = types.UnsupportedError;
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;
isDeaf?: boolean;
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.isDeaf = false;
const state = useApplicationState();
this.signaling.on(
"data",
(json) => {
const data = json.data;
switch (json.type) {
case WSEventType.UserJoined: {
this.participants.set(data.id, {});
state.settings.sounds.playSound("call_join");
this.emit("userJoined", data.id);
break;
}
case WSEventType.UserLeft: {
this.participants.delete(data.id);
state.settings.sounds.playSound("call_leave");
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",
() => {
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);
const [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 (const 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) {
// eslint-disable-next-line
if ((error as any).error === WSErrorCode.ProducerNotFound) return;
throw error;
}
}
}

View File

@ -1,210 +0,0 @@
import { action, makeAutoObservable, runInAction } from "mobx";
import { Channel, Nullable, toNullable } from "revolt.js";
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();
this.syncState = this.syncState.bind(this);
this.connect = this.connect.bind(this);
this.disconnect = this.disconnect.bind(this);
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();
client.on("startProduce", this.syncState);
client.on("stopProduce", this.syncState);
client.on("userJoined", this.syncState);
client.on("userLeft", this.syncState);
client.on("userStartProduce", this.syncState);
client.on("userStopProduce", this.syncState);
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(
channel.client.configuration!.features.voso.ws,
channel._id,
);
runInAction(() => {
this.status = VoiceStatus.AUTHENTICATING;
});
await this.client.authenticate(call.token);
this.syncState();
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() {
this.connecting = false;
this.status = VoiceStatus.READY;
this.client?.disconnect();
this.syncState();
}
isProducing(type: ProduceType) {
switch (type) {
case "audio":
return this.client?.audioProducer !== undefined;
}
}
isDeaf() {
if (!this.client) return false;
return this.client.isDeaf;
}
async startDeafen() {
if (!this.client) return console.log("No client object"); // ! TODO: let the user know
if (this.client.isDeaf) return;
this.client.isDeaf = true;
this.client?.consumers.forEach((consumer) => {
consumer.audio?.pause();
});
this.syncState();
}
async stopDeafen() {
if (!this.client) return console.log("No client object"); // ! TODO: let the user know
if (!this.client.isDeaf) return;
this.client.isDeaf = false;
this.client?.consumers.forEach((consumer) => {
consumer.audio?.resume();
});
this.syncState();
}
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 mediaDevice =
window.localStorage.getItem("audioInputDevice");
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: mediaDevice ? { deviceId: mediaDevice } : true,
});
await this.client?.startProduce(
mediaStream.getAudioTracks()[0],
"audio",
);
}
}
this.syncState();
}
async stopProducing(type: ProduceType) {
await this.client?.stopProduce(type);
this.syncState();
}
}
export const voiceState = new VoiceStateReference();

View File

@ -30,7 +30,6 @@ export const DEFAULT_STATES: {
DirectMessage: "all",
Group: "all",
TextChannel: undefined!,
VoiceChannel: undefined!,
};
/**

View File

@ -26,7 +26,6 @@ import { PageHeader } from "../../components/ui/Header";
import { useClient } from "../../controllers/client/ClientController";
import ChannelHeader from "./ChannelHeader";
import { MessageArea } from "./messaging/MessageArea";
import VoiceHeader from "./voice/VoiceHeader";
const ChannelMain = styled.div.attrs({ "data-component": "channel" })`
flex-grow: 1;
@ -126,9 +125,6 @@ export const Channel = observer(
}
const channel = client.channels.get(id)!;
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
}
return <TextChannel channel={channel} />;
},
@ -191,7 +187,6 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
<ChannelMain>
<ErrorBoundary section="renderer">
<ChannelContent>
<VoiceHeader id={channel._id} />
<NewMessages channel={channel} last_id={lastId} />
<MessageArea channel={channel} last_id={lastId} />
<TypingIndicator channel={channel} />
@ -208,15 +203,6 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
);
});
function VoiceChannel({ channel }: { channel: ChannelI }) {
return (
<>
<ChannelHeader channel={channel} />
<VoiceHeader id={channel._id} />
</>
);
}
function ChannelPlaceholder() {
return (
<PlaceholderBase>

View File

@ -16,7 +16,6 @@ import { IconButton } from "@revoltchat/ui";
import { chainedDefer, defer } from "../../../lib/defer";
import { internalEmit } from "../../../lib/eventEmitter";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { voiceState, VoiceStatus } from "../../../lib/vortex/VoiceState";
import { useApplicationState } from "../../../mobx/State";
import { SIDEBAR_MEMBERS } from "../../../mobx/stores/Layout";
@ -132,7 +131,6 @@ export default function HeaderActions({ channel }: ChannelHeaderProps) {
</IconButton>
</>
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") && (
<IconButton onClick={openMembers}>

View File

@ -1,179 +0,0 @@
import {
BarChartAlt2,
Microphone,
MicrophoneOff,
PhoneOff,
VolumeFull,
VolumeMute,
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import styled from "styled-components/macro";
import { Text } from "preact-i18n";
import { useMemo } from "preact/hooks";
import { Button } from "@revoltchat/ui";
import { voiceState, VoiceStatus } from "../../../lib/vortex/VoiceState";
import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon";
import { useClient } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
interface Props {
id: string;
}
const VoiceBase = styled.div`
margin-top: 48px;
padding: 20px;
background: var(--secondary-background);
flex-grow: 1;
.status {
flex: 1 0;
display: flex;
position: absolute;
align-items: center;
padding: 10px;
font-size: 13px;
font-weight: 500;
user-select: none;
gap: 6px;
color: var(--success);
border-radius: var(--border-radius);
background: var(--primary-background);
svg {
cursor: help;
}
}
display: flex;
flex-direction: column;
.participants {
margin: 40px 20px;
justify-content: center;
user-select: none;
display: flex;
flex-flow: row wrap;
gap: 16px;
div:hover img {
opacity: 0.8;
}
.disconnected {
opacity: 0.5;
}
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
}
`;
export default observer(({ id }: Props) => {
if (voiceState.roomId !== id) return null;
const client = useClient();
const self = client.users.get(client.user!._id);
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>
<div className="participants">
{users && users.length !== 0
? users.map((user, index) => {
const user_id = keys![index];
return (
<div key={user_id}>
<UserIcon
size={80}
target={user}
status={false}
voice={
client.user?._id === user_id &&
voiceState.isDeaf()
? "deaf"
: voiceState.participants!.get(
user_id,
)?.audio
? undefined
: "muted"
}
onClick={() =>
modalController.push({
type: "user_profile",
user_id,
})
}
/>
</div>
);
})
: self !== undefined && (
<div key={self._id} className="disconnected">
<UserIcon
size={80}
target={self}
status={false}
/>
</div>
)}
</div>
<div className="status">
<BarChartAlt2 size={16} />
{voiceState.status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" />
)}
</div>
<div className="actions">
<Tooltip content={"Leave call"} placement={"top"}>
<Button palette="error" onClick={voiceState.disconnect}>
<PhoneOff width={20} />
</Button>
</Tooltip>
{voiceState.isProducing("audio") ? (
<Tooltip content={"Mute microphone"} placement={"top"}>
<Button
onClick={() => voiceState.stopProducing("audio")}>
<Microphone width={20} />
</Button>
</Tooltip>
) : (
<Tooltip content={"Unmute microphone"} placement={"top"}>
<Button
onClick={() => voiceState.startProducing("audio")}>
<MicrophoneOff width={20} />
</Button>
</Tooltip>
)}
{voiceState.isDeaf() ? (
<Tooltip content={"Undeafen"} placement={"top"}>
<Button onClick={() => voiceState.stopDeafen()}>
<VolumeMute width={20} />
</Button>
</Tooltip>
) : (
<Tooltip content={"Deafen"} placement={"top"}>
<Button onClick={() => voiceState.startDeafen()}>
<VolumeFull width={20} />
</Button>
</Tooltip>
)}
</div>
</VoiceBase>
);
});

View File

@ -12,7 +12,6 @@ import { Text } from "preact-i18n";
import { IconButton } from "@revoltchat/ui";
import { stopPropagation } from "../../lib/stopPropagation";
import { voiceState } from "../../lib/vortex/VoiceState";
import UserIcon from "../../components/common/user/UserIcon";
import UserStatus from "../../components/common/user/UserStatus";
@ -32,20 +31,6 @@ export const Friend = observer(({ user }: Props) => {
subtext = <UserStatus user={user} />;
actions.push(
<>
<IconButton
shape="circle"
className={classNames(styles.button, styles.success)}
onClick={(ev) =>
stopPropagation(
ev,
user
.openDM()
.then(voiceState.connect)
.then((x) => history.push(`/channel/${x._id}`)),
)
}>
<PhoneCall size={20} />
</IconButton>
<IconButton
shape="circle"
className={styles.button}

View File

@ -48,7 +48,6 @@ import { APP_VERSION } from "../../version";
import { GenericSettings } from "./GenericSettings";
import { Account } from "./panes/Account";
import { Appearance } from "./panes/Appearance";
import { Audio } from "./panes/Audio";
import { ExperimentsPage } from "./panes/Experiments";
import { Feedback } from "./panes/Feedback";
import { Languages } from "./panes/Languages";
@ -155,14 +154,6 @@ export default observer(() => {
icon: <CheckShield size={20} />,
title: <Text id="app.settings.pages.sessions.title" />,
},
{
category: (
<Text id="app.settings.categories.client_settings" />
),
id: "audio",
icon: <Speaker size={20} />,
title: <Text id="app.settings.pages.audio.title" />,
},
{
id: "appearance",
icon: <Palette size={20} />,
@ -229,9 +220,6 @@ export default observer(() => {
<Route path="/settings/plugins">
<PluginsPage />
</Route>
<Route path="/settings/audio">
<Audio />
</Route>
<Route path="/settings/notifications">
<Notifications />
</Route>

View File

@ -1,272 +0,0 @@
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks";
import { Button, Category, ComboBox, Tip } from "@revoltchat/ui";
import { stopPropagation } from "../../../lib/stopPropagation";
import { voiceState } from "../../../lib/vortex/VoiceState";
import { I18nError } from "../../../context/Locale";
import opusSVG from "../assets/opus_logo.svg";
{
/*import OpusSVG from "../assets/opus_logo.svg";*/
}
const constraints = { audio: true };
// TODO: do not rewrite this code until voice is rewritten!
export function Audio() {
const [mediaStream, setMediaStream] = useState<MediaStream | undefined>(
undefined,
);
const [mediaDevices, setMediaDevices] = useState<
MediaDeviceInfo[] | undefined
>(undefined);
const [permission, setPermission] = useState<PermissionState | undefined>(
undefined,
);
const [error, setError] = useState<DOMException | undefined>(undefined);
const askOrGetPermission = async () => {
try {
const result = await navigator.mediaDevices.getUserMedia(
constraints,
);
setMediaStream(result);
} catch (err) {
// The user has blocked the permission
setError(err as DOMException);
}
try {
const { state } = await navigator.permissions.query({
// eslint-disable-next-line
// @ts-ignore: very few browsers accept this `PermissionName`, but it has been drafted in https://www.w3.org/TR/permissions/#powerful-features-registry
name: "microphone",
});
setPermission(state);
} catch (err) {
// the browser might not support `query` functionnality or `PermissionName`
// nothing to do
}
};
useEffect(() => {
return () => {
if (mediaStream) {
// close microphone access on unmount
mediaStream.getTracks().forEach((track) => {
track.stop();
});
}
};
}, [mediaStream]);
useEffect(() => {
if (!mediaStream) {
return;
}
navigator.mediaDevices.enumerateDevices().then(
(devices) => {
setMediaDevices(devices);
},
(err) => {
setError(err as DOMException);
},
);
}, [mediaStream]);
const handleAskForPermission = (
ev: JSX.TargetedMouseEvent<HTMLElement>,
) => {
stopPropagation(ev);
setError(undefined);
askOrGetPermission();
};
return (
<>
<div className={styles.audio}>
<Tip palette="warning">
<span>
We are currently{" "}
<a
style={{ color: "inherit", fontWeight: "600" }}
href="https://github.com/revoltchat/frontend/issues/14"
target="_blank"
rel="noreferrer">
rebuilding the client
</a>{" "}
and{" "}
<a
style={{ color: "inherit", fontWeight: "600" }}
href="https://trello.com/c/Ay6KdiOV/1-voice-overhaul-and-video-calling"
target="_blank"
rel="noreferrer">
the voice server
</a>{" "}
from scratch.
<br />
<br />
The old voice should work in most cases, but it may
inexplicably not connect in some scenarios and / or
exhibit weird behaviour.
</span>
</Tip>
{!permission && (
<Tip palette="error">
<Text id="app.settings.pages.audio.tip_grant_permission" />
</Tip>
)}
{error && permission === "prompt" && (
<Tip palette="error">
<Text id="app.settings.pages.audio.tip_retry" />
<a onClick={handleAskForPermission}>
<Text id="app.settings.pages.audio.button_retry" />
</a>
.
</Tip>
)}
<div className={styles.audioRow}>
<div className={styles.select}>
<h3>
<Text id="app.settings.pages.audio.input_device" />
</h3>
<div className={styles.audioBox}>
<ComboBox
value={
window.localStorage.getItem(
"audioInputDevice",
) ?? 0
}
onChange={(e) =>
changeAudioDevice(
e.currentTarget.value,
"input",
)
}>
{mediaDevices
?.filter(
(device) =>
device.kind === "audioinput",
)
.map((device) => {
return (
<option
value={device.deviceId}
key={device.deviceId}>
{device.label || (
<Text id="app.settings.pages.audio.device_label_NA" />
)}
</option>
);
})}
</ComboBox>
{/*TOFIX: add logic to sound notches*/}
{/*<div className={styles.notches}>
<div />
<div />
<div />
<div />
<div />
<div />
<div />
<div />
<div />
<div />
</div>*/}
{!permission && (
<Button
compact
onClick={(e: any) =>
handleAskForPermission(e)
}
palette="error">
<Text id="app.settings.pages.audio.button_grant" />
</Button>
)}
{error && error.name === "NotAllowedError" && (
<Category>
<I18nError error="AudioPermissionBlock" />
</Category>
)}
</div>
</div>
<div className={styles.select}>
<h3>
<Text id="app.settings.pages.audio.output_device" />
</h3>
{/* TOFIX: create audio output combobox*/}
<ComboBox
value={
window.localStorage.getItem(
"audioOutputDevice",
) ?? 0
}
onChange={(e) =>
changeAudioDevice(
e.currentTarget.value,
"output",
)
}>
{mediaDevices
?.filter(
(device) => device.kind === "audiooutput",
)
.map((device) => {
return (
<option
value={device.deviceId}
key={device.deviceId}>
{device.label || (
<Text id="app.settings.pages.audio.device_label_NA" />
)}
</option>
);
})}
</ComboBox>
{/*<div className={styles.notches}>
<div />
<div />
<div />
<div />
<div />
<div />
<div />
<div />
<div />
<div />
</div>*/}
</div>
</div>
</div>
<hr />
<div className={styles.opus}>
<img height="20" src={opusSVG} draggable={false} />
Audio codec powered by Opus
</div>
</>
);
}
function changeAudioDevice(deviceId: string, deviceType: string) {
if (deviceType === "input") {
window.localStorage.setItem("audioInputDevice", deviceId);
if (voiceState.isProducing("audio")) {
voiceState.stopProducing("audio");
voiceState.startProducing("audio");
}
} else if (deviceType === "output") {
window.localStorage.setItem("audioOutputDevice", deviceId);
}
}