import EventEmitter3 from "eventemitter3"; import { Client } from "revolt.js"; import { Message } from "revolt.js/dist/maps/Messages"; import { useEffect, useState } from "preact/hooks"; import { SimpleRenderer } from "./simple/SimpleRenderer"; import { RendererRoutines, RenderState, ScrollState } from "./types"; 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) { 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, 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, message_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; const messageContainer = ref.children[0]; if (messageContainer) { 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 + // 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, }; } 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; const messageContainer = ref.children[0]; if (messageContainer) { 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 + // 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, }; } 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, undefined, true); } } } export const SingletonMessageRenderer = new SingletonRenderer(); export function useRenderState(id: string) { const [state, setState] = useState>( 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; }