revite/src/pages/channels/messaging/MessageArea.tsx

338 lines
11 KiB
TypeScript
Raw Normal View History

2021-08-07 15:43:08 -04:00
import { runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { useHistory, useParams } from "react-router-dom";
import { animateScroll } from "react-scroll";
2021-08-07 15:43:08 -04:00
import { Channel } from "revolt.js/dist/maps/Channels";
2021-07-05 06:23:23 -04:00
import styled from "styled-components";
import useResizeObserver from "use-resize-observer";
2021-07-05 06:23:23 -04:00
import { createContext } from "preact";
import {
2021-08-05 09:47:00 -04:00
useCallback,
2021-07-05 06:25:20 -04:00
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
2021-07-05 06:23:23 -04:00
} from "preact/hooks";
import { defer } from "../../../lib/defer";
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
2021-08-07 15:43:08 -04:00
import { getRenderer } from "../../../lib/renderer/Singleton";
import { ScrollState } from "../../../lib/renderer/types";
2021-07-05 06:23:23 -04:00
import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import {
2021-07-05 06:25:20 -04:00
ClientStatus,
StatusContext,
2021-07-05 06:23:23 -04:00
} from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
import ConversationStart from "./ConversationStart";
import MessageRenderer from "./MessageRenderer";
const Area = styled.div`
2021-07-05 06:25:20 -04:00
height: 100%;
flex-grow: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: scroll;
word-break: break-word;
> div {
display: flex;
min-height: 100%;
2021-07-05 14:53:59 -04:00
padding-bottom: 24px;
2021-07-05 06:25:20 -04:00
flex-direction: column;
justify-content: flex-end;
}
`;
interface Props {
2021-08-07 15:43:08 -04:00
channel: Channel;
}
export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82;
2021-08-07 15:43:08 -04:00
export const MessageArea = observer(({ channel }: Props) => {
2021-07-08 17:47:56 -04:00
const history = useHistory();
2021-07-05 06:25:20 -04:00
const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext);
2021-07-09 04:58:38 -04:00
// ? Required data for message links.
2021-07-08 17:47:56 -04:00
const { message } = useParams<{ message: string }>();
2021-07-09 04:58:38 -04:00
const [highlight, setHighlight] = useState<string | undefined>(undefined);
2021-07-08 17:47:56 -04:00
2021-07-05 06:25:20 -04:00
// ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
// ? Current channel state.
2021-08-07 15:43:08 -04:00
const renderer = getRenderer(channel);
2021-07-05 06:25:20 -04:00
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
2021-08-07 15:43:08 -04:00
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" };
}
2021-07-05 06:25:20 -04:00
} else {
2021-08-07 15:43:08 -04:00
scrollState.current = v;
2021-07-05 06:25:20 -04:00
}
2021-08-07 15:43:08 -04:00
defer(() => {
if (scrollState.current.type === "ScrollToBottom") {
setScrollState({
type: "Bottom",
scrollingUntil: +new Date() + 150,
});
2021-08-07 15:43:08 -04:00
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, {
2021-07-05 06:25:20 -04:00
container: ref.current,
duration: 0,
2021-08-07 15:43:08 -04:00
});
2021-07-05 06:25:20 -04:00
2021-08-07 15:43:08 -04:00
setScrollState({ type: "Free" });
}
2021-07-05 06:25:20 -04:00
2021-08-07 15:43:08 -04:00
defer(() => renderer.complete());
});
},
// eslint-disable-next-line
[scrollState],
);
2021-07-05 06:25:20 -04:00
// ? 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) -
2021-07-05 06:25:20 -04:00
offset <=
ref.current?.clientHeight
2021-07-05 06:25:20 -04:00
: true;
2021-07-06 14:29:27 -04:00
const atTop = (offset = 0) =>
ref.current ? ref.current.scrollTop <= offset : false;
2021-07-05 06:25:20 -04:00
// ? Handle global jump to bottom, e.g. when editing last message in chat.
useEffect(() => {
return internalSubscribe("MessageArea", "jump_to_bottom", () =>
setScrollState({ type: "ScrollToBottom" }),
);
2021-08-05 09:47:00 -04:00
}, [setScrollState]);
2021-07-05 06:25:20 -04:00
// ? Handle events from renderer.
2021-08-07 15:43:08 -04:00
useLayoutEffect(
() => setScrollState(renderer.scrollState),
// eslint-disable-next-line
[renderer.scrollState],
);
2021-07-05 06:25:20 -04:00
// ? Load channel initially.
useEffect(() => {
2021-07-08 17:47:56 -04:00
if (message) return;
2021-08-07 15:43:08 -04:00
if (renderer.state === "RENDER") {
runInAction(() => (renderer.fetching = true));
if (renderer.scrollAnchored) {
setScrollState({ type: "ScrollToBottom" });
} else {
setScrollState({
type: "ScrollTop",
y: renderer.scrollPosition,
});
}
2021-08-07 15:43:08 -04:00
} else {
renderer.init();
}
2021-08-05 09:47:00 -04:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2021-08-07 15:43:08 -04:00
}, []);
2021-07-05 06:25:20 -04:00
2021-07-08 17:47:56 -04:00
// ? If message present or changes, load it as well.
useEffect(() => {
if (message) {
2021-07-09 04:58:38 -04:00
setHighlight(message);
2021-08-07 15:43:08 -04:00
renderer.init(message);
2021-07-08 17:47:56 -04:00
2021-08-07 15:43:08 -04:00
if (channel.channel_type === "TextChannel") {
history.push(
`/server/${channel.server_id}/channel/${channel._id}`,
);
2021-07-08 17:47:56 -04:00
} else {
2021-08-07 15:43:08 -04:00
history.push(`/channel/${channel._id}`);
2021-07-08 17:47:56 -04:00
}
}
2021-08-05 09:47:00 -04:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2021-07-08 17:47:56 -04:00
}, [message]);
2021-07-05 06:25:20 -04:00
// ? If we are waiting for network, try again.
useEffect(() => {
switch (status) {
case ClientStatus.ONLINE:
2021-08-07 15:43:08 -04:00
if (renderer.state === "WAITING_FOR_NETWORK") {
renderer.init();
2021-07-05 06:25:20 -04:00
} else {
2021-08-07 15:43:08 -04:00
renderer.reloadStale();
2021-07-05 06:25:20 -04:00
}
break;
case ClientStatus.OFFLINE:
case ClientStatus.DISCONNECTED:
case ClientStatus.CONNECTING:
2021-08-07 15:43:08 -04:00
renderer.markStale();
2021-07-05 06:25:20 -04:00
break;
}
2021-08-07 15:43:08 -04:00
}, [renderer, status]);
2021-07-05 06:25:20 -04:00
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
2021-08-05 09:47:00 -04:00
const current = ref.current;
if (!current) return;
2021-07-05 06:25:20 -04:00
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" });
}
}
2021-08-05 09:47:00 -04:00
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
}, [ref, scrollState, setScrollState]);
2021-07-05 06:25:20 -04:00
// ? Top and bottom loaders.
useEffect(() => {
2021-08-05 09:47:00 -04:00
const current = ref.current;
if (!current) return;
2021-07-05 06:25:20 -04:00
async function onScroll() {
renderer.scrollPosition = current!.scrollTop;
2021-07-05 06:25:20 -04:00
if (atTop(100)) {
renderer.loadTop(current!);
2021-07-05 06:25:20 -04:00
}
if (atBottom(100)) {
renderer.loadBottom(current!);
2021-07-05 06:25:20 -04:00
}
if (atBottom()) {
renderer.scrollAnchored = true;
} else {
renderer.scrollAnchored = false;
}
2021-07-05 06:25:20 -04:00
}
2021-08-05 09:47:00 -04:00
current.addEventListener("scroll", onScroll);
return () => current.removeEventListener("scroll", onScroll);
2021-08-07 15:43:08 -04:00
}, [ref, renderer]);
2021-07-05 06:25:20 -04:00
// ? Scroll down whenever the message area resizes.
2021-08-05 09:47:00 -04:00
const stbOnResize = useCallback(() => {
2021-07-05 06:25:20 -04:00
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
duration: 0,
});
setScrollState({ type: "Bottom" });
}
2021-08-05 09:47:00 -04:00
}, [setScrollState]);
2021-07-05 06:25:20 -04:00
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
2021-08-05 09:47:00 -04:00
}, [stbOnResize, height]);
2021-07-05 06:25:20 -04:00
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
2021-08-05 09:47:00 -04:00
}, [ref, scrollState, stbOnResize]);
2021-07-05 06:25:20 -04:00
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
2021-08-07 15:43:08 -04:00
renderer.jumpToBottom(true);
2021-07-05 06:25:20 -04:00
internalEmit("TextArea", "focus", "message");
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
2021-08-07 15:43:08 -04:00
}, [renderer, ref, focusTaken]);
2021-07-05 06:25:20 -04:00
return (
<MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}>
<div>
2021-08-07 15:43:08 -04:00
{renderer.state === "LOADING" && <Preloader type="ring" />}
{renderer.state === "WAITING_FOR_NETWORK" && (
2021-07-05 06:25:20 -04:00
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>
)}
2021-08-07 15:43:08 -04:00
{renderer.state === "RENDER" && (
<MessageRenderer
2021-08-07 15:43:08 -04:00
renderer={renderer}
highlight={highlight}
/>
2021-07-05 06:25:20 -04:00
)}
2021-08-07 15:43:08 -04:00
{renderer.state === "EMPTY" && (
<ConversationStart channel={channel} />
)}
2021-07-05 06:25:20 -04:00
</div>
</Area>
</MessageAreaWidthContext.Provider>
);
2021-08-07 15:43:08 -04:00
});