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 1449 additions and 257 deletions
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 { useContext } from "preact/hooks";
import { Dictionary } from "../context/Locale";
import { Children } from "../types/Preact"; import { Children } from "../types/Preact";
interface Fields { interface Fields {
[key: string]: Children [key: string]: Children;
} }
interface Props { interface Props {
id: string; id: string;
fields: Fields fields: Fields;
} }
export interface IntlType { export interface IntlType {
intl: { intl: {
dictionary: { dictionary: Dictionary;
[key: string]: Object | string };
}
}
} }
// This will exhibit O(2^n) behaviour. // This will exhibit O(2^n) behaviour.
...@@ -24,31 +25,46 @@ function recursiveReplaceFields(input: string, fields: Fields) { ...@@ -24,31 +25,46 @@ function recursiveReplaceFields(input: string, fields: Fields) {
const key = Object.keys(fields)[0]; const key = Object.keys(fields)[0];
if (key) { if (key) {
const { [key]: field, ...restOfFields } = fields; const { [key]: field, ...restOfFields } = fields;
if (typeof field === 'undefined') return [ input ]; if (typeof field === "undefined") return [input];
const values: (Children | string[])[] = input.split(`{{${key}}}`) const values: (Children | string[])[] = input
.map(v => recursiveReplaceFields(v, restOfFields)); .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); values.splice(i, 0, field);
} }
return values.flat(); return values.flat();
} else {
// base case
return [ input ];
} }
// base case
return [input];
} }
export function TextReact({ id, fields }: Props) { export function TextReact({ id, fields }: Props) {
const { intl } = useContext(IntlContext) as unknown as IntlType; const { intl } = useContext(IntlContext) as unknown as IntlType;
const path = id.split('.'); const path = id.split(".");
let entry = intl.dictionary[path.shift()!]; let entry = intl.dictionary[path.shift()!];
for (let key of path) { for (const key of path) {
// @ts-expect-error // @ts-expect-error TODO: lazy
entry = entry[key]; 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"; import { isDesktop, isMobile, isTablet } from "react-device-detect";
export const isTouchscreenDevice = export const isTouchscreenDevice =
isDesktop && !isTablet isDesktop || isTablet
? false ? false
: (typeof window !== "undefined" : (typeof window !== "undefined"
? navigator.maxTouchPoints > 0 ? 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"; /* eslint-disable react-hooks/rules-of-hooks */
import { SimpleRenderer } from "./simple/SimpleRenderer"; import EventEmitter3 from "eventemitter3";
import { Client } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { useEffect, useState } from "preact/hooks"; 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; export const SMOOTH_SCROLL_ON_RECEIVE = false;
...@@ -23,7 +27,7 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -23,7 +27,7 @@ export class SingletonRenderer extends EventEmitter3 {
this.edit = this.edit.bind(this); this.edit = this.edit.bind(this);
this.delete = this.delete.bind(this); this.delete = this.delete.bind(this);
this.state = { type: 'LOADING' }; this.state = { type: "LOADING" };
this.currentRenderer = SimpleRenderer; this.currentRenderer = SimpleRenderer;
} }
...@@ -41,23 +45,23 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -41,23 +45,23 @@ export class SingletonRenderer extends EventEmitter3 {
subscribe(client: Client) { subscribe(client: Client) {
if (this.client) { if (this.client) {
this.client.removeListener('message', this.receive); this.client.removeListener("message", this.receive);
this.client.removeListener('message/update', this.edit); this.client.removeListener("message/update", this.edit);
this.client.removeListener('message/delete', this.delete); this.client.removeListener("message/delete", this.delete);
} }
this.client = client; this.client = client;
client.addListener('message', this.receive); client.addListener("message", this.receive);
client.addListener('message/update', this.edit); client.addListener("message/update", this.edit);
client.addListener('message/delete', this.delete); client.addListener("message/delete", this.delete);
} }
private setStateUnguarded(state: RenderState, scroll?: ScrollState) { private setStateUnguarded(state: RenderState, scroll?: ScrollState) {
this.state = state; this.state = state;
this.emit('state', state); this.emit("state", state);
if (scroll) { if (scroll) {
this.emit('scroll', scroll); this.emit("scroll", scroll);
} }
} }
...@@ -67,14 +71,29 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -67,14 +71,29 @@ export class SingletonRenderer extends EventEmitter3 {
} }
markStale() { 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.channel = id;
this.stale = false; this.stale = false;
this.setStateUnguarded({ type: 'LOADING' }); this.setStateUnguarded({ type: "LOADING" });
await this.currentRenderer.init(this, id); await this.currentRenderer.init(this, id, message_id);
} }
async reloadStale(id: string) { async reloadStale(id: string) {
...@@ -91,37 +110,42 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -91,37 +110,42 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(end: string): ScrollState { function generateScroll(end: string): ScrollState {
if (ref) { if (ref) {
let heightRemoved = 0; let heightRemoved = 0;
let messageContainer = ref.children[0]; const messageContainer = ref.children[0];
if (messageContainer) { 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 this child has a ulid.
if (child.id?.length === 26) { if (child.id?.length === 26) {
// Check whether it was removed. // Check whether it was removed.
if (child.id.localeCompare(end) === 1) { 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. // 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 { return {
type: 'OffsetTop', type: "OffsetTop",
previousHeight: ref.scrollHeight - heightRemoved previousHeight: ref.scrollHeight - heightRemoved,
} };
} else {
return {
type: 'OffsetTop',
previousHeight: 0
}
} }
return {
type: "OffsetTop",
previousHeight: 0,
};
} }
await this.currentRenderer.loadTop(this, generateScroll); await this.currentRenderer.loadTop(this, generateScroll);
// Allow state updates to propagate. // Allow state updates to propagate.
setTimeout(() => this.fetchingTop = false, 0); setTimeout(() => (this.fetchingTop = false), 0);
} }
async loadBottom(ref?: HTMLDivElement) { async loadBottom(ref?: HTMLDivElement) {
...@@ -131,44 +155,49 @@ export class SingletonRenderer extends EventEmitter3 { ...@@ -131,44 +155,49 @@ export class SingletonRenderer extends EventEmitter3 {
function generateScroll(start: string): ScrollState { function generateScroll(start: string): ScrollState {
if (ref) { if (ref) {
let heightRemoved = 0; let heightRemoved = 0;
let messageContainer = ref.children[0]; const messageContainer = ref.children[0];
if (messageContainer) { 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 this child has a ulid.
if (child.id?.length === 26) { if (child.id?.length === 26) {
// Check whether it was removed. // Check whether it was removed.
if (child.id.localeCompare(start) === -1) { 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. // 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 { return {
type: 'ScrollTop', type: "ScrollTop",
y: ref.scrollTop - heightRemoved y: ref.scrollTop - heightRemoved,
} };
} else {
return {
type: 'ScrollToBottom'
}
} }
return {
type: "ScrollToBottom",
};
} }
await this.currentRenderer.loadBottom(this, generateScroll); await this.currentRenderer.loadBottom(this, generateScroll);
// Allow state updates to propagate. // Allow state updates to propagate.
setTimeout(() => this.fetchingBottom = false, 0); setTimeout(() => (this.fetchingBottom = false), 0);
} }
async jumpToBottom(id: string, smooth: boolean) { async jumpToBottom(id: string, smooth: boolean) {
if (id !== this.channel) return; if (id !== this.channel) return;
if (this.state.type === 'RENDER' && this.state.atBottom) { if (this.state.type === "RENDER" && this.state.atBottom) {
this.emit('scroll', { type: 'ScrollToBottom', smooth }); this.emit("scroll", { type: "ScrollToBottom", smooth });
} else { } 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 { ...@@ -176,7 +205,9 @@ export class SingletonRenderer extends EventEmitter3 {
export const SingletonMessageRenderer = new SingletonRenderer(); export const SingletonMessageRenderer = new SingletonRenderer();
export function useRenderState(id: string) { 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; if (typeof id === "undefined") return;
function render(state: RenderState) { function render(state: RenderState) {
......
import { mapMessage } from "../../../context/revoltjs/util"; import { noopAsync } from "../../js";
import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton"; import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
import { RendererRoutines } from "../types"; import { RendererRoutines } from "../types";
export const SimpleRenderer: RendererRoutines = { export const SimpleRenderer: RendererRoutines = {
init: async (renderer, id, smooth) => { init: async (renderer, id, nearby, smooth) => {
if (renderer.client!.websocket.connected) { if (renderer.client!.websocket.connected) {
renderer.client!.channels if (nearby)
.fetchMessagesWithUsers(id, { }, true) renderer
.then(({ messages: data }) => { .client!.channels.get(id)!
data.reverse(); .fetchMessagesWithUsers({ nearby, limit: 100 })
let messages = data.map(x => mapMessage(x)); .then(({ messages }) => {
renderer.setState( messages.sort((a, b) => a._id.localeCompare(b._id));
id, renderer.setState(
{ id,
type: 'RENDER', {
messages, type: "RENDER",
atTop: data.length < 50, messages,
atBottom: true atTop: false,
}, atBottom: false,
{ type: 'ScrollToBottom', smooth } },
); { 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 { } else {
renderer.setState(id, { type: 'WAITING_FOR_NETWORK' }); renderer.setState(id, { type: "WAITING_FOR_NETWORK" });
} }
}, },
receive: async (renderer, message) => { receive: async (renderer, message) => {
if (message.channel !== renderer.channel) return; if (message.channel_id !== renderer.channel) return;
if (renderer.state.type !== 'RENDER') return; if (renderer.state.type !== "RENDER") return;
if (renderer.state.messages.find(x => x._id === message._id)) return; if (renderer.state.messages.find((x) => x._id === message._id)) return;
if (!renderer.state.atBottom) return; if (!renderer.state.atBottom) return;
let messages = [ ...renderer.state.messages, mapMessage(message) ]; let messages = [...renderer.state.messages, message];
let atTop = renderer.state.atTop; let atTop = renderer.state.atTop;
if (messages.length > 150) { if (messages.length > 150) {
messages = messages.slice(messages.length - 150); messages = messages.slice(messages.length - 150);
...@@ -39,44 +57,23 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -39,44 +57,23 @@ export const SimpleRenderer: RendererRoutines = {
} }
renderer.setState( renderer.setState(
message.channel, message.channel_id,
{ {
...renderer.state, ...renderer.state,
messages, messages,
atTop atTop,
}, },
{ type: 'StayAtBottom', smooth: SMOOTH_SCROLL_ON_RECEIVE } { type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE },
); );
}, },
edit: async (renderer, id, patch) => { edit: noopAsync,
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' }
);
}
},
delete: async (renderer, id) => { delete: async (renderer, id) => {
const channel = renderer.channel; const channel = renderer.channel;
if (!channel) return; if (!channel) return;
if (renderer.state.type !== 'RENDER') return; if (renderer.state.type !== "RENDER") return;
let messages = [ ...renderer.state.messages ]; const messages = [...renderer.state.messages];
let index = messages.findIndex(x => x._id === id); const index = messages.findIndex((x) => x._id === id);
if (index > -1) { if (index > -1) {
messages.splice(index, 1); messages.splice(index, 1);
...@@ -85,9 +82,9 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -85,9 +82,9 @@ export const SimpleRenderer: RendererRoutines = {
channel, channel,
{ {
...renderer.state, ...renderer.state,
messages messages,
}, },
{ type: 'StayAtBottom' } { type: "StayAtBottom" },
); );
} }
}, },
...@@ -96,25 +93,24 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -96,25 +93,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return; if (!channel) return;
const state = renderer.state; const state = renderer.state;
if (state.type !== 'RENDER') return; if (state.type !== "RENDER") return;
if (state.atTop) return; if (state.atTop) return;
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { const { messages: data } = await renderer
before: state.messages[0]._id .client!.channels.get(channel)!
}, true); .fetchMessagesWithUsers({
before: state.messages[0]._id,
});
if (data.length === 0) { if (data.length === 0) {
return renderer.setState( return renderer.setState(channel, {
channel, ...state,
{ atTop: true,
...state, });
atTop: true
}
);
} }
data.reverse(); data.reverse();
let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ]; let messages = [...data, ...state.messages];
let atTop = false; let atTop = false;
if (data.length < 50) { if (data.length < 50) {
...@@ -130,7 +126,7 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -130,7 +126,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState( renderer.setState(
channel, channel,
{ ...state, atTop, atBottom, messages }, { ...state, atTop, atBottom, messages },
generateScroll(messages[messages.length - 1]._id) generateScroll(messages[messages.length - 1]._id),
); );
}, },
loadBottom: async (renderer, generateScroll) => { loadBottom: async (renderer, generateScroll) => {
...@@ -138,25 +134,24 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -138,25 +134,24 @@ export const SimpleRenderer: RendererRoutines = {
if (!channel) return; if (!channel) return;
const state = renderer.state; const state = renderer.state;
if (state.type !== 'RENDER') return; if (state.type !== "RENDER") return;
if (state.atBottom) return; if (state.atBottom) return;
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, { const { messages: data } = await renderer
after: state.messages[state.messages.length - 1]._id, .client!.channels.get(channel)!
sort: 'Oldest' .fetchMessagesWithUsers({
}, true); after: state.messages[state.messages.length - 1]._id,
sort: "Oldest",
});
if (data.length === 0) { if (data.length === 0) {
return renderer.setState( return renderer.setState(channel, {
channel, ...state,
{ atBottom: true,
...state, });
atBottom: true
}
);
} }
let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ]; let messages = [...state.messages, ...data];
let atBottom = false; let atBottom = false;
if (data.length < 50) { if (data.length < 50) {
...@@ -172,7 +167,7 @@ export const SimpleRenderer: RendererRoutines = { ...@@ -172,7 +167,7 @@ export const SimpleRenderer: RendererRoutines = {
renderer.setState( renderer.setState(
channel, channel,
{ ...state, atTop, atBottom, messages }, { ...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 { SingletonRenderer } from "./Singleton";
import { MessageObject } from "../../context/revoltjs/util";
export type ScrollState = export type ScrollState =
| { type: "Free" } | { type: "Free" }
| { type: "Bottom", scrollingUntil?: number } | { type: "Bottom"; scrollingUntil?: number }
| { type: "ScrollToBottom" | "StayAtBottom", smooth?: boolean } | { type: "ScrollToBottom" | "StayAtBottom"; smooth?: boolean }
| { type: "ScrollToView"; id: string }
| { type: "OffsetTop"; previousHeight: number } | { type: "OffsetTop"; previousHeight: number }
| { type: "ScrollTop"; y: number }; | { type: "ScrollTop"; y: number };
export type RenderState = export type RenderState =
| { | {
type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY"; type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
} }
| { | {
type: "RENDER"; type: "RENDER";
atTop: boolean; atTop: boolean;
atBottom: boolean; atBottom: boolean;
messages: MessageObject[]; messages: Message[];
}; };
export interface RendererRoutines { 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>; 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>; delete: (renderer: SingletonRenderer, id: string) => Promise<void>;
loadTop: (renderer: SingletonRenderer, generateScroll: (end: string) => ScrollState) => Promise<void>; loadTop: (
loadBottom: (renderer: SingletonRenderer, generateScroll: (start: string) => ScrollState) => Promise<void>; 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.preventDefault();
ev.stopPropagation(); 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"; ...@@ -3,14 +3,14 @@ import { useEffect, useState } from "preact/hooks";
export function useWindowSize() { export function useWindowSize() {
const [windowSize, setWindowSize] = useState({ const [windowSize, setWindowSize] = useState({
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight height: window.innerHeight,
}); });
useEffect(() => { useEffect(() => {
function handleResize() { function handleResize() {
setWindowSize({ setWindowSize({
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight height: window.innerHeight,
}); });
} }
......
import { render } from "preact"; import { registerSW } from "virtual:pwa-register";
import "./styles/index.scss"; 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() { onNeedRefresh() {
// ! FIXME: temp internalEmit("PWA", "update");
updateSW(true);
// show a prompt to user
}, },
onOfflineReady() { onOfflineReady() {
console.info('Ready to work offline.'); console.info("Ready to work offline.");
// show a ready to work offline to user // show a ready to work offline to user
}, },
}) });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
render(<App />, document.getElementById("app")!); 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 { Docked, OverlappingPanels, ShowIf } from "react-overlapping-panels";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import { Switch, Route, useLocation } from "react-router-dom";
import { Switch, Route } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import Popovers from "../context/intermediate/Popovers";
import ContextMenus from "../lib/ContextMenus"; 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 LeftSidebar from "../components/navigation/LeftSidebar";
import RightSidebar from "../components/navigation/RightSidebar"; import RightSidebar from "../components/navigation/RightSidebar";
import Open from "./Open";
import Home from './home/Home';
import Friends from "./friends/Friends";
import Channel from "./channels/Channel"; import Channel from "./channels/Channel";
import Settings from './settings/Settings';
import Developer from "./developer/Developer"; 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 ChannelSettings from "./settings/ChannelSettings";
import ServerSettings from "./settings/ServerSettings";
import Settings from "./settings/Settings";
const Routes = styled.div` const Routes = styled.div`
min-width: 0; min-width: 0;
...@@ -26,49 +33,102 @@ const Routes = styled.div` ...@@ -26,49 +33,102 @@ const Routes = styled.div`
`; `;
export default function App() { 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 ( return (
<OverlappingPanels <>
width="100vw" {window.isNative && !window.native.getConfig().frame && (
height="100vh" <Titlebar />
leftPanel={{ width: 292, component: <LeftSidebar /> }} )}
rightPanel={{ width: 240, component: <RightSidebar /> }} <OverlappingPanels
docked={isTouchscreenDevice ? Docked.None : Docked.Left}> width="100vw"
<Routes> height={
<Switch> window.isNative && !window.native.getConfig().frame
<Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} /> ? "calc(var(--app-height) - var(--titlebar-height))"
<Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} /> : "var(--app-height)"
<Route path="/server/:server/settings/:page" component={ServerSettings} /> }
<Route path="/server/:server/settings" component={ServerSettings} /> leftPanel={
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} /> inSpecial
<Route path="/channel/:channel/settings" component={ChannelSettings} /> ? 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
<Route path="/server/:server/channel/:channel" component={Channel} /> path="/channel/:channel/:message"
<Route path="/server/:server" /> component={Channel}
<Route path="/channel/:channel" component={Channel} /> />
<Route
<Route path="/settings/:page" component={Settings} /> path="/server/:server/channel/:channel/:message"
<Route path="/settings" component={Settings} /> component={Channel}
/>
<Route path="/dev" component={Developer} /> <Route
<Route path="/friends" component={Friends} /> path="/server/:server/channel/:channel"
<Route path="/" component={Home} /> component={Channel}
</Switch> />
</Routes> <Route path="/server/:server" />
<ContextMenus /> <Route path="/channel/:channel" component={Channel} />
<Popovers />
</OverlappingPanels> <Route path="/settings/:page" component={Settings} />
); <Route path="/settings" component={Settings} />
};
/** <Route path="/dev" component={Developer} />
* <Route path="/friends" component={Friends} />
* <Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} />
<Route path="/open/:id"> <Route path="/" component={Home} />
<Open /> </Switch>
</Route> </Routes>
{/*<Route path="/invite/:code"> <ContextMenus />
<OpenInvite /> <Popovers />
</Route> <Notifications />
*/ <StateMonitor />
<SyncManager />
</OverlappingPanels>
</>
);
}
import { CheckAuth } from "../context/revoltjs/CheckAuth";
import Preloader from "../components/ui/Preloader";
import { Route, Switch } from "react-router-dom"; import { Route, Switch } from "react-router-dom";
import Context from "../context";
import { lazy, Suspense } from "preact/compat"; 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() { export function App() {
return ( return (
<Context> <Context>
<Masks />
{/* {/*
// @ts-expect-error */} // @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader />}> <Suspense fallback={<Preloader type="spinner" />}>
<Switch> <Switch>
<Route path="/login"> <Route path="/login">
<CheckAuth> <CheckAuth>
......
import styled from "styled-components"; import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import Header from "../../components/ui/Header"; import { Channel as ChannelI } from "revolt.js/dist/maps/Channels";
import { useRenderState } from "../../lib/renderer/Singleton"; import styled from "styled-components";
import { useChannel, useForceUpdate, useUsers } from "../../context/revoltjs/hooks";
import { MessageArea } from "./messaging/MessageArea"; 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 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` const ChannelMain = styled.div`
flex-grow: 1; flex-grow: 1;
...@@ -21,26 +36,81 @@ const ChannelContent = styled.div` ...@@ -21,26 +36,81 @@ const ChannelContent = styled.div`
flex-direction: column; flex-direction: column;
`; `;
export default function Channel() { export function Channel({ id }: { id: string }) {
const { channel: id } = useParams<{ channel: string }>(); const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
const ctx = useForceUpdate(); if (channel.channel_type === "VoiceChannel") {
const channel = useChannel(id, ctx); return <VoiceChannel channel={channel} />;
}
if (!channel) return null; return <TextChannel channel={channel} />;
// const view = useRenderState(id); }
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 ( return (
<> <AgeGate
<Header placement="primary"> type="channel"
Channel channel={channel}
</Header> 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> <ChannelMain>
<ChannelContent> <ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} /> <MessageArea id={id} />
<TypingIndicator channel={channel} />
<JumpToBottom id={id} />
<MessageBox channel={channel} /> <MessageBox channel={channel} />
</ChannelContent> </ChannelContent>
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain> </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>
);
});
/* eslint-disable react-hooks/rules-of-hooks */
import {
UserPlus,
Cog,
PhoneCall,
PhoneOff,
Group,
} from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom";
import { useContext } from "preact/hooks";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import UpdateIndicator from "../../../components/common/UpdateIndicator";
import IconButton from "../../../components/ui/IconButton";
import { ChannelHeaderProps } from "../ChannelHeader";
export default function HeaderActions({
channel,
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate();
const history = useHistory();
return (
<>
<UpdateIndicator style="channel" />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipient_ids!,
callback: async (users) => {
for (const user of users) {
await channel.addMember(user);
}
},
})
}>
<UserPlus size={27} />
</IconButton>
<IconButton
onClick={() =>
history.push(`/channel/${channel._id}/settings`)
}>
<Cog size={24} />
</IconButton>
</>
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") && (
<IconButton onClick={toggleSidebar}>
<Group size={25} />
</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>
);
}
return (
<IconButton
onClick={() => {
disconnect();
connect(channel);
}}>
<PhoneCall size={24} />
</IconButton>
);
}
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
}
import { Text } from "preact-i18n"; import { observer } from "mobx-react-lite";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util"; import { getChannelName } from "../../../context/revoltjs/util";
import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
const StartBase = styled.div` const StartBase = styled.div`
margin: 18px 16px 10px 16px; margin: 18px 16px 10px 16px;
...@@ -22,17 +25,17 @@ interface Props { ...@@ -22,17 +25,17 @@ interface Props {
id: string; id: string;
} }
export default function ConversationStart({ id }: Props) { export default observer(({ id }: Props) => {
const ctx = useForceUpdate(); const client = useClient();
const channel = useChannel(id, ctx); const channel = client.channels.get(id);
if (!channel) return null; if (!channel) return null;
return ( return (
<StartBase> <StartBase>
<h1>{ getChannelName(ctx.client, channel, true) }</h1> <h1>{getChannelName(channel, true)}</h1>
<h4> <h4>
<Text id="app.main.channel.start.group" /> <Text id="app.main.channel.start.group" />
</h4> </h4>
</StartBase> </StartBase>
); );
} });