enhance chat functionality with infinite scrolling and message retrieval improvements
This commit is contained in:
parent
4f94192857
commit
066e5463b2
@ -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({
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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> {
|
||||||
|
Loading…
Reference in New Issue
Block a user