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 { 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({

View File

@ -18,7 +18,7 @@
<mat-drawer-content>
<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>
<app-message-bar

View File

@ -41,7 +41,7 @@ export class ChatComponent implements OnInit {
private getMessages(): void {
this.channels.forEach((channelObj, i) => {
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);
}

View File

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

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';
@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<void> = new EventEmitter<void>();
constructor(private element: ElementRef<HTMLElement>) { }
@ -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);
}
}

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" />
<div class="message-inner-container">

View File

@ -37,10 +37,9 @@ export class ChatService {
);
}
public GetMessages(channelID: number): Observable<Message> {
let url = `${environment.apiBase}/chat/messages/${channelID}`;
let messages: Observable<Message[]> =
this.http.get<GetMessagesResponse>(url, { withCredentials: true })
public GetMessages(channelID: number, from: Date, limit: number = 10): Observable<Message[]> {
let url = `${environment.apiBase}/chat/messages/${channelID}?limit=${limit}&from=${from.toISOString()}`;
return this.http.get<GetMessagesResponse>(url, { withCredentials: true })
.pipe(
map(response => {
if (response.error) {
@ -53,20 +52,37 @@ export class ChatService {
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)))
);
}
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);
return merge(
messages.pipe(mergeMap(msgArray => from(msgArray))),
socket.asObservable())
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<SendMessageResponse> {