implemented progressive message fetching on scroll

This commit is contained in:
2026-01-05 19:34:29 +01:00
parent 5bd0a015e4
commit 2f230a4465
2 changed files with 94 additions and 28 deletions

View File

@@ -1,8 +1,12 @@
import { apiFetch } from './client' import { apiFetch } from './client'
import type { Message } from '../types' import type { Message } from '../types'
export function fetchMessages(roomUuid: string) { export function fetchMessages(roomUuid: string, before?: string, limit: number = 30) {
return apiFetch<Message[]>(`/messages/${roomUuid}`) let url = `/messages/${roomUuid}?limit=${limit}`;
if (before) {
url += `&before=${before}`;
}
return apiFetch<Message[]>(url);
} }
export function sendMessage(roomUuid: string, content: string) { export function sendMessage(roomUuid: string, content: string) {

View File

@@ -9,7 +9,7 @@
</div> </div>
<div v-else class="chat-container"> <div v-else class="chat-container">
<div class="messages-container" ref="messageListRef"> <div class="messages-container" ref="messageListRef" @scroll="handleScroll">
<MessageList :messages="messages" /> <MessageList :messages="messages" />
</div> </div>
@@ -32,7 +32,6 @@ import { API_WS } from '../main.ts';
import MessageList from "./MessageList.vue"; import MessageList from "./MessageList.vue";
import MessageInput from "./MessageInput.vue"; import MessageInput from "./MessageInput.vue";
import InvitePeopleModal from './InvitePeopleModal.vue'; import InvitePeopleModal from './InvitePeopleModal.vue';
import WebSocket from '@tauri-apps/plugin-websocket'; import WebSocket from '@tauri-apps/plugin-websocket';
import { getAuthData } from "../authStore.ts"; import { getAuthData } from "../authStore.ts";
import { fetchRoomInfo } from "../api/rooms.ts"; import { fetchRoomInfo } from "../api/rooms.ts";
@@ -44,8 +43,10 @@ const messageInputRef = ref<InstanceType<typeof MessageInput> | null>(null);
const currentUser = ref<User | null>(null); const currentUser = ref<User | null>(null);
const currentRoom = ref<Room | null>(null); const currentRoom = ref<Room | null>(null);
// Pagination State
const isLoadingMore = ref(false);
const hasMore = ref(true); // Assume there are more until API returns empty
const showInviteModal = ref(false); const showInviteModal = ref(false);
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
const isOwner = computed(() => { const isOwner = computed(() => {
@@ -53,32 +54,18 @@ const isOwner = computed(() => {
return currentUser.value.uuid === currentRoom.value.owner_uuid; return currentUser.value.uuid === currentRoom.value.owner_uuid;
}); });
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
const active = document.activeElement?.tagName.toLowerCase();
const isTyping = active === 'input' || active === 'textarea';
if (!isTyping && messageInputRef.value) {
event.preventDefault();
messageInputRef.value.focus();
}
}
};
async function initializeRoom() { async function initializeRoom() {
if (socket) { if (socket) { await socket.disconnect(); socket = null; }
await socket.disconnect();
socket = null;
}
// messages.value = []; messages.value = [];
hasMore.value = true;
currentRoom.value = null; currentRoom.value = null;
if (props.uuid === 'none') return; if (props.uuid === 'none') return;
try { try {
// 5. Fetch Room Details and Messages in parallel
const [msgs, roomInfo, auth] = await Promise.all([ const [msgs, roomInfo, auth] = await Promise.all([
fetchMessages(props.uuid), fetchMessages(props.uuid, undefined, 40), // Load first 40
fetchRoomInfo(props.uuid), fetchRoomInfo(props.uuid),
getAuthData() getAuthData()
]); ]);
@@ -86,6 +73,7 @@ async function initializeRoom() {
messages.value = msgs; messages.value = msgs;
currentRoom.value = roomInfo; currentRoom.value = roomInfo;
currentUser.value = auth.user; currentUser.value = auth.user;
if (msgs.length < 40) hasMore.value = false;
await nextTick(); await nextTick();
scrollToBottom(); scrollToBottom();
@@ -99,19 +87,58 @@ async function initializeRoom() {
const data: Message = JSON.parse(msg.data); const data: Message = JSON.parse(msg.data);
if (!messages.value.some(m => m.uuid === data.uuid)) { if (!messages.value.some(m => m.uuid === data.uuid)) {
messages.value.push(data); messages.value.push(data);
nextTick().then(scrollToBottom); nextTick().then(scrollToBottomIfAtEnd);
} }
} }
}); });
} catch (err) { } catch (err) {
console.error("Room initialization failed:", err); console.error("Room initialization failed:", err);
} }
} }
async function onSend(content: string) { async function handleScroll() {
if (props.uuid === 'none') return; const el = messageListRef.value;
await sendMessage(props.uuid, content); if (!el) return;
// If user scrolls to the top, is not already loading, and there's more data
if (el.scrollTop < 50 && !isLoadingMore.value && hasMore.value) {
await loadMore();
}
}
async function loadMore() {
if (messages.value.length === 0) return;
isLoadingMore.value = true;
const oldestMsgUuid = messages.value[0].uuid;
try {
const olderMsgs = await fetchMessages(props.uuid, oldestMsgUuid, 30);
if (olderMsgs.length === 0) {
hasMore.value = false;
return;
}
// Capture height before adding messages to maintain scroll position
const el = messageListRef.value;
const previousScrollHeight = el?.scrollHeight || 0;
messages.value = [...olderMsgs, ...messages.value];
await nextTick();
// Restore scroll position so the view doesn't jump
if (el) {
el.scrollTop = el.scrollHeight - previousScrollHeight;
}
if (olderMsgs.length < 30) hasMore.value = false;
} catch (err) {
console.error("Failed to load more messages:", err);
} finally {
isLoadingMore.value = false;
}
} }
function scrollToBottom() { function scrollToBottom() {
@@ -120,6 +147,33 @@ function scrollToBottom() {
} }
} }
// Only scroll to bottom for new messages if the user is already near the bottom
function scrollToBottomIfAtEnd() {
const el = messageListRef.value;
if (!el) return;
const threshold = 150;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
if (isAtBottom) scrollToBottom();
}
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
const active = document.activeElement?.tagName.toLowerCase();
const isTyping = active === 'input' || active === 'textarea';
if (!isTyping && messageInputRef.value) {
event.preventDefault();
messageInputRef.value.focus();
}
}
};
async function onSend(content: string) {
if (props.uuid === 'none') return;
await sendMessage(props.uuid, content);
}
watch(() => props.uuid, () => { watch(() => props.uuid, () => {
initializeRoom(); initializeRoom();
}); });
@@ -148,6 +202,7 @@ onUnmounted(async () => {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1.5rem; padding: 1.5rem;
scroll-behavior: auto;
} }
.input-container { .input-container {
@@ -163,6 +218,13 @@ onUnmounted(async () => {
flex: 1; flex: 1;
} }
.loading-more {
text-align: center;
padding: 10px;
font-size: 0.8rem;
color: var(--muted);
}
.invite-btn { .invite-btn {
margin: 0; margin: 0;
padding: 18px; padding: 18px;