import styled from "styled-components"; import { createContext } from "preact"; import { animateScroll } from "react-scroll"; import MessageRenderer from "./MessageRenderer"; import ConversationStart from './ConversationStart'; import useResizeObserver from "use-resize-observer"; import Preloader from "../../../components/ui/Preloader"; import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; import { RenderState, ScrollState } from "../../../lib/renderer/types"; import { SingletonMessageRenderer } from "../../../lib/renderer/Singleton"; import { IntermediateContext } from "../../../context/intermediate/Intermediate"; import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient"; import { useContext, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; 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%; 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 status = useContext(StatusContext); const { focusTaken } = useContext(IntermediateContext); // ? This is the scroll container. const ref = useRef(null); const { width, height } = useResizeObserver({ ref }); // ? Current channel state. const [state, setState] = useState({ type: "LOADING" }); // ? Hook-based scrolling mechanism. const [scrollState, setSS] = useState({ type: "Free" }); const setScrollState = (v: ScrollState) => { if (v.type === 'StayAtBottom') { if (scrollState.type === 'Bottom' || atBottom()) { setSS({ type: 'ScrollToBottom', smooth: v.smooth }); } else { setSS({ type: 'Free' }); } } else { setSS(v); } } // ? 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.scrollTop <= offset; // ? 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(() => { SingletonMessageRenderer.init(id); }, [ id ]); // ? 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 ]); // ? Scroll to the bottom before the browser paints. useLayoutEffect(() => { if (scrollState.type === "ScrollToBottom") { setScrollState({ type: "Bottom", scrollingUntil: + new Date() + 150 }); animateScroll.scrollToBottom({ container: ref.current, duration: scrollState.smooth ? 150 : 0 }); } else if (scrollState.type === "OffsetTop") { animateScroll.scrollTo( Math.max( 101, ref.current.scrollTop + (ref.current.scrollHeight - scrollState.previousHeight) ), { container: ref.current, duration: 0 } ); setScrollState({ type: "Free" }); } else if (scrollState.type === "ScrollTop") { animateScroll.scrollTo(scrollState.y, { container: ref.current, duration: 0 }); setScrollState({ type: "Free" }); } }, [scrollState]); // ? When the container is scrolled. // ? Also handle StayAtBottom useEffect(() => { async function onScroll() { if (scrollState.type === "Free" && atBottom()) { setScrollState({ type: "Bottom" }); } else if (scrollState.type === "Bottom" && !atBottom()) { if (scrollState.scrollingUntil && scrollState.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.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); } } 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" && }
); }