forked from abner/for-legacy-web
Manage state per channel. Closes #2
This commit is contained in:
@@ -1,34 +1,52 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import EventEmitter3 from "eventemitter3";
|
||||
import { Client } from "revolt.js";
|
||||
import { action, makeAutoObservable } from "mobx";
|
||||
import { Channel } from "revolt.js/dist/maps/Channels";
|
||||
import { Message } from "revolt.js/dist/maps/Messages";
|
||||
import { Nullable } from "revolt.js/dist/util/null";
|
||||
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { defer } from "../defer";
|
||||
import { SimpleRenderer } from "./simple/SimpleRenderer";
|
||||
import { RendererRoutines, RenderState, ScrollState } from "./types";
|
||||
import { RendererRoutines, ScrollState } from "./types";
|
||||
|
||||
export const SMOOTH_SCROLL_ON_RECEIVE = false;
|
||||
|
||||
export class SingletonRenderer extends EventEmitter3 {
|
||||
client?: Client;
|
||||
channel?: string;
|
||||
state: RenderState;
|
||||
currentRenderer: RendererRoutines;
|
||||
export class ChannelRenderer {
|
||||
channel: Channel;
|
||||
|
||||
state: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY" | "RENDER" = "LOADING";
|
||||
scrollState: ScrollState = { type: "ScrollToBottom" };
|
||||
atTop: Nullable<boolean> = null;
|
||||
atBottom: Nullable<boolean> = null;
|
||||
messages: Message[] = [];
|
||||
|
||||
currentRenderer: RendererRoutines = SimpleRenderer;
|
||||
|
||||
stale = false;
|
||||
fetchingTop = false;
|
||||
fetchingBottom = false;
|
||||
fetching = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
constructor(channel: Channel) {
|
||||
this.channel = channel;
|
||||
|
||||
makeAutoObservable(this, {
|
||||
channel: false,
|
||||
currentRenderer: false,
|
||||
});
|
||||
|
||||
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;
|
||||
const client = this.channel.client;
|
||||
client.addListener("message", this.receive);
|
||||
client.addListener("message/update", this.edit);
|
||||
client.addListener("message/delete", this.delete);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
const client = this.channel.client;
|
||||
client.removeListener("message", this.receive);
|
||||
client.removeListener("message/update", this.edit);
|
||||
client.removeListener("message/delete", this.delete);
|
||||
}
|
||||
|
||||
private receive(message: Message) {
|
||||
@@ -43,90 +61,73 @@ export class SingletonRenderer extends EventEmitter3 {
|
||||
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) {
|
||||
@action async init(message_id?: string) {
|
||||
if (message_id) {
|
||||
if (this.state.type === "RENDER") {
|
||||
const message = this.state.messages.find(
|
||||
(x) => x._id === message_id,
|
||||
);
|
||||
if (this.state === "RENDER") {
|
||||
const message = this.messages.find((x) => x._id === message_id);
|
||||
|
||||
if (message) {
|
||||
this.emit("scroll", {
|
||||
this.emitScroll({
|
||||
type: "ScrollToView",
|
||||
id: message_id,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.channel = id;
|
||||
this.stale = false;
|
||||
this.setStateUnguarded({ type: "LOADING" });
|
||||
await this.currentRenderer.init(this, id, message_id);
|
||||
this.state = "LOADING";
|
||||
this.currentRenderer.init(this, message_id);
|
||||
}
|
||||
|
||||
async reloadStale(id: string) {
|
||||
@action emitScroll(state: ScrollState) {
|
||||
this.scrollState = state;
|
||||
}
|
||||
|
||||
@action markStale() {
|
||||
this.stale = true;
|
||||
}
|
||||
|
||||
@action complete() {
|
||||
this.fetching = false;
|
||||
}
|
||||
|
||||
async reloadStale() {
|
||||
if (this.stale) {
|
||||
this.stale = false;
|
||||
await this.init(id);
|
||||
await this.init();
|
||||
}
|
||||
}
|
||||
|
||||
async loadTop(ref?: HTMLDivElement) {
|
||||
if (this.fetchingTop) return;
|
||||
this.fetchingTop = true;
|
||||
if (this.fetching) return;
|
||||
this.fetching = true;
|
||||
|
||||
function generateScroll(end: string): ScrollState {
|
||||
if (ref) {
|
||||
let heightRemoved = 0;
|
||||
let heightRemoved = 0,
|
||||
removing = false;
|
||||
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),
|
||||
10,
|
||||
);
|
||||
}
|
||||
// If this child has a ulid, check whether it was removed.
|
||||
if (
|
||||
removing ||
|
||||
(child.id?.length === 26 &&
|
||||
child.id.localeCompare(end) === 1)
|
||||
) {
|
||||
removing = true;
|
||||
heightRemoved +=
|
||||
child.clientHeight +
|
||||
// We also need to take into account the top margin of the container.
|
||||
parseInt(
|
||||
window
|
||||
.getComputedStyle(child)
|
||||
.marginTop.slice(0, -2),
|
||||
10,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,37 +143,44 @@ export class SingletonRenderer extends EventEmitter3 {
|
||||
};
|
||||
}
|
||||
|
||||
await this.currentRenderer.loadTop(this, generateScroll);
|
||||
|
||||
// Allow state updates to propagate.
|
||||
setTimeout(() => (this.fetchingTop = false), 0);
|
||||
if (await this.currentRenderer.loadTop(this, generateScroll)) {
|
||||
this.fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadBottom(ref?: HTMLDivElement) {
|
||||
if (this.fetchingBottom) return;
|
||||
this.fetchingBottom = true;
|
||||
if (this.fetching) return;
|
||||
this.fetching = true;
|
||||
|
||||
function generateScroll(start: string): ScrollState {
|
||||
if (ref) {
|
||||
let heightRemoved = 0;
|
||||
let heightRemoved = 0,
|
||||
removing = true;
|
||||
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),
|
||||
10,
|
||||
);
|
||||
}
|
||||
// If this child has a ulid check whether it was removed.
|
||||
if (
|
||||
removing /* ||
|
||||
(child.id?.length === 26 &&
|
||||
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),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
child.id?.length === 26 &&
|
||||
child.id.localeCompare(start) !== -1
|
||||
)
|
||||
removing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,38 +194,28 @@ export class SingletonRenderer extends EventEmitter3 {
|
||||
};
|
||||
}
|
||||
|
||||
await this.currentRenderer.loadBottom(this, generateScroll);
|
||||
|
||||
// Allow state updates to propagate.
|
||||
setTimeout(() => (this.fetchingBottom = false), 0);
|
||||
if (await this.currentRenderer.loadBottom(this, generateScroll)) {
|
||||
this.fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
async jumpToBottom(smooth: boolean) {
|
||||
if (this.state === "RENDER" && this.atBottom) {
|
||||
this.emitScroll({ type: "ScrollToBottom", smooth });
|
||||
} else {
|
||||
await this.currentRenderer.init(this, id, undefined, true);
|
||||
await this.currentRenderer.init(this, undefined, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SingletonMessageRenderer = new SingletonRenderer();
|
||||
const renderers: Record<string, ChannelRenderer> = {};
|
||||
|
||||
export function useRenderState(id: string) {
|
||||
const [state, setState] = useState<Readonly<RenderState>>(
|
||||
SingletonMessageRenderer.state,
|
||||
);
|
||||
if (typeof id === "undefined") return;
|
||||
|
||||
function render(state: RenderState) {
|
||||
setState(state);
|
||||
export function getRenderer(channel: Channel) {
|
||||
let renderer = renderers[channel._id];
|
||||
if (!renderer) {
|
||||
renderer = new ChannelRenderer(channel);
|
||||
renderers[channel._id] = renderer;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
SingletonMessageRenderer.addListener("state", render);
|
||||
return () => SingletonMessageRenderer.removeListener("state", render);
|
||||
}, [id]);
|
||||
|
||||
return state;
|
||||
return renderer;
|
||||
}
|
||||
|
||||
@@ -1,173 +1,160 @@
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
import { noopAsync } from "../../js";
|
||||
import { SMOOTH_SCROLL_ON_RECEIVE } from "../Singleton";
|
||||
import { RendererRoutines } from "../types";
|
||||
|
||||
export const SimpleRenderer: RendererRoutines = {
|
||||
init: async (renderer, id, nearby, smooth) => {
|
||||
if (renderer.client!.websocket.connected) {
|
||||
init: async (renderer, nearby, smooth) => {
|
||||
if (renderer.channel.client.websocket.connected) {
|
||||
if (nearby)
|
||||
renderer
|
||||
.client!.channels.get(id)!
|
||||
renderer.channel
|
||||
.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 },
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
renderer.state = "RENDER";
|
||||
renderer.messages = messages;
|
||||
renderer.atTop = false;
|
||||
renderer.atBottom = false;
|
||||
|
||||
renderer.emitScroll({
|
||||
type: "ScrollToView",
|
||||
id: nearby,
|
||||
});
|
||||
});
|
||||
});
|
||||
else
|
||||
renderer
|
||||
.client!.channels.get(id)!
|
||||
renderer.channel
|
||||
.fetchMessagesWithUsers({})
|
||||
.then(({ messages }) => {
|
||||
messages.reverse();
|
||||
renderer.setState(
|
||||
id,
|
||||
{
|
||||
type: "RENDER",
|
||||
messages,
|
||||
atTop: messages.length < 50,
|
||||
atBottom: true,
|
||||
},
|
||||
{ type: "ScrollToBottom", smooth },
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
renderer.state = "RENDER";
|
||||
renderer.messages = messages;
|
||||
renderer.atTop = messages.length < 50;
|
||||
renderer.atBottom = true;
|
||||
|
||||
renderer.emitScroll({
|
||||
type: "ScrollToBottom",
|
||||
smooth,
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
renderer.setState(id, { type: "WAITING_FOR_NETWORK" });
|
||||
runInAction(() => {
|
||||
renderer.state = "WAITING_FOR_NETWORK";
|
||||
});
|
||||
}
|
||||
},
|
||||
receive: async (renderer, message) => {
|
||||
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;
|
||||
if (message.channel_id !== renderer.channel._id) return;
|
||||
if (renderer.state !== "RENDER") return;
|
||||
if (renderer.messages.find((x) => x._id === message._id)) return;
|
||||
if (!renderer.atBottom) return;
|
||||
|
||||
let messages = [...renderer.state.messages, message];
|
||||
let atTop = renderer.state.atTop;
|
||||
let messages = [...renderer.messages, message];
|
||||
let atTop = renderer.atTop;
|
||||
if (messages.length > 150) {
|
||||
messages = messages.slice(messages.length - 150);
|
||||
atTop = false;
|
||||
}
|
||||
|
||||
renderer.setState(
|
||||
message.channel_id,
|
||||
{
|
||||
...renderer.state,
|
||||
messages,
|
||||
atTop,
|
||||
},
|
||||
{ type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE },
|
||||
);
|
||||
runInAction(() => {
|
||||
renderer.messages = messages;
|
||||
renderer.atTop = atTop;
|
||||
|
||||
renderer.emitScroll({
|
||||
type: "StayAtBottom",
|
||||
smooth: SMOOTH_SCROLL_ON_RECEIVE,
|
||||
});
|
||||
});
|
||||
},
|
||||
edit: noopAsync,
|
||||
delete: async (renderer, id) => {
|
||||
const channel = renderer.channel;
|
||||
if (!channel) return;
|
||||
if (renderer.state.type !== "RENDER") return;
|
||||
if (renderer.state !== "RENDER") return;
|
||||
|
||||
const messages = [...renderer.state.messages];
|
||||
const index = messages.findIndex((x) => x._id === id);
|
||||
const index = renderer.messages.findIndex((x) => x._id === id);
|
||||
|
||||
if (index > -1) {
|
||||
messages.splice(index, 1);
|
||||
|
||||
renderer.setState(
|
||||
channel,
|
||||
{
|
||||
...renderer.state,
|
||||
messages,
|
||||
},
|
||||
{ type: "StayAtBottom" },
|
||||
);
|
||||
runInAction(() => {
|
||||
renderer.messages.splice(index, 1);
|
||||
renderer.emitScroll({ type: "StayAtBottom" });
|
||||
});
|
||||
}
|
||||
},
|
||||
loadTop: async (renderer, generateScroll) => {
|
||||
const channel = renderer.channel;
|
||||
if (!channel) return;
|
||||
if (!channel) return true;
|
||||
|
||||
const state = renderer.state;
|
||||
if (state.type !== "RENDER") return;
|
||||
if (state.atTop) return;
|
||||
if (renderer.state !== "RENDER") return true;
|
||||
if (renderer.atTop) return true;
|
||||
|
||||
const { messages: data } = await renderer
|
||||
.client!.channels.get(channel)!
|
||||
.fetchMessagesWithUsers({
|
||||
before: state.messages[0]._id,
|
||||
const { messages: data } =
|
||||
await renderer.channel.fetchMessagesWithUsers({
|
||||
before: renderer.messages[0]._id,
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
return renderer.setState(channel, {
|
||||
...state,
|
||||
atTop: true,
|
||||
});
|
||||
}
|
||||
runInAction(() => {
|
||||
if (data.length === 0) {
|
||||
renderer.atTop = true;
|
||||
return;
|
||||
}
|
||||
|
||||
data.reverse();
|
||||
let messages = [...data, ...state.messages];
|
||||
data.reverse();
|
||||
renderer.messages = [...data, ...renderer.messages];
|
||||
|
||||
let atTop = false;
|
||||
if (data.length < 50) {
|
||||
atTop = true;
|
||||
}
|
||||
if (data.length < 50) {
|
||||
renderer.atTop = true;
|
||||
}
|
||||
|
||||
let atBottom = state.atBottom;
|
||||
if (messages.length > 150) {
|
||||
messages = messages.slice(0, 150);
|
||||
atBottom = false;
|
||||
}
|
||||
if (renderer.messages.length > 150) {
|
||||
renderer.messages = renderer.messages.slice(0, 150);
|
||||
renderer.atBottom = false;
|
||||
}
|
||||
|
||||
renderer.setState(
|
||||
channel,
|
||||
{ ...state, atTop, atBottom, messages },
|
||||
generateScroll(messages[messages.length - 1]._id),
|
||||
);
|
||||
renderer.emitScroll(
|
||||
generateScroll(
|
||||
renderer.messages[renderer.messages.length - 1]._id,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
loadBottom: async (renderer, generateScroll) => {
|
||||
const channel = renderer.channel;
|
||||
if (!channel) return;
|
||||
if (!channel) return true;
|
||||
|
||||
const state = renderer.state;
|
||||
if (state.type !== "RENDER") return;
|
||||
if (state.atBottom) return;
|
||||
if (renderer.state !== "RENDER") return true;
|
||||
if (renderer.atBottom) return true;
|
||||
|
||||
const { messages: data } = await renderer
|
||||
.client!.channels.get(channel)!
|
||||
.fetchMessagesWithUsers({
|
||||
after: state.messages[state.messages.length - 1]._id,
|
||||
const { messages: data } =
|
||||
await renderer.channel.fetchMessagesWithUsers({
|
||||
after: renderer.messages[renderer.messages.length - 1]._id,
|
||||
sort: "Oldest",
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
return renderer.setState(channel, {
|
||||
...state,
|
||||
atBottom: true,
|
||||
});
|
||||
}
|
||||
runInAction(() => {
|
||||
if (data.length === 0) {
|
||||
renderer.atBottom = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let messages = [...state.messages, ...data];
|
||||
renderer.messages.splice(renderer.messages.length, 0, ...data);
|
||||
|
||||
let atBottom = false;
|
||||
if (data.length < 50) {
|
||||
atBottom = true;
|
||||
}
|
||||
if (data.length < 50) {
|
||||
renderer.atBottom = true;
|
||||
}
|
||||
|
||||
let atTop = state.atTop;
|
||||
if (messages.length > 150) {
|
||||
messages = messages.slice(messages.length - 150);
|
||||
atTop = false;
|
||||
}
|
||||
if (renderer.messages.length > 150) {
|
||||
renderer.messages.splice(0, renderer.messages.length - 150);
|
||||
renderer.atTop = false;
|
||||
}
|
||||
|
||||
renderer.setState(
|
||||
channel,
|
||||
{ ...state, atTop, atBottom, messages },
|
||||
generateScroll(messages[0]._id),
|
||||
);
|
||||
renderer.emitScroll(generateScroll(renderer.messages[0]._id));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Message } from "revolt.js/dist/maps/Messages";
|
||||
|
||||
import { SingletonRenderer } from "./Singleton";
|
||||
import { ChannelRenderer } from "./Singleton";
|
||||
|
||||
export type ScrollState =
|
||||
| { type: "Free" }
|
||||
@@ -23,26 +23,25 @@ export type RenderState =
|
||||
|
||||
export interface RendererRoutines {
|
||||
init: (
|
||||
renderer: SingletonRenderer,
|
||||
id: string,
|
||||
renderer: ChannelRenderer,
|
||||
message?: string,
|
||||
smooth?: boolean,
|
||||
) => Promise<void>;
|
||||
|
||||
receive: (renderer: SingletonRenderer, message: Message) => Promise<void>;
|
||||
receive: (renderer: ChannelRenderer, message: Message) => Promise<void>;
|
||||
edit: (
|
||||
renderer: SingletonRenderer,
|
||||
renderer: ChannelRenderer,
|
||||
id: string,
|
||||
partial: Partial<Message>,
|
||||
) => Promise<void>;
|
||||
delete: (renderer: SingletonRenderer, id: string) => Promise<void>;
|
||||
delete: (renderer: ChannelRenderer, id: string) => Promise<void>;
|
||||
|
||||
loadTop: (
|
||||
renderer: SingletonRenderer,
|
||||
renderer: ChannelRenderer,
|
||||
generateScroll: (end: string) => ScrollState,
|
||||
) => Promise<void>;
|
||||
) => Promise<void | true>;
|
||||
loadBottom: (
|
||||
renderer: SingletonRenderer,
|
||||
renderer: ChannelRenderer,
|
||||
generateScroll: (start: string) => ScrollState,
|
||||
) => Promise<void>;
|
||||
) => Promise<void | true>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user