From d6a5372175166a3d82d2ebe50b8f362535789815 Mon Sep 17 00:00:00 2001 From: Supertiger Date: Wed, 4 Jan 2023 13:07:05 +0000 Subject: [PATCH] feat: :sparkles: Finally finished with scroll to load more... I think --- public/assets/spinner.svg | 60 +++++ src/chat-api/services/MessageService.ts | 8 +- src/chat-api/store/useMessages.ts | 40 +++- src/components/Markup.scss | 1 + src/components/message-pane/MessagePane.tsx | 207 ++++++++++++++++-- .../message-pane/styles.module.scss | 30 +++ src/components/ui/Spinner.tsx | 4 + 7 files changed, 320 insertions(+), 30 deletions(-) create mode 100644 public/assets/spinner.svg create mode 100644 src/components/ui/Spinner.tsx diff --git a/public/assets/spinner.svg b/public/assets/spinner.svg new file mode 100644 index 00000000..fb6a49a9 --- /dev/null +++ b/public/assets/spinner.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/chat-api/services/MessageService.ts b/src/chat-api/services/MessageService.ts index 7697d023..51c28f74 100644 --- a/src/chat-api/services/MessageService.ts +++ b/src/chat-api/services/MessageService.ts @@ -6,11 +6,15 @@ import Endpoints from './ServiceEndpoints'; -export const fetchMessages = async (channelId: string, limit = 50, afterMessageId?: string) => { +export const fetchMessages = async (channelId: string, limit = 50, afterMessageId?: string, beforeMessageId?: string) => { const data = await request({ method: 'GET', url: env.SERVER_URL + "/api" + Endpoints.messages(channelId), - params: {limit, ...(afterMessageId ? {after: afterMessageId}: undefined)}, + params: { + limit, + ...(afterMessageId ? {after: afterMessageId}: undefined), + ...(beforeMessageId ? {before: beforeMessageId}: undefined) + }, useToken: true }); return data; diff --git a/src/chat-api/store/useMessages.ts b/src/chat-api/store/useMessages.ts index 6f659e78..09e4f3d8 100644 --- a/src/chat-api/store/useMessages.ts +++ b/src/chat-api/store/useMessages.ts @@ -20,8 +20,8 @@ export type Message = RawMessage & { } const [messages, setMessages] = createStore>({}); -const fetchAndStoreMessages = async (channelId: string) => { - if (getMessagesByChannelId(channelId)) return; +const fetchAndStoreMessages = async (channelId: string, force = false) => { + if (!force && getMessagesByChannelId(channelId)) return; const channelProperties = useChannelProperties(); channelProperties.setMoreTopToLoad(channelId, true); @@ -33,7 +33,7 @@ const fetchAndStoreMessages = async (channelId: string) => { }); } -const loadMoreAndStoreMessages = async (channelId: string, beforeSet: () => void, afterSet: (data: {hasMore: boolean}) => void) => { +const loadMoreTopAndStoreMessages = async (channelId: string, beforeSet: () => void, afterSet: (data: {hasMore: boolean}) => void) => { const channelMessages = messages[channelId]!; const newMessages = await fetchMessages(channelId, env.MESSAGE_LIMIT, channelMessages[0].id); const clamp = sliceEnd([...newMessages, ...channelMessages]); @@ -46,12 +46,25 @@ const loadMoreAndStoreMessages = async (channelId: string, beforeSet: () => void afterSet({ hasMore }); } +const loadMoreBottomAndStoreMessages = async (channelId: string, beforeSet: () => void, afterSet: (data: {hasMore: boolean}) => void) => { + const channelMessages = messages[channelId]!; + const newMessages = await fetchMessages(channelId, env.MESSAGE_LIMIT, undefined, channelMessages[channelMessages.length - 1].id); + const clamp = sliceBeginning([...channelMessages, ...newMessages]); + const hasMore = newMessages.length === env.MESSAGE_LIMIT + + beforeSet(); + setMessages({ + [channelId]: clamp + }); + afterSet({ hasMore }); +} + function sliceEnd(arr: any[]) { - return arr.slice(0, env.MESSAGE_LIMIT * 3); + return arr.slice(0, env.MESSAGE_LIMIT * 4); } function sliceBeginning(arr: any[]) { - return arr.slice(-(env.MESSAGE_LIMIT * 3), arr.length); + return arr.slice(-(env.MESSAGE_LIMIT * 4), arr.length); } @@ -84,6 +97,8 @@ const updateLocalMessage = async (message: Partial { const channels = useChannels(); + const channelProperties = useChannelProperties(); + const properties = channelProperties.get(channelId); const tempMessageId = `${Date.now()}-${Math.random()}`; const channel = channels.get(channelId); @@ -107,7 +122,7 @@ const sendAndStoreMessage = async (channelId: string, content: string) => { }, }; - setMessages({ + !properties?.moreBottomToLoad && setMessages({ [channelId]: sliceBeginning([...messages[channelId]!, localMessage]) }) @@ -126,18 +141,22 @@ const sendAndStoreMessage = async (channelId: string, content: string) => { const index = messages[channelId]?.findIndex(m => m.tempId === tempMessageId); if (!message) { - setMessages(channelId, index!, 'sentStatus', MessageSentStatus.FAILED); + !properties?.moreBottomToLoad && setMessages(channelId, index!, 'sentStatus', MessageSentStatus.FAILED); return; } message.tempId = tempMessageId; - setMessages(channelId, index!, reconcile(message, {key: "tempId"})); + !properties?.moreBottomToLoad && setMessages(channelId, index!, reconcile(message, {key: "tempId"})); } const pushMessage = (channelId: string, message: Message) => { if (!messages[channelId]) return; - setMessages(channelId, messages[channelId]?.length!, message); + const channelProperties = useChannelProperties(); + const properties = channelProperties.get(channelId); + !properties?.moreBottomToLoad && setMessages({ + [channelId]: sliceBeginning([...messages[channelId]!, message]) + }); }; const locallyRemoveMessage = (channelId: string, messageId: string) => { @@ -159,7 +178,8 @@ export default function useMessages() { return { getMessagesByChannelId, fetchAndStoreMessages, - loadMoreAndStoreMessages, + loadMoreTopAndStoreMessages, + loadMoreBottomAndStoreMessages, editAndStoreMessage, sendAndStoreMessage, locallyRemoveMessage, diff --git a/src/components/Markup.scss b/src/components/Markup.scss index e3b17457..17095fdf 100644 --- a/src/components/Markup.scss +++ b/src/components/Markup.scss @@ -44,6 +44,7 @@ &.no-wrap { .content { word-break: keep-all; + white-space: pre; overflow: auto; } } diff --git a/src/components/message-pane/MessagePane.tsx b/src/components/message-pane/MessagePane.tsx index bb70c75e..5c08fdce 100644 --- a/src/components/message-pane/MessagePane.tsx +++ b/src/components/message-pane/MessagePane.tsx @@ -1,5 +1,5 @@ import styles from './styles.module.scss'; -import { createEffect, createMemo, createSignal, For, JSX, Match, on, onCleanup, onMount, Show, Switch} from 'solid-js'; +import { batch, createEffect, createMemo, createRenderEffect, createSignal, For, JSX, Match, on, onCleanup, onMount, Show, Switch} from 'solid-js'; import { createStore, reconcile } from 'solid-js/store'; import { useParams } from '@nerimity/solid-router'; import useStore from '../../chat-api/store/useStore'; @@ -14,7 +14,9 @@ import { postChannelTyping } from '@/chat-api/services/MessageService'; import { classNames } from '@/common/classNames'; import { emojiShortcodeToUnicode } from '@/emoji'; import { Rerun } from '@solid-primitives/keyed'; -import { Message } from '@/chat-api/store/useMessages'; +import Spinner from '../ui/Spinner'; +import env from '@/common/env'; +import Text from '../ui/Text'; export default function MessagePane(props: {mainPaneEl: HTMLDivElement}) { const params = useParams(); @@ -47,16 +49,21 @@ export default function MessagePane(props: {mainPaneEl: HTMLDivElement}) { ); } -const saveScrollPosition = (scrollElement: HTMLDivElement, logElement: HTMLDivElement) => { +const saveScrollPosition = (scrollElement: HTMLDivElement, logElement: HTMLDivElement, element: "first" | "last") => { + + let el = logElement?.querySelector(".messageItem") as HTMLDivElement; + + if (element === "last") { + el = logElement.lastElementChild as HTMLDivElement; + } - const firstMessageEl = logElement?.querySelector(".messageItem") as HTMLDivElement; let beforeTop: undefined | number; const save = () => { - beforeTop = firstMessageEl.getBoundingClientRect().top; + beforeTop = el.getBoundingClientRect().top; } const load = () => { - const afterTop = firstMessageEl.getBoundingClientRect().top; + const afterTop = el.getBoundingClientRect().top; const difference = afterTop - beforeTop!; scrollElement.scrollTop = scrollElement.scrollTop + difference; } @@ -64,29 +71,111 @@ const saveScrollPosition = (scrollElement: HTMLDivElement, logElement: HTMLDivEl } const MessageLogArea = (props: {mainPaneEl: HTMLDivElement}) => { + const params = useParams<{channelId: string}>(); + const {hasFocus} = useWindowProperties(); const {channels, messages, account, channelProperties} = useStore(); let messageLogElement: undefined | HTMLDivElement; - const params = useParams<{channelId: string}>(); const channelMessages = createMemo(() => messages.getMessagesByChannelId(params.channelId!)); let loadedTimestamp: number | undefined; + const [unreadMarker, setUnreadMarker] = createStore<{lastSeenAt: number | null, messageId: string | null}>({lastSeenAt: null, messageId: null}); + + const [loadingMessages, setLoadingMessages] = createStore({top: false, bottom: true}); + const scrollTracker = createScrollTracker(props.mainPaneEl); + + const channel = () => channels.get(params.channelId!)!; + const properties = () => channelProperties.get(params.channelId); - const onMessageCreated = (message: Message) => { - if (message.channelId !== params.channelId) return; - console.log(message); + const updateUnreadMarker = (ignoreFocus = false) => { + if (!ignoreFocus && hasFocus()) return; + const lastSeenAt = channel().lastSeen || -1; + const message = channelMessages()?.find(m => m.createdAt - lastSeenAt >= 0 ); + setUnreadMarker({ + lastSeenAt, + messageId: message?.id || null + }); + if (scrollTracker.scrolledBottom()) { + setTimeout(() => { + props.mainPaneEl.scrollTop = props.mainPaneEl.scrollHeight; + }); + } + } + + createEffect(on(() => channelMessages()?.length,(length, prevLength) => { + if (!length) return; + updateUnreadMarker(prevLength === undefined); + if (prevLength === undefined) return; + dismissNotification(); + })) + + createEffect(on(hasFocus, () => { + dismissNotification(); + }, {defer: true})) + + const dismissNotification = () =>{ + if (!hasFocus()) return; + if (!scrollTracker.scrolledBottom()) return; + channel()?.dismissNotification(); } + + const onMessageCreated = (message: RawMessage) => { + if (message.channelId !== params.channelId) return; + if (scrollTracker.scrolledBottom()) { + props.mainPaneEl.scrollTop = props.mainPaneEl.scrollHeight; + } + } + + createEffect(on(channelMessages, (messages, prevMessages) => { + if (prevMessages) return; + + const scrollPosition = () => { + if (properties()?.isScrolledBottom === undefined ) return props.mainPaneEl.scrollHeight; + if (properties()?.isScrolledBottom) return props.mainPaneEl.scrollHeight; + return properties()?.scrollTop!; + } + props.mainPaneEl.scrollTop = scrollPosition(); + scrollTracker.forceUpdate(); + + setTimeout(() => { + setLoadingMessages('bottom', false); + }, 100); + })) + + createEffect(on(scrollTracker.scrolledBottom, () => { + dismissNotification(); + channelProperties.setScrolledBottom(params.channelId, scrollTracker.scrolledBottom()); + })); + onMount(async () => { - await fetchMessages(); - props.mainPaneEl.scrollTop = props.mainPaneEl.scrollHeight; + let authenticated = false; + createEffect(on(account.isAuthenticated, async (isAuthenticated) => { + if (!isAuthenticated) return; + if (authenticated) return; + authenticated = true; + if (!channelMessages()) { + channelProperties.setMoreTopToLoad(params.channelId, true); + channelProperties.setMoreBottomToLoad(params.channelId, false); + } - socketClient.socket.on(ServerEvents.MESSAGE_CREATED, onMessageCreated) + await fetchMessages(); + + dismissNotification(); + })) + + const channelId = params.channelId; + socketClient.socket.on(ServerEvents.MESSAGE_CREATED, onMessageCreated); onCleanup(() => { - socketClient.socket.off(ServerEvents.MESSAGE_CREATED, onMessageCreated) - + scrollTracker.forceUpdate(); + batch(() => { + channelProperties.setScrolledBottom(channelId, scrollTracker.scrolledBottom()); + channelProperties.setScrollTop(channelId, scrollTracker.scrollTop()); + }) + socketClient.socket.off(ServerEvents.MESSAGE_CREATED, onMessageCreated); }) + }) const fetchMessages = async () => { @@ -95,11 +184,65 @@ const MessageLogArea = (props: {mainPaneEl: HTMLDivElement}) => { await messages.fetchAndStoreMessages(params.channelId); } + const areMessagesLoading = () => loadingMessages.top || loadingMessages.bottom; + + // Load more top when scrolled to the top + createEffect(on([scrollTracker.loadMoreTop, areMessagesLoading], ([loadMoreTop, alreadyLoading]) => { + if (!channelMessages()) return; + if (channelMessages()?.length! < env.MESSAGE_LIMIT) return; + if (!properties()?.moreTopToLoad) return; + if (alreadyLoading) return; + if (!loadMoreTop) return; + setLoadingMessages('top', true); + const {save, load} = saveScrollPosition(props.mainPaneEl!, messageLogElement!, "first"); + + const beforeSet = () => { + save(); + } + + const afterSet = ({hasMore}: {hasMore: boolean}) => { + load(); + channelProperties.setMoreBottomToLoad(params.channelId, true); + channelProperties.setMoreTopToLoad(params.channelId, hasMore); + scrollTracker.forceUpdate(); + setLoadingMessages('top', false); + } + messages.loadMoreTopAndStoreMessages(params.channelId, beforeSet, afterSet); + })) + + // Load more bottom when scrolled to the bottom + createEffect(on([scrollTracker.loadMoreBottom, areMessagesLoading], ([loadMoreBottom, alreadyLoading]) => { + if (!channelMessages()) return; + if (channelMessages()?.length! < env.MESSAGE_LIMIT) return; + if (!properties()?.moreBottomToLoad) return; + if (alreadyLoading) return; + if (!loadMoreBottom) return; + setLoadingMessages('bottom', true); + const {save, load} = saveScrollPosition(props.mainPaneEl!, messageLogElement!, "last"); + + const beforeSet = () => { + save(); + } + + const afterSet = ({hasMore}: {hasMore: boolean}) => { + load(); + channelProperties.setMoreTopToLoad(params.channelId, true); + channelProperties.setMoreBottomToLoad(params.channelId, hasMore); + scrollTracker.forceUpdate(); + setLoadingMessages('bottom', false); + } + messages.loadMoreBottomAndStoreMessages(params.channelId, beforeSet, afterSet); + })) + + return (
{(message, i) => ( <> + + + loadedTimestamp} message={message} @@ -118,7 +261,7 @@ function createScrollTracker(scrollElement: HTMLElement) { const [scrolledBottom, setScrolledBottom] = createSignal(true); const [scrollTop, setScrollTop] = createSignal(scrollElement.scrollTop); - const LOAD_MORE_LENGTH = 500; + const LOAD_MORE_LENGTH = 300; const SCROLLED_BOTTOM_LENGTH = 20; @@ -140,7 +283,7 @@ function createScrollTracker(scrollElement: HTMLElement) { scrollElement.addEventListener("scroll", onScroll, {passive: true}); onCleanup(() => scrollElement.removeEventListener("scroll", onScroll)); }) - return { loadMoreTop, loadMoreBottom, scrolledBottom, scrollTop, forceUpdateScroll: onScroll } + return { loadMoreTop, loadMoreBottom, scrolledBottom, scrollTop, forceUpdate: onScroll } } function MessageArea(props: {mainPaneEl: HTMLDivElement}) { @@ -182,6 +325,7 @@ function MessageArea(props: {mainPaneEl: HTMLDivElement}) { } if(event.key === "ArrowUp") { if (message().trim().length) return; + if (channelProperty()?.moreBottomToLoad) return; const msg = [...messages.get(params.channelId) || []].reverse()?.find(m => m.type === MessageType.CONTENT && m.createdBy.id === myId); if (msg) { channelProperties.setEditMessage(params.channelId, msg); @@ -210,7 +354,7 @@ function MessageArea(props: {mainPaneEl: HTMLDivElement}) { cancelEdit(); } else { messages.sendAndStoreMessage(channel.id, formattedMessage); - props.mainPaneEl!.scrollTop = props.mainPaneEl!.scrollHeight; + !channelProperty()?.moreBottomToLoad && (props.mainPaneEl!.scrollTop = props.mainPaneEl!.scrollHeight); } typingTimeoutId && clearTimeout(typingTimeoutId) typingTimeoutId = null; @@ -241,6 +385,7 @@ function MessageArea(props: {mainPaneEl: HTMLDivElement}) { +