feat: infinite scroll in messages, add loader in sidebar
This commit is contained in:
parent
fad7658ead
commit
98cef31ae2
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
8
frontend/src/app/core/directives/infinite-scroll.spec.ts
Normal file
8
frontend/src/app/core/directives/infinite-scroll.spec.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { InfiniteScroll } from './infinite-scroll';
|
||||||
|
|
||||||
|
describe('InfiniteScroll', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const directive = new InfiniteScroll();
|
||||||
|
expect(directive).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
26
frontend/src/app/core/directives/infinite-scroll.ts
Normal file
26
frontend/src/app/core/directives/infinite-scroll.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user