forked from abner/for-legacy-web
Work on channels, render content of messages.
This commit is contained in:
54
src/lib/i18n.tsx
Normal file
54
src/lib/i18n.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { IntlContext } from "preact-i18n";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { Children } from "../types/Preact";
|
||||
|
||||
interface Fields {
|
||||
[key: string]: Children
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
fields: Fields
|
||||
}
|
||||
|
||||
export interface IntlType {
|
||||
intl: {
|
||||
dictionary: {
|
||||
[key: string]: Object | string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This will exhibit O(2^n) behaviour.
|
||||
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 ];
|
||||
|
||||
const values: (Children | string[])[] = input.split(`{{${key}}}`)
|
||||
.map(v => recursiveReplaceFields(v, restOfFields));
|
||||
|
||||
for (let i=values.length - 1;i>0;i-=2) {
|
||||
values.splice(i, 0, field);
|
||||
}
|
||||
|
||||
return values.flat();
|
||||
} else {
|
||||
// base case
|
||||
return [ input ];
|
||||
}
|
||||
}
|
||||
|
||||
export function TextReact({ id, fields }: Props) {
|
||||
const { intl } = useContext(IntlContext) as unknown as IntlType;
|
||||
|
||||
const path = id.split('.');
|
||||
let entry = intl.dictionary[path.shift()!];
|
||||
for (let key of path) {
|
||||
// @ts-expect-error
|
||||
entry = entry[key];
|
||||
}
|
||||
|
||||
return <>{ recursiveReplaceFields(entry as string, fields) }</>;
|
||||
}
|
||||
192
src/lib/renderer/Singleton.ts
Normal file
192
src/lib/renderer/Singleton.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { RendererRoutines, RenderState, ScrollState } from "./types";
|
||||
import { SimpleRenderer } from "./simple/SimpleRenderer";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import EventEmitter3 from 'eventemitter3';
|
||||
import { Client, Message } from "revolt.js";
|
||||
|
||||
export const SMOOTH_SCROLL_ON_RECEIVE = false;
|
||||
|
||||
export class SingletonRenderer extends EventEmitter3 {
|
||||
client?: Client;
|
||||
channel?: string;
|
||||
state: RenderState;
|
||||
currentRenderer: RendererRoutines;
|
||||
|
||||
stale = false;
|
||||
fetchingTop = false;
|
||||
fetchingBottom = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.receive = this.receive.bind(this);
|
||||
this.edit = this.edit.bind(this);
|
||||
this.delete = this.delete.bind(this);
|
||||
|
||||
this.state = { type: 'LOADING' };
|
||||
this.currentRenderer = SimpleRenderer;
|
||||
}
|
||||
|
||||
private receive(message: Message) {
|
||||
this.currentRenderer.receive(this, message);
|
||||
}
|
||||
|
||||
private edit(id: string, patch: Partial<Message>) {
|
||||
this.currentRenderer.edit(this, id, patch);
|
||||
}
|
||||
|
||||
private delete(id: string) {
|
||||
this.currentRenderer.delete(this, id);
|
||||
}
|
||||
|
||||
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 = client;
|
||||
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);
|
||||
|
||||
if (scroll) {
|
||||
this.emit('scroll', scroll);
|
||||
}
|
||||
}
|
||||
|
||||
setState(id: string, state: RenderState, scroll?: ScrollState) {
|
||||
if (id !== this.channel) return;
|
||||
this.setStateUnguarded(state, scroll);
|
||||
}
|
||||
|
||||
markStale() {
|
||||
this.stale = true;
|
||||
}
|
||||
|
||||
async init(id: string) {
|
||||
this.channel = id;
|
||||
this.stale = false;
|
||||
this.setStateUnguarded({ type: 'LOADING' });
|
||||
await this.currentRenderer.init(this, id);
|
||||
}
|
||||
|
||||
async reloadStale(id: string) {
|
||||
if (this.stale) {
|
||||
this.stale = false;
|
||||
await this.init(id);
|
||||
}
|
||||
}
|
||||
|
||||
async loadTop(ref?: HTMLDivElement) {
|
||||
if (this.fetchingTop) return;
|
||||
this.fetchingTop = true;
|
||||
|
||||
function generateScroll(end: string): ScrollState {
|
||||
if (ref) {
|
||||
let heightRemoved = 0;
|
||||
let messageContainer = ref.children[0];
|
||||
if (messageContainer) {
|
||||
for (let 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 +
|
||||
// We also need to take into account the top margin of the container.
|
||||
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'OffsetTop',
|
||||
previousHeight: ref.scrollHeight - heightRemoved
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'OffsetTop',
|
||||
previousHeight: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.currentRenderer.loadTop(this, generateScroll);
|
||||
|
||||
// Allow state updates to propagate.
|
||||
setTimeout(() => this.fetchingTop = false, 0);
|
||||
}
|
||||
|
||||
async loadBottom(ref?: HTMLDivElement) {
|
||||
if (this.fetchingBottom) return;
|
||||
this.fetchingBottom = true;
|
||||
|
||||
function generateScroll(start: string): ScrollState {
|
||||
if (ref) {
|
||||
let heightRemoved = 0;
|
||||
let messageContainer = ref.children[0];
|
||||
if (messageContainer) {
|
||||
for (let 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 +
|
||||
// We also need to take into account the top margin of the container.
|
||||
parseInt(window.getComputedStyle(child).marginTop.slice(0, -2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'ScrollTop',
|
||||
y: ref.scrollTop - heightRemoved
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'ScrollToBottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.currentRenderer.loadBottom(this, generateScroll);
|
||||
|
||||
// Allow state updates to propagate.
|
||||
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 });
|
||||
} else {
|
||||
await this.currentRenderer.init(this, id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SingletonMessageRenderer = new SingletonRenderer();
|
||||
|
||||
export function useRenderState(id: string) {
|
||||
const [state, setState] = useState<Readonly<RenderState>>(SingletonMessageRenderer.state);
|
||||
if (typeof id === "undefined") return;
|
||||
|
||||
function render(state: RenderState) {
|
||||
setState(state);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
SingletonMessageRenderer.addListener("state", render);
|
||||
return () => SingletonMessageRenderer.removeListener("state", render);
|
||||
}, [id]);
|
||||
|
||||
return state;
|
||||
}
|
||||
178
src/lib/renderer/simple/SimpleRenderer.ts
Normal file
178
src/lib/renderer/simple/SimpleRenderer.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { mapMessage } from "../../../context/revoltjs/util";
|
||||
import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
|
||||
import { RendererRoutines } from "../types";
|
||||
|
||||
export const SimpleRenderer: RendererRoutines = {
|
||||
init: async (renderer, id, 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 }
|
||||
);
|
||||
});
|
||||
} else {
|
||||
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 (!renderer.state.atBottom) return;
|
||||
|
||||
let messages = [ ...renderer.state.messages, mapMessage(message) ];
|
||||
let atTop = renderer.state.atTop;
|
||||
if (messages.length > 150) {
|
||||
messages = messages.slice(messages.length - 150);
|
||||
atTop = false;
|
||||
}
|
||||
|
||||
renderer.setState(
|
||||
message.channel,
|
||||
{
|
||||
...renderer.state,
|
||||
messages,
|
||||
atTop
|
||||
},
|
||||
{ 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' }
|
||||
);
|
||||
}
|
||||
},
|
||||
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 (index > -1) {
|
||||
messages.splice(index, 1);
|
||||
|
||||
renderer.setState(
|
||||
channel,
|
||||
{
|
||||
...renderer.state,
|
||||
messages
|
||||
},
|
||||
{ type: 'StayAtBottom' }
|
||||
);
|
||||
}
|
||||
},
|
||||
loadTop: async (renderer, generateScroll) => {
|
||||
const channel = renderer.channel;
|
||||
if (!channel) return;
|
||||
|
||||
const state = renderer.state;
|
||||
if (state.type !== 'RENDER') return;
|
||||
if (state.atTop) return;
|
||||
|
||||
const { messages: data } = await renderer.client!.channels.fetchMessagesWithUsers(channel, {
|
||||
before: state.messages[0]._id
|
||||
}, true);
|
||||
|
||||
if (data.length === 0) {
|
||||
return renderer.setState(
|
||||
channel,
|
||||
{
|
||||
...state,
|
||||
atTop: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
data.reverse();
|
||||
let messages = [ ...data.map(x => mapMessage(x)), ...state.messages ];
|
||||
|
||||
let atTop = false;
|
||||
if (data.length < 50) {
|
||||
atTop = true;
|
||||
}
|
||||
|
||||
let atBottom = state.atBottom;
|
||||
if (messages.length > 150) {
|
||||
messages = messages.slice(0, 150);
|
||||
atBottom = false;
|
||||
}
|
||||
|
||||
renderer.setState(
|
||||
channel,
|
||||
{ ...state, atTop, atBottom, messages },
|
||||
generateScroll(messages[messages.length - 1]._id)
|
||||
);
|
||||
},
|
||||
loadBottom: async (renderer, generateScroll) => {
|
||||
const channel = renderer.channel;
|
||||
if (!channel) return;
|
||||
|
||||
const state = renderer.state;
|
||||
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);
|
||||
|
||||
if (data.length === 0) {
|
||||
return renderer.setState(
|
||||
channel,
|
||||
{
|
||||
...state,
|
||||
atBottom: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let messages = [ ...state.messages, ...data.map(x => mapMessage(x)) ];
|
||||
|
||||
let atBottom = false;
|
||||
if (data.length < 50) {
|
||||
atBottom = true;
|
||||
}
|
||||
|
||||
let atTop = state.atTop;
|
||||
if (messages.length > 150) {
|
||||
messages = messages.slice(messages.length - 150);
|
||||
atTop = false;
|
||||
}
|
||||
|
||||
renderer.setState(
|
||||
channel,
|
||||
{ ...state, atTop, atBottom, messages },
|
||||
generateScroll(messages[0]._id)
|
||||
);
|
||||
}
|
||||
};
|
||||
32
src/lib/renderer/types.ts
Normal file
32
src/lib/renderer/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Message } from "revolt.js";
|
||||
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: "OffsetTop"; previousHeight: number }
|
||||
| { type: "ScrollTop"; y: number };
|
||||
|
||||
export type RenderState =
|
||||
| {
|
||||
type: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY";
|
||||
}
|
||||
| {
|
||||
type: "RENDER";
|
||||
atTop: boolean;
|
||||
atBottom: boolean;
|
||||
messages: MessageObject[];
|
||||
};
|
||||
|
||||
export interface RendererRoutines {
|
||||
init: (renderer: SingletonRenderer, id: string, smooth?: boolean) => Promise<void>
|
||||
|
||||
receive: (renderer: SingletonRenderer, message: 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user