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.
This commit is contained in:
@@ -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 { useMessageStore } from "../stores/message.ts";
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
import { useChannelStore } from "../stores/channel.ts";
|
||||||
import { useServerStore } from "../stores/server.ts";
|
import { useServerStore } from "../stores/server.ts";
|
||||||
@@ -115,7 +115,9 @@ export function ChatArea() {
|
|||||||
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
|
activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [],
|
||||||
);
|
);
|
||||||
const isLoading = useMessageStore((s) => s.isLoading);
|
const isLoading = useMessageStore((s) => s.isLoading);
|
||||||
|
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
||||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||||
|
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
||||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
const threads = useThreadStore((s) => activeChannelId ? s.threadsByParent[activeChannelId] || [] : []);
|
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 [activeThread, setActiveThread] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [profileUserId, setProfileUserId] = useState<string | null>(null);
|
const [profileUserId, setProfileUserId] = useState<string | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
|
const toggleSelectedMessage = useMessageStore((s) => s.toggleSelectedMessage);
|
||||||
@@ -161,6 +164,20 @@ export function ChatArea() {
|
|||||||
}
|
}
|
||||||
}, [activeChannelId, messages.length, isLoading]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
@@ -352,7 +369,8 @@ export function ChatArea() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||||
|
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
|
||||||
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||||
{!isLoading && messages.length === 0 && (
|
{!isLoading && messages.length === 0 && (
|
||||||
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
||||||
import { useAuthStore } from "../stores/auth.ts";
|
import { useAuthStore } from "../stores/auth.ts";
|
||||||
@@ -36,15 +36,31 @@ export function DMChat() {
|
|||||||
const conversations = useConversationStore((s) => s.conversations);
|
const conversations = useConversationStore((s) => s.conversations);
|
||||||
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
|
||||||
const fetchMessages = useConversationStore((s) => s.fetchMessages);
|
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 sendMessage = useConversationStore((s) => s.sendMessage);
|
||||||
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
const fetchConversations = useConversationStore((s) => s.fetchConversations);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const id = conversationId || activeId;
|
const id = conversationId || activeId;
|
||||||
const conversation = conversations.find((c) => c.id === id);
|
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] || [] : [];
|
const messages = id ? messagesByConv[id] || [] : [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,7 +107,8 @@ export function DMChat() {
|
|||||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
|
||||||
@ {title}
|
@ {title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
||||||
|
{isLoadingOlder && <p className="text-center text-gb-fg-f text-xs">[loading older messages...]</p>}
|
||||||
{!id && <p className="text-gb-fg-f">[select a conversation]</p>}
|
{!id && <p className="text-gb-fg-f">[select a conversation]</p>}
|
||||||
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
|
{id && messages.length === 0 && <p className="text-gb-fg-f">[no messages]</p>}
|
||||||
{messages.map((msg: ConversationMessage) => (
|
{messages.map((msg: ConversationMessage) => (
|
||||||
|
|||||||
@@ -32,20 +32,25 @@ interface ConversationState {
|
|||||||
activeConversationId: string | null;
|
activeConversationId: string | null;
|
||||||
messagesByConversation: Record<string, ConversationMessage[]>;
|
messagesByConversation: Record<string, ConversationMessage[]>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isLoadingOlder: boolean;
|
||||||
|
hasMoreByConversation: Record<string, boolean>;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetchConversations: () => Promise<void>;
|
fetchConversations: () => Promise<void>;
|
||||||
createConversation: (userIds: string[]) => Promise<Conversation>;
|
createConversation: (userIds: string[]) => Promise<Conversation>;
|
||||||
setActiveConversation: (id: string | null) => void;
|
setActiveConversation: (id: string | null) => void;
|
||||||
fetchMessages: (conversationId: string, before?: string) => Promise<void>;
|
fetchMessages: (conversationId: string, before?: string) => Promise<void>;
|
||||||
|
fetchOlderMessages: (conversationId: string) => Promise<void>;
|
||||||
sendMessage: (conversationId: string, content: string) => Promise<ConversationMessage>;
|
sendMessage: (conversationId: string, content: string) => Promise<ConversationMessage>;
|
||||||
addMessage: (message: ConversationMessage) => void;
|
addMessage: (message: ConversationMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useConversationStore = create<ConversationState>((set, _get) => ({
|
export const useConversationStore = create<ConversationState>((set, get) => ({
|
||||||
conversations: [],
|
conversations: [],
|
||||||
activeConversationId: null,
|
activeConversationId: null,
|
||||||
messagesByConversation: {},
|
messagesByConversation: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isLoadingOlder: false,
|
||||||
|
hasMoreByConversation: {},
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchConversations: async () => {
|
fetchConversations: async () => {
|
||||||
@@ -77,10 +82,15 @@ export const useConversationStore = create<ConversationState>((set, _get) => ({
|
|||||||
const messages = await api.get<ConversationMessage[]>(
|
const messages = await api.get<ConversationMessage[]>(
|
||||||
`/conversations/${conversationId}/messages${params}`,
|
`/conversations/${conversationId}/messages${params}`,
|
||||||
);
|
);
|
||||||
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[conversationId]: Array.isArray(messages) ? messages : [],
|
[conversationId]: list,
|
||||||
|
},
|
||||||
|
hasMoreByConversation: {
|
||||||
|
...state.hasMoreByConversation,
|
||||||
|
[conversationId]: list.length >= 50,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
@@ -89,6 +99,34 @@ export const useConversationStore = create<ConversationState>((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<ConversationMessage[]>(
|
||||||
|
`/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) => {
|
sendMessage: async (conversationId, content) => {
|
||||||
const message = await api.post<ConversationMessage>(
|
const message = await api.post<ConversationMessage>(
|
||||||
`/conversations/${conversationId}/messages`,
|
`/conversations/${conversationId}/messages`,
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ export interface MessageState {
|
|||||||
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
||||||
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isLoadingOlder: boolean;
|
||||||
|
hasMoreByChannel: Record<string, boolean>;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
||||||
|
fetchOlderMessages: (channelId: string) => Promise<void>;
|
||||||
sendMessage: (channelId: string, content: string, replyTo?: string) => Promise<Message>;
|
sendMessage: (channelId: string, content: string, replyTo?: string) => Promise<Message>;
|
||||||
searchMessages: (channelId: string, query: string) => Promise<SearchResultMessage[]>;
|
searchMessages: (channelId: string, query: string) => Promise<SearchResultMessage[]>;
|
||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
@@ -44,11 +47,13 @@ export interface MessageState {
|
|||||||
clearSelectedMessages: (channelId: string) => void;
|
clearSelectedMessages: (channelId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMessageStore = create<MessageState>((set) => ({
|
export const useMessageStore = create<MessageState>((set, get) => ({
|
||||||
messagesByChannel: {},
|
messagesByChannel: {},
|
||||||
searchResultsByChannel: {},
|
searchResultsByChannel: {},
|
||||||
selectedMessageIds: {},
|
selectedMessageIds: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isLoadingOlder: false,
|
||||||
|
hasMoreByChannel: {},
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchMessages: async (channelId, before) => {
|
fetchMessages: async (channelId, before) => {
|
||||||
@@ -58,10 +63,15 @@ export const useMessageStore = create<MessageState>((set) => ({
|
|||||||
const messages = await api.get<Message[]>(
|
const messages = await api.get<Message[]>(
|
||||||
`/channels/${channelId}/messages${params}`,
|
`/channels/${channelId}/messages${params}`,
|
||||||
);
|
);
|
||||||
|
const list = Array.isArray(messages) ? messages : [];
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messagesByChannel: {
|
messagesByChannel: {
|
||||||
...state.messagesByChannel,
|
...state.messagesByChannel,
|
||||||
[channelId]: Array.isArray(messages) ? messages : [],
|
[channelId]: list,
|
||||||
|
},
|
||||||
|
hasMoreByChannel: {
|
||||||
|
...state.hasMoreByChannel,
|
||||||
|
[channelId]: list.length >= 50,
|
||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
@@ -76,6 +86,34 @@ export const useMessageStore = create<MessageState>((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<Message[]>(
|
||||||
|
`/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) => {
|
searchMessages: async (channelId, query) => {
|
||||||
const results = await api.get<SearchResultMessage[]>(
|
const results = await api.get<SearchResultMessage[]>(
|
||||||
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,
|
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user