From e3dd6a3427df7b2b4d94864e8e382d3d0b581b43 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 16 Aug 2026 17:56:39 +0700 Subject: [PATCH] fix(messages): Discord-style order (oldest top, newest bottom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend returns messages DESC (newest first); the view previously rendered that directly, so the feed was inverted vs Discord (old at bottom, new at top) while the load-older control sat at the top — contradictory. - Reverse the display list so it reads oldest→newest top→bottom, like DC. - Load-older (cursor to lower created_at) prepends at the top; scroll position is preserved by offsetting scrollTop by the height added above. - Open at the bottom (newest visible) on first load / scope change. - New live messages append at the bottom and auto-scroll only when the user is already near the bottom (nearBottomRef), so reading history isn't disrupted. - Scroll container now tracked via ref; onScroll updates nearBottom + triggers load-older when scrolled to the top. tsc, biome, next build all clean. --- .../src/app/(dashboard)/messages/view.tsx | 137 ++++++++++++------ 1 file changed, 95 insertions(+), 42 deletions(-) diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index e7d5e45..ecf374d 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -10,7 +10,7 @@ import { Search, ShieldAlert, } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAmbient } from "@/components/ambient/ambient-context"; import { Avatar, @@ -89,7 +89,10 @@ export function MessagesView({ error, } = useMessages(guildId ?? "", channelId ?? undefined); // Cursor to the next (older) page + whether more history exists. - const { data: pageInfo } = useMessagesHasMore(guildId ?? "", channelId ?? undefined); + const { data: pageInfo } = useMessagesHasMore( + guildId ?? "", + channelId ?? undefined, + ); const nextCursor = pageInfo?.cursor ?? null; const hasMore = pageInfo?.hasMore ?? false; const loadMore = useLoadMore(); @@ -99,14 +102,23 @@ export function MessagesView({ const ambient = useAmbient(); // Fetch the next (older) page via cursor and bump the loaded-page counter. + // Older messages prepend at the top, so preserve the viewport by offsetting + // scrollTop by the height added above (Discord keeps your place while loading). const loadOlder = useCallback(async () => { if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return; + const el = scrollRef.current; + const prevHeight = el ? el.scrollHeight : 0; await loadMore.mutateAsync({ guildId, channelId: channelId ?? undefined, cursor: nextCursor ?? "", }); setLoadedPages((n) => n + 1); + if (el) { + requestAnimationFrame(() => { + el.scrollTop = el.scrollTop + (el.scrollHeight - prevHeight); + }); + } }, [guildId, channelId, hasMore, loadedPages, nextCursor, loadMore]); useEffect(() => { @@ -115,6 +127,40 @@ export function MessagesView({ const searching = query.trim().length >= 2; const list = searching ? (search.data ?? []) : (messages ?? []); + // Discord-style order: oldest at the top, newest at the bottom. The backend + // returns DESC (newest first); reverse so the feed reads top→bottom like DC. + const display = useMemo(() => [...list].reverse(), [list]); + + // Ref to the scroll container so we can manage scroll position like Discord: + // open at the bottom (newest), keep the viewport stable when prepending older + // messages at the top, and follow new live messages only when already near + // the bottom. + const scrollRef = useRef(null); + const nearBottomRef = useRef(true); + + // Scroll to the bottom on the first load / when switching guild-channel, so + // the newest messages are visible (Discord behaviour). + const firstLoadRef = useRef(true); + useEffect(() => { + if (firstLoadRef.current && display.length > 0) { + firstLoadRef.current = false; + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + } + }, [display.length]); + + // When a new live message lands (list grows, still searching off), follow it + // to the bottom only if the user was already near the bottom. + const prevLen = useRef(list.length); + useEffect(() => { + if (searching) return; + const el = scrollRef.current; + if (!el) return; + if (list.length > prevLen.current && nearBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + prevLen.current = list.length; + }, [list.length, searching]); return (
@@ -129,6 +175,7 @@ export function MessagesView({ setChannelId(c); setSelected(null); setLoadedPages(0); + firstLoadRef.current = true; }} />
@@ -182,7 +229,8 @@ export function MessagesView({ ) : loadedPages >= MAX_OLDER_PAGES ? ( - capped at {MAX_OLDER_PAGES} older pages · use search for more + capped at {MAX_OLDER_PAGES} older pages · use search for + more ) : ( messages && @@ -195,56 +243,61 @@ export function MessagesView({
)}
{ - // Auto-load older messages when the user scrolls to the top. + const el = e.currentTarget; + // Track whether the user is near the bottom (to follow live + // messages) and auto-load older messages when scrolled to top. + nearBottomRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 120; if (searching || !hasMore || loadMore.isPending) return; if (loadedPages >= MAX_OLDER_PAGES) return; - if (e.currentTarget.scrollTop <= 8) { + if (el.scrollTop <= 8) { loadOlder(); } }} > - {list.map((m) => ( -
- - - ))} + + + ))} +
- )}