stoat-for-desktop/components/app/interface/channels/text/Messages.tsx

1027 lines
25 KiB
TypeScript

import {
Accessor,
For,
JSX,
Match,
Show,
Switch,
batch,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
onMount,
splitProps,
} from "solid-js";
import isEqual from "lodash.isequal";
import { Channel, Message as MessageInterface } from "stoat.js";
import { styled } from "styled-system/jsx";
import { useClient, useClientLifecycle } from "@revolt/client";
import { State } from "@revolt/client/Controller";
import { useTime } from "@revolt/i18n";
import { useState } from "@revolt/state";
import {
BlockedMessage,
ConversationStart,
Deferred,
JumpToBottom,
MessageDivider,
} from "@revolt/ui";
import {
ListView2,
ListView2Update,
} from "@revolt/ui/components/utils/ListView2";
import { Message } from "./Message";
import { useMessageCache } from "./MessageCache";
/**
* Initial fetch limit
*/
const INITIAL_FETCH_LIMIT = 30;
/**
* Fetch limit
*/
const FETCH_LIMIT = 50;
/**
* Display limit
*/
const DISPLAY_LIMIT = 150;
interface Props {
/**
* Channel to fetch messages from
*/
channel: Channel;
/**
* Pending messages to render at the end of the list
*/
pendingMessages?: (props: { tail: boolean; ids: string[] }) => JSX.Element;
/**
* Display typing indicator instead of padding
*/
typingIndicator?: JSX.Element;
/**
* Highlighted message id
*/
highlightedMessageId: Accessor<string | undefined>;
/**
* Last read message id
*/
lastReadId: Accessor<string | undefined>;
/**
* Clear the highlighted message
*/
clearHighlightedMessage: () => void;
/**
* Bind the initial messages function to the parent component
* @param fn Function
*/
jumpToBottomRef?: (fn: (nearby?: string) => void) => void;
/**
* Bind the atEnd signal to the parent component
* @param fn Function
*/
atEndRef?: (fn: () => boolean) => void;
}
/**
* Render messages in a Channel
*/
export function Messages(props: Props) {
const cache = useMessageCache();
const lifecycle = useClientLifecycle();
const client = useClient();
const state = useState();
const dayjs = useTime();
/**
* Loaded messages
*/
const [messages, setMessages] = createSignal<MessageInterface[]>([]);
/**
* Whether we've reached the start of the conversation
*/
const [atStart, setStart] = createSignal(false);
/**
* Whether we've reached the end of the conversation
*/
const [atEnd, setEnd] = createSignal(true);
/**
* The current direction of fetching
*/
const [fetching, setFetching] = createSignal<
"initial" | "upwards" | "downwards" | "jump_end" | "jump_msg"
>();
/**
* Whether the current fetch has failed
*/
const [failure, setFailure] = createSignal(false);
/**
* Collect messages during fetches
*
* The new message handler should write into this if it
* is defined as opposed to appending to messages[] list
*/
let collectedMessages: MessageInterface[] | undefined;
/**
* Pre-empt the current fetch
*/
let preemptFetch: () => void | undefined;
/**
* Reference for the list container so we can scroll to elements
*/
let listRef: HTMLDivElement | undefined;
/**
* Whether we can fetch
* @returns Boolean
*/
function canFetch() {
return !fetching() || failure();
}
/**
* Pre-empt the current fetch
*/
function preempt() {
batch(() => {
setFetching();
setFailure(false);
preemptFetch?.();
});
}
/**
* Helper for checking if we've been pre-empted
* @returns Function to check if we have been pre-empted
*/
function newPreempted() {
let preempted = false;
preemptFetch = () => {
preempted = true;
};
return () => preempted;
}
/**
* Safely update messages by applying consistency checks
* @param messagesArr Array of message arrays
*/
function setMessagesSafely(...messagesArr: MessageInterface[][]) {
setMessages(
messagesArr.flat().toSorted((a, b) => b.id.localeCompare(a.id)),
);
}
/**
* Initial load subroutine
* @param nearby Message we should load around (and then scroll to)
*/
async function caseInitialLoad(nearby?: string) {
// Pre-empt any fetches
preempt();
setFetching("initial");
// Handle incoming pre-emptions
const preempted = newPreempted();
// Clear the messages list
// NB. component does not remount on channel switch
setMessages([]);
// Set the initial position
setStart(false);
setEnd(true);
// Start collecting messages
collectedMessages = [];
try {
// Fetch messages for channel
let messages;
const existingState = cache!.unmanage(props.channel);
const useExistingState = existingState && !nearby;
if (useExistingState) {
messages = existingState.messages;
} else {
messages = await props.channel
.fetchMessagesWithUsers({
limit: INITIAL_FETCH_LIMIT,
nearby,
})
.then(({ messages }) => messages);
}
// Cancel if we've been pre-empted
if (preempted()) return;
// Assume we are not at the end if we jumped to a message
// NB. we set this late to not display the "jump to bottom" bar
if (typeof nearby === "string") {
setEnd(
// If the messages fetched include the latest message,
// then we are at the end and mark the channel as such.
messages.findIndex(
(msg) => msg.id === props.channel.lastMessageId,
) !== -1,
);
}
// Check if we're at the start of the conversation otherwise
else if (!useExistingState && messages.length < INITIAL_FETCH_LIMIT) {
setStart(true);
}
// Apply existing state if present
else if (existingState) {
setStart(existingState.atStart);
setEnd(existingState.atEnd);
}
// Merge list with any new ones that have come in if we are at the end
if (atEnd()) {
const knownIds = new Set(collectedMessages!.map((x) => x.id));
setMessagesSafely(
collectedMessages!,
messages.filter((x) => !knownIds.has(x.id)),
);
}
// Otherwise just replace the whole list
else {
setMessages(messages);
}
// Stop collecting messages
collectedMessages = [];
// Mark as fetching has ended
setFetching();
// If we're not at the end, restore scroll position
if (existingState && !existingState.atEnd) {
setTimeout(() =>
listRef!.scrollTo({
top: existingState.scrollTop!,
behavior: "instant",
}),
);
}
// Or... reset scroll to the end
else if (atEnd()) {
setTimeout(() =>
listRef!.scrollTo({
top: 9999999,
behavior: "instant",
}),
);
}
} catch {
// Keep track of any failures (and allow retry / other actions)
setFailure(true);
setFetching();
}
}
/**
* Fetch upwards from current position
*/
async function caseFetchUpwards(): Promise<ListView2Update | undefined> {
// Pre-conditions:
// - Must not already be at the start
// - Must not already be fetching (or otherwise the fetch must have failed)
if (atStart() || !canFetch()) return;
// Indicate we are fetching upwards
setFetching("upwards");
// Handle incoming pre-emptions
const preempted = newPreempted();
try {
// Fetch messages for channel
const result = await props.channel.fetchMessagesWithUsers({
limit: FETCH_LIMIT,
// Take the id of the oldest message currently fetched
before: messages().slice(-1)[0].id,
});
// Cancel if we've been pre-empted
if (preempted()) return;
// If it's less than we expected, we are at the start
if (result.messages.length < FETCH_LIMIT) {
setStart(true);
}
// Prepend messages if we received any
if (result.messages.length) {
// Calculate how much we need to cut off the other end
const tooManyBy = Math.max(
0,
result.messages.length + messages().length - DISPLAY_LIMIT,
);
// If it's at least one element, we are no longer at the end
if (tooManyBy > 0) {
setEnd(false);
}
const msgs = messages();
return {
scrollAnchorId: msgs[msgs.length - 1].id,
commitToDOM() {
setMessagesSafely(messages(), result.messages);
if (tooManyBy) {
setMessages((prev) => {
return prev.slice(tooManyBy);
});
}
setFetching();
},
};
} else {
setFetching();
}
} catch {
// Keep track of any failures (and allow retry / other actions)
setFailure(true);
setFetching();
}
}
/**
* Fetch downwards from current position
*/
async function caseFetchDownwards(): Promise<ListView2Update | undefined> {
// Pre-conditions:
// - Must not already be at the end
// - Must not already be fetching (or otherwise the fetch must have failed)
if (atEnd() || !canFetch()) return;
// Indicate we are fetching downwards
setFetching("downwards");
// Handle incoming pre-emptions
const preempted = newPreempted();
try {
// Fetch messages after the newest message we have
const result = await props.channel.fetchMessagesWithUsers({
limit: FETCH_LIMIT,
after: messages()[0].id,
sort: "Oldest",
});
// Cancel if we've been pre-empted
if (preempted()) return;
// If it's less than we expected, we are at the end
if (result.messages.length < FETCH_LIMIT) {
setEnd(true);
}
// If we received any messages at all, append them to the bottom
if (result.messages.length) {
// Calculate how much we need to cut off the other end
const tooManyBy = Math.max(
0,
result.messages.length + messages().length - DISPLAY_LIMIT,
);
// If it's at least one element, we are no longer at the start
if (tooManyBy > 0) {
setStart(false);
}
return {
scrollAnchorId: messages()[0].id,
commitToDOM() {
setMessages(() => {
return [...result.messages.reverse(), ...messages()];
});
if (tooManyBy) {
setMessages((prev) => prev.slice(0, -tooManyBy));
}
setFetching();
},
};
} else {
// Mark as fetching has ended
setFetching();
}
} catch {
// Keep track of any failures (and allow retry / other actions)
setFailure(true);
setFetching();
}
}
/**
* Jump to the present messages
*/
async function caseJumpToBottom() {
/**
* Helper function to find the closest parent scroll container
* @param el Element
* @returns Element
*/
function findScrollContainer(el: Element | null) {
if (!el) {
return null;
} else if (getComputedStyle(el).overflowY === "scroll") {
return el;
} else {
return el.parentElement;
}
}
// Scroll to the bottom if we're already at the end
if (atEnd()) {
const containerChild = findScrollContainer(listRef!)!.children[0];
containerChild!.scrollIntoView({
behavior: "smooth",
block: "end",
});
}
// Otherwise fetch present messages
else {
// Pre-empty any fetches
preempt();
setFetching("jump_end");
// Handle incoming pre-emptions
const preempted = newPreempted();
// Start collecting messages
collectedMessages = [];
try {
// Fetch messages for channel
const { messages } = await props.channel.fetchMessagesWithUsers({
limit: FETCH_LIMIT,
});
// Cancel if we've been pre-empted
if (preempted()) return;
// Check if we're at the start of the conversation
// NB. this may be counter-intuitive because we are in history but,
// this could be a very rare edge case for large moderation actions
if (messages.length < FETCH_LIMIT) {
setStart(true);
} else {
setStart(false);
}
// Indicate we are at the end now
setEnd(true);
// Merge list with any new ones that have come in
const knownIds = new Set(collectedMessages!.map((x) => x.id));
setMessagesSafely(
collectedMessages!,
messages.filter((x) => !knownIds.has(x.id)),
);
// Stop collecting messages
collectedMessages = [];
// Animate scroll to bottom
setTimeout(() => {
const containerChild = findScrollContainer(listRef!)!.children[0];
containerChild!.scrollIntoView({
behavior: "instant",
block: "start",
});
setTimeout(() => {
containerChild!.scrollIntoView({
behavior: "smooth",
block: "end",
});
// Mark as fetching has ended
setFetching();
});
});
} catch {
// Keep track of any failures (and allow retry / other actions)
setFailure(true);
setFetching();
}
}
}
/**
* Jump to a given message
* @param messageId Message Id
*/
async function caseJumpToMessage(messageId: string) {
/**
* Scroll to the nearest message (to the id) in history
*/
const scrollToNearestMessage = () => {
const index = messagesWithTail().findIndex(
(entry) => entry.t === 0 && entry.message.id === messageId,
); // use localeCompare
listRef!.children[index + (atStart() ? 1 : 0)].scrollIntoView({
behavior: "smooth",
block: "center",
});
};
if (messages().find((message) => message.id === messageId)) {
scrollToNearestMessage();
return;
}
// Pre-empty any fetches
preempt();
setFetching("jump_msg");
// Handle incoming pre-emptions
const preempted = newPreempted();
try {
// Fetch messages for channel
const { messages } = await props.channel.fetchMessagesWithUsers({
limit: FETCH_LIMIT,
nearby: messageId,
});
// Cancel if we've been pre-empted
if (preempted()) return;
// Assume we are somewhere in history
// NB. we could be clever here, but best not to be
setStart(false);
setEnd(false);
// Replace the messages
setMessagesSafely(messages);
setTimeout(() => {
// Scroll to the message
scrollToNearestMessage();
// Mark as fetching has ended
setTimeout(() => {
setFetching();
}, 300 /* probably long enough for scroll to finish */);
});
} catch {
// Keep track of any failures (and allow retry / other actions)
setFailure(true);
setFetching();
}
}
// Setup references if they exists
onMount(() => {
props.jumpToBottomRef?.(jumpToBottom);
props.atEndRef?.(atEnd);
});
/**
* Fetch messages on channel mount
*/
createEffect(
on(
() => props.channel,
(channel) => {
caseInitialLoad(props.highlightedMessageId());
// move state into cache when navigating away
onCleanup(() => {
if (fetching() !== "initial") {
cache!.manage(channel, {
messages: messages(),
atStart: atStart(),
atEnd: atEnd(),
scrollTop: listRef?.scrollTop,
});
}
});
},
),
);
/**
* Jump to highlighted message
*/
createEffect(
on(
() => props.highlightedMessageId(),
(messageId) => {
// Jump only if messages are loaded
if (messageId && messages()) {
caseJumpToMessage(messageId);
}
},
),
);
/**
* Handle incoming messages
* @param message Message object
*/
function onMessage(message: MessageInterface) {
if (message.channelId === props.channel.id && atEnd()) {
setMessages([message, ...messages()]);
}
}
/**
* Handle deleted messages
*/
function onMessageDelete(message: { id: string; channelId: string }) {
if (
message.channelId === props.channel.id &&
messages().find((msg) => msg.id === message.id)
) {
setMessages((messages) =>
messages.filter((msg) => msg.id !== message.id),
);
}
}
// Add listener for messages
onMount(() => {
const c = client();
c.addListener("messageCreate", onMessage);
c.addListener("messageDelete", onMessageDelete);
});
onCleanup(() => {
const c = client();
c.removeListener("messageCreate", onMessage);
c.removeListener("messageDelete", onMessageDelete);
});
// Ensure that we reload when lifecycle state changes
createEffect(
on(
() => lifecycle.lifecycle.state(),
(state) => {
if (
state === State.Connected &&
atEnd() &&
!props.highlightedMessageId
) {
caseInitialLoad();
}
},
{ defer: true },
),
);
// We need to cache created objects to prevent needless re-rendering
const objectCache = new Map();
// Determine which messages have a tail and add message dividers
const messagesWithTail = createMemo<ListEntry[]>(() => {
const messagesWithTail: ListEntry[] = [];
const lastReadId = props.lastReadId() ?? "0";
let blockedMessages = 0;
let insertedUnreadDivider = false;
/**
* Create blocked message divider
*/
const createBlockedMessageCount = () => {
if (blockedMessages) {
messagesWithTail.push({
t: 2,
count: blockedMessages,
});
blockedMessages = 0;
}
};
const arr = messages();
arr.forEach((message, index) => {
const next = arr[index + 1];
let tail = true;
// If there is a next message, compare it to the current message
let date = null;
if (next) {
// Compare dates between messages
const adate = message.createdAt,
bdate = next.createdAt,
atime = +adate,
btime = +bdate;
if (
adate.getFullYear() !== bdate.getFullYear() ||
adate.getMonth() !== bdate.getMonth() ||
adate.getDate() !== bdate.getDate()
) {
date = adate;
}
// Compare time and properties of messages
if (
// split up different authors
message.authorId !== next.authorId ||
// split up chains which are too far apart
Math.abs(btime - atime) >= 420000 ||
// treat masquerade as a change in author
!isEqual(message.masquerade, next.masquerade) ||
// ensure all system messages render independently
message.systemMessage ||
next.systemMessage ||
// replies present on current message
message.replyIds?.length ||
// next message in history has already been read
// so there will be a message divider present
(next.id.localeCompare(lastReadId) === -1 && !insertedUnreadDivider)
) {
tail = false;
}
} else {
tail = false;
}
// Try to add the unread divider
if (
!insertedUnreadDivider &&
message.id.localeCompare(lastReadId) === -1
) {
insertedUnreadDivider = true;
messagesWithTail.push(
objectCache.get(true) ?? {
t: 1,
unread: true,
},
);
}
if (message.author?.relationship === "Blocked") {
blockedMessages++;
} else {
// Push any blocked messages if they haven't been yet
createBlockedMessageCount();
// Add message to list, retrieve if it exists in the cache
messagesWithTail.push(
objectCache.get(`${message.id}:${tail}`) ?? {
t: 0,
message,
tail,
},
);
}
// Add date to list, retrieve if it exists in the cache
if (date) {
messagesWithTail.push(
objectCache.get(date) ?? {
t: 1,
date: dayjs(date).format("LL"),
},
);
}
});
// Push remainder of blocked messages
createBlockedMessageCount();
// Strip unread divider if it is the first item
// (hence would show alone at the bottom of messages)
if (messagesWithTail[0]?.t === 1) {
messagesWithTail.shift();
}
// Flush cache
objectCache.clear();
// Populate cache with current objects
for (const object of messagesWithTail) {
if (object.t === 0) {
objectCache.set(`${object.message.id}:${object.tail}`, object);
} else if (object.t === 1) {
objectCache.set(object.unread ?? object.date, object);
}
}
return messagesWithTail.reverse();
});
/**
* Jump to the bottom of the chat
*/
function jumpToBottom() {
caseJumpToBottom();
if (props.highlightedMessageId()) {
props.clearHighlightedMessage();
}
}
/**
* Select last message for editing if signal is true
*/
createEffect(
on(
() => state.draft.editingMessageId,
(shouldSetEditingMessageId) =>
shouldSetEditingMessageId === true &&
state.draft.setEditingMessage(
messages().find((message) => message.author?.self),
),
),
);
/**
* Check whether to trail the currently pending messages
* @returns Whether to trail pending message
*/
function pendingMessageIsTrailing() {
const messages = messagesWithTail();
const lastMessage = messages[messages.length - 1];
return lastMessage &&
lastMessage.t === 0 &&
// check if last message is authored by us
lastMessage.message.author?.self &&
// split up chains that are too far apart
Math.abs(+new Date() - +lastMessage.message.createdAt) < 420000
? true
: false;
}
/**
* Message ids
* @returns List of message ids
*/
function sentMessageIdempotency() {
return messages().map((msg) => msg.nonce!);
}
return (
<>
<ListView2
fetchTop={caseFetchUpwards}
fetchBottom={caseFetchDownwards}
atStart={atStart}
atEnd={atEnd}
permitFetching={() => typeof fetching() !== "string"}
>
<Deferred>
<div>
<div ref={listRef}>
<Show when={atStart()}>
<ConversationStart channel={props.channel} />
</Show>
{/* TODO: else show (loading icon) OR (load more) */}
<For each={messagesWithTail()}>
{(entry) => (
<Entry
{...entry}
highlightedMessageId={props.highlightedMessageId}
editingMessageId={
typeof state.draft.editingMessageId === "string"
? state.draft.editingMessageId
: undefined
}
/>
)}
</For>
{/* TODO: show (loading icon) OR (load more) */}
<Show when={atEnd()}>
{props.pendingMessages?.({
tail: pendingMessageIsTrailing(),
ids: sentMessageIdempotency(),
})}
{props.typingIndicator ?? <Padding />}
</Show>
</div>
</div>
</Deferred>
</ListView2>
<Show when={!atEnd()}>
<AnchorToEnd>
<JumpToBottom onClick={jumpToBottom} />
</AnchorToEnd>
</Show>
</>
);
}
/**
* Anchor to the end of the messages list
*/
const AnchorToEnd = styled("div", {
base: {
zIndex: 30,
position: "relative",
"& > div": {
width: "100%",
position: "absolute",
bottom: "var(--gap-md)",
},
},
});
/**
* Container padding
*/
const Padding = styled("div", {
base: {
height: "24px",
},
});
/**
* List entries
*/
type ListEntry =
| {
// Message
t: 0;
message: MessageInterface;
tail: boolean;
highlight: boolean;
}
| {
// Message Divider
t: 1;
date?: string;
unread?: boolean;
}
| {
// Blocked messages
t: 2;
count: number;
};
/**
* Render individual list entry
*/
function Entry(
props: ListEntry &
Pick<Props, "highlightedMessageId"> & { editingMessageId?: string },
) {
const [local, other] = splitProps(props, [
"t",
"highlightedMessageId",
"editingMessageId",
]);
return (
<Switch>
<Match when={local.t === 0}>
<Message
{...(other as ListEntry & { t: 0 })}
highlight={
(other as ListEntry & { t: 0 }).message.id ===
local.highlightedMessageId()
}
editing={
(other as ListEntry & { t: 0 }).message.id ===
local.editingMessageId
}
/>
</Match>
<Match when={local.t === 1}>
<MessageDivider {...(other as ListEntry & { t: 1 })} />
</Match>
<Match when={local.t === 2}>
<BlockedMessage count={(other as ListEntry & { t: 2 }).count} />
</Match>
</Switch>
);
}