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 580 additions and 359 deletions
/* eslint-disable react-hooks/rules-of-hooks */
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
const counts: { [key: string]: number } = {}; 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; if (import.meta.env.PROD && !always) return null;
const [uniqueId] = useState('' + Math.random()); const [uniqueId] = useState(`${Math.random()}`);
const count = counts[uniqueId] ?? 0; const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1; counts[uniqueId] = count + 1;
return ( return (
<div style={{ textAlign: 'center', fontSize: '0.8em' }}> <div style={{ textAlign: "center", fontSize: "0.8em" }}>
{ small ? <>P: { count + 1 }</> : <> {small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
Painted {count + 1} time(s).
</> }
</div> </div>
) );
} }
import TextArea, { DEFAULT_LINE_HEIGHT, DEFAULT_TEXT_AREA_PADDING, TextAreaProps, TEXT_AREA_BORDER_WIDTH } from "../components/ui/TextArea"; import styled from "styled-components";
import { useEffect, useRef } from "preact/hooks";
import { RefObject } from "preact";
import { useEffect, useLayoutEffect, useRef } from "preact/hooks";
import TextArea, { TextAreaProps } from "../components/ui/TextArea";
import { internalSubscribe } from "./eventEmitter"; 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 & { const Container = styled.div`
forceFocus?: boolean flex-grow: 1;
autoFocus?: boolean display: flex;
minHeight?: number flex-direction: column;
maxRows?: number `;
value: string
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) { export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
const { autoFocus, minHeight, maxRows, value, padding, lineHeight, hideBorder, forceFocus, children, as, ...textAreaProps } = props; const {
const line = lineHeight ?? DEFAULT_LINE_HEIGHT; 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 ref = useRef<HTMLTextAreaElement>() as RefObject<HTMLTextAreaElement>;
const height = Math.max(Math.min(value.split('\n').length, maxRows ?? Infinity) * line + heightPadding, minHeight ?? 0); 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(() => { useEffect(() => {
autoFocus && ref.current.focus(); if (isTouchscreenDevice) return;
}, [value]); autoFocus && ref.current && ref.current.focus();
}, [value, autoFocus]);
const inputSelected = () => const inputSelected = () =>
["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? ""); ["TEXTAREA", "INPUT"].includes(document.activeElement?.nodeName ?? "");
useEffect(() => { useEffect(() => {
if (!ref.current) return;
if (forceFocus) { if (forceFocus) {
ref.current.focus(); ref.current.focus();
} }
if (isTouchscreenDevice) return;
if (autoFocus && !inputSelected()) { if (autoFocus && !inputSelected()) {
ref.current.focus(); ref.current.focus();
} }
...@@ -48,30 +106,55 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) { ...@@ -48,30 +106,55 @@ export default function TextAreaAutoSize(props: TextAreaAutoSizeProps) {
if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return; if ((e.ctrlKey && e.key !== "v") || e.altKey || e.metaKey) return;
if (e.key.length !== 1) return; if (e.key.length !== 1) return;
if (ref && !inputSelected()) { if (ref && !inputSelected()) {
ref.current.focus(); ref.current!.focus();
} }
} }
document.body.addEventListener("keydown", keyDown); document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown); return () => document.body.removeEventListener("keydown", keyDown);
}, [ref]); }, [ref, autoFocus, forceFocus, value]);
useEffect(() => { useEffect(() => {
if (!ref.current) return;
function focus(id: string) { function focus(id: string) {
if (id === props.id) { if (id === props.id) {
ref.current.focus(); ref.current!.focus();
} }
} }
return internalSubscribe("TextArea", "focus", focus); return internalSubscribe(
}, [ref]); "TextArea",
"focus",
return <TextArea focus as (...args: unknown[]) => void,
ref={ref} );
value={value} }, [props.id, ref]);
padding={padding}
style={{ height }} return (
hideBorder={hideBorder} <Container>
lineHeight={lineHeight} <TextArea
{...textAreaProps} />; 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) { export function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding) const base64 = (base64String + padding)
.replace(/\-/g, "+") .replace(/-/g, "+")
.replace(/_/g, "/"); .replace(/_/g, "/");
const rawData = window.atob(base64); 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. // Store the timer variable.
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
// This function is given to React. // This function is given to React.
return (...args: any[]) => { return (...args: unknown[]) => {
// Get rid of the old timer. // Get rid of the old timer.
clearTimeout(timer); clearTimeout(timer);
// Set a new timer. // Set a new timer.
......
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
export const InternalEvent = new EventEmitter(); export const InternalEvent = new EventEmitter();
export function internalSubscribe(ns: string, event: string, fn: (...args: any[]) => void) { export function internalSubscribe(
InternalEvent.addListener(ns + '/' + event, fn); ns: string,
return () => InternalEvent.removeListener(ns + '/' + event, fn); 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[]) { export function internalEmit(ns: string, event: string, ...args: unknown[]) {
InternalEvent.emit(ns + '/' + event, ...args); InternalEvent.emit(`${ns}/${event}`, ...args);
} }
// Event structure: namespace/event // Event structure: namespace/event
/// Event List /// Event List
// - MessageArea/jump_to_bottom
// - MessageRenderer/edit_last // - MessageRenderer/edit_last
// - MessageRenderer/edit_message // - MessageRenderer/edit_message
// - Intermediate/open_profile // - Intermediate/open_profile
...@@ -20,4 +26,5 @@ export function internalEmit(ns: string, event: string, ...args: any[]) { ...@@ -20,4 +26,5 @@ export function internalEmit(ns: string, event: string, ...args: any[]) {
// - MessageBox/append // - MessageBox/append
// - TextArea/focus // - TextArea/focus
// - ReplyBar/add // - ReplyBar/add
// - Modal/close
// - PWA/update // - PWA/update
import { IntlContext, translate } 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,36 +25,46 @@ function recursiveReplaceFields(input: string, fields: Fields) { ...@@ -24,36 +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() { export function useTranslation() {
const { intl } = useContext(IntlContext) as unknown as IntlType; 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"; 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; return true;
......
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { import {
RtpCapabilities, RtpCapabilities,
RtpParameters RtpParameters,
} from "mediasoup-client/lib/RtpParameters"; } from "mediasoup-client/lib/RtpParameters";
import { DtlsParameters } from "mediasoup-client/lib/Transport"; import { DtlsParameters } from "mediasoup-client/lib/Transport";
...@@ -12,13 +13,14 @@ import { ...@@ -12,13 +13,14 @@ import {
WSCommandType, WSCommandType,
WSErrorCode, WSErrorCode,
ProduceType, ProduceType,
ConsumerData ConsumerData,
} from "./Types"; } from "./Types";
interface SignalingEvents { interface SignalingEvents {
open: (event: Event) => void; open: (event: Event) => void;
close: (event: CloseEvent) => void; close: (event: CloseEvent) => void;
error: (event: Event) => void; error: (event: Event) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: (data: any) => void; data: (data: any) => void;
} }
...@@ -44,10 +46,10 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -44,10 +46,10 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
connect(address: string): Promise<void> { connect(address: string): Promise<void> {
this.disconnect(); this.disconnect();
this.ws = new WebSocket(address); this.ws = new WebSocket(address);
this.ws.onopen = e => this.emit("open", e); this.ws.onopen = (e) => this.emit("open", e);
this.ws.onclose = e => this.emit("close", e); this.ws.onclose = (e) => this.emit("close", e);
this.ws.onerror = e => this.emit("error", e); this.ws.onerror = (e) => this.emit("error", e);
this.ws.onmessage = e => this.parseData(e); this.ws.onmessage = (e) => this.parseData(e);
let finished = false; let finished = false;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
...@@ -86,6 +88,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -86,6 +88,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
entry(json); entry(json);
} }
/* eslint-disable @typescript-eslint/no-explicit-any */
sendRequest(type: string, data?: any): Promise<any> { sendRequest(type: string, data?: any): Promise<any> {
if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN) if (this.ws === undefined || this.ws.readyState !== WebSocket.OPEN)
return Promise.reject({ error: WSErrorCode.NotConnected }); return Promise.reject({ error: WSErrorCode.NotConnected });
...@@ -97,7 +100,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -97,7 +100,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
const onClose = (e: CloseEvent) => { const onClose = (e: CloseEvent) => {
reject({ reject({
error: e.code, error: e.code,
message: e.reason message: e.reason,
}); });
}; };
...@@ -107,7 +110,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -107,7 +110,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
reject({ reject({
error: data.error, error: data.error,
message: data.message, message: data.message,
data: data.data data: data.data,
}); });
resolve(data.data); resolve(data.data);
}; };
...@@ -116,13 +119,14 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -116,13 +119,14 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
this.once("close", onClose); this.once("close", onClose);
const json = { const json = {
id: this.index, id: this.index,
type: type, type,
data data,
}; };
ws.send(JSON.stringify(json) + "\n"); ws.send(`${JSON.stringify(json)}\n`);
this.index++; this.index++;
}); });
} }
/* eslint-enable @typescript-eslint/no-explicit-any */
authenticate(token: string, roomId: string): Promise<AuthenticationResult> { authenticate(token: string, roomId: string): Promise<AuthenticationResult> {
return this.sendRequest(WSCommandType.Authenticate, { token, roomId }); return this.sendRequest(WSCommandType.Authenticate, { token, roomId });
...@@ -133,36 +137,36 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -133,36 +137,36 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
return { return {
id: room.id, id: room.id,
videoAllowed: room.videoAllowed, videoAllowed: room.videoAllowed,
users: new Map(Object.entries(room.users)) users: new Map(Object.entries(room.users)),
}; };
} }
initializeTransports( initializeTransports(
rtpCapabilities: RtpCapabilities rtpCapabilities: RtpCapabilities,
): Promise<TransportInitDataTuple> { ): Promise<TransportInitDataTuple> {
return this.sendRequest(WSCommandType.InitializeTransports, { return this.sendRequest(WSCommandType.InitializeTransports, {
mode: "SplitWebRTC", mode: "SplitWebRTC",
rtpCapabilities rtpCapabilities,
}); });
} }
connectTransport( connectTransport(
id: string, id: string,
dtlsParameters: DtlsParameters dtlsParameters: DtlsParameters,
): Promise<void> { ): Promise<void> {
return this.sendRequest(WSCommandType.ConnectTransport, { return this.sendRequest(WSCommandType.ConnectTransport, {
id, id,
dtlsParameters dtlsParameters,
}); });
} }
async startProduce( async startProduce(
type: ProduceType, type: ProduceType,
rtpParameters: RtpParameters rtpParameters: RtpParameters,
): Promise<string> { ): Promise<string> {
let result = await this.sendRequest(WSCommandType.StartProduce, { const result = await this.sendRequest(WSCommandType.StartProduce, {
type, type,
rtpParameters rtpParameters,
}); });
return result.producerId; return result.producerId;
} }
...@@ -182,7 +186,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> { ...@@ -182,7 +186,7 @@ export default class Signaling extends EventEmitter<SignalingEvents> {
setConsumerPause(consumerId: string, paused: boolean): Promise<void> { setConsumerPause(consumerId: string, paused: boolean): Promise<void> {
return this.sendRequest(WSCommandType.SetConsumerPause, { return this.sendRequest(WSCommandType.SetConsumerPause, {
id: consumerId, id: consumerId,
paused paused,
}); });
} }
} }
...@@ -2,13 +2,13 @@ import { Consumer } from "mediasoup-client/lib/Consumer"; ...@@ -2,13 +2,13 @@ import { Consumer } from "mediasoup-client/lib/Consumer";
import { import {
MediaKind, MediaKind,
RtpCapabilities, RtpCapabilities,
RtpParameters RtpParameters,
} from "mediasoup-client/lib/RtpParameters"; } from "mediasoup-client/lib/RtpParameters";
import { SctpParameters } from "mediasoup-client/lib/SctpParameters"; import { SctpParameters } from "mediasoup-client/lib/SctpParameters";
import { import {
DtlsParameters, DtlsParameters,
IceCandidate, IceCandidate,
IceParameters IceParameters,
} from "mediasoup-client/lib/Transport"; } from "mediasoup-client/lib/Transport";
export enum WSEventType { export enum WSEventType {
...@@ -16,7 +16,7 @@ export enum WSEventType { ...@@ -16,7 +16,7 @@ export enum WSEventType {
UserLeft = "UserLeft", UserLeft = "UserLeft",
UserStartProduce = "UserStartProduce", UserStartProduce = "UserStartProduce",
UserStopProduce = "UserStopProduce" UserStopProduce = "UserStopProduce",
} }
export enum WSCommandType { export enum WSCommandType {
...@@ -31,7 +31,7 @@ export enum WSCommandType { ...@@ -31,7 +31,7 @@ export enum WSCommandType {
StartConsume = "StartConsume", StartConsume = "StartConsume",
StopConsume = "StopConsume", StopConsume = "StopConsume",
SetConsumerPause = "SetConsumerPause" SetConsumerPause = "SetConsumerPause",
} }
export enum WSErrorCode { export enum WSErrorCode {
...@@ -44,7 +44,7 @@ export enum WSErrorCode { ...@@ -44,7 +44,7 @@ export enum WSErrorCode {
ProducerNotFound = 614, ProducerNotFound = 614,
ConsumerFailure = 621, ConsumerFailure = 621,
ConsumerNotFound = 624 ConsumerNotFound = 624,
} }
export enum WSCloseCode { export enum WSCloseCode {
...@@ -54,7 +54,7 @@ export enum WSCloseCode { ...@@ -54,7 +54,7 @@ export enum WSCloseCode {
RoomClosed = 4004, RoomClosed = 4004,
// Sent when a client tries to send an opcode in the wrong state // Sent when a client tries to send an opcode in the wrong state
InvalidState = 1002, InvalidState = 1002,
ServerError = 1011 ServerError = 1011,
} }
export interface VoiceError { export interface VoiceError {
......
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import * as mediasoupClient from "mediasoup-client"; import * as mediasoupClient from "mediasoup-client";
import { import { types } from "mediasoup-client";
Device,
Producer, import { Device, Producer, Transport } from "mediasoup-client/lib/types";
Transport,
UnsupportedError
} from "mediasoup-client/lib/types";
import Signaling from "./Signaling";
import { import {
ProduceType, ProduceType,
WSEventType, WSEventType,
VoiceError, VoiceError,
VoiceUser, VoiceUser,
ConsumerList, ConsumerList,
WSErrorCode WSErrorCode,
} from "./Types"; } from "./Types";
import Signaling from "./Signaling";
const UnsupportedError = types.UnsupportedError;
interface VoiceEvents { interface VoiceEvents {
ready: () => void; ready: () => void;
...@@ -58,7 +56,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -58,7 +56,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
this.signaling.on( this.signaling.on(
"data", "data",
json => { (json) => {
const data = json.data; const data = json.data;
switch (json.type) { switch (json.type) {
case WSEventType.UserJoined: { case WSEventType.UserJoined: {
...@@ -82,7 +80,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -82,7 +80,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
break; break;
default: default:
throw new Error( throw new Error(
`Invalid produce type ${data.type}` `Invalid produce type ${data.type}`,
); );
} }
...@@ -100,7 +98,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -100,7 +98,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
break; break;
default: default:
throw new Error( throw new Error(
`Invalid produce type ${data.type}` `Invalid produce type ${data.type}`,
); );
} }
...@@ -111,29 +109,29 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -111,29 +109,29 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
} }
} }
}, },
this this,
); );
this.signaling.on( this.signaling.on(
"error", "error",
error => { () => {
this.emit("error", new Error("Signaling error")); this.emit("error", new Error("Signaling error"));
}, },
this this,
); );
this.signaling.on( this.signaling.on(
"close", "close",
error => { (error) => {
this.disconnect( this.disconnect(
{ {
error: error.code, error: error.code,
message: error.reason message: error.reason,
}, },
true true,
); );
}, },
this this,
); );
} }
...@@ -174,9 +172,9 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -174,9 +172,9 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
if (this.device === undefined || this.roomId === undefined) if (this.device === undefined || this.roomId === undefined)
throw new ReferenceError("Voice Client is in an invalid state"); throw new ReferenceError("Voice Client is in an invalid state");
const result = await this.signaling.authenticate(token, this.roomId); const result = await this.signaling.authenticate(token, this.roomId);
let [room] = await Promise.all([ const [room] = await Promise.all([
this.signaling.roomInfo(), this.signaling.roomInfo(),
this.device.load({ routerRtpCapabilities: result.rtpCapabilities }) this.device.load({ routerRtpCapabilities: result.rtpCapabilities }),
]); ]);
this.userId = result.userId; this.userId = result.userId;
...@@ -188,14 +186,14 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -188,14 +186,14 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
if (this.device === undefined) if (this.device === undefined)
throw new ReferenceError("Voice Client is in an invalid state"); throw new ReferenceError("Voice Client is in an invalid state");
const initData = await this.signaling.initializeTransports( const initData = await this.signaling.initializeTransports(
this.device.rtpCapabilities this.device.rtpCapabilities,
); );
this.sendTransport = this.device.createSendTransport( this.sendTransport = this.device.createSendTransport(
initData.sendTransport initData.sendTransport,
); );
this.recvTransport = this.device.createRecvTransport( this.recvTransport = this.device.createRecvTransport(
initData.recvTransport initData.recvTransport,
); );
const connectTransport = (transport: Transport) => { const connectTransport = (transport: Transport) => {
...@@ -226,12 +224,12 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -226,12 +224,12 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
return errback(); return errback();
this.signaling this.signaling
.startProduce(type, parameters.rtpParameters) .startProduce(type, parameters.rtpParameters)
.then(id => callback({ id })) .then((id) => callback({ id }))
.catch(errback); .catch(errback);
}); });
this.emit("ready"); this.emit("ready");
for (let user of this.participants) { for (const user of this.participants) {
if (user[1].audio && user[0] !== this.userId) if (user[1].audio && user[0] !== this.userId)
this.startConsume(user[0], "audio"); this.startConsume(user[0], "audio");
} }
...@@ -283,7 +281,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -283,7 +281,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
throw new Error("Send transport undefined"); throw new Error("Send transport undefined");
const producer = await this.sendTransport.produce({ const producer = await this.sendTransport.produce({
track, track,
appData: { type } appData: { type },
}); });
switch (type) { switch (type) {
...@@ -325,7 +323,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> { ...@@ -325,7 +323,7 @@ export default class VoiceClient extends EventEmitter<VoiceEvents> {
await this.signaling.stopProduce(type); await this.signaling.stopProduce(type);
} catch (error) { } catch (error) {
if (error.error === WSErrorCode.ProducerNotFound) return; if (error.error === WSErrorCode.ProducerNotFound) return;
else throw error; 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 { registerSW } from 'virtual:pwa-register'; import { registerSW } from "virtual:pwa-register";
import { internalEmit } from './lib/eventEmitter';
import "./styles/index.scss";
import { render } from "preact";
import { internalEmit } from "./lib/eventEmitter";
import { App } from "./pages/app";
export const updateSW = registerSW({ export const updateSW = registerSW({
onNeedRefresh() { onNeedRefresh() {
internalEmit('PWA', 'update'); internalEmit("PWA", "update");
}, },
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
}, },
}) });
import "./styles/index.scss";
import { render } from "preact";
import { App } from "./pages/app";
// 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 { Text } from "preact-i18n";
import Header from "../components/ui/Header";
import { useContext, useEffect } from "preact/hooks"; import { useContext, useEffect } from "preact/hooks";
import { useHistory, useParams } from "react-router-dom";
import { useIntermediate } from "../context/intermediate/Intermediate"; import { useIntermediate } from "../context/intermediate/Intermediate";
import { useChannels, useForceUpdate, useUser } from "../context/revoltjs/hooks"; import {
import { AppContext, ClientStatus, StatusContext } from "../context/revoltjs/RevoltClient"; AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import Header from "../components/ui/Header";
export default function Open() { export default function Open() {
const history = useHistory(); const history = useHistory();
...@@ -21,48 +28,48 @@ export default function Open() { ...@@ -21,48 +28,48 @@ export default function Open() {
); );
} }
const ctx = useForceUpdate();
const channels = useChannels(undefined, ctx);
const user = useUser(id, ctx);
useEffect(() => { useEffect(() => {
if (id === "saved") { if (id === "saved") {
for (const channel of channels) { for (const channel of [...client.channels.values()]) {
if (channel?.channel_type === "SavedMessages") { if (channel?.channel_type === "SavedMessages") {
history.push(`/channel/${channel._id}`); history.push(`/channel/${channel._id}`);
return; return;
} }
} }
client.users client
.openDM(client.user?._id as string) .user!.openDM()
.then(channel => history.push(`/channel/${channel?._id}`)) .then((channel) => history.push(`/channel/${channel?._id}`))
.catch(error => openScreen({ id: "error", error })); .catch((error) => openScreen({ id: "error", error }));
return; return;
} }
const user = client.users.get(id);
if (user) { if (user) {
const channel: string | undefined = channels.find( const channel: string | undefined = [
channel => ...client.channels.values(),
].find(
(channel) =>
channel?.channel_type === "DirectMessage" && channel?.channel_type === "DirectMessage" &&
channel.recipients.includes(id) channel.recipient_ids!.includes(id),
)?._id; )?._id;
if (channel) { if (channel) {
history.push(`/channel/${channel}`); history.push(`/channel/${channel}`);
} else { } else {
client.users client.users
.openDM(id) .get(id)
.then(channel => history.push(`/channel/${channel?._id}`)) ?.openDM()
.catch(error => openScreen({ id: "error", error })); .then((channel) => history.push(`/channel/${channel?._id}`))
.catch((error) => openScreen({ id: "error", error }));
} }
return; return;
} }
history.push("/"); history.push("/");
}, []); });
return ( return (
<Header placement="primary"> <Header placement="primary">
......
import { Docked, OverlappingPanels, ShowIf } 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, useLocation } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import ContextMenus from "../lib/ContextMenus"; import ContextMenus from "../lib/ContextMenus";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import Popovers from "../context/intermediate/Popovers"; 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 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 BottomNavigation from "../components/navigation/BottomNavigation";
import Open from "./Open"; 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 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;
...@@ -33,52 +34,101 @@ const Routes = styled.div` ...@@ -33,52 +34,101 @@ const Routes = styled.div`
export default function App() { export default function App() {
const path = useLocation().pathname; const path = useLocation().pathname;
const fixedBottomNav = (path === '/' || path === '/settings' || path.startsWith("/friends")); const fixedBottomNav =
const inSettings = path.includes('/settings'); path === "/" || path === "/settings" || path.startsWith("/friends");
const inChannel = path.includes('/channel'); const inChannel = path.includes("/channel");
const inSpecial = (path.startsWith("/friends") && isTouchscreenDevice) || path.startsWith('/invite') || path.startsWith("/settings"); 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={inSpecial ? undefined : { width: 292, component: <LeftSidebar /> }} )}
rightPanel={(!inSettings && inChannel) ? { width: 240, component: <RightSidebar /> } : undefined} <OverlappingPanels
bottomNav={{ width="100vw"
component: <BottomNavigation />, height={
showIf: fixedBottomNav ? ShowIf.Always : ShowIf.Left, window.isNative && !window.native.getConfig().frame
height: 50 ? "calc(var(--app-height) - var(--titlebar-height))"
}} : "var(--app-height)"
docked={isTouchscreenDevice ? Docked.None : Docked.Left}> }
<Routes> leftPanel={
<Switch> inSpecial
<Route path="/server/:server/channel/:channel/settings/:page" component={ChannelSettings} /> ? undefined
<Route path="/server/:server/channel/:channel/settings" component={ChannelSettings} /> : { width: 292, component: <LeftSidebar /> }
<Route path="/server/:server/settings/:page" component={ServerSettings} /> }
<Route path="/server/:server/settings" component={ServerSettings} /> rightPanel={
<Route path="/channel/:channel/settings/:page" component={ChannelSettings} /> !inSpecial && inChannel
<Route path="/channel/:channel/settings" component={ChannelSettings} /> ? { 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="/settings/:page" component={Settings} />
<Route path="/server/:server/channel/:channel" component={Channel} /> <Route path="/settings" component={Settings} />
<Route path="/server/:server" />
<Route path="/channel/:channel" component={Channel} />
<Route path="/settings/:page" component={Settings} />
<Route path="/settings" component={Settings} />
<Route path="/dev" component={Developer} /> <Route path="/dev" component={Developer} />
<Route path="/friends" component={Friends} /> <Route path="/friends" component={Friends} />
<Route path="/open/:id" component={Open} /> <Route path="/open/:id" component={Open} />
<Route path="/invite/:code" component={Invite} /> <Route path="/invite/:code" component={Invite} />
<Route path="/" component={Home} /> <Route path="/" component={Home} />
</Switch> </Switch>
</Routes> </Routes>
<ContextMenus /> <ContextMenus />
<Popovers /> <Popovers />
<Notifications /> <Notifications />
<StateMonitor /> <StateMonitor />
<SyncManager /> <SyncManager />
</OverlappingPanels> </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 Masks from "../components/ui/Masks";
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 /> <Masks />
{/* {/*
// @ts-expect-error */} // @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader type="spinner" />}> <Suspense fallback={<Preloader type="spinner" />}>
<Switch> <Switch>
<Route path="/login"> <Route path="/login">
......