Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Showing
with 1340 additions and 251 deletions
export function debounce(cb: Function, duration: number) {
export function debounce(cb: (...args: unknown[]) => void, duration: number) {
// Store the timer variable.
let timer: NodeJS.Timeout;
// This function is given to React.
return (...args: any[]) => {
return (...args: unknown[]) => {
// Get rid of the old timer.
clearTimeout(timer);
// Set a new timer.
......
export const defer = (cb: () => void) => setTimeout(cb, 0);
import EventEmitter from "eventemitter3";
export const InternalEvent = new EventEmitter();
export function internalSubscribe(
ns: string,
event: string,
fn: (...args: unknown[]) => void,
) {
InternalEvent.addListener(`${ns}/${event}`, fn);
return () => InternalEvent.removeListener(`${ns}/${event}`, fn);
}
export function internalEmit(ns: string, event: string, ...args: unknown[]) {
InternalEvent.emit(`${ns}/${event}`, ...args);
}
// Event structure: namespace/event
/// Event List
// - MessageArea/jump_to_bottom
// - MessageRenderer/edit_last
// - MessageRenderer/edit_message
// - Intermediate/open_profile
// - Intermediate/navigate
// - MessageBox/append
// - TextArea/focus
// - ReplyBar/add
// - Modal/close
// - PWA/update
import { IntlContext } from "preact-i18n";
import { IntlContext, translate } from "preact-i18n";
import { useContext } from "preact/hooks";
import { Dictionary } from "../context/Locale";
import { Children } from "../types/Preact";
interface Fields {
[key: string]: Children
[key: string]: Children;
}
interface Props {
id: string;
fields: Fields
fields: Fields;
}
export interface IntlType {
intl: {
dictionary: {
[key: string]: Object | string
}
}
dictionary: Dictionary;
};
}
// This will exhibit O(2^n) behaviour.
......@@ -24,31 +25,46 @@ function recursiveReplaceFields(input: string, fields: Fields) {
const key = Object.keys(fields)[0];
if (key) {
const { [key]: field, ...restOfFields } = fields;
if (typeof field === 'undefined') return [ input ];
if (typeof field === "undefined") return [input];
const values: (Children | string[])[] = input.split(`{{${key}}}`)
.map(v => recursiveReplaceFields(v, restOfFields));
const values: (Children | string[])[] = input
.split(`{{${key}}}`)
.map((v) => recursiveReplaceFields(v, restOfFields));
for (let i=values.length - 1;i>0;i-=2) {
for (let i = values.length - 1; i > 0; i -= 2) {
values.splice(i, 0, field);
}
return values.flat();
} else {
// base case
return [ input ];
}
// base case
return [input];
}
export function TextReact({ id, fields }: Props) {
const { intl } = useContext(IntlContext) as unknown as IntlType;
const path = id.split('.');
const path = id.split(".");
let entry = intl.dictionary[path.shift()!];
for (let key of path) {
// @ts-expect-error
for (const key of path) {
// @ts-expect-error TODO: lazy
entry = entry[key];
}
return <>{ recursiveReplaceFields(entry as string, fields) }</>;
return <>{recursiveReplaceFields(entry as string, fields)}</>;
}
export function useTranslation() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
return (
id: string,
fields?: Record<string, string | undefined>,
plural?: number,
fallback?: string,
) => translate(id, "", intl.dictionary, fields, plural, fallback);
}
export function useDictionary() {
const { intl } = useContext(IntlContext) as unknown as IntlType;
return intl.dictionary;
}
import { isDesktop, isMobile, isTablet } from "react-device-detect";
export const isTouchscreenDevice =
isDesktop && !isTablet
isDesktop || isTablet
? false
: (typeof window !== "undefined"
? navigator.maxTouchPoints > 0
......
/* eslint-disable @typescript-eslint/no-empty-function */
export const noop = () => {};
export const noopAsync = async () => {};
/* eslint-enable @typescript-eslint/no-empty-function */
import { RendererRoutines, RenderState, ScrollState } from "./types";
import { SimpleRenderer } from "./simple/SimpleRenderer";
/* eslint-disable react-hooks/rules-of-hooks */
import EventEmitter3 from "eventemitter3";
import { Client } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { useEffect, useState } from "preact/hooks";
import EventEmitter3 from 'eventemitter3';
import { Client, Message } from "revolt.js";
import { SimpleRenderer } from "./simple/SimpleRenderer";
import { RendererRoutines, RenderState, ScrollState } from "./types";
export const SMOOTH_SCROLL_ON_RECEIVE = false;
......@@ -23,7 +27,7 @@ export class SingletonRenderer extends EventEmitter3 {
this.edit = this.edit.bind(this);
this.delete = this.delete.bind(this);
this.state = { type: 'LOADING' };
this.state = { type: "LOADING" };
this.currentRenderer = SimpleRenderer;
}
......@@ -41,23 +45,23 @@ export class SingletonRenderer extends EventEmitter3 {
subscribe(client: Client) {
if (this.client) {
this.client.removeListener('message', this.receive);
this.client.removeListener('message/update', this.edit);
this.client.removeListener('message/delete', this.delete);
this.client.removeListener("message", this.receive);
this.client.removeListener("message/update", this.edit);
this.client.removeListener("message/delete", this.delete);
}
this.client = client;
client.addListener('message', this.receive);
client.addListener('message/update', this.edit);
client.addListener('message/delete', this.delete);
client.addListener("message", this.receive);
client.addListener("message/update", this.edit);
client.addListener("message/delete", this.delete);
}
private setStateUnguarded(state: RenderState, scroll?: ScrollState) {
this.state = state;
this.emit('state', state);
this.emit("state", state);
if (scroll) {
this.emit('scroll', scroll);
this.emit("scroll", scroll);
}
}
......@@ -67,14 +71,29 @@ export class SingletonRenderer extends EventEmitter3 {
}
markStale() {
this.stale = true;
this.stale = true;
}
async init(id: string) {
async init(id: string, message_id?: string) {
if (message_id) {
if (this.state.type === "RENDER") {
const message = this.state.messages.find(
(x) => x._id === message_id,
);
if (message) {
this.emit("scroll", {
type: "ScrollToView",
id: message_id,
});
return;
}
}
}
this.channel = id;
this.stale = false;
this.setStateUnguarded({ type: 'LOADING' });
await this.currentRenderer.init(this, id);
this.setStateUnguarded({ type: "LOADING" });
await this.currentRenderer.init(this, id, message_id);
}
async reloadStale(id: string) {
......@@ -91,37 +110,42 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(end: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
const messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(end) === 1) {
heightRemoved += child.clientHeight +
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
}
}
return {
type: 'OffsetTop',
previousHeight: ref.scrollHeight - heightRemoved
}
} else {
return {
type: 'OffsetTop',
previousHeight: 0
}
type: "OffsetTop",
previousHeight: ref.scrollHeight - heightRemoved,
};
}
return {
type: "OffsetTop",
previousHeight: 0,
};
}
await this.currentRenderer.loadTop(this, generateScroll);
// Allow state updates to propagate.
setTimeout(() => this.fetchingTop = false, 0);
setTimeout(() => (this.fetchingTop = false), 0);
}
async loadBottom(ref?: HTMLDivElement) {
......@@ -131,44 +155,49 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(start: string): ScrollState {
if (ref) {
let heightRemoved = 0;
let messageContainer = ref.children[0];
const messageContainer = ref.children[0];
if (messageContainer) {
for (let child of Array.from(messageContainer.children)) {
for (const child of Array.from(messageContainer.children)) {
// If this child has a ulid.
if (child.id?.length === 26) {
// Check whether it was removed.
if (child.id.localeCompare(start) === -1) {
heightRemoved += child.clientHeight +
heightRemoved +=
child.clientHeight +
// We also need to take into account the top margin of the container.
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
parseInt(
window
.getComputedStyle(child)
.marginTop.slice(0, -2),
10,
);
}
}
}
}
return {
type: 'ScrollTop',
y: ref.scrollTop - heightRemoved
}
} else {
return {
type: 'ScrollToBottom'
}
type: "ScrollTop",
y: ref.scrollTop - heightRemoved,
};
}
return {
type: "ScrollToBottom",
};
}
await this.currentRenderer.loadBottom(this, generateScroll);
// Allow state updates to propagate.
setTimeout(() => this.fetchingBottom = false, 0);
setTimeout(() => (this.fetchingBottom = false), 0);
}
async jumpToBottom(id: string, smooth: boolean) {
if (id !== this.channel) return;
if (this.state.type === 'RENDER' && this.state.atBottom) {
this.emit('scroll', { type: 'ScrollToBottom', smooth });
if (this.state.type === "RENDER" && this.state.atBottom) {
this.emit("scroll", { type: "ScrollToBottom", smooth });
} else {
await this.currentRenderer.init(this, id, true);
await this.currentRenderer.init(this, id, undefined, true);
}
}
}
......@@ -176,7 +205,9 @@ export class SingletonRenderer extends EventEmitter3 {
export const SingletonMessageRenderer = new SingletonRenderer();
export function useRenderState(id: string) {
const [state, setState] = useState<Readonly<RenderState>>(SingletonMessageRenderer.state);
const [state, setState] = useState<Readonly<RenderState>>(
SingletonMessageRenderer.state,
);
if (typeof id === "undefined") return;
function render(state: RenderState) {
......
import { mapMessage } from "../../../context/revoltjs/util";
import { noopAsync } from "../../js";
import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
import { RendererRoutines } from "../types";
export const SimpleRenderer: RendererRoutines = {
init: async (renderer, id, smooth) => {
init: async (renderer, id, nearby, smooth) => {
if (renderer.client!.websocket.connected) {
renderer.client!.channels
.fetchMessagesWithUsers(id, { }, true)
.then(({ messages: data }) => {
data.reverse();
let messages = data.map(x => mapMessage(x));
renderer.setState(
id,
{
type: 'RENDER',
messages,
atTop: data.length < 50,
atBottom: true
},
{ type: 'ScrollToBottom', smooth }
);
});
if (nearby)
renderer
.client!.channels.get(id)!
.fetchMessagesWithUsers({ nearby, limit: 100 })
.then(({ messages }) => {
messages.sort((a, b) => a._id.localeCompare(b._id));
renderer.setState(
id,
{
type: "RENDER",
messages,
atTop: false,
atBottom: false,
},
{ type: "ScrollToView", id: nearby },
);
});
else
renderer
.client!.channels.get(id)!
.fetchMessagesWithUsers({})
.then(({ messages }) => {
messages.reverse();
renderer.setState(
id,
{
type: "RENDER",
messages,
atTop: messages.length < 50,
atBottom: true,
},
{ type: "ScrollToBottom", smooth },
);
});
} else {
renderer.setState(id, { type: 'WAITING_FOR_NETWORK' });
renderer.setState(id, { type: "WAITING_FOR_NETWORK" });
}
},
receive: async (renderer, message) => {
if (message.channel !== renderer.channel) return;
if (renderer.state.type !== 'RENDER') return;
if (renderer.state.messages.find(x => x._id === message._id)) return;
if (message.channel_id !== renderer.channel) return;
if (renderer.state.type !== "RENDER") return;
if (renderer.state.messages.find((x) => x._id === message._id)) return;
if (!renderer.state.atBottom) return;
let messages = [ ...renderer.state.messages, mapMessage(message) ];
let messages = [...renderer.state.messages, message];
let atTop = renderer.state.atTop;
if (messages.length > 150) {
messages = messages.slice(messages.length - 150);
......@@ -39,44 +57,23 @@ export const SimpleRenderer: RendererRoutines = {
}
renderer.setState(
message.channel,
message.channel_id,
{
...renderer.state,
messages,
atTop
atTop,
},
{ type: 'StayAtBottom', smooth: SMOOTH_SCROLL_ON_RECEIVE }
{ type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE },
);
},
edit: async (renderer, id, patch) => {
const channel = renderer.channel;
if (!channel) return;
if (renderer.state.type !== 'RENDER') return;
let messages = [ ...renderer.state.messages ];
let index = messages.findIndex(x => x._id === id);
if (index > -1) {
let message = { ...messages[index], ...mapMessage(patch) };
messages.splice(index, 1, message);
renderer.setState(
channel,
{
...renderer.state,
messages
},
{ type: 'StayAtBottom' }
);
}
},
edit: noopAsync,
delete: async (renderer, id) => {
const channel = renderer.channel;
if (!channel) return;
if (renderer.state.type !== 'RENDER') return;
let messages = [ ...renderer.state.messages ];
let index = messages.findIndex(x => x._id === id);
if (renderer.state.type !== "RENDER") return;
const messages = [...renderer.state.messages];
const index = messages.findIndex((x) => x._id === id);
if (index > -1) {
messages.splice(index, 1);
......@@ -85,9 +82,9 @@ export const SimpleRenderer: RendererRoutines = {
channel,
{
...renderer.state,
messages
messages,
},
{ type: 'StayAtBottom' }
{ type: "StayAtBottom" },
);
}
},
......@@ -96,25 +93,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return;
const state = renderer.state;
if (state.type !== 'RENDER') return;
if (state.type !== "RENDER") return;
if (state.atTop) return;
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, {
before: state.messages[0]._id
}, true);
const { messages: data } = await renderer
.client!.channels.get(channel)!
.fetchMessagesWithUsers({
before: state.messages[0]._id,
});
if (data.length === 0) {
return renderer.setState(
channel,
{
...state,
atTop: true
}
);
return renderer.setState(channel, {
...state,
atTop: true,
});
}
data.reverse();
let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ];
let messages = [...data, ...state.messages];
let atTop = false;
if (data.length < 50) {
......@@ -130,7 +126,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[messages.length - 1]._id)
generateScroll(messages[messages.length - 1]._id),
);
},
loadBottom: async (renderer, generateScroll) => {
......@@ -138,25 +134,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return;
const state = renderer.state;
if (state.type !== 'RENDER') return;
if (state.type !== "RENDER") return;
if (state.atBottom) return;
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, {
after: state.messages[state.messages.length - 1]._id,
sort: 'Oldest'
}, true);
const { messages: data } = await renderer
.client!.channels.get(channel)!
.fetchMessagesWithUsers({
after: state.messages[state.messages.length - 1]._id,
sort: "Oldest",
});
if (data.length === 0) {
return renderer.setState(
channel,
{
...state,
atBottom: true
}
);
return renderer.setState(channel, {
...state,
atBottom: true,
});
}
let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ];
let messages = [...state.messages, ...data];
let atBottom = false;
if (data.length < 50) {
......@@ -172,7 +167,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState(
channel,
{ ...state, atTop, atBottom, messages },
generateScroll(messages[0]._id)
generateScroll(messages[0]._id),
);
}
},
};
import { Message } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { SingletonRenderer } from "./Singleton";
import { MessageObject } from "../../context/revoltjs/util";
export type ScrollState =
| { type: "Free" }
| { type: "Bottom", scrollingUntil?: number }
| { type: "ScrollToBottom" | "StayAtBottom", smooth?: boolean }
| { type: "Bottom"; scrollingUntil?: number }
| { type: "ScrollToBottom" | "StayAtBottom"; smooth?: boolean }
| { type: "ScrollToView"; id: string }
| { type: "OffsetTop"; previousHeight: number }
| { type: "ScrollTop"; y: number };
export type RenderState =
| {
type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
}
type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
}
| {
type: "RENDER";
atTop: boolean;
atBottom: boolean;
messages: MessageObject[];
};
type: "RENDER";
atTop: boolean;
atBottom: boolean;
messages: Message[];
};
export interface RendererRoutines {
init: (renderer: SingletonRenderer, id: string, smooth?: boolean) => Promise<void>
init: (
renderer: SingletonRenderer,
id: string,
message?: string,
smooth?: boolean,
) => Promise<void>;
receive: (renderer: SingletonRenderer, message: Message) => Promise<void>;
edit: (renderer: SingletonRenderer, id: string, partial: Partial<Message>) => Promise<void>;
edit: (
renderer: SingletonRenderer,
id: string,
partial: Partial<Message>,
) => Promise<void>;
delete: (renderer: SingletonRenderer, id: string) => Promise<void>;
loadTop: (renderer: SingletonRenderer, generateScroll: (end: string) => ScrollState) => Promise<void>;
loadBottom: (renderer: SingletonRenderer, generateScroll: (start: string) => ScrollState) => Promise<void>;
loadTop: (
renderer: SingletonRenderer,
generateScroll: (end: string) => ScrollState,
) => Promise<void>;
loadBottom: (
renderer: SingletonRenderer,
generateScroll: (start: string) => ScrollState,
) => Promise<void>;
}
export const stopPropagation = (ev: JSX.TargetedMouseEvent<HTMLDivElement>, _consume?: any) => {
export const stopPropagation = (
ev: JSX.TargetedMouseEvent<HTMLElement>,
// eslint-disable-next-line
_consume?: unknown,
) => {
ev.preventDefault();
ev.stopPropagation();
return true;
};
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,
});
}
}
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;
}
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 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;
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",
() => {
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) {
if (error.error === WSErrorCode.ProducerNotFound) return;
throw error;
}
}
}
......@@ -3,14 +3,14 @@ import { useEffect, useState } from "preact/hooks";
export function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
height: window.innerHeight,
});
}
......
import { render } from "preact";
import { registerSW } from "virtual:pwa-register";
import "./styles/index.scss";
import { App } from "./pages/app";
import { render } from "preact";
import { internalEmit } from "./lib/eventEmitter";
import { registerSW } from 'virtual:pwa-register'
import { App } from "./pages/app";
const updateSW = registerSW({
export const updateSW = registerSW({
onNeedRefresh() {
// ! FIXME: temp
updateSW(true);
// show a prompt to user
internalEmit("PWA", "update");
},
onOfflineReady() {
console.info('Ready to work offline.');
console.info("Ready to work offline.");
// show a ready to work offline to user
},
})
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
render(<App />, document.getElementById("app")!);
/* eslint-disable react-hooks/rules-of-hooks */
import { useHistory, useParams } from "react-router-dom";
import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks";
import { useIntermediate } from "../context/intermediate/Intermediate";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import Header from "../components/ui/Header";
export default function Open() {
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const { id } = useParams<{ id: string }>();
const { openScreen } = useIntermediate();
if (status !== ClientStatus.ONLINE) {
return (
<Header placement="primary">
<Text id="general.loading" />
</Header>
);
}
useEffect(() => {
if (id === "saved") {
for (const channel of [...client.channels.values()]) {
if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`);
return;
}
}
client
.user!.openDM()
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
return;
}
const user = client.users.get(id);
if (user) {
const channel: string | undefined = [
...client.channels.values(),
].find(
(channel) =>
channel?.channel_type === "DirectMessage" &&
channel.recipient_ids!.includes(id),
)?._id;
if (channel) {
history.push(`/channel/${channel}`);
} else {
client.users
.get(id)
?.openDM()
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
}
return;
}
history.push("/");
});
return (
<Header placement="primary">
<Text id="general.loading" />
</Header>
);
}
import { Docked, OverlappingPanels } from "react-overlapping-panels";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { Switch, Route } from "react-router-dom";
import { Docked, OverlappingPanels, ShowIf } from "react-overlapping-panels";
import { Switch, Route, useLocation } from "react-router-dom";
import styled from "styled-components";
import Popovers from "../context/intermediate/Popovers";
import ContextMenus from "../lib/ContextMenus";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import Popovers from "../context/intermediate/Popovers";
import Notifications from "../context/revoltjs/Notifications";
import StateMonitor from "../context/revoltjs/StateMonitor";
import SyncManager from "../context/revoltjs/SyncManager";
import { Titlebar } from "../components/native/Titlebar";
import BottomNavigation from "../components/navigation/BottomNavigation";
import LeftSidebar from "../components/navigation/LeftSidebar";
import RightSidebar from "../components/navigation/RightSidebar";
import Home from './home/Home';
import Friends from "./friends/Friends";
import Open from "./Open";
import Channel from "./channels/Channel";
import Settings from './settings/Settings';
import Developer from "./developer/Developer";
import ServerSettings from "./settings/ServerSettings";
import Friends from "./friends/Friends";
import Home from "./home/Home";
import Invite from "./invite/Invite";
import ChannelSettings from "./settings/ChannelSettings";
import ServerSettings from "./settings/ServerSettings";
import Settings from "./settings/Settings";
const Routes = styled.div`
min-width: 0;
......@@ -26,49 +33,102 @@ const Routes = styled.div`
`;
export default function App() {
const path = useLocation().pathname;
const fixedBottomNav =
path === "/" || path === "/settings" || path.startsWith("/friends");
const inChannel = path.includes("/channel");
const inSpecial =
(path.startsWith("/friends") && isTouchscreenDevice) ||
path.startsWith("/invite") ||
path.includes("/settings");
return (
<OverlappingPanels
width="100vw"
height="100%"
leftPanel={{ width: 292, component: <LeftSidebar /> }}
rightPanel={{ width: 240, component: <RightSidebar /> }}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} />
<Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} />
<Route path="/server/:server/settings/:page" component={ServerSettings} />
<Route path="/server/:server/settings" component={ServerSettings} />
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} />
<Route path="/channel/:channel/settings" component={ChannelSettings} />
<>
{window.isNative && !window.native.getConfig().frame && (
<Titlebar />
)}
<OverlappingPanels
width="100vw"
height={
window.isNative && !window.native.getConfig().frame
? "calc(var(--app-height) - var(--titlebar-height))"
: "var(--app-height)"
}
leftPanel={
inSpecial
? undefined
: { width: 292, component: <LeftSidebar /> }
}
rightPanel={
!inSpecial && inChannel
? { width: 240, component: <RightSidebar /> }
: undefined
}
bottomNav={{
component: <BottomNavigation />,
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left,
height: 50,
}}
docked={isTouchscreenDevice ? Docked.None : Docked.Left}>
<Routes>
<Switch>
<Route
path="/server/:server/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/server/:server/channel/:channel/settings"
component={ChannelSettings}
/>
<Route
path="/server/:server/settings/:page"
component={ServerSettings}
/>
<Route
path="/server/:server/settings"
component={ServerSettings}
/>
<Route
path="/channel/:channel/settings/:page"
component={ChannelSettings}
/>
<Route
path="/channel/:channel/settings"
component={ChannelSettings}
/>
<Route path="/channel/:channel/message/:message" component={Channel} />
<Route path="/server/:server/channel/:channel" component={Channel} />
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route
path="/channel/:channel/:message"
component={Channel}
/>
<Route
path="/server/:server/channel/:channel/:message"
component={Channel}
/>
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
</OverlappingPanels>
);
};
<Route
path="/server/:server/channel/:channel"
component={Channel}
/>
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
/**
*
*
<Route path="/open/:id">
<Open />
</Route>
{/*<Route path="/invite/:code">
<OpenInvite />
</Route>
*/
<Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
</>
);
}
import { CheckAuth } from "../context/revoltjs/CheckAuth";
import Preloader from "../components/ui/Preloader";
import { Route, Switch } from "react-router-dom";
import Context from "../context";
import { lazy, Suspense } from "preact/compat";
const Login = lazy(() => import('./login/Login'));
const RevoltApp = lazy(() => import('./RevoltApp'));
import Context from "../context";
import { CheckAuth } from "../context/revoltjs/CheckAuth";
import Masks from "../components/ui/Masks";
import Preloader from "../components/ui/Preloader";
const Login = lazy(() => import("./login/Login"));
const RevoltApp = lazy(() => import("./RevoltApp"));
export function App() {
return (
<Context>
<Masks />
{/*
// @ts-expect-error */}
<Suspense fallback={<Preloader />}>
// @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader type="spinner" />}>
<Switch>
<Route path="/login">
<CheckAuth>
......
import styled from "styled-components";
import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom";
import Header from "../../components/ui/Header";
import { useRenderState } from "../../lib/renderer/Singleton";
import { useChannel, useForceUpdate, useUsers } from "../../context/revoltjs/hooks";
import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import styled from "styled-components";
import { useState } from "preact/hooks";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { dispatch, getState } from "../../redux";
import { useClient } from "../../context/revoltjs/RevoltClient";
import AgeGate from "../../components/common/AgeGate";
import MessageBox from "../../components/common/messaging/MessageBox";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
import MemberSidebar from "../../components/navigation/right/MemberSidebar";
import ChannelHeader from "./ChannelHeader";
import { MessageArea } from "./messaging/MessageArea";
import VoiceHeader from "./voice/VoiceHeader";
const ChannelMain = styled.div`
flex-grow: 1;
......@@ -20,25 +36,81 @@ const ChannelContent = styled.div`
flex-direction: column;
`;
export default function Channel() {
const { channel: id } = useParams<{ channel: string }>();
export function Channel({ id }: { id: string }) {
const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
}
if (!channel) return null;
// const view = useRenderState(id);
return <TextChannel channel={channel} />;
}
const MEMBERS_SIDEBAR_KEY = "sidebar_members";
const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
const [showMembers, setMembers] = useState(
getState().sectionToggle[MEMBERS_SIDEBAR_KEY] ?? true,
);
const id = channel._id;
return (
<>
<Header placement="primary">
Channel
</Header>
<AgeGate
type="channel"
channel={channel}
gated={
!!(
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name?.includes("nsfw")
)
}>
<ChannelHeader
channel={channel}
toggleSidebar={() => {
setMembers(!showMembers);
if (showMembers) {
dispatch({
type: "SECTION_TOGGLE_SET",
id: MEMBERS_SIDEBAR_KEY,
state: false,
});
} else {
dispatch({
type: "SECTION_TOGGLE_UNSET",
id: MEMBERS_SIDEBAR_KEY,
});
}
}}
/>
<ChannelMain>
<ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} />
<TypingIndicator channel={channel} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</AgeGate>
);
});
function VoiceChannel({ channel }: { channel: ChannelI }) {
return (
<>
<ChannelHeader channel={channel} />
<VoiceHeader id={channel._id} />
</>
)
);
}
export default function ChannelComponent() {
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
}
import { At, Hash, Menu } from "@styled-icons/boxicons-regular";
import { Notepad, Group } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import { User } from "revolt.js/dist/maps/Users";
import styled from "styled-components";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { getChannelName } from "../../context/revoltjs/util";
import { useStatusColour } from "../../components/common/user/UserIcon";
import UserStatus from "../../components/common/user/UserStatus";
import Header from "../../components/ui/Header";
import Markdown from "../../components/markdown/Markdown";
import HeaderActions from "./actions/HeaderActions";
export interface ChannelHeaderProps {
channel: Channel;
toggleSidebar?: () => void;
}
const Info = styled.div`
flex-grow: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
display: flex;
gap: 8px;
align-items: center;
* {
display: inline-block;
}
.divider {
height: 20px;
margin: 0 5px;
padding-left: 1px;
background-color: var(--tertiary-background);
}
.status {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-inline-end: 6px;
}
.desc {
cursor: pointer;
margin-top: 2px;
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
> * {
pointer-events: none;
}
}
`;
export default observer(({ channel, toggleSidebar }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate();
const name = getChannelName(channel);
let icon, recipient: User | undefined;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
recipient = channel.recipient;
break;
case "Group":
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={24} />;
break;
}
return (
<Header placement="primary">
{isTouchscreenDevice && (
<div className="menu">
<Menu size={27} />
</div>
)}
{icon}
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor:
useStatusColour(recipient),
}}
/>
<UserStatus user={recipient} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
);
});