import { useHistory, useParams } from "react-router-dom"; import { animateScroll } from "react-scroll"; import styled from "styled-components"; import useResizeObserver from "use-resize-observer"; import { createContext } from "preact"; import { useContext, useEffect, useLayoutEffect, useRef, useState, } from "preact/hooks"; 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 { IntermediateContext } from "../../../context/intermediate/Intermediate"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import { AppContext, ClientStatus, StatusContext, } from "../../../context/revoltjs/RevoltClient"; import Preloader from "../../../components/ui/Preloader"; import ConversationStart from "./ConversationStart"; import MessageRenderer from "./MessageRenderer"; const Area = styled.div` height: 100%; flex-grow: 1; min-height: 0; overflow-x: hidden; overflow-y: scroll; word-break: break-word; > div { display: flex; min-height: 100%; padding-bottom: 24px; flex-direction: column; justify-content: flex-end; } `; interface Props { id: string; } export const MessageAreaWidthContext = createContext(0); export const MESSAGE_AREA_PADDING = 82; export function MessageArea({ id }: Props) { const history = useHistory(); const client = useContext(AppContext); const status = useContext(StatusContext); const { focusTaken } = useContext(IntermediateContext); // ? Required data for message links. const { message } = useParams<{ message: string }>(); const [highlight, setHighlight] = useState(undefined); // ? This is the scroll container. const ref = useRef(null); const { width, height } = useResizeObserver({ ref }); // ? Current channel state. const [state, setState] = useState({ type: "LOADING" }); // ? useRef to avoid re-renders const scrollState = useRef({ type: "Free" }); const setScrollState = (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 = v; } 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" }); 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" }); } }); }; // ? Determine if we are at the bottom of the scroll container. // -> https://stackoverflow.com/a/44893438 // By default, we assume we are at the bottom, i.e. when we first load. const atBottom = (offset = 0) => ref.current ? Math.floor(ref.current?.scrollHeight - ref.current?.scrollTop) - offset <= ref.current?.clientHeight : true; const atTop = (offset = 0) => ref.current ? ref.current.scrollTop <= offset : false; // ? Handle global jump to bottom, e.g. when editing last message in chat. useEffect(() => { return internalSubscribe("MessageArea", "jump_to_bottom", () => setScrollState({ type: "ScrollToBottom" }), ); }, []); // ? 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]); // ? Load channel initially. useEffect(() => { if (message) return; SingletonMessageRenderer.init(id); }, [id]); // ? If message present or changes, load it as well. useEffect(() => { if (message) { setHighlight(message); SingletonMessageRenderer.init(id, message); const channel = client.channels.get(id); if (channel?.channel_type === "TextChannel") { history.push(`/server/${channel.server_id}/channel/${id}`); } else { history.push(`/channel/${id}`); } } }, [message]); // ? If we are waiting for network, try again. useEffect(() => { switch (status) { case ClientStatus.ONLINE: if (state.type === "WAITING_FOR_NETWORK") { SingletonMessageRenderer.init(id); } else { SingletonMessageRenderer.reloadStale(id); } break; case ClientStatus.OFFLINE: case ClientStatus.DISCONNECTED: case ClientStatus.CONNECTING: SingletonMessageRenderer.markStale(); break; } }, [status, state]); // ? When the container is scrolled. // ? Also handle StayAtBottom useEffect(() => { async function onScroll() { if (scrollState.current.type === "Free" && atBottom()) { setScrollState({ type: "Bottom" }); } else if (scrollState.current.type === "Bottom" && !atBottom()) { if ( scrollState.current.scrollingUntil && scrollState.current.scrollingUntil > +new Date() ) return; setScrollState({ type: "Free" }); } } ref.current?.addEventListener("scroll", onScroll); return () => ref.current?.removeEventListener("scroll", onScroll); }, [ref, scrollState]); // ? Top and bottom loaders. useEffect(() => { async function onScroll() { if (atTop(100)) { SingletonMessageRenderer.loadTop(ref.current!); } if (atBottom(100)) { SingletonMessageRenderer.loadBottom(ref.current!); } } ref.current?.addEventListener("scroll", onScroll); return () => ref.current?.removeEventListener("scroll", onScroll); }, [ref]); // ? Scroll down whenever the message area resizes. function stbOnResize() { if (!atBottom() && scrollState.current.type === "Bottom") { animateScroll.scrollToBottom({ container: ref.current, duration: 0, }); setScrollState({ type: "Bottom" }); } } // ? Scroll down when container resized. useLayoutEffect(() => { stbOnResize(); }, [height]); // ? Scroll down whenever the window resizes. useLayoutEffect(() => { document.addEventListener("resize", stbOnResize); return () => document.removeEventListener("resize", stbOnResize); }, [ref, scrollState]); // ? Scroll to bottom when pressing 'Escape'. useEffect(() => { function keyUp(e: KeyboardEvent) { if (e.key === "Escape" && !focusTaken) { SingletonMessageRenderer.jumpToBottom(id, true); internalEmit("TextArea", "focus", "message"); } } document.body.addEventListener("keyup", keyUp); return () => document.body.removeEventListener("keyup", keyUp); }, [ref, focusTaken]); return (
{state.type === "LOADING" && } {state.type === "WAITING_FOR_NETWORK" && ( )} {state.type === "RENDER" && ( )} {state.type === "EMPTY" && }
); }