enhance chat functionality with infinite scrolling and message retrieval improvements

This commit is contained in:
BENEDEK László 2025-06-09 15:08:43 +02:00
parent 4f94192857
commit 066e5463b2
7 changed files with 90 additions and 33 deletions

View File

@ -1,5 +1,5 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
@ -10,7 +10,10 @@ import { provideMarkdown } from 'ngx-markdown';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes, withInMemoryScrolling({
anchorScrolling: "enabled",
scrollPositionRestoration: 'top'
})),
provideHttpClient(), provideHttpClient(),
provideAnimations(), provideAnimations(),
provideToastr({ provideToastr({

View File

@ -18,7 +18,7 @@
<mat-drawer-content> <mat-drawer-content>
<app-toolbar (sidebar)="drawer.toggle()" /> <app-toolbar (sidebar)="drawer.toggle()" />
<app-feed #feed <app-feed #feed (outOfMessages)="this.GetMoreMessages()"
[messages]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].messages : []"></app-feed> [messages]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].messages : []"></app-feed>
<app-message-bar <app-message-bar

View File

@ -41,7 +41,7 @@ export class ChatComponent implements OnInit {
private getMessages(): void { private getMessages(): void {
this.channels.forEach((channelObj, i) => { this.channels.forEach((channelObj, i) => {
this.chatService.GetMessages(channelObj.channel.id).subscribe({ this.chatService.GetMessageStream(channelObj.channel.id).subscribe({
next: message => { next: message => {
channelObj.messages.push(message); channelObj.messages.push(message);
this.feed.ScrollEventHandler(); 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[] { public GetChannelList(): Channel[] {
return this.channels.map(c => c.channel); return this.channels.map(c => c.channel);
} }

View File

@ -1,3 +1 @@
<div #scrollable id="scrollable">
<app-message *ngFor="let message of this.messages" [message]="message" /> <app-message *ngFor="let message of this.messages" [message]="message" />
</div>

View File

@ -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'; import { Message } from '../../../models/message';
@Component({ @Component({
@ -9,6 +9,7 @@ import { Message } from '../../../models/message';
}) })
export class FeedComponent implements AfterViewInit { export class FeedComponent implements AfterViewInit {
@Input('messages') public messages?: Message[]; @Input('messages') public messages?: Message[];
@Output('outOfMessages') public outOfMessages: EventEmitter<void> = new EventEmitter<void>();
constructor(private element: ElementRef<HTMLElement>) { } constructor(private element: ElementRef<HTMLElement>) { }
@ -21,11 +22,38 @@ export class FeedComponent implements AfterViewInit {
return Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 100; 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 { private scrollToBottom(): void {
setTimeout(() => this.element.nativeElement.scrollTop = this.element.nativeElement.scrollHeight, 100); 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 { public ScrollEventHandler(): void {
if (this.isAtBottom()) this.scrollToBottom(); 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);
}
} }

View File

@ -1,4 +1,4 @@
<div class="message-container"> <div [id]="`c${message.channel_id}m${message.id}`" class="message-container">
<app-profile-picture [username]="this.message.sender_name" /> <app-profile-picture [username]="this.message.sender_name" />
<div class="message-inner-container"> <div class="message-inner-container">

View File

@ -37,10 +37,9 @@ export class ChatService {
); );
} }
public GetMessages(channelID: number): Observable<Message> { public GetMessages(channelID: number, from: Date, limit: number = 10): Observable<Message[]> {
let url = `${environment.apiBase}/chat/messages/${channelID}`; let url = `${environment.apiBase}/chat/messages/${channelID}?limit=${limit}&from=${from.toISOString()}`;
let messages: Observable<Message[]> = return this.http.get<GetMessagesResponse>(url, { withCredentials: true })
this.http.get<GetMessagesResponse>(url, { withCredentials: true })
.pipe( .pipe(
map(response => { map(response => {
if (response.error) { if (response.error) {
@ -53,20 +52,37 @@ export class ChatService {
throw new Error("bad API response, missing messages with no error"); 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);
return msg;
})
}),
catchError(error => throwError(() => new Error(error.error.message))) catchError(error => throwError(() => new Error(error.error.message)))
); );
}
url = `${environment.apiBase}/chat/subscribe/${channelID}`; public GetMessageStream(channelID: number, historyLimit: number = 10): Observable<Message> {
let messages: Observable<Message[]> = this.GetMessages(channelID, new Date(), historyLimit);
let url = `${environment.apiBase}/chat/subscribe/${channelID}`;
let socket = webSocket<Message>(url); let socket = webSocket<Message>(url);
return merge( return merge(
messages.pipe(mergeMap(msgArray => from(msgArray))), messages
socket.asObservable()) .pipe(mergeMap(msgArray => from(msgArray))),
socket.asObservable()
.pipe(map(msg => { .pipe(map(msg => {
// HttpClient doesn't parse Date objects by default // HttpClient doesn't parse Date objects by default
if (typeof msg.time == 'string')
msg.time = new Date(msg.time); msg.time = new Date(msg.time);
return msg; return msg;
})); }))
);
} }
public SendMessage(channel: number, content: string): Observable<SendMessageResponse> { public SendMessage(channel: number, content: string): Observable<SendMessageResponse> {