feat: infinite scroll in messages, add loader in sidebar

This commit is contained in:
kushal-saha 2026-05-04 10:31:49 +00:00
parent fad7658ead
commit 98cef31ae2
9 changed files with 114 additions and 14 deletions

View File

@ -20,8 +20,8 @@ export class ChatService {
});
}
public getMessages(id: string) {
return this.http.get<MessageCollection>(`${this.apiUrl}/chats/${id}/messages`);
public getMessages(id: string, page: number = 1) {
return this.http.get<MessageCollection>(`${this.apiUrl}/chats/${id}/messages?page=${page}`);
}
public getChats() {

View File

@ -4,6 +4,13 @@
class="flex-1 overflow-y-auto p-8 flex flex-col gap-6 scroll-smooth custom-scrollbar"
#scrollContainer
>
<div appInfiniteScroll (scrolled)="loadMoreChats()">
@if(messageStore.isLoadingPaginated()){
<div class="w-full max-h-14 flex items-center justify-center">
<app-loader class="w-8" />
</div>
}
</div>
@if(messageStore.isLoading()) {
<div class="w-full h-full flex items-center justify-center">
<app-loader class="w-8" />
@ -15,7 +22,7 @@
>
<div
class="px-5 py-4 rounded-2xl text-sm leading-relaxed shadow-[0_4px_15px_rgba(0,0,0,0.1)] relative whitespace-pre-wrap"
[ngClass]="msg.attributes.role === 'user' ? 'bg-linear-to-br from-indigo-500 to-purple-600 text-white rounded-br-sm' : 'bg-white/10 border border-white/5 text-slate-200 rounded-bl-sm backdrop-blur-md'"
[ngClass]="msg.attributes.role === 'user' ? 'bg-linear-to-br from-indigo-500 to-blue-600 text-white rounded-br-sm' : 'bg-white/10 border border-white/5 text-slate-200 rounded-bl-sm backdrop-blur-md'"
>
{{ msg.attributes.content }}
</div>

View File

@ -6,6 +6,7 @@ import { MessageState, Message, MessageResponse, ChatResponse, ChatState } from
import { lastValueFrom } from 'rxjs';
import { Router } from '@angular/router';
import { JsonApiResource } from '../core/types/api';
import { environment } from '../../environments/environment.development';
const initialMessageState: MessageState = {
messages: [
@ -22,6 +23,9 @@ const initialMessageState: MessageState = {
],
isLoading: true,
isAgentThinking: false,
isLoadingPaginated: false,
currentPage: 1,
lastPage: 1,
id: null,
};
@ -147,7 +151,11 @@ export const MessageStore = signalStore(
try {
const chatHistory = await lastValueFrom(chatService.getMessages(<string>store.id()));
if (chatHistory.data.length !== 0) {
patchState(store, { messages: chatHistory.data });
patchState(store, {
messages: chatHistory.data,
currentPage: chatHistory.meta.current_page,
lastPage: chatHistory.meta.last_page,
});
} else {
patchState(store, { messages: initialMessageState.messages });
}
@ -173,6 +181,34 @@ export const MessageStore = signalStore(
patchState(store, { isLoading: false });
}
},
loadOlderMessages: async () => {
if (
store.isLoadingPaginated() ||
store.isLoading() ||
store.currentPage() >= store.lastPage() ||
!store.id()
) {
return;
}
patchState(store, { isLoadingPaginated: true });
const nextPage = store.currentPage() + 1;
try {
const chatHistory = await lastValueFrom(
chatService.getMessages(<string>store.id(), nextPage),
);
patchState(store, (state) => ({
messages: [...chatHistory.data, ...state.messages],
currentPage: chatHistory.meta.current_page,
lastPage: chatHistory.meta.last_page,
isLoadingPaginated: false,
}));
} catch (err) {
if (!environment.production) console.error(err);
patchState(store, { isLoadingPaginated: false });
}
},
};
}),
);

View File

@ -3,13 +3,15 @@ import { CommonModule } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ChatStore, MessageStore } from './chat.store';
import { ActivatedRoute } from '@angular/router';
import { Sidebar } from "../core/layout/sidebar/sidebar";
import { Loader } from "../shared/loader/loader";
import { Sidebar } from '../core/layout/sidebar/sidebar';
import { Loader } from '../shared/loader/loader';
import { InfiniteScroll } from '../core/directives/infinite-scroll';
import { environment } from '../../environments/environment.development';
@Component({
selector: 'app-chat',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, Sidebar, Loader],
imports: [CommonModule, ReactiveFormsModule, Sidebar, Loader, InfiniteScroll],
templateUrl: './chat.html',
styleUrl: './chat.css',
})
@ -24,8 +26,6 @@ export class Chat {
constructor() {
// Scroll to bottom when messages change
effect(() => {
// Accessing messages will trigger effect on change
const msgs = this.messageStore.messages();
const loading = this.messageStore.isLoading();
setTimeout(() => {
@ -65,6 +65,12 @@ export class Chat {
try {
const el = this.scrollContainer.nativeElement;
el.scrollTop = el.scrollHeight;
} catch (err) {}
} catch (err) {
if (!environment.production) console.log(err);
}
}
protected loadMoreChats() {
this.messageStore.loadOlderMessages();
}
}

View File

@ -24,6 +24,9 @@ export type MessageState = {
messages: JsonApiResource<Message>[];
isLoading: boolean;
isAgentThinking: boolean;
isLoadingPaginated: boolean;
currentPage: number;
lastPage: number;
id: string | null;
};
export type ChatState = {

View File

@ -0,0 +1,8 @@
import { InfiniteScroll } from './infinite-scroll';
describe('InfiniteScroll', () => {
it('should create an instance', () => {
const directive = new InfiniteScroll();
expect(directive).toBeTruthy();
});
});

View File

@ -0,0 +1,26 @@
import { DestroyRef, Directive, ElementRef, inject, output } from '@angular/core';
@Directive({
selector: '[appInfiniteScroll]',
})
export class InfiniteScroll {
scrolled = output<void>();
private elementRef = inject(ElementRef);
private destroyRef = inject(DestroyRef);
private observer?: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
this.scrolled.emit();
}
},
{ root: null, rootMargin: '0px', threshold: 0.1 },
);
this.observer.observe(this.elementRef.nativeElement);
this.destroyRef.onDestroy(() => {
this.observer?.disconnect();
});
}
}

View File

@ -150,6 +150,13 @@
</div>
</div>
}
<!--Loading state-->
@if(chatStore.isLoading()){
<div class="flex flex-1 h-full flex-col items-center justify-center py-10 px-4">
<app-loader class="w-12" />
</div>
}
<!-- Empty state -->
@if (chatStore.chats().length === 0) {
<div class="flex flex-1 flex-col items-center justify-center py-10 px-4 text-center">

View File

@ -1,17 +1,18 @@
import { Component, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { ChatStore, MessageStore } from '../../../chat/chat.store';
import { AuthStore } from '../../../auth/auth.store';
import { InitialsPipe } from './initials-pipe';
import { JsonApiResource } from '../../types/api';
import { Chat } from '../../../chat/chat.types';
import { Login } from '../../../auth/login/login';
import { Loader } from "../../../shared/loader/loader";
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [CommonModule, RouterModule, InitialsPipe],
imports: [CommonModule, RouterModule, InitialsPipe, Loader],
templateUrl: 'sidebar.html',
styleUrl: 'sidebar.css',
})
@ -19,6 +20,7 @@ export class Sidebar implements OnInit {
protected chatStore = inject(ChatStore);
protected messageStore = inject(MessageStore);
protected authStore = inject(AuthStore);
private route = inject(ActivatedRoute);
protected isOpen = signal(true);
protected isCogMenuOpen = signal(false);
@ -27,6 +29,12 @@ export class Sidebar implements OnInit {
ngOnInit() {
this.chatStore.fetchChats();
this.route.paramMap.subscribe((params) => {
const urlId = params.get('id');
if (urlId) {
this.activeChatId.set(urlId);
}
});
}
protected sidebarClasses = computed(() => {
@ -46,7 +54,7 @@ export class Sidebar implements OnInit {
}
protected chatIconClasses(chat: any): string {
const base = 'w-6 h-6 rounded-md flex items-center justify-center shrink-0 mt-0.5';
const base = 'w-6 h-6 rounded-md flex items-center justify-center shrink-0 mt-0.5 pt-0.5';
return chat.isActive
? base + ' bg-[#2d5be3]/20 text-[#5b8af0]'
: base + ' bg-[#111a2e] text-[#3a5272] group-hover:bg-[#1a2a48] group-hover:text-[#5a80a8]';
@ -66,7 +74,6 @@ export class Sidebar implements OnInit {
protected selectChat(chatId: string): void {
this.activeChatId.set(chatId);
console.log(chatId);
this.messageStore.fetchChatHistory(chatId);
}