diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 17b3eb2d..abb2ecf1 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -16,7 +16,7 @@ import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter"; import { useTranslation } from "../../../lib/i18n"; import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { - SingletonMessageRenderer, + getRenderer, SMOOTH_SCROLL_ON_RECEIVE, } from "../../../lib/renderer/Singleton"; @@ -122,6 +122,8 @@ export default observer(({ channel }: Props) => { const client = useContext(AppContext); const translate = useTranslation(); + const renderer = getRenderer(channel); + if (!(channel.permission & ChannelPermission.SendMessage)) { return ( @@ -213,12 +215,7 @@ export default observer(({ channel }: Props) => { }, }); - defer(() => - SingletonMessageRenderer.jumpToBottom( - channel._id, - SMOOTH_SCROLL_ON_RECEIVE, - ), - ); + defer(() => renderer.jumpToBottom(SMOOTH_SCROLL_ON_RECEIVE)); try { await channel.sendMessage({ @@ -405,7 +402,7 @@ export default observer(({ channel }: Props) => { }} /> diff --git a/src/components/common/messaging/attachments/MessageReply.tsx b/src/components/common/messaging/attachments/MessageReply.tsx index 71cd1e1d..d966fecf 100644 --- a/src/components/common/messaging/attachments/MessageReply.tsx +++ b/src/components/common/messaging/attachments/MessageReply.tsx @@ -10,7 +10,7 @@ import styled, { css } from "styled-components"; import { Text } from "preact-i18n"; import { useLayoutEffect, useState } from "preact/hooks"; -import { useRenderState } from "../../../../lib/renderer/Singleton"; +import { getRenderer } from "../../../../lib/renderer/Singleton"; import Markdown from "../../../markdown/Markdown"; import UserShort from "../../user/UserShort"; @@ -134,8 +134,8 @@ export const ReplyBase = styled.div<{ `; export const MessageReply = observer(({ index, channel, id }: Props) => { - const view = useRenderState(channel._id); - if (view?.type !== "RENDER") return null; + const view = getRenderer(channel); + if (view.state !== "RENDER") return null; const [message, setMessage] = useState(undefined); diff --git a/src/components/common/messaging/bars/JumpToBottom.tsx b/src/components/common/messaging/bars/JumpToBottom.tsx index 0f786135..dc8e50a1 100644 --- a/src/components/common/messaging/bars/JumpToBottom.tsx +++ b/src/components/common/messaging/bars/JumpToBottom.tsx @@ -1,12 +1,11 @@ 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 { Text } from "preact-i18n"; -import { - SingletonMessageRenderer, - useRenderState, -} from "../../../../lib/renderer/Singleton"; +import { getRenderer } from "../../../../lib/renderer/Singleton"; const Bar = styled.div` z-index: 10; @@ -51,14 +50,13 @@ const Bar = styled.div` } `; -export default function JumpToBottom({ id }: { id: string }) { - const view = useRenderState(id); - if (!view || view.type !== "RENDER" || view.atBottom) return null; +export default observer(({ channel }: { channel: Channel }) => { + const renderer = getRenderer(channel); + if (renderer.state !== "RENDER" || renderer.atBottom) return null; return ( -
SingletonMessageRenderer.jumpToBottom(id, true)}> +
renderer.jumpToBottom(true)}>
@@ -69,4 +67,4 @@ export default function JumpToBottom({ id }: { id: string }) {
); -} +}); diff --git a/src/components/common/messaging/bars/ReplyBar.tsx b/src/components/common/messaging/bars/ReplyBar.tsx index b274b490..bbcc79bd 100644 --- a/src/components/common/messaging/bars/ReplyBar.tsx +++ b/src/components/common/messaging/bars/ReplyBar.tsx @@ -2,13 +2,14 @@ import { At, Reply as ReplyIcon } from "@styled-icons/boxicons-regular"; import { File, XCircle } from "@styled-icons/boxicons-solid"; import { observer } from "mobx-react-lite"; import { SYSTEM_USER_ID } from "revolt.js"; +import { Channel } from "revolt.js/dist/maps/Channels"; import styled from "styled-components"; import { Text } from "preact-i18n"; import { StateUpdater, useEffect } from "preact/hooks"; import { internalSubscribe } from "../../../../lib/eventEmitter"; -import { useRenderState } from "../../../../lib/renderer/Singleton"; +import { getRenderer } from "../../../../lib/renderer/Singleton"; import { Reply } from "../../../../redux/reducers/queue"; @@ -20,7 +21,7 @@ import { SystemMessage } from "../SystemMessage"; import { ReplyBase } from "../attachments/MessageReply"; interface Props { - channel: string; + channel: Channel; replies: Reply[]; setReplies: StateUpdater; } @@ -87,11 +88,11 @@ export default observer(({ channel, replies, setReplies }: Props) => { ); }, [replies, setReplies]); - const view = useRenderState(channel); - if (view?.type !== "RENDER") return null; + const renderer = getRenderer(channel); + if (renderer.state !== "RENDER") return null; 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 (
diff --git a/src/components/navigation/right/ChannelDebugInfo.tsx b/src/components/navigation/right/ChannelDebugInfo.tsx index 9b23c7d3..fc78a79e 100644 --- a/src/components/navigation/right/ChannelDebugInfo.tsx +++ b/src/components/navigation/right/ChannelDebugInfo.tsx @@ -1,14 +1,16 @@ /* 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 { - id: string; + channel: Channel; } -export function ChannelDebugInfo({ id }: Props) { +export const ChannelDebugInfo = observer(({ channel }: Props) => { if (process.env.NODE_ENV !== "development") return null; - const view = useRenderState(id); - if (!view) return null; + const renderer = getRenderer(channel); return ( @@ -22,20 +24,26 @@ export function ChannelDebugInfo({ id }: Props) { Channel Info

- State: {view.type}
- {view.type === "RENDER" && view.messages.length > 0 && ( + State: {renderer.state}
+ Stale: {renderer.stale ? "Yes" : "No"}
+ Fetching: {renderer.fetching ? "Yes" : "No"}
+
+ {renderer.state === "RENDER" && renderer.messages.length > 0 && ( <> - Start: {view.messages[0]._id}
+ Start: {renderer.messages[0]._id}
End:{" "} - {view.messages[view.messages.length - 1]._id} + { + renderer.messages[renderer.messages.length - 1] + ._id + } {" "}
- At Top: {view.atTop ? "Yes" : "No"}
- At Bottom: {view.atBottom ? "Yes" : "No"} + At Top: {renderer.atTop ? "Yes" : "No"}
+ At Bottom: {renderer.atBottom ? "Yes" : "No"} )}

); -} +}); diff --git a/src/components/navigation/right/MemberSidebar.tsx b/src/components/navigation/right/MemberSidebar.tsx index 1eed07d3..b710b22a 100644 --- a/src/components/navigation/right/MemberSidebar.tsx +++ b/src/components/navigation/right/MemberSidebar.tsx @@ -90,7 +90,7 @@ export const GroupMemberSidebar = observer( return ( - + {/*voiceActive && voiceParticipants.length !== 0 && ( @@ -202,7 +202,7 @@ export const ServerMemberSidebar = observer( return ( - +
{users.length === 0 && }
{users.length > 0 && ( diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx index 30df4745..5e6efcef 100644 --- a/src/context/revoltjs/RevoltClient.tsx +++ b/src/context/revoltjs/RevoltClient.tsx @@ -5,8 +5,6 @@ import { Route } from "revolt.js/dist/api/routes"; import { createContext } from "preact"; import { useContext, useEffect, useMemo, useState } from "preact/hooks"; -import { SingletonMessageRenderer } from "../../lib/renderer/Singleton"; - import { dispatch } from "../../redux"; import { connectState } from "../../redux/connector"; import { AuthState } from "../../redux/reducers/auth"; @@ -64,7 +62,6 @@ function Context({ auth, children }: Props) { }); setClient(client); - SingletonMessageRenderer.subscribe(client); setStatus(ClientStatus.LOADING); })(); }, []); diff --git a/src/lib/renderer/Singleton.ts b/src/lib/renderer/Singleton.ts index 15c206d6..1f52a6c1 100644 --- a/src/lib/renderer/Singleton.ts +++ b/src/lib/renderer/Singleton.ts @@ -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 = null; + atBottom: Nullable = 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 = {}; -export function useRenderState(id: string) { - const [state, setState] = useState>( - 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; } diff --git a/src/lib/renderer/simple/SimpleRenderer.ts b/src/lib/renderer/simple/SimpleRenderer.ts index 5d05832e..aa7b1af8 100644 --- a/src/lib/renderer/simple/SimpleRenderer.ts +++ b/src/lib/renderer/simple/SimpleRenderer.ts @@ -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)); + }); }, }; diff --git a/src/lib/renderer/types.ts b/src/lib/renderer/types.ts index 61d830f1..b315c92e 100644 --- a/src/lib/renderer/types.ts +++ b/src/lib/renderer/types.ts @@ -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; - receive: (renderer: SingletonRenderer, message: Message) => Promise; + receive: (renderer: ChannelRenderer, message: Message) => Promise; edit: ( - renderer: SingletonRenderer, + renderer: ChannelRenderer, id: string, partial: Partial, ) => Promise; - delete: (renderer: SingletonRenderer, id: string) => Promise; + delete: (renderer: ChannelRenderer, id: string) => Promise; loadTop: ( - renderer: SingletonRenderer, + renderer: ChannelRenderer, generateScroll: (end: string) => ScrollState, - ) => Promise; + ) => Promise; loadBottom: ( - renderer: SingletonRenderer, + renderer: ChannelRenderer, generateScroll: (start: string) => ScrollState, - ) => Promise; + ) => Promise; } diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx index 64e22287..00653181 100644 --- a/src/pages/channels/Channel.tsx +++ b/src/pages/channels/Channel.tsx @@ -88,9 +88,9 @@ const TextChannel = observer(({ channel }: { channel: ChannelI }) => { - + - + {!isTouchscreenDevice && showMembers && ( diff --git a/src/pages/channels/messaging/ConversationStart.tsx b/src/pages/channels/messaging/ConversationStart.tsx index 1d26bed6..1c829dd9 100644 --- a/src/pages/channels/messaging/ConversationStart.tsx +++ b/src/pages/channels/messaging/ConversationStart.tsx @@ -1,9 +1,9 @@ import { observer } from "mobx-react-lite"; +import { Channel } from "revolt.js/dist/maps/Channels"; import styled from "styled-components"; import { Text } from "preact-i18n"; -import { useClient } from "../../../context/revoltjs/RevoltClient"; import { getChannelName } from "../../../context/revoltjs/util"; const StartBase = styled.div` @@ -22,14 +22,10 @@ const StartBase = styled.div` `; interface Props { - id: string; + channel: Channel; } -export default observer(({ id }: Props) => { - const client = useClient(); - const channel = client.channels.get(id); - if (!channel) return null; - +export default observer(({ channel }: Props) => { return (

{getChannelName(channel, true)}

diff --git a/src/pages/channels/messaging/MessageArea.tsx b/src/pages/channels/messaging/MessageArea.tsx index 71f07ae5..dc9bd6dd 100644 --- a/src/pages/channels/messaging/MessageArea.tsx +++ b/src/pages/channels/messaging/MessageArea.tsx @@ -1,5 +1,8 @@ +import { runInAction } from "mobx"; +import { observer } from "mobx-react-lite"; import { useHistory, useParams } from "react-router-dom"; import { animateScroll } from "react-scroll"; +import { Channel } from "revolt.js/dist/maps/Channels"; import styled from "styled-components"; import useResizeObserver from "use-resize-observer"; @@ -15,13 +18,12 @@ import { import { defer } from "../../../lib/defer"; import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter"; -import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; -import { RenderState, ScrollState } from "../../../lib/renderer/types"; +import { getRenderer } from "../../../lib/renderer/Singleton"; +import { ScrollState } from "../../../lib/renderer/types"; import { IntermediateContext } from "../../../context/intermediate/Intermediate"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import { - AppContext, ClientStatus, StatusContext, } from "../../../context/revoltjs/RevoltClient"; @@ -49,15 +51,14 @@ const Area = styled.div` `; interface Props { - id: string; + channel: Channel; } export const MessageAreaWidthContext = createContext(0); export const MESSAGE_AREA_PADDING = 82; -export function MessageArea({ id }: Props) { +export const MessageArea = observer(({ channel }: Props) => { const history = useHistory(); - const client = useContext(AppContext); const status = useContext(StatusContext); const { focusTaken } = useContext(IntermediateContext); @@ -70,69 +71,75 @@ export function MessageArea({ id }: Props) { const { width, height } = useResizeObserver({ ref }); // ? Current channel state. - const [state, setState] = useState({ type: "LOADING" }); + const renderer = getRenderer(channel); // ? useRef to avoid re-renders const scrollState = useRef({ type: "Free" }); - const setScrollState = useCallback((v: ScrollState) => { - if (v.type === "StayAtBottom") { - if (scrollState.current.type === "Bottom" || atBottom()) { - scrollState.current = { - type: "ScrollToBottom", - smooth: v.smooth, - }; + const setScrollState = useCallback( + (v: ScrollState) => { + if (v.type === "StayAtBottom") { + if (scrollState.current.type === "Bottom" || atBottom()) { + scrollState.current = { + type: "ScrollToBottom", + smooth: v.smooth, + }; + } else { + scrollState.current = { type: "Free" }; + } } else { - scrollState.current = { type: "Free" }; + scrollState.current = v; } - } else { - scrollState.current = v; - } - defer(() => { - if (scrollState.current.type === "ScrollToBottom") { - setScrollState({ - type: "Bottom", - scrollingUntil: +new Date() + 150, - }); + defer(() => { + if (scrollState.current.type === "ScrollToBottom") { + setScrollState({ + type: "Bottom", + scrollingUntil: +new Date() + 150, + }); - animateScroll.scrollToBottom({ - container: ref.current, - duration: scrollState.current.smooth ? 150 : 0, - }); - } else if (scrollState.current.type === "ScrollToView") { - document - .getElementById(scrollState.current.id) - ?.scrollIntoView({ block: "center" }); + animateScroll.scrollToBottom({ + container: ref.current, + duration: scrollState.current.smooth ? 150 : 0, + }); + } else if (scrollState.current.type === "ScrollToView") { + document + .getElementById(scrollState.current.id) + ?.scrollIntoView({ block: "center" }); - setScrollState({ type: "Free" }); - } else if (scrollState.current.type === "OffsetTop") { - animateScroll.scrollTo( - Math.max( - 101, - ref.current - ? ref.current.scrollTop + - (ref.current.scrollHeight - - scrollState.current.previousHeight) - : 101, - ), - { + setScrollState({ type: "Free" }); + } else if (scrollState.current.type === "OffsetTop") { + animateScroll.scrollTo( + Math.max( + 101, + ref.current + ? ref.current.scrollTop + + (ref.current.scrollHeight - + scrollState.current.previousHeight) + : 101, + ), + { + container: ref.current, + duration: 0, + }, + ); + + setScrollState({ type: "Free" }); + } else if (scrollState.current.type === "ScrollTop") { + animateScroll.scrollTo(scrollState.current.y, { container: ref.current, duration: 0, - }, - ); + }); - setScrollState({ type: "Free" }); - } else if (scrollState.current.type === "ScrollTop") { - animateScroll.scrollTo(scrollState.current.y, { - container: ref.current, - duration: 0, - }); + 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. // -> https://stackoverflow.com/a/44893438 @@ -155,35 +162,36 @@ export function MessageArea({ id }: Props) { }, [setScrollState]); // ? Handle events from renderer. - useEffect(() => { - SingletonMessageRenderer.addListener("state", setState); - return () => SingletonMessageRenderer.removeListener("state", setState); - }, []); - - useEffect(() => { - SingletonMessageRenderer.addListener("scroll", setScrollState); - return () => - SingletonMessageRenderer.removeListener("scroll", setScrollState); - }, [scrollState, setScrollState]); + useLayoutEffect( + () => setScrollState(renderer.scrollState), + // eslint-disable-next-line + [renderer.scrollState], + ); // ? Load channel initially. useEffect(() => { 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 - }, [id]); + }, []); // ? If message present or changes, load it as well. useEffect(() => { if (message) { setHighlight(message); - SingletonMessageRenderer.init(id, message); + renderer.init(message); - const channel = client.channels.get(id); - if (channel?.channel_type === "TextChannel") { - history.push(`/server/${channel.server_id}/channel/${id}`); + if (channel.channel_type === "TextChannel") { + history.push( + `/server/${channel.server_id}/channel/${channel._id}`, + ); } else { - history.push(`/channel/${id}`); + history.push(`/channel/${channel._id}`); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -193,20 +201,20 @@ export function MessageArea({ id }: Props) { useEffect(() => { switch (status) { case ClientStatus.ONLINE: - if (state.type === "WAITING_FOR_NETWORK") { - SingletonMessageRenderer.init(id); + if (renderer.state === "WAITING_FOR_NETWORK") { + renderer.init(); } else { - SingletonMessageRenderer.reloadStale(id); + renderer.reloadStale(); } break; case ClientStatus.OFFLINE: case ClientStatus.DISCONNECTED: case ClientStatus.CONNECTING: - SingletonMessageRenderer.markStale(); + renderer.markStale(); break; } - }, [id, status, state]); + }, [renderer, status]); // ? When the container is scrolled. // ? Also handle StayAtBottom @@ -238,17 +246,17 @@ export function MessageArea({ id }: Props) { async function onScroll() { if (atTop(100)) { - SingletonMessageRenderer.loadTop(ref.current!); + renderer.loadTop(ref.current!); } if (atBottom(100)) { - SingletonMessageRenderer.loadBottom(ref.current!); + renderer.loadBottom(ref.current!); } } current.addEventListener("scroll", onScroll); return () => current.removeEventListener("scroll", onScroll); - }, [ref]); + }, [ref, renderer]); // ? Scroll down whenever the message area resizes. const stbOnResize = useCallback(() => { @@ -277,36 +285,37 @@ export function MessageArea({ id }: Props) { useEffect(() => { function keyUp(e: KeyboardEvent) { if (e.key === "Escape" && !focusTaken) { - SingletonMessageRenderer.jumpToBottom(id, true); + renderer.jumpToBottom(true); internalEmit("TextArea", "focus", "message"); } } document.body.addEventListener("keyup", keyUp); return () => document.body.removeEventListener("keyup", keyUp); - }, [id, ref, focusTaken]); + }, [renderer, ref, focusTaken]); return (
- {state.type === "LOADING" && } - {state.type === "WAITING_FOR_NETWORK" && ( + {renderer.state === "LOADING" && } + {renderer.state === "WAITING_FOR_NETWORK" && ( )} - {state.type === "RENDER" && ( + {renderer.state === "RENDER" && ( )} - {state.type === "EMPTY" && } + {renderer.state === "EMPTY" && ( + + )}
); -} +}); diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx index fc470c96..d1a442fa 100644 --- a/src/pages/channels/messaging/MessageRenderer.tsx +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { X } from "@styled-icons/boxicons-regular"; +import { observer } from "mobx-react-lite"; import { RelationshipStatus } from "revolt-api/types/Users"; import { SYSTEM_USER_ID } from "revolt.js"; 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 { internalSubscribe, internalEmit } from "../../../lib/eventEmitter"; -import { RenderState } from "../../../lib/renderer/types"; +import { ChannelRenderer } from "../../../lib/renderer/Singleton"; import { connectState } from "../../../redux/connector"; import { QueuedMessage } from "../../../redux/reducers/queue"; @@ -29,10 +30,9 @@ import ConversationStart from "./ConversationStart"; import MessageEditor from "./MessageEditor"; interface Props { - id: string; - state: RenderState; highlight?: string; queue: QueuedMessage[]; + renderer: ChannelRenderer; } const BlockedMessage = styled.div` @@ -46,9 +46,7 @@ const BlockedMessage = styled.div` } `; -function MessageRenderer({ id, state, queue, highlight }: Props) { - if (state.type !== "RENDER") return null; - +const MessageRenderer = observer(({ renderer, queue, highlight }: Props) => { const client = useClient(); const userId = client.user!._id; @@ -60,10 +58,10 @@ function MessageRenderer({ id, state, queue, highlight }: Props) { useEffect(() => { function editLast() { - if (state.type !== "RENDER") return; - for (let i = state.messages.length - 1; i >= 0; i--) { - if (state.messages[i].author_id === userId) { - setEditing(state.messages[i]._id); + if (renderer.state !== "RENDER") return; + for (let i = renderer.messages.length - 1; i >= 0; i--) { + if (renderer.messages[i].author_id === userId) { + setEditing(renderer.messages[i]._id); internalEmit("MessageArea", "jump_to_bottom"); return; } @@ -80,13 +78,13 @@ function MessageRenderer({ id, state, queue, highlight }: Props) { ]; return () => subs.forEach((unsub) => unsub()); - }, [state.messages, state.type, userId]); + }, [renderer.messages, renderer.state, userId]); const render: Children[] = []; let previous: MessageI | undefined; - if (state.atTop) { - render.push(); + if (renderer.atTop) { + render.push(); } else { render.push( @@ -133,7 +131,7 @@ function MessageRenderer({ id, state, queue, highlight }: Props) { blocked = 0; } - for (const message of state.messages) { + for (const message of renderer.messages) { if (previous) { compare( message._id, @@ -183,10 +181,10 @@ function MessageRenderer({ id, state, queue, highlight }: Props) { if (blocked > 0) pushBlocked(); - const nonces = state.messages.map((x) => x.nonce); - if (state.atBottom) { + const nonces = renderer.messages.map((x) => x.nonce); + if (renderer.atBottom) { for (const msg of queue) { - if (msg.channel !== id) continue; + if (msg.channel !== renderer.channel._id) continue; if (nonces.includes(msg.id)) continue; if (previous) { @@ -222,7 +220,7 @@ function MessageRenderer({ id, state, queue, highlight }: Props) { } return <>{render}; -} +}); export default memo( connectState>(MessageRenderer, (state) => {