From 08dfc4e4003aeb05b5f9ecf6146be170e36d92e3 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 2 Jul 2026 09:28:28 -0400 Subject: [PATCH] feat: scroll-to-top loads older messages Both ChatArea and DMChat now detect when you scroll near the top and fetch the next 50 older messages. Scroll position is preserved so you don't lose your place. Works for both server channels and DM conversations. --- web/src/components/ChatArea.tsx | 22 +++++++++++++++-- web/src/components/DMChat.tsx | 21 +++++++++++++++-- web/src/stores/conversation.ts | 42 +++++++++++++++++++++++++++++++-- web/src/stores/message.ts | 42 +++++++++++++++++++++++++++++++-- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 18134ff..119bb6c 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import { useMessageStore } from "../stores/message.ts"; import { useChannelStore } from "../stores/channel.ts"; import { useServerStore } from "../stores/server.ts"; @@ -115,7 +115,9 @@ export function ChatArea() { activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [], ); const isLoading = useMessageStore((s) => s.isLoading); + const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder); const fetchMessages = useMessageStore((s) => s.fetchMessages); + const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages); const sendMessage = useMessageStore((s) => s.sendMessage); const membersByServer = useMemberStore((s) => s.membersByServer); const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []); @@ -130,6 +132,7 @@ export function ChatArea() { const [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null); const [profileUserId, setProfileUserId] = useState(null); const bottomRef = useRef(null); + const scrollContainerRef = useRef(null); const inputRef = useRef(null); const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage); @@ -161,6 +164,20 @@ export function ChatArea() { } }, [activeChannelId, messages.length, isLoading]); + const handleScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el || !activeChannelId || isLoadingOlder) return; + if (el.scrollTop < 100) { + const prevHeight = el.scrollHeight; + fetchOlderMessages(activeChannelId).then(() => { + // ponytail: maintain scroll position after prepending older messages + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight - prevHeight; + }); + }); + } + }, [activeChannelId, isLoadingOlder, fetchOlderMessages]); + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, [messages]); @@ -352,7 +369,8 @@ export function ChatArea() { ) : ( <>
-
+
+ {isLoadingOlder &&

[loading older messages...]

} {isLoading &&

[loading...]

} {!isLoading && messages.length === 0 && (

[no messages in this channel]

diff --git a/web/src/components/DMChat.tsx b/web/src/components/DMChat.tsx index 441ad8d..6588974 100644 --- a/web/src/components/DMChat.tsx +++ b/web/src/components/DMChat.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import { useParams } from "react-router-dom"; import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts"; import { useAuthStore } from "../stores/auth.ts"; @@ -36,15 +36,31 @@ export function DMChat() { const conversations = useConversationStore((s) => s.conversations); const messagesByConv = useConversationStore((s) => s.messagesByConversation); const fetchMessages = useConversationStore((s) => s.fetchMessages); + const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages); + const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder); const sendMessage = useConversationStore((s) => s.sendMessage); const fetchConversations = useConversationStore((s) => s.fetchConversations); const currentUser = useAuthStore((s) => s.user); const [input, setInput] = useState(""); const [error, setError] = useState(null); const bottomRef = useRef(null); + const scrollContainerRef = useRef(null); const id = conversationId || activeId; const conversation = conversations.find((c) => c.id === id); + + const handleScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el || !id || isLoadingOlder) return; + if (el.scrollTop < 100) { + const prevHeight = el.scrollHeight; + fetchOlderMessages(id).then(() => { + requestAnimationFrame(() => { + el.scrollTop = el.scrollHeight - prevHeight; + }); + }); + } + }, [id, isLoadingOlder, fetchOlderMessages]); const messages = id ? messagesByConv[id] || [] : []; useEffect(() => { @@ -91,7 +107,8 @@ export function DMChat() {
@ {title}
-
+
+ {isLoadingOlder &&

[loading older messages...]

} {!id &&

[select a conversation]

} {id && messages.length === 0 &&

[no messages]

} {messages.map((msg: ConversationMessage) => ( diff --git a/web/src/stores/conversation.ts b/web/src/stores/conversation.ts index b1b4825..e9eb43b 100644 --- a/web/src/stores/conversation.ts +++ b/web/src/stores/conversation.ts @@ -32,20 +32,25 @@ interface ConversationState { activeConversationId: string | null; messagesByConversation: Record; isLoading: boolean; + isLoadingOlder: boolean; + hasMoreByConversation: Record; error: string | null; fetchConversations: () => Promise; createConversation: (userIds: string[]) => Promise; setActiveConversation: (id: string | null) => void; fetchMessages: (conversationId: string, before?: string) => Promise; + fetchOlderMessages: (conversationId: string) => Promise; sendMessage: (conversationId: string, content: string) => Promise; addMessage: (message: ConversationMessage) => void; } -export const useConversationStore = create((set, _get) => ({ +export const useConversationStore = create((set, get) => ({ conversations: [], activeConversationId: null, messagesByConversation: {}, isLoading: false, + isLoadingOlder: false, + hasMoreByConversation: {}, error: null, fetchConversations: async () => { @@ -77,10 +82,15 @@ export const useConversationStore = create((set, _get) => ({ const messages = await api.get( `/conversations/${conversationId}/messages${params}`, ); + const list = Array.isArray(messages) ? messages : []; set((state) => ({ messagesByConversation: { ...state.messagesByConversation, - [conversationId]: Array.isArray(messages) ? messages : [], + [conversationId]: list, + }, + hasMoreByConversation: { + ...state.hasMoreByConversation, + [conversationId]: list.length >= 50, }, isLoading: false, })); @@ -89,6 +99,34 @@ export const useConversationStore = create((set, _get) => ({ } }, + fetchOlderMessages: async (conversationId) => { + const state = get(); + if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return; + const existing = state.messagesByConversation[conversationId] || []; + if (existing.length === 0) return; + const oldestId = existing[0].id; + set({ isLoadingOlder: true }); + try { + const older = await api.get( + `/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`, + ); + const list = Array.isArray(older) ? older : []; + set((state) => ({ + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: [...list, ...existing], + }, + hasMoreByConversation: { + ...state.hasMoreByConversation, + [conversationId]: list.length >= 50, + }, + isLoadingOlder: false, + })); + } catch { + set({ isLoadingOlder: false }); + } + }, + sendMessage: async (conversationId, content) => { const message = await api.post( `/conversations/${conversationId}/messages`, diff --git a/web/src/stores/message.ts b/web/src/stores/message.ts index 6d0d8f5..6cdf51c 100644 --- a/web/src/stores/message.ts +++ b/web/src/stores/message.ts @@ -32,8 +32,11 @@ export interface MessageState { searchResultsByChannel: Record; selectedMessageIds: Record>; // ponytail: per-channel bulk selection isLoading: boolean; + isLoadingOlder: boolean; + hasMoreByChannel: Record; error: string | null; fetchMessages: (channelId: string, before?: string) => Promise; + fetchOlderMessages: (channelId: string) => Promise; sendMessage: (channelId: string, content: string, replyTo?: string) => Promise; searchMessages: (channelId: string, query: string) => Promise; addMessage: (message: Message) => void; @@ -44,11 +47,13 @@ export interface MessageState { clearSelectedMessages: (channelId: string) => void; } -export const useMessageStore = create((set) => ({ +export const useMessageStore = create((set, get) => ({ messagesByChannel: {}, searchResultsByChannel: {}, selectedMessageIds: {}, isLoading: false, + isLoadingOlder: false, + hasMoreByChannel: {}, error: null, fetchMessages: async (channelId, before) => { @@ -58,10 +63,15 @@ export const useMessageStore = create((set) => ({ const messages = await api.get( `/channels/${channelId}/messages${params}`, ); + const list = Array.isArray(messages) ? messages : []; set((state) => ({ messagesByChannel: { ...state.messagesByChannel, - [channelId]: Array.isArray(messages) ? messages : [], + [channelId]: list, + }, + hasMoreByChannel: { + ...state.hasMoreByChannel, + [channelId]: list.length >= 50, }, isLoading: false, })); @@ -76,6 +86,34 @@ export const useMessageStore = create((set) => ({ } }, + fetchOlderMessages: async (channelId) => { + const state = get(); + if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return; + const existing = state.messagesByChannel[channelId] || []; + if (existing.length === 0) return; + const oldestId = existing[0].id; + set({ isLoadingOlder: true }); + try { + const older = await api.get( + `/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`, + ); + const list = Array.isArray(older) ? older : []; + set((state) => ({ + messagesByChannel: { + ...state.messagesByChannel, + [channelId]: [...list, ...existing], + }, + hasMoreByChannel: { + ...state.hasMoreByChannel, + [channelId]: list.length >= 50, + }, + isLoadingOlder: false, + })); + } catch { + set({ isLoadingOlder: false }); + } + }, + searchMessages: async (channelId, query) => { const results = await api.get( `/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,