From 066e5463b226a938468dbd4eebf55df3e2702f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?BENEDEK=20L=C3=A1szl=C3=B3?= Date: Mon, 9 Jun 2025 15:08:43 +0200 Subject: [PATCH] enhance chat functionality with infinite scrolling and message retrieval improvements --- src/app/app.config.ts | 7 +- src/app/chat/chat/chat.component.html | 2 +- src/app/chat/chat/chat.component.ts | 14 +++- src/app/chat/chat/feed/feed.component.html | 4 +- src/app/chat/chat/feed/feed.component.ts | 30 ++++++++- .../chat/feed/message/message.component.html | 2 +- src/app/services/chat.service.ts | 64 ++++++++++++------- 7 files changed, 90 insertions(+), 33 deletions(-) diff --git a/src/app/app.config.ts b/src/app/app.config.ts index d4c56a4..bd3f2c5 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,5 @@ import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { routes } from './app.routes'; import { provideHttpClient } from '@angular/common/http'; @@ -10,7 +10,10 @@ import { provideMarkdown } from 'ngx-markdown'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes), + provideRouter(routes, withInMemoryScrolling({ + anchorScrolling: "enabled", + scrollPositionRestoration: 'top' + })), provideHttpClient(), provideAnimations(), provideToastr({ diff --git a/src/app/chat/chat/chat.component.html b/src/app/chat/chat/chat.component.html index 4024057..178544e 100644 --- a/src/app/chat/chat/chat.component.html +++ b/src/app/chat/chat/chat.component.html @@ -18,7 +18,7 @@ - { - this.chatService.GetMessages(channelObj.channel.id).subscribe({ + this.chatService.GetMessageStream(channelObj.channel.id).subscribe({ next: message => { channelObj.messages.push(message); this.feed.ScrollEventHandler(); @@ -56,6 +56,18 @@ export class ChatComponent implements OnInit { }); } + public GetMoreMessages(): void { + this.chatService.GetMessages(this.channels[this.selectedChannel].channel.id, this.channels[this.selectedChannel].messages[0]?.time ?? new Date()) + .subscribe({ + next: messages => { + let lastMessage = this.channels[this.selectedChannel].messages[0]; + this.channels[this.selectedChannel].messages.unshift(...messages); + this.feed.ScrollToMessage(lastMessage.channel_id, lastMessage.id); + }, + error: () => this.toastrService.error("Failed to fetch older messages.", "Error") + }); + } + public GetChannelList(): Channel[] { return this.channels.map(c => c.channel); } diff --git a/src/app/chat/chat/feed/feed.component.html b/src/app/chat/chat/feed/feed.component.html index 8ee4812..d0e1bb9 100644 --- a/src/app/chat/chat/feed/feed.component.html +++ b/src/app/chat/chat/feed/feed.component.html @@ -1,3 +1 @@ -
- -
\ No newline at end of file + \ No newline at end of file diff --git a/src/app/chat/chat/feed/feed.component.ts b/src/app/chat/chat/feed/feed.component.ts index 7ad0317..4dd0850 100644 --- a/src/app/chat/chat/feed/feed.component.ts +++ b/src/app/chat/chat/feed/feed.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, DoCheck, ElementRef, EventEmitter, Input, IterableDiffer, IterableDiffers, Output } from '@angular/core'; +import { AfterViewInit, Component, DoCheck, ElementRef, EventEmitter, HostListener, Input, IterableDiffer, IterableDiffers, Output, QueryList, ViewChildren } from '@angular/core'; import { Message } from '../../../models/message'; @Component({ @@ -9,6 +9,7 @@ import { Message } from '../../../models/message'; }) export class FeedComponent implements AfterViewInit { @Input('messages') public messages?: Message[]; + @Output('outOfMessages') public outOfMessages: EventEmitter = new EventEmitter(); constructor(private element: ElementRef) { } @@ -21,11 +22,38 @@ export class FeedComponent implements AfterViewInit { return Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 100; } + private isAtTop(): boolean { + let element = this.element.nativeElement; + return element.scrollTop == 0; + } + private scrollToBottom(): void { setTimeout(() => this.element.nativeElement.scrollTop = this.element.nativeElement.scrollHeight, 100); } + // handles scroll to bottom events from the chat component when a new message arrives public ScrollEventHandler(): void { if (this.isAtBottom()) this.scrollToBottom(); } + + @HostListener('scroll') + private detectOutOfMessages(): void { + if (this.isAtTop()) { + this.outOfMessages.emit(); + } + } + + public ScrollToMessage(channelID: number, messageID: number): void { + setTimeout(() => { + let id = `#c${channelID}m${messageID}`; + + let target = this.element.nativeElement.querySelector(id) as HTMLDivElement | null; + if (!target) return; + + this.element.nativeElement.scrollTo({ + top: target.offsetTop - target.offsetHeight, + behavior: "instant" + }); + }, 100); + } } diff --git a/src/app/chat/chat/feed/message/message.component.html b/src/app/chat/chat/feed/message/message.component.html index 50188a3..ca4f30f 100644 --- a/src/app/chat/chat/feed/message/message.component.html +++ b/src/app/chat/chat/feed/message/message.component.html @@ -1,4 +1,4 @@ -
+
diff --git a/src/app/services/chat.service.ts b/src/app/services/chat.service.ts index 7ae40ce..42bfdb5 100644 --- a/src/app/services/chat.service.ts +++ b/src/app/services/chat.service.ts @@ -37,36 +37,52 @@ export class ChatService { ); } - public GetMessages(channelID: number): Observable { - let url = `${environment.apiBase}/chat/messages/${channelID}`; - let messages: Observable = - this.http.get(url, { withCredentials: true }) - .pipe( - map(response => { - if (response.error) { - throw new Error(response.error); - } + public GetMessages(channelID: number, from: Date, limit: number = 10): Observable { + let url = `${environment.apiBase}/chat/messages/${channelID}?limit=${limit}&from=${from.toISOString()}`; + return this.http.get(url, { withCredentials: true }) + .pipe( + map(response => { + if (response.error) { + throw new Error(response.error); + } - if (response.messages) { - return response.messages; - } + if (response.messages) { + return response.messages; + } - throw new Error("bad API response, missing messages with no error"); - }), - catchError(error => throwError(() => new Error(error.error.message))) - ); + throw new Error("bad API response, missing messages with no error"); + }), + // HttpClient doesn't parse Date objects by default + map(msgs => { + return msgs.map(msg => { + if (typeof msg.time == 'string') + msg.time = new Date(msg.time); - url = `${environment.apiBase}/chat/subscribe/${channelID}`; + return msg; + }) + }), + catchError(error => throwError(() => new Error(error.error.message))) + ); + } + + public GetMessageStream(channelID: number, historyLimit: number = 10): Observable { + let messages: Observable = this.GetMessages(channelID, new Date(), historyLimit); + + let url = `${environment.apiBase}/chat/subscribe/${channelID}`; let socket = webSocket(url); return merge( - messages.pipe(mergeMap(msgArray => from(msgArray))), - socket.asObservable()) - .pipe(map(msg => { - // HttpClient doesn't parse Date objects by default - msg.time = new Date(msg.time); - return msg; - })); + messages + .pipe(mergeMap(msgArray => from(msgArray))), + socket.asObservable() + .pipe(map(msg => { + // HttpClient doesn't parse Date objects by default + if (typeof msg.time == 'string') + msg.time = new Date(msg.time); + + return msg; + })) + ); } public SendMessage(channel: number, content: string): Observable {