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) { public getMessages(id: string, page: number = 1) {
return this.http.get<MessageCollection>(`${this.apiUrl}/chats/${id}/messages`); return this.http.get<MessageCollection>(`${this.apiUrl}/chats/${id}/messages?page=${page}`);
} }
public getChats() { 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" class="flex-1 overflow-y-auto p-8 flex flex-col gap-6 scroll-smooth custom-scrollbar"
#scrollContainer #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()) { @if(messageStore.isLoading()) {
<div class="w-full h-full flex items-center justify-center"> <div class="w-full h-full flex items-center justify-center">
<app-loader class="w-8" /> <app-loader class="w-8" />
@ -15,7 +22,7 @@
> >
<div <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" 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 }} {{ msg.attributes.content }}
</div> </div>

View File

@ -6,6 +6,7 @@ import { MessageState, Message, MessageResponse, ChatResponse, ChatState } from
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { JsonApiResource } from '../core/types/api'; import { JsonApiResource } from '../core/types/api';
import { environment } from '../../environments/environment.development';
const initialMessageState: MessageState = { const initialMessageState: MessageState = {
messages: [ messages: [
@ -22,6 +23,9 @@ const initialMessageState: MessageState = {
], ],
isLoading: true, isLoading: true,
isAgentThinking: false, isAgentThinking: false,
isLoadingPaginated: false,
currentPage: 1,
lastPage: 1,
id: null, id: null,
}; };
@ -147,7 +151,11 @@ export const MessageStore = signalStore(
try { try {
const chatHistory = await lastValueFrom(chatService.getMessages(<string>store.id())); const chatHistory = await lastValueFrom(chatService.getMessages(<string>store.id()));
if (chatHistory.data.length !== 0) { 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 { } else {
patchState(store, { messages: initialMessageState.messages }); patchState(store, { messages: initialMessageState.messages });
} }
@ -173,6 +181,34 @@ export const MessageStore = signalStore(
patchState(store, { isLoading: false }); 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 { FormControl, ReactiveFormsModule } from '@angular/forms';
import { ChatStore, MessageStore } from './chat.store'; import { ChatStore, MessageStore } from './chat.store';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Sidebar } from "../core/layout/sidebar/sidebar"; import { Sidebar } from '../core/layout/sidebar/sidebar';
import { Loader } from "../shared/loader/loader"; import { Loader } from '../shared/loader/loader';
import { InfiniteScroll } from '../core/directives/infinite-scroll';
import { environment } from '../../environments/environment.development';
@Component({ @Component({
selector: 'app-chat', selector: 'app-chat',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, Sidebar, Loader], imports: [CommonModule, ReactiveFormsModule, Sidebar, Loader, InfiniteScroll],
templateUrl: './chat.html', templateUrl: './chat.html',
styleUrl: './chat.css', styleUrl: './chat.css',
}) })
@ -24,8 +26,6 @@ export class Chat {
constructor() { constructor() {
// Scroll to bottom when messages change // Scroll to bottom when messages change
effect(() => { effect(() => {
// Accessing messages will trigger effect on change
const msgs = this.messageStore.messages();
const loading = this.messageStore.isLoading(); const loading = this.messageStore.isLoading();
setTimeout(() => { setTimeout(() => {
@ -65,6 +65,12 @@ export class Chat {
try { try {
const el = this.scrollContainer.nativeElement; const el = this.scrollContainer.nativeElement;
el.scrollTop = el.scrollHeight; 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>[]; messages: JsonApiResource<Message>[];
isLoading: boolean; isLoading: boolean;
isAgentThinking: boolean; isAgentThinking: boolean;
isLoadingPaginated: boolean;
currentPage: number;
lastPage: number;
id: string | null; id: string | null;
}; };
export type ChatState = { 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>
</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 --> <!-- Empty state -->
@if (chatStore.chats().length === 0) { @if (chatStore.chats().length === 0) {
<div class="flex flex-1 flex-col items-center justify-center py-10 px-4 text-center"> <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 { Component, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
import { ChatStore, MessageStore } from '../../../chat/chat.store'; import { ChatStore, MessageStore } from '../../../chat/chat.store';
import { AuthStore } from '../../../auth/auth.store'; import { AuthStore } from '../../../auth/auth.store';
import { InitialsPipe } from './initials-pipe'; import { InitialsPipe } from './initials-pipe';
import { JsonApiResource } from '../../types/api'; import { JsonApiResource } from '../../types/api';
import { Chat } from '../../../chat/chat.types'; import { Chat } from '../../../chat/chat.types';
import { Login } from '../../../auth/login/login'; import { Login } from '../../../auth/login/login';
import { Loader } from "../../../shared/loader/loader";
@Component({ @Component({
selector: 'app-sidebar', selector: 'app-sidebar',
standalone: true, standalone: true,
imports: [CommonModule, RouterModule, InitialsPipe], imports: [CommonModule, RouterModule, InitialsPipe, Loader],
templateUrl: 'sidebar.html', templateUrl: 'sidebar.html',
styleUrl: 'sidebar.css', styleUrl: 'sidebar.css',
}) })
@ -19,6 +20,7 @@ export class Sidebar implements OnInit {
protected chatStore = inject(ChatStore); protected chatStore = inject(ChatStore);
protected messageStore = inject(MessageStore); protected messageStore = inject(MessageStore);
protected authStore = inject(AuthStore); protected authStore = inject(AuthStore);
private route = inject(ActivatedRoute);
protected isOpen = signal(true); protected isOpen = signal(true);
protected isCogMenuOpen = signal(false); protected isCogMenuOpen = signal(false);
@ -27,6 +29,12 @@ export class Sidebar implements OnInit {
ngOnInit() { ngOnInit() {
this.chatStore.fetchChats(); this.chatStore.fetchChats();
this.route.paramMap.subscribe((params) => {
const urlId = params.get('id');
if (urlId) {
this.activeChatId.set(urlId);
}
});
} }
protected sidebarClasses = computed(() => { protected sidebarClasses = computed(() => {
@ -46,7 +54,7 @@ export class Sidebar implements OnInit {
} }
protected chatIconClasses(chat: any): string { 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 return chat.isActive
? base + ' bg-[#2d5be3]/20 text-[#5b8af0]' ? base + ' bg-[#2d5be3]/20 text-[#5b8af0]'
: base + ' bg-[#111a2e] text-[#3a5272] group-hover:bg-[#1a2a48] group-hover:text-[#5a80a8]'; : 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 { protected selectChat(chatId: string): void {
this.activeChatId.set(chatId); this.activeChatId.set(chatId);
console.log(chatId);
this.messageStore.fetchChatHistory(chatId); this.messageStore.fetchChatHistory(chatId);
} }