diff --git a/frontend/src/app/chat/chat-service.ts b/frontend/src/app/chat/chat-service.ts index 7829d3a..76fc32a 100644 --- a/frontend/src/app/chat/chat-service.ts +++ b/frontend/src/app/chat/chat-service.ts @@ -20,8 +20,8 @@ export class ChatService { }); } - public getMessages(id: string) { - return this.http.get(`${this.apiUrl}/chats/${id}/messages`); + public getMessages(id: string, page: number = 1) { + return this.http.get(`${this.apiUrl}/chats/${id}/messages?page=${page}`); } public getChats() { diff --git a/frontend/src/app/chat/chat.html b/frontend/src/app/chat/chat.html index d4a3bb6..d82157a 100644 --- a/frontend/src/app/chat/chat.html +++ b/frontend/src/app/chat/chat.html @@ -4,6 +4,13 @@ class="flex-1 overflow-y-auto p-8 flex flex-col gap-6 scroll-smooth custom-scrollbar" #scrollContainer > +
+ @if(messageStore.isLoadingPaginated()){ +
+ +
+ } +
@if(messageStore.isLoading()) {
@@ -15,7 +22,7 @@ >
{{ msg.attributes.content }}
diff --git a/frontend/src/app/chat/chat.store.ts b/frontend/src/app/chat/chat.store.ts index 9202b64..9779d72 100644 --- a/frontend/src/app/chat/chat.store.ts +++ b/frontend/src/app/chat/chat.store.ts @@ -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(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(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 }); + } + }, }; }), ); diff --git a/frontend/src/app/chat/chat.ts b/frontend/src/app/chat/chat.ts index 4db7c5b..c819e8e 100644 --- a/frontend/src/app/chat/chat.ts +++ b/frontend/src/app/chat/chat.ts @@ -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(); } } diff --git a/frontend/src/app/chat/chat.types.ts b/frontend/src/app/chat/chat.types.ts index 72492af..16d7464 100644 --- a/frontend/src/app/chat/chat.types.ts +++ b/frontend/src/app/chat/chat.types.ts @@ -24,6 +24,9 @@ export type MessageState = { messages: JsonApiResource[]; isLoading: boolean; isAgentThinking: boolean; + isLoadingPaginated: boolean; + currentPage: number; + lastPage: number; id: string | null; }; export type ChatState = { diff --git a/frontend/src/app/core/directives/infinite-scroll.spec.ts b/frontend/src/app/core/directives/infinite-scroll.spec.ts new file mode 100644 index 0000000..4c4e0c3 --- /dev/null +++ b/frontend/src/app/core/directives/infinite-scroll.spec.ts @@ -0,0 +1,8 @@ +import { InfiniteScroll } from './infinite-scroll'; + +describe('InfiniteScroll', () => { + it('should create an instance', () => { + const directive = new InfiniteScroll(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/core/directives/infinite-scroll.ts b/frontend/src/app/core/directives/infinite-scroll.ts new file mode 100644 index 0000000..a419537 --- /dev/null +++ b/frontend/src/app/core/directives/infinite-scroll.ts @@ -0,0 +1,26 @@ +import { DestroyRef, Directive, ElementRef, inject, output } from '@angular/core'; + +@Directive({ + selector: '[appInfiniteScroll]', +}) +export class InfiniteScroll { + scrolled = output(); + 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(); + }); + } +} diff --git a/frontend/src/app/core/layout/sidebar/sidebar.html b/frontend/src/app/core/layout/sidebar/sidebar.html index 698dd3a..e2f5262 100644 --- a/frontend/src/app/core/layout/sidebar/sidebar.html +++ b/frontend/src/app/core/layout/sidebar/sidebar.html @@ -150,6 +150,13 @@
} + + + @if(chatStore.isLoading()){ +
+ +
+ } @if (chatStore.chats().length === 0) {
diff --git a/frontend/src/app/core/layout/sidebar/sidebar.ts b/frontend/src/app/core/layout/sidebar/sidebar.ts index 08a5d74..ba6715a 100644 --- a/frontend/src/app/core/layout/sidebar/sidebar.ts +++ b/frontend/src/app/core/layout/sidebar/sidebar.ts @@ -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); }