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)}`,