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 581 additions and 357 deletions
/* eslint-disable react-hooks/rules-of-hooks */
import { useState } from "preact/hooks";
const counts: { [key: string]: number } = {};
export default function PaintCounter({ small, always }: { small?: boolean, always?: boolean }) {
export default function PaintCounter({
small,
always,
}: {
small?: boolean;
always?: boolean;
}) {
if (import.meta.env.PROD && !always) return null;
const [uniqueId] = useState('' + Math.random());
const [uniqueId] = useState(`${Math.random()}`);
const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1;
return (
<div style={{ textAlign: 'center', fontSize: '0.8em' }}>
{ small ? <>P: { count + 1 }</> : <>
Painted {count + 1} time(s).
</> }
<div style={{ textAlign: "center", fontSize: "0.8em" }}>
{small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
</div>
)
);
}
import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea";
import { useEffect, useRef } from "preact/hooks";
import styled from "styled-components";
import { RefObject } from "preact";
import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
import TextArea, { TextAreaProps } from "../components/ui/TextArea";
import { internalSubscribe } from "./eventEmitter";
import { isTouchscreenDevice } from "./isTouchscreenDevice";
type TextAreaAutoSizeProps = Omit<
JSX.HTMLAttributes<HTMLTextAreaElement>,
"style" | "value" | "onChange" | "children" | "as"
> &
TextAreaProps & {
forceFocus?: boolean;
autoFocus?: boolean;
minHeight?: number;
maxRows?: number;
value: string;
id?: string;
onChange?: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
};
type TextAreaAutoSizeProps = Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, 'style' | 'value'> & TextAreaProps & {
forceFocus?: boolean
autoFocus?: boolean
minHeight?: number
maxRows?: number
value: string
const Container = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
`;
id?: string
};
const Ghost = styled.div<{ lineHeight: string; maxRows: number }>`
flex: 0;
width: 100%;
overflow: hidden;
visibility: hidden;
position: relative;
> div {
width: 100%;
white-space: pre-wrap;
word-break: break-all;
top: 0;
position: absolute;
font-size: var(--text-size);
line-height: ${(props) => props.lineHeight};
max-height: calc(
calc(${(props) => props.lineHeight} * ${(props) => props.maxRows})
);
}
`;
export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, forceFocus, children, as, ...textAreaProps } = props;
const line = lineHeight ?? DEFAULT_LINE_HEIGHT;
const {
autoFocus,
minHeight,
maxRows,
value,
padding,
lineHeight,
hideBorder,
forceFocus,
onChange,
...textAreaProps
} = props;
const heightPadding = ((padding ?? DEFAULT_TEXT_AREA_PADDING) + (hideBorder ? 0 : TEXT_AREA_BORDER_WIDTH)) * 2;
const height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * line + heightPadding, minHeight ?? 0);
const ref = useRef<HTMLTextAreaElement>() as RefObject<HTMLTextAreaElement>;
const ghost = useRef<HTMLDivElement>() as RefObject<HTMLDivElement>;
const ref = useRef<HTMLTextAreaElement>();
useLayoutEffect(() => {
if (ref.current && ghost.current) {
ref.current.style.height = `${ghost.current.clientHeight}px`;
}
}, [ghost, props.value]);
useEffect(() => {
autoFocus && ref.current.focus();
}, [value]);
if (isTouchscreenDevice) return;
autoFocus && ref.current && ref.current.focus();
}, [value, autoFocus]);
const inputSelected = () =>
["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? "");
useEffect(() => {
if (!ref.current) return;
if (forceFocus) {
ref.current.focus();
}
if (isTouchscreenDevice) return;
if (autoFocus && !inputSelected()) {
ref.current.focus();
}
......@@ -48,30 +106,55 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return;
if (e.key.length !== 1) return;
if (ref && !inputSelected()) {
ref.current.focus();
ref.current!.focus();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [ref]);
}, [ref, autoFocus, forceFocus, value]);
useEffect(() => {
if (!ref.current) return;
function focus(id: string) {
if (id === props.id) {
ref.current.focus();
ref.current!.focus();
}
}
return internalSubscribe("TextArea", "focus", focus);
}, [ref]);
return <TextArea
ref={ref}
value={value}
padding={padding}
style={{ height }}
hideBorder={hideBorder}
lineHeight={lineHeight}
{...textAreaProps} />;
return internalSubscribe(
"TextArea",
"focus",
focus as (...args: unknown[]) => void,
);
}, [props.id, ref]);
return (
<Container>
<TextArea
ref={ref}
value={value}
padding={padding}
style={{ minHeight }}
hideBorder={hideBorder}
lineHeight={lineHeight}
onChange={(ev) => {
onChange && onChange(ev);
}}
{...textAreaProps}
/>
<Ghost
lineHeight={lineHeight ?? "var(--textarea-line-height)"}
maxRows={maxRows ?? 5}>
<div ref={ghost} style={{ padding }}>
{props.value
? props.value
.split("\n")
.map((x) => `\u200e${x}`)
.join("\n")
: undefined ?? "\n"}
</div>
</Ghost>
</Container>
);
}
export function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, "+")
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
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.
......
import EventEmitter from "eventemitter3";
export const InternalEvent = new EventEmitter();
export function internalSubscribe(ns: string, event: string, fn: (...args: any[]) => void) {
InternalEvent.addListener(ns + '/' + event, fn);
return () => InternalEvent.removeListener(ns + '/' + event, fn);
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: any[]) {
InternalEvent.emit(ns + '/' + event, ...args);
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
......@@ -20,4 +26,5 @@ export function internalEmit(ns: string, event: string, ...args: any[]) {
// - MessageBox/append
// - TextArea/focus
// - ReplyBar/add
// - Modal/close
// - PWA/update
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,36 +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?: Object, plural?: number, fallback?: string) => translate(id, "", intl.dictionary, fields, plural, fallback);
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
RtpParameters,
} from "mediasoup-client/lib/RtpParameters";
import { DtlsParameters } from "mediasoup-client/lib/Transport";
......@@ -12,13 +13,14 @@ import {
WSCommandType,
WSErrorCode,
ProduceType,
ConsumerData
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;
}
......@@ -44,10 +46,10 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
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);
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) => {
......@@ -86,6 +88,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
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 });
......@@ -97,7 +100,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
const onClose = (e: CloseEvent) => {
reject({
error: e.code,
message: e.reason
message: e.reason,
});
};
......@@ -107,7 +110,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
reject({
error: data.error,
message: data.message,
data: data.data
data: data.data,
});
resolve(data.data);
};
......@@ -116,13 +119,14 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
this.once("close", onClose);
const json = {
id: this.index,
type: type,
data
type,
data,
};
ws.send(JSON.stringify(json) + "\n");
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 });
......@@ -133,36 +137,36 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
return {
id: room.id,
videoAllowed: room.videoAllowed,
users: new Map(Object.entries(room.users))
users: new Map(Object.entries(room.users)),
};
}
initializeTransports(
rtpCapabilities: RtpCapabilities
rtpCapabilities: RtpCapabilities,
): Promise<TransportInitDataTuple> {
return this.sendRequest(WSCommandType.InitializeTransports, {
mode: "SplitWebRTC",
rtpCapabilities
rtpCapabilities,
});
}
connectTransport(
id: string,
dtlsParameters: DtlsParameters
dtlsParameters: DtlsParameters,
): Promise<void> {
return this.sendRequest(WSCommandType.ConnectTransport, {
id,
dtlsParameters
dtlsParameters,
});
}
async startProduce(
type: ProduceType,
rtpParameters: RtpParameters
rtpParameters: RtpParameters,
): Promise<string> {
let result = await this.sendRequest(WSCommandType.StartProduce, {
const result = await this.sendRequest(WSCommandType.StartProduce, {
type,
rtpParameters
rtpParameters,
});
return result.producerId;
}
......@@ -182,7 +186,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
setConsumerPause(consumerId: string, paused: boolean): Promise<void> {
return this.sendRequest(WSCommandType.SetConsumerPause, {
id: consumerId,
paused
paused,
});
}
}
......@@ -2,13 +2,13 @@ import { Consumer } from "mediasoup-client/lib/Consumer";
import {
MediaKind,
RtpCapabilities,
RtpParameters
RtpParameters,
} from "mediasoup-client/lib/RtpParameters";
import { SctpParameters } from "mediasoup-client/lib/SctpParameters";
import {
DtlsParameters,
IceCandidate,
IceParameters
IceParameters,
} from "mediasoup-client/lib/Transport";
export enum WSEventType {
......@@ -16,7 +16,7 @@ export enum WSEventType {
UserLeft = "UserLeft",
UserStartProduce = "UserStartProduce",
UserStopProduce = "UserStopProduce"
UserStopProduce = "UserStopProduce",
}
export enum WSCommandType {
......@@ -31,7 +31,7 @@ export enum WSCommandType {
StartConsume = "StartConsume",
StopConsume = "StopConsume",
SetConsumerPause = "SetConsumerPause"
SetConsumerPause = "SetConsumerPause",
}
export enum WSErrorCode {
......@@ -44,7 +44,7 @@ export enum WSErrorCode {
ProducerNotFound = 614,
ConsumerFailure = 621,
ConsumerNotFound = 624
ConsumerNotFound = 624,
}
export enum WSCloseCode {
......@@ -54,7 +54,7 @@ export enum WSCloseCode {
RoomClosed = 4004,
// Sent when a client tries to send an opcode in the wrong state
InvalidState = 1002,
ServerError = 1011
ServerError = 1011,
}
export interface VoiceError {
......
import EventEmitter from "eventemitter3";
import * as mediasoupClient from "mediasoup-client";
import {
Device,
Producer,
Transport,
UnsupportedError
} from "mediasoup-client/lib/types";
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
WSErrorCode,
} from "./Types";
import Signaling from "./Signaling";
const UnsupportedError = types.UnsupportedError;
interface VoiceEvents {
ready: () => void;
......@@ -58,7 +56,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
this.signaling.on(
"data",
json => {
(json) => {
const data = json.data;
switch (json.type) {
case WSEventType.UserJoined: {
......@@ -82,7 +80,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
break;
default:
throw new Error(
`Invalid produce type ${data.type}`
`Invalid produce type ${data.type}`,
);
}
......@@ -100,7 +98,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
break;
default:
throw new Error(
`Invalid produce type ${data.type}`
`Invalid produce type ${data.type}`,
);
}
......@@ -111,29 +109,29 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
}
}
},
this
this,
);
this.signaling.on(
"error",
error => {
() => {
this.emit("error", new Error("Signaling error"));
},
this
this,
);
this.signaling.on(
"close",
error => {
(error) => {
this.disconnect(
{
error: error.code,
message: error.reason
message: error.reason,
},
true
true,
);
},
this
this,
);
}
......@@ -174,9 +172,9 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
if (this.device === undefined || this.roomId === undefined)
throw new ReferenceError("Voice Client is in an invalid state");
const result = await this.signaling.authenticate(token, this.roomId);
let [room] = await Promise.all([
const [room] = await Promise.all([
this.signaling.roomInfo(),
this.device.load({ routerRtpCapabilities: result.rtpCapabilities })
this.device.load({ routerRtpCapabilities: result.rtpCapabilities }),
]);
this.userId = result.userId;
......@@ -188,14 +186,14 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
if (this.device === undefined)
throw new ReferenceError("Voice Client is in an invalid state");
const initData = await this.signaling.initializeTransports(
this.device.rtpCapabilities
this.device.rtpCapabilities,
);
this.sendTransport = this.device.createSendTransport(
initData.sendTransport
initData.sendTransport,
);
this.recvTransport = this.device.createRecvTransport(
initData.recvTransport
initData.recvTransport,
);
const connectTransport = (transport: Transport) => {
......@@ -226,12 +224,12 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
return errback();
this.signaling
.startProduce(type, parameters.rtpParameters)
.then(id => callback({ id }))
.then((id) => callback({ id }))
.catch(errback);
});
this.emit("ready");
for (let user of this.participants) {
for (const user of this.participants) {
if (user[1].audio && user[0] !== this.userId)
this.startConsume(user[0], "audio");
}
......@@ -283,7 +281,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
throw new Error("Send transport undefined");
const producer = await this.sendTransport.produce({
track,
appData: { type }
appData: { type },
});
switch (type) {
......@@ -325,7 +323,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
await this.signaling.stopProduce(type);
} catch (error) {
if (error.error === WSErrorCode.ProducerNotFound) return;
else throw error;
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 { registerSW } from 'virtual:pwa-register';
import { internalEmit } from './lib/eventEmitter';
import { registerSW } from "virtual:pwa-register";
import "./styles/index.scss";
import { render } from "preact";
import { internalEmit } from "./lib/eventEmitter";
import { App } from "./pages/app";
export const updateSW = registerSW({
onNeedRefresh() {
internalEmit('PWA', 'update');
internalEmit("PWA", "update");
},
onOfflineReady() {
console.info('Ready to work offline.');
console.info("Ready to work offline.");
// show a ready to work offline to user
},
})
import "./styles/index.scss";
import { render } from "preact";
import { App } from "./pages/app";
});
// 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 Header from "../components/ui/Header";
import { useContext, useEffect } from "preact/hooks";
import { useHistory, useParams } from "react-router-dom";
import { useIntermediate } from "../context/intermediate/Intermediate";
import { useChannels, useForceUpdate, useUser } from "../context/revoltjs/hooks";
import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import Header from "../components/ui/Header";
export default function Open() {
const history = useHistory();
......@@ -21,48 +28,48 @@ export default function Open() {
);
}
const ctx = useForceUpdate();
const channels = useChannels(undefined, ctx);
const user = useUser(id, ctx);
useEffect(() => {
if (id === "saved") {
for (const channel of channels) {
for (const channel of [...client.channels.values()]) {
if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`);
return;
}
}
client.users
.openDM(client.user?._id as string)
.then(channel => history.push(`/channel/${channel?._id}`))
.catch(error => openScreen({ id: "error", error }));
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 = channels.find(
channel =>
const channel: string | undefined = [
...client.channels.values(),
].find(
(channel) =>
channel?.channel_type === "DirectMessage" &&
channel.recipients.includes(id)
channel.recipient_ids!.includes(id),
)?._id;
if (channel) {
history.push(`/channel/${channel}`);
} else {
client.users
.openDM(id)
.then(channel => history.push(`/channel/${channel?._id}`))
.catch(error => openScreen({ id: "error", error }));
.get(id)
?.openDM()
.then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
}
return;
}
history.push("/");
}, []);
});
return (
<Header placement="primary">
......
import { Docked, OverlappingPanels, ShowIf } from "react-overlapping-panels";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { Switch, Route, useLocation } from "react-router-dom";
import styled from "styled-components";
import ContextMenus from "../lib/ContextMenus";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import Popovers from "../context/intermediate/Popovers";
import SyncManager from "../context/revoltjs/SyncManager";
import StateMonitor from "../context/revoltjs/StateMonitor";
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 BottomNavigation from "../components/navigation/BottomNavigation";
import Open from "./Open";
import Home from './home/Home';
import Invite from "./invite/Invite";
import Friends from "./friends/Friends";
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;
......@@ -33,51 +34,101 @@ const Routes = styled.div`
export default function App() {
const path = useLocation().pathname;
const fixedBottomNav = (path === '/' || path === '/settings' || path.startsWith("/friends"));
const inSettings = path === '/settings';
const inChannel = path.includes('/channel');
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="100vh"
leftPanel={inSettings ? undefined : { width: 292, component: <LeftSidebar /> }}
rightPanel={(!inSettings && 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} />
<>
{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"
component={Channel}
/>
<Route
path="/server/:server/channel/:channel/:message"
component={Channel}
/>
<Route
path="/server/:server/channel/:channel"
component={Channel}
/>
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<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="/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="/" component={Home} />
</Switch>
</Routes>
<ContextMenus />
<Popovers />
<Notifications />
<StateMonitor />
<SyncManager />
</OverlappingPanels>
<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 */}
// @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader type="spinner" />}>
<Switch>
<Route path="/login">
......