Manage state per channel. Closes #2

This commit is contained in:
Paul 2021-08-07 20:43:08 +01:00
parent 7d76a657fa
commit 1f903cd56b
14 changed files with 392 additions and 404 deletions

View file

@ -16,7 +16,7 @@ import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { useTranslation } from "../../../lib/i18n"; import { useTranslation } from "../../../lib/i18n";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { import {
SingletonMessageRenderer, getRenderer,
SMOOTH_SCROLL_ON_RECEIVE, SMOOTH_SCROLL_ON_RECEIVE,
} from "../../../lib/renderer/Singleton"; } from "../../../lib/renderer/Singleton";
@ -122,6 +122,8 @@ export default observer(({ channel }: Props) => {
const client = useContext(AppContext); const client = useContext(AppContext);
const translate = useTranslation(); const translate = useTranslation();
const renderer = getRenderer(channel);
if (!(channel.permission & ChannelPermission.SendMessage)) { if (!(channel.permission & ChannelPermission.SendMessage)) {
return ( return (
<Base> <Base>
@ -213,12 +215,7 @@ export default observer(({ channel }: Props) => {
}, },
}); });
defer(() => defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE));
SingletonMessageRenderer.jumpToBottom(
channel._id,
SMOOTH_SCROLL_ON_RECEIVE,
),
);
try { try {
await channel.sendMessage({ await channel.sendMessage({
@ -405,7 +402,7 @@ export default observer(({ channel }: Props) => {
}} }}
/> />
<ReplyBar <ReplyBar
channel={channel._id} channel={channel}
replies={replies} replies={replies}
setReplies={setReplies} setReplies={setReplies}
/> />

View file

@ -10,7 +10,7 @@ import styled, { css } from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useLayoutEffect, useState } from "preact/hooks"; import { useLayoutEffect, useState } from "preact/hooks";
import { useRenderState } from "../../../../lib/renderer/Singleton"; import { getRenderer } from "../../../../lib/renderer/Singleton";
import Markdown from "../../../markdown/Markdown"; import Markdown from "../../../markdown/Markdown";
import UserShort from "../../user/UserShort"; import UserShort from "../../user/UserShort";
@ -134,8 +134,8 @@ export const ReplyBase = styled.div<{
`; `;
export const MessageReply = observer(({ index, channel, id }: Props) => { export const MessageReply = observer(({ index, channel, id }: Props) => {
const view = useRenderState(channel._id); const view = getRenderer(channel);
if (view?.type !== "RENDER") return null; if (view.state !== "RENDER") return null;
const [message, setMessage] = useState<Message | undefined>(undefined); const [message, setMessage] = useState<Message | undefined>(undefined);

View file

@ -1,12 +1,11 @@
import { DownArrowAlt } from "@styled-icons/boxicons-regular"; import { DownArrowAlt } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { import { getRenderer } from "../../../../lib/renderer/Singleton";
SingletonMessageRenderer,
useRenderState,
} from "../../../../lib/renderer/Singleton";
const Bar = styled.div` const Bar = styled.div`
z-index: 10; z-index: 10;
@ -51,14 +50,13 @@ const Bar = styled.div`
} }
`; `;
export default function JumpToBottom({ id }: { id: string }) { export default observer(({ channel }: { channel: Channel }) => {
const view = useRenderState(id); const renderer = getRenderer(channel);
if (!view || view.type !== "RENDER" || view.atBottom) return null; if (renderer.state !== "RENDER" || renderer.atBottom) return null;
return ( return (
<Bar> <Bar>
<div <div onClick={() => renderer.jumpToBottom(true)}>
onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
<div> <div>
<Text id="app.main.channel.misc.viewing_old" /> <Text id="app.main.channel.misc.viewing_old" />
</div> </div>
@ -69,4 +67,4 @@ export default function JumpToBottom({ id }: { id: string }) {
</div> </div>
</Bar> </Bar>
); );
} });

View file

@ -2,13 +2,14 @@ import { At, Reply as ReplyIcon } from "@styled-icons/boxicons-regular";
import { File, XCircle } from "@styled-icons/boxicons-solid"; import { File, XCircle } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { SYSTEM_USER_ID } from "revolt.js"; import { SYSTEM_USER_ID } from "revolt.js";
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { StateUpdater, useEffect } from "preact/hooks"; import { StateUpdater, useEffect } from "preact/hooks";
import { internalSubscribe } from "../../../../lib/eventEmitter"; import { internalSubscribe } from "../../../../lib/eventEmitter";
import { useRenderState } from "../../../../lib/renderer/Singleton"; import { getRenderer } from "../../../../lib/renderer/Singleton";
import { Reply } from "../../../../redux/reducers/queue"; import { Reply } from "../../../../redux/reducers/queue";
@ -20,7 +21,7 @@ import { SystemMessage } from "../SystemMessage";
import { ReplyBase } from "../attachments/MessageReply"; import { ReplyBase } from "../attachments/MessageReply";
interface Props { interface Props {
channel: string; channel: Channel;
replies: Reply[]; replies: Reply[];
setReplies: StateUpdater<Reply[]>; setReplies: StateUpdater<Reply[]>;
} }
@ -87,11 +88,11 @@ export default observer(({ channel, replies, setReplies }: Props) => {
); );
}, [replies, setReplies]); }, [replies, setReplies]);
const view = useRenderState(channel); const renderer = getRenderer(channel);
if (view?.type !== "RENDER") return null; if (renderer.state !== "RENDER") return null;
const ids = replies.map((x) => x.id); const ids = replies.map((x) => x.id);
const messages = view.messages.filter((x) => ids.includes(x._id)); const messages = renderer.messages.filter((x) => ids.includes(x._id));
return ( return (
<div> <div>

View file

@ -1,14 +1,16 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { useRenderState } from "../../../lib/renderer/Singleton"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import { getRenderer } from "../../../lib/renderer/Singleton";
interface Props { interface Props {
id: string; channel: Channel;
} }
export function ChannelDebugInfo({ id }: Props) { export const ChannelDebugInfo = observer(({ channel }: Props) => {
if (process.env.NODE_ENV !== "development") return null; if (process.env.NODE_ENV !== "development") return null;
const view = useRenderState(id); const renderer = getRenderer(channel);
if (!view) return null;
return ( return (
<span style={{ display: "block", padding: "12px 10px 0 10px" }}> <span style={{ display: "block", padding: "12px 10px 0 10px" }}>
@ -22,20 +24,26 @@ export function ChannelDebugInfo({ id }: Props) {
Channel Info Channel Info
</span> </span>
<p style={{ fontSize: "10px", userSelect: "text" }}> <p style={{ fontSize: "10px", userSelect: "text" }}>
State: <b>{view.type}</b> <br /> State: <b>{renderer.state}</b> <br />
{view.type === "RENDER" && view.messages.length > 0 && ( Stale: <b>{renderer.stale ? "Yes" : "No"}</b> <br />
Fetching: <b>{renderer.fetching ? "Yes" : "No"}</b> <br />
<br />
{renderer.state === "RENDER" && renderer.messages.length > 0 && (
<> <>
Start: <b>{view.messages[0]._id}</b> <br /> Start: <b>{renderer.messages[0]._id}</b> <br />
End:{" "} End:{" "}
<b> <b>
{view.messages[view.messages.length - 1]._id} {
renderer.messages[renderer.messages.length - 1]
._id
}
</b>{" "} </b>{" "}
<br /> <br />
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br /> At Top: <b>{renderer.atTop ? "Yes" : "No"}</b> <br />
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b> At Bottom: <b>{renderer.atBottom ? "Yes" : "No"}</b>
</> </>
)} )}
</p> </p>
</span> </span>
); );
} });

View file

@ -90,7 +90,7 @@ export const GroupMemberSidebar = observer(
return ( return (
<GenericSidebarBase> <GenericSidebarBase>
<GenericSidebarList> <GenericSidebarList>
<ChannelDebugInfo id={channel._id} /> <ChannelDebugInfo channel={channel} />
<Search channel={channel} /> <Search channel={channel} />
{/*voiceActive && voiceParticipants.length !== 0 && ( {/*voiceActive && voiceParticipants.length !== 0 && (
@ -202,7 +202,7 @@ export const ServerMemberSidebar = observer(
return ( return (
<GenericSidebarBase> <GenericSidebarBase>
<GenericSidebarList> <GenericSidebarList>
<ChannelDebugInfo id={channel._id} /> <ChannelDebugInfo channel={channel} />
<Search channel={channel} /> <Search channel={channel} />
<div>{users.length === 0 && <Preloader type="ring" />}</div> <div>{users.length === 0 && <Preloader type="ring" />}</div>
{users.length > 0 && ( {users.length > 0 && (

View file

@ -5,8 +5,6 @@ import { Route } from "revolt.js/dist/api/routes";
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks"; import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { SingletonMessageRenderer } from "../../lib/renderer/Singleton";
import { dispatch } from "../../redux"; import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { AuthState } from "../../redux/reducers/auth"; import { AuthState } from "../../redux/reducers/auth";
@ -64,7 +62,6 @@ function Context({ auth, children }: Props) {
}); });
setClient(client); setClient(client);
SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING); setStatus(ClientStatus.LOADING);
})(); })();
}, []); }, []);

View file

@ -1,34 +1,52 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import EventEmitter3 from "eventemitter3"; import { action, makeAutoObservable } from "mobx";
import { Client } from "revolt.js"; import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages"; 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 { SimpleRenderer } from "./simple/SimpleRenderer";
import { RendererRoutines, RenderState, ScrollState } from "./types"; import { RendererRoutines, ScrollState } from "./types";
export const SMOOTH_SCROLL_ON_RECEIVE = false; export const SMOOTH_SCROLL_ON_RECEIVE = false;
export class SingletonRenderer extends EventEmitter3 { export class ChannelRenderer {
client?: Client; channel: Channel;
channel?: string;
state: RenderState; state: "LOADING" | "WAITING_FOR_NETWORK" | "EMPTY" | "RENDER" = "LOADING";
currentRenderer: RendererRoutines; scrollState: ScrollState = { type: "ScrollToBottom" };
atTop: Nullable<boolean> = null;
atBottom: Nullable<boolean> = null;
messages: Message[] = [];
currentRenderer: RendererRoutines = SimpleRenderer;
stale = false; stale = false;
fetchingTop = false; fetching = false;
fetchingBottom = false;
constructor() { constructor(channel: Channel) {
super(); this.channel = channel;
makeAutoObservable(this, {
channel: false,
currentRenderer: false,
});
this.receive = this.receive.bind(this); this.receive = this.receive.bind(this);
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" }; const client = this.channel.client;
this.currentRenderer = SimpleRenderer; 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) { private receive(message: Message) {
@ -43,80 +61,64 @@ export class SingletonRenderer extends EventEmitter3 {
this.currentRenderer.delete(this, id); this.currentRenderer.delete(this, id);
} }
subscribe(client: Client) { @action async init(message_id?: string) {
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 (message_id) {
if (this.state.type === "RENDER") { if (this.state === "RENDER") {
const message = this.state.messages.find( const message = this.messages.find((x) => x._id === message_id);
(x) => x._id === message_id,
);
if (message) { if (message) {
this.emit("scroll", { this.emitScroll({
type: "ScrollToView", type: "ScrollToView",
id: message_id, id: message_id,
}); });
return; return;
} }
} }
} }
this.channel = id;
this.stale = false; this.stale = false;
this.setStateUnguarded({ type: "LOADING" }); this.state = "LOADING";
await this.currentRenderer.init(this, id, message_id); 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) { if (this.stale) {
this.stale = false; this.stale = false;
await this.init(id); await this.init();
} }
} }
async loadTop(ref?: HTMLDivElement) { async loadTop(ref?: HTMLDivElement) {
if (this.fetchingTop) return; if (this.fetching) return;
this.fetchingTop = true; this.fetching = true;
function generateScroll(end: string): ScrollState { function generateScroll(end: string): ScrollState {
if (ref) { if (ref) {
let heightRemoved = 0; let heightRemoved = 0,
removing = false;
const messageContainer = ref.children[0]; const messageContainer = ref.children[0];
if (messageContainer) { if (messageContainer) {
for (const 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, check whether it was removed.
if (child.id?.length === 26) { if (
// Check whether it was removed. removing ||
if (child.id.localeCompare(end) === 1) { (child.id?.length === 26 &&
child.id.localeCompare(end) === 1)
) {
removing = true;
heightRemoved += heightRemoved +=
child.clientHeight + 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.
@ -129,7 +131,6 @@ export class SingletonRenderer extends EventEmitter3 {
} }
} }
} }
}
return { return {
type: "OffsetTop", type: "OffsetTop",
@ -142,26 +143,28 @@ export class SingletonRenderer extends EventEmitter3 {
}; };
} }
await this.currentRenderer.loadTop(this, generateScroll); if (await this.currentRenderer.loadTop(this, generateScroll)) {
this.fetching = false;
// Allow state updates to propagate. }
setTimeout(() => (this.fetchingTop = false), 0);
} }
async loadBottom(ref?: HTMLDivElement) { async loadBottom(ref?: HTMLDivElement) {
if (this.fetchingBottom) return; if (this.fetching) return;
this.fetchingBottom = true; this.fetching = true;
function generateScroll(start: string): ScrollState { function generateScroll(start: string): ScrollState {
if (ref) { if (ref) {
let heightRemoved = 0; let heightRemoved = 0,
removing = true;
const messageContainer = ref.children[0]; const messageContainer = ref.children[0];
if (messageContainer) { if (messageContainer) {
for (const 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 check whether it was removed.
if (child.id?.length === 26) { if (
// Check whether it was removed. removing /* ||
if (child.id.localeCompare(start) === -1) { (child.id?.length === 26 &&
child.id.localeCompare(start) === -1)*/
) {
heightRemoved += heightRemoved +=
child.clientHeight + 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.
@ -172,7 +175,12 @@ export class SingletonRenderer extends EventEmitter3 {
10, 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); if (await this.currentRenderer.loadBottom(this, generateScroll)) {
this.fetching = false;
// Allow state updates to propagate. }
setTimeout(() => (this.fetchingBottom = false), 0);
} }
async jumpToBottom(id: string, smooth: boolean) { async jumpToBottom(smooth: boolean) {
if (id !== this.channel) return; if (this.state === "RENDER" && this.atBottom) {
if (this.state.type === "RENDER" && this.state.atBottom) { this.emitScroll({ type: "ScrollToBottom", smooth });
this.emit("scroll", { type: "ScrollToBottom", smooth });
} else { } 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) { export function getRenderer(channel: Channel) {
const [state, setState] = useState<Readonly<RenderState>>( let renderer = renderers[channel._id];
SingletonMessageRenderer.state, if (!renderer) {
); renderer = new ChannelRenderer(channel);
if (typeof id === "undefined") return; renderers[channel._id] = renderer;
function render(state: RenderState) {
setState(state);
} }
useEffect(() => { return renderer;
SingletonMessageRenderer.addListener("state", render);
return () => SingletonMessageRenderer.removeListener("state", render);
}, [id]);
return state;
} }

View file

@ -1,173 +1,160 @@
import { runInAction } from "mobx";
import { noopAsync } from "../../js"; 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, nearby, smooth) => { init: async (renderer, nearby, smooth) => {
if (renderer.client!.websocket.connected) { if (renderer.channel.client.websocket.connected) {
if (nearby) if (nearby)
renderer renderer.channel
.client!.channels.get(id)!
.fetchMessagesWithUsers({ nearby, limit: 100 }) .fetchMessagesWithUsers({ nearby, limit: 100 })
.then(({ messages }) => { .then(({ messages }) => {
messages.sort((a, b) => a._id.localeCompare(b._id)); messages.sort((a, b) => a._id.localeCompare(b._id));
renderer.setState(
id, runInAction(() => {
{ renderer.state = "RENDER";
type: "RENDER", renderer.messages = messages;
messages, renderer.atTop = false;
atTop: false, renderer.atBottom = false;
atBottom: false,
}, renderer.emitScroll({
{ type: "ScrollToView", id: nearby }, type: "ScrollToView",
); id: nearby,
});
});
}); });
else else
renderer renderer.channel
.client!.channels.get(id)!
.fetchMessagesWithUsers({}) .fetchMessagesWithUsers({})
.then(({ messages }) => { .then(({ messages }) => {
messages.reverse(); messages.reverse();
renderer.setState(
id, runInAction(() => {
{ renderer.state = "RENDER";
type: "RENDER", renderer.messages = messages;
messages, renderer.atTop = messages.length < 50;
atTop: messages.length < 50, renderer.atBottom = true;
atBottom: true,
}, renderer.emitScroll({
{ type: "ScrollToBottom", smooth }, type: "ScrollToBottom",
); smooth,
});
});
}); });
} else { } else {
renderer.setState(id, { type: "WAITING_FOR_NETWORK" }); runInAction(() => {
renderer.state = "WAITING_FOR_NETWORK";
});
} }
}, },
receive: async (renderer, message) => { receive: async (renderer, message) => {
if (message.channel_id !== renderer.channel) return; if (message.channel_id !== renderer.channel._id) return;
if (renderer.state.type !== "RENDER") return; if (renderer.state !== "RENDER") return;
if (renderer.state.messages.find((x) => x._id === message._id)) return; if (renderer.messages.find((x) => x._id === message._id)) return;
if (!renderer.state.atBottom) return; if (!renderer.atBottom) return;
let messages = [...renderer.state.messages, message]; let messages = [...renderer.messages, message];
let atTop = renderer.state.atTop; let atTop = renderer.atTop;
if (messages.length > 150) { if (messages.length > 150) {
messages = messages.slice(messages.length - 150); messages = messages.slice(messages.length - 150);
atTop = false; atTop = false;
} }
renderer.setState( runInAction(() => {
message.channel_id, renderer.messages = messages;
{ renderer.atTop = atTop;
...renderer.state,
messages, renderer.emitScroll({
atTop, type: "StayAtBottom",
}, smooth: SMOOTH_SCROLL_ON_RECEIVE,
{ type: "StayAtBottom", smooth: SMOOTH_SCROLL_ON_RECEIVE }, });
); });
}, },
edit: noopAsync, edit: noopAsync,
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 !== "RENDER") return;
const messages = [...renderer.state.messages]; const index = renderer.messages.findIndex((x) => x._id === id);
const index = messages.findIndex((x) => x._id === id);
if (index > -1) { if (index > -1) {
messages.splice(index, 1); runInAction(() => {
renderer.messages.splice(index, 1);
renderer.setState( renderer.emitScroll({ type: "StayAtBottom" });
channel, });
{
...renderer.state,
messages,
},
{ type: "StayAtBottom" },
);
} }
}, },
loadTop: async (renderer, generateScroll) => { loadTop: async (renderer, generateScroll) => {
const channel = renderer.channel; const channel = renderer.channel;
if (!channel) return; if (!channel) return true;
const state = renderer.state; if (renderer.state !== "RENDER") return true;
if (state.type !== "RENDER") return; if (renderer.atTop) return true;
if (state.atTop) return;
const { messages: data } = await renderer const { messages: data } =
.client!.channels.get(channel)! await renderer.channel.fetchMessagesWithUsers({
.fetchMessagesWithUsers({ before: renderer.messages[0]._id,
before: state.messages[0]._id,
}); });
runInAction(() => {
if (data.length === 0) { if (data.length === 0) {
return renderer.setState(channel, { renderer.atTop = true;
...state, return;
atTop: true,
});
} }
data.reverse(); data.reverse();
let messages = [...data, ...state.messages]; renderer.messages = [...data, ...renderer.messages];
let atTop = false;
if (data.length < 50) { if (data.length < 50) {
atTop = true; renderer.atTop = true;
} }
let atBottom = state.atBottom; if (renderer.messages.length > 150) {
if (messages.length > 150) { renderer.messages = renderer.messages.slice(0, 150);
messages = messages.slice(0, 150); renderer.atBottom = false;
atBottom = false;
} }
renderer.setState( renderer.emitScroll(
channel, generateScroll(
{ ...state, atTop, atBottom, messages }, renderer.messages[renderer.messages.length - 1]._id,
generateScroll(messages[messages.length - 1]._id), ),
); );
});
}, },
loadBottom: async (renderer, generateScroll) => { loadBottom: async (renderer, generateScroll) => {
const channel = renderer.channel; const channel = renderer.channel;
if (!channel) return; if (!channel) return true;
const state = renderer.state; if (renderer.state !== "RENDER") return true;
if (state.type !== "RENDER") return; if (renderer.atBottom) return true;
if (state.atBottom) return;
const { messages: data } = await renderer const { messages: data } =
.client!.channels.get(channel)! await renderer.channel.fetchMessagesWithUsers({
.fetchMessagesWithUsers({ after: renderer.messages[renderer.messages.length - 1]._id,
after: state.messages[state.messages.length - 1]._id,
sort: "Oldest", sort: "Oldest",
}); });
runInAction(() => {
if (data.length === 0) { if (data.length === 0) {
return renderer.setState(channel, { renderer.atBottom = true;
...state, return;
atBottom: true,
});
} }
let messages = [...state.messages, ...data]; renderer.messages.splice(renderer.messages.length, 0, ...data);
let atBottom = false;
if (data.length < 50) { if (data.length < 50) {
atBottom = true; renderer.atBottom = true;
} }
let atTop = state.atTop; if (renderer.messages.length > 150) {
if (messages.length > 150) { renderer.messages.splice(0, renderer.messages.length - 150);
messages = messages.slice(messages.length - 150); renderer.atTop = false;
atTop = false;
} }
renderer.setState( renderer.emitScroll(generateScroll(renderer.messages[0]._id));
channel, });
{ ...state, atTop, atBottom, messages },
generateScroll(messages[0]._id),
);
}, },
}; };

View file

@ -1,6 +1,6 @@
import { Message } from "revolt.js/dist/maps/Messages"; import { Message } from "revolt.js/dist/maps/Messages";
import { SingletonRenderer } from "./Singleton"; import { ChannelRenderer } from "./Singleton";
export type ScrollState = export type ScrollState =
| { type: "Free" } | { type: "Free" }
@ -23,26 +23,25 @@ export type RenderState =
export interface RendererRoutines { export interface RendererRoutines {
init: ( init: (
renderer: SingletonRenderer, renderer: ChannelRenderer,
id: string,
message?: string, message?: string,
smooth?: boolean, smooth?: boolean,
) => Promise<void>; ) => Promise<void>;
receive: (renderer: SingletonRenderer, message: Message) => Promise<void>; receive: (renderer: ChannelRenderer, message: Message) => Promise<void>;
edit: ( edit: (
renderer: SingletonRenderer, renderer: ChannelRenderer,
id: string, id: string,
partial: Partial<Message>, partial: Partial<Message>,
) => Promise<void>; ) => Promise<void>;
delete: (renderer: SingletonRenderer, id: string) => Promise<void>; delete: (renderer: ChannelRenderer, id: string) => Promise<void>;
loadTop: ( loadTop: (
renderer: SingletonRenderer, renderer: ChannelRenderer,
generateScroll: (end: string) => ScrollState, generateScroll: (end: string) => ScrollState,
) => Promise<void>; ) => Promise<void | true>;
loadBottom: ( loadBottom: (
renderer: SingletonRenderer, renderer: ChannelRenderer,
generateScroll: (start: string) => ScrollState, generateScroll: (start: string) => ScrollState,
) => Promise<void>; ) => Promise<void | true>;
} }

View file

@ -88,9 +88,9 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => {
<ChannelMain> <ChannelMain>
<ChannelContent> <ChannelContent>
<VoiceHeader id={id} /> <VoiceHeader id={id} />
<MessageArea id={id} /> <MessageArea channel={channel} />
<TypingIndicator channel={channel} /> <TypingIndicator channel={channel} />
<JumpToBottom id={id} /> <JumpToBottom channel={channel} />
<MessageBox channel={channel} /> <MessageBox channel={channel} />
</ChannelContent> </ChannelContent>
{!isTouchscreenDevice && showMembers && ( {!isTouchscreenDevice && showMembers && (

View file

@ -1,9 +1,9 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { getChannelName } from "../../../context/revoltjs/util"; import { getChannelName } from "../../../context/revoltjs/util";
const StartBase = styled.div` const StartBase = styled.div`
@ -22,14 +22,10 @@ const StartBase = styled.div`
`; `;
interface Props { interface Props {
id: string; channel: Channel;
} }
export default observer(({ id }: Props) => { export default observer(({ channel }: Props) => {
const client = useClient();
const channel = client.channels.get(id);
if (!channel) return null;
return ( return (
<StartBase> <StartBase>
<h1>{getChannelName(channel, true)}</h1> <h1>{getChannelName(channel, true)}</h1>

View file

@ -1,5 +1,8 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { useHistory, useParams } from "react-router-dom"; import { useHistory, useParams } from "react-router-dom";
import { animateScroll } from "react-scroll"; import { animateScroll } from "react-scroll";
import { Channel } from "revolt.js/dist/maps/Channels";
import styled from "styled-components"; import styled from "styled-components";
import useResizeObserver from "use-resize-observer"; import useResizeObserver from "use-resize-observer";
@ -15,13 +18,12 @@ import {
import { defer } from "../../../lib/defer"; import { defer } from "../../../lib/defer";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter"; import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; import { getRenderer } from "../../../lib/renderer/Singleton";
import { RenderState, ScrollState } from "../../../lib/renderer/types"; import { ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate"; import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import { import {
AppContext,
ClientStatus, ClientStatus,
StatusContext, StatusContext,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../context/revoltjs/RevoltClient";
@ -49,15 +51,14 @@ const Area = styled.div`
`; `;
interface Props { interface Props {
id: string; channel: Channel;
} }
export const MessageAreaWidthContext = createContext(0); export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82; export const MESSAGE_AREA_PADDING = 82;
export function MessageArea({ id }: Props) { export const MessageArea = observer(({ channel }: Props) => {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext); const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext); const { focusTaken } = useContext(IntermediateContext);
@ -70,12 +71,13 @@ export function MessageArea({ id }: Props) {
const { width, height } = useResizeObserver<HTMLDivElement>({ ref }); const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
// ? Current channel state. // ? Current channel state.
const [state, setState] = useState<RenderState>({ type: "LOADING" }); const renderer = getRenderer(channel);
// ? useRef to avoid re-renders // ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" }); const scrollState = useRef<ScrollState>({ type: "Free" });
const setScrollState = useCallback((v: ScrollState) => { const setScrollState = useCallback(
(v: ScrollState) => {
if (v.type === "StayAtBottom") { if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) { if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = { scrollState.current = {
@ -131,8 +133,13 @@ export function MessageArea({ id }: Props) {
setScrollState({ type: "Free" }); setScrollState({ type: "Free" });
} }
defer(() => renderer.complete());
}); });
}, []); },
// eslint-disable-next-line
[scrollState],
);
// ? Determine if we are at the bottom of the scroll container. // ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438 // -> https://stackoverflow.com/a/44893438
@ -155,35 +162,36 @@ export function MessageArea({ id }: Props) {
}, [setScrollState]); }, [setScrollState]);
// ? Handle events from renderer. // ? Handle events from renderer.
useEffect(() => { useLayoutEffect(
SingletonMessageRenderer.addListener("state", setState); () => setScrollState(renderer.scrollState),
return () => SingletonMessageRenderer.removeListener("state", setState); // eslint-disable-next-line
}, []); [renderer.scrollState],
);
useEffect(() => {
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState, setScrollState]);
// ? Load channel initially. // ? Load channel initially.
useEffect(() => { useEffect(() => {
if (message) return; if (message) return;
SingletonMessageRenderer.init(id); if (renderer.state === "RENDER") {
runInAction(() => (renderer.fetching = true));
setScrollState({ type: "ScrollTop", y: 151 });
} else {
renderer.init();
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, []);
// ? If message present or changes, load it as well. // ? If message present or changes, load it as well.
useEffect(() => { useEffect(() => {
if (message) { if (message) {
setHighlight(message); setHighlight(message);
SingletonMessageRenderer.init(id, message); renderer.init(message);
const channel = client.channels.get(id); if (channel.channel_type === "TextChannel") {
if (channel?.channel_type === "TextChannel") { history.push(
history.push(`/server/${channel.server_id}/channel/${id}`); `/server/${channel.server_id}/channel/${channel._id}`,
);
} else { } else {
history.push(`/channel/${id}`); history.push(`/channel/${channel._id}`);
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -193,20 +201,20 @@ export function MessageArea({ id }: Props) {
useEffect(() => { useEffect(() => {
switch (status) { switch (status) {
case ClientStatus.ONLINE: case ClientStatus.ONLINE:
if (state.type === "WAITING_FOR_NETWORK") { if (renderer.state === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id); renderer.init();
} else { } else {
SingletonMessageRenderer.reloadStale(id); renderer.reloadStale();
} }
break; break;
case ClientStatus.OFFLINE: case ClientStatus.OFFLINE:
case ClientStatus.DISCONNECTED: case ClientStatus.DISCONNECTED:
case ClientStatus.CONNECTING: case ClientStatus.CONNECTING:
SingletonMessageRenderer.markStale(); renderer.markStale();
break; break;
} }
}, [id, status, state]); }, [renderer, status]);
// ? When the container is scrolled. // ? When the container is scrolled.
// ? Also handle StayAtBottom // ? Also handle StayAtBottom
@ -238,17 +246,17 @@ export function MessageArea({ id }: Props) {
async function onScroll() { async function onScroll() {
if (atTop(100)) { if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current!); renderer.loadTop(ref.current!);
} }
if (atBottom(100)) { if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current!); renderer.loadBottom(ref.current!);
} }
} }
current.addEventListener("scroll", onScroll); current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll); return () => current.removeEventListener("scroll", onScroll);
}, [ref]); }, [ref, renderer]);
// ? Scroll down whenever the message area resizes. // ? Scroll down whenever the message area resizes.
const stbOnResize = useCallback(() => { const stbOnResize = useCallback(() => {
@ -277,36 +285,37 @@ export function MessageArea({ id }: Props) {
useEffect(() => { useEffect(() => {
function keyUp(e: KeyboardEvent) { function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) { if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true); renderer.jumpToBottom(true);
internalEmit("TextArea", "focus", "message"); internalEmit("TextArea", "focus", "message");
} }
} }
document.body.addEventListener("keyup", keyUp); document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp); return () => document.body.removeEventListener("keyup", keyUp);
}, [id, ref, focusTaken]); }, [renderer, ref, focusTaken]);
return ( return (
<MessageAreaWidthContext.Provider <MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}> value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}> <Area ref={ref}>
<div> <div>
{state.type === "LOADING" && <Preloader type="ring" />} {renderer.state === "LOADING" && <Preloader type="ring" />}
{state.type === "WAITING_FOR_NETWORK" && ( {renderer.state === "WAITING_FOR_NETWORK" && (
<RequiresOnline> <RequiresOnline>
<Preloader type="ring" /> <Preloader type="ring" />
</RequiresOnline> </RequiresOnline>
)} )}
{state.type === "RENDER" && ( {renderer.state === "RENDER" && (
<MessageRenderer <MessageRenderer
id={id} renderer={renderer}
state={state}
highlight={highlight} highlight={highlight}
/> />
)} )}
{state.type === "EMPTY" && <ConversationStart id={id} />} {renderer.state === "EMPTY" && (
<ConversationStart channel={channel} />
)}
</div> </div>
</Area> </Area>
</MessageAreaWidthContext.Provider> </MessageAreaWidthContext.Provider>
); );
} });

View file

@ -1,5 +1,6 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { X } from "@styled-icons/boxicons-regular"; import { X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { RelationshipStatus } from "revolt-api/types/Users"; import { RelationshipStatus } from "revolt-api/types/Users";
import { SYSTEM_USER_ID } from "revolt.js"; import { SYSTEM_USER_ID } from "revolt.js";
import { Message as MessageI } from "revolt.js/dist/maps/Messages"; import { Message as MessageI } from "revolt.js/dist/maps/Messages";
@ -11,7 +12,7 @@ import { memo } from "preact/compat";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter"; import { internalSubscribe, internalEmit } from "../../../lib/eventEmitter";
import { RenderState } from "../../../lib/renderer/types"; import { ChannelRenderer } from "../../../lib/renderer/Singleton";
import { connectState } from "../../../redux/connector"; import { connectState } from "../../../redux/connector";
import { QueuedMessage } from "../../../redux/reducers/queue"; import { QueuedMessage } from "../../../redux/reducers/queue";
@ -29,10 +30,9 @@ import ConversationStart from "./ConversationStart";
import MessageEditor from "./MessageEditor"; import MessageEditor from "./MessageEditor";
interface Props { interface Props {
id: string;
state: RenderState;
highlight?: string; highlight?: string;
queue: QueuedMessage[]; queue: QueuedMessage[];
renderer: ChannelRenderer;
} }
const BlockedMessage = styled.div` const BlockedMessage = styled.div`
@ -46,9 +46,7 @@ const BlockedMessage = styled.div`
} }
`; `;
function MessageRenderer({ id, state, queue, highlight }: Props) { const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => {
if (state.type !== "RENDER") return null;
const client = useClient(); const client = useClient();
const userId = client.user!._id; const userId = client.user!._id;
@ -60,10 +58,10 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
useEffect(() => { useEffect(() => {
function editLast() { function editLast() {
if (state.type !== "RENDER") return; if (renderer.state !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) { for (let i = renderer.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author_id === userId) { if (renderer.messages[i].author_id === userId) {
setEditing(state.messages[i]._id); setEditing(renderer.messages[i]._id);
internalEmit("MessageArea", "jump_to_bottom"); internalEmit("MessageArea", "jump_to_bottom");
return; return;
} }
@ -80,13 +78,13 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
]; ];
return () => subs.forEach((unsub) => unsub()); return () => subs.forEach((unsub) => unsub());
}, [state.messages, state.type, userId]); }, [renderer.messages, renderer.state, userId]);
const render: Children[] = []; const render: Children[] = [];
let previous: MessageI | undefined; let previous: MessageI | undefined;
if (state.atTop) { if (renderer.atTop) {
render.push(<ConversationStart id={id} />); render.push(<ConversationStart channel={renderer.channel} />);
} else { } else {
render.push( render.push(
<RequiresOnline> <RequiresOnline>
@ -133,7 +131,7 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
blocked = 0; blocked = 0;
} }
for (const message of state.messages) { for (const message of renderer.messages) {
if (previous) { if (previous) {
compare( compare(
message._id, message._id,
@ -183,10 +181,10 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
if (blocked > 0) pushBlocked(); if (blocked > 0) pushBlocked();
const nonces = state.messages.map((x) => x.nonce); const nonces = renderer.messages.map((x) => x.nonce);
if (state.atBottom) { if (renderer.atBottom) {
for (const msg of queue) { for (const msg of queue) {
if (msg.channel !== id) continue; if (msg.channel !== renderer.channel._id) continue;
if (nonces.includes(msg.id)) continue; if (nonces.includes(msg.id)) continue;
if (previous) { if (previous) {
@ -222,7 +220,7 @@ function MessageRenderer({ id, state, queue, highlight }: Props) {
} }
return <>{render}</>; return <>{render}</>;
} });
export default memo( export default memo(
connectState<Omit<Props, "queue">>(MessageRenderer, (state) => { connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {