feat: Update how messages load and sync, allow plugins to import messages
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
/** Maximum number of recent messages to include in sync inventories. */
|
||||
export const INVENTORY_LIMIT = 1000;
|
||||
/** Maximum number of messages to include in sync inventories.
|
||||
*
|
||||
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
|
||||
* chunked at `CHUNK_SIZE`, so peers converge on the full history regardless
|
||||
* of how lopsided their message counts are. The constant remains as a safety
|
||||
* ceiling for pathological rooms.
|
||||
*/
|
||||
export const INVENTORY_LIMIT = 1_000_000;
|
||||
|
||||
/** Number of messages per chunk for inventory / batch transfers. */
|
||||
export const CHUNK_SIZE = 200;
|
||||
@@ -14,7 +20,7 @@ export const SYNC_POLL_SLOW_MS = 900_000;
|
||||
export const SYNC_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Large limit used for legacy full-sync operations. */
|
||||
export const FULL_SYNC_LIMIT = 10_000;
|
||||
export const FULL_SYNC_LIMIT = 1_000_000;
|
||||
|
||||
/** Inventory item representing a message's sync state. */
|
||||
export interface InventoryItem {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
[isAdmin]="isAdmin()"
|
||||
[bottomPadding]="composerBottomPadding()"
|
||||
[conversationKey]="conversationKey()"
|
||||
[loadingOlder]="loadingOlder()"
|
||||
[conversationExhausted]="conversationExhausted()"
|
||||
(replyRequested)="setReplyTo($event)"
|
||||
(deleteRequested)="handleDeleteRequested($event)"
|
||||
(editSaved)="handleEditSaved($event)"
|
||||
@@ -20,6 +22,7 @@
|
||||
(imageOpened)="openLightbox($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
(embedRemoved)="handleEmbedRemoved($event)"
|
||||
(loadOlderRequested)="handleLoadOlderRequested($event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
@@ -18,7 +20,9 @@ import { KlipyGif, KlipyService } from '../../application/services/klipy.service
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import {
|
||||
selectAllMessages,
|
||||
selectConversationExhausted,
|
||||
selectMessagesLoading,
|
||||
selectMessagesLoadingOlder,
|
||||
selectMessagesSyncing
|
||||
} from '../../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||
@@ -72,6 +76,7 @@ export class ChatMessagesComponent {
|
||||
|
||||
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
||||
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
@@ -83,6 +88,12 @@ export class ChatMessagesComponent {
|
||||
});
|
||||
|
||||
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
||||
readonly conversationExhausted = toSignal(
|
||||
toObservable(this.conversationKey).pipe(
|
||||
switchMap((key) => this.store.select(selectConversationExhausted(key)))
|
||||
),
|
||||
{ initialValue: false }
|
||||
);
|
||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
||||
readonly composerBottomPadding = signal(140);
|
||||
readonly klipyGifPickerAnchorRight = signal(16);
|
||||
@@ -213,6 +224,22 @@ export class ChatMessagesComponent {
|
||||
);
|
||||
}
|
||||
|
||||
handleLoadOlderRequested(event: { beforeTimestamp: number; limit: number }): void {
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
if (!roomId)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
MessagesActions.loadOlderMessages({
|
||||
roomId,
|
||||
channelId: this.activeChannelId() ?? 'general',
|
||||
beforeTimestamp: event.beforeTimestamp,
|
||||
limit: event.limit
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
toggleKlipyGifPicker(): void {
|
||||
const nextState = !this.showKlipyGifPicker();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
@@ -153,6 +154,7 @@ interface MissingPluginEmbedFallback {
|
||||
],
|
||||
templateUrl: './chat-message-item.component.html',
|
||||
styleUrl: './chat-message-item.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AfterViewChecked,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
@@ -48,6 +49,7 @@ declare global {
|
||||
ThemeNodeDirective
|
||||
],
|
||||
templateUrl: './chat-message-list.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
style: 'display: contents;'
|
||||
}
|
||||
@@ -82,6 +84,16 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
readonly imageOpened = output<Attachment>();
|
||||
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
|
||||
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
|
||||
/**
|
||||
* Emitted when the user scrolls up past the in-store window and the
|
||||
* component needs the parent to fetch an older page from the DB.
|
||||
*/
|
||||
readonly loadOlderRequested = output<{ beforeTimestamp: number; limit: number }>();
|
||||
|
||||
/** True while a DB-backed older-page fetch dispatched by the parent is in flight. */
|
||||
readonly loadingOlder = input(false);
|
||||
/** True once the parent has paginated all the way back to the start of DB history. */
|
||||
readonly conversationExhausted = input(false);
|
||||
|
||||
private readonly PAGE_SIZE = 50;
|
||||
|
||||
@@ -141,6 +153,21 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
return lookup;
|
||||
});
|
||||
|
||||
/**
|
||||
* O(1) index of messages by id, built once per `allMessages()` change.
|
||||
* Used by `findRepliedMessage` so each rendered row doing a reply lookup
|
||||
* costs a Map.get instead of an Array.find over the full message list.
|
||||
*/
|
||||
private readonly messagesById = computed<ReadonlyMap<string, Message>>(() => {
|
||||
const index = new Map<string, Message>();
|
||||
|
||||
for (const message of this.allMessages()) {
|
||||
index.set(message.id, message);
|
||||
}
|
||||
|
||||
return index;
|
||||
});
|
||||
|
||||
private bottomScrollObserver: MutationObserver | null = null;
|
||||
private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private boundOnImageLoad: (() => void) | null = null;
|
||||
@@ -150,12 +177,41 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
private lastMessageCount = 0;
|
||||
private initialScrollPending = true;
|
||||
private prismHighlightScheduled = false;
|
||||
/**
|
||||
* Set when an older-page DB fetch is in flight. While true, the
|
||||
* `onMessagesChanged` effect treats incoming message-count growth as a
|
||||
* prepend (older history arriving) and preserves the user's scroll
|
||||
* position instead of running sticky-bottom / new-messages-indicator
|
||||
* logic.
|
||||
*/
|
||||
private pendingOlderFetchScrollHeight: number | null = null;
|
||||
|
||||
private readonly onConversationChanged = effect(() => {
|
||||
void this.conversationKey();
|
||||
this.resetScrollingState();
|
||||
});
|
||||
|
||||
/**
|
||||
* Clears the in-flight older-fetch flag when the parent reports the
|
||||
* load has finished (regardless of how many rows were returned, even
|
||||
* zero). Without this, `loadingMore` would stick on if the DB had no
|
||||
* rows older than the cursor.
|
||||
*/
|
||||
private readonly onLoadingOlderChanged = effect(() => {
|
||||
const inFlight = this.loadingOlder();
|
||||
|
||||
if (!inFlight && this.pendingOlderFetchScrollHeight !== null) {
|
||||
// If onMessagesChanged already consumed the pending state because
|
||||
// rows arrived, this is a no-op; otherwise we clear it now.
|
||||
queueMicrotask(() => {
|
||||
if (this.pendingOlderFetchScrollHeight !== null) {
|
||||
this.pendingOlderFetchScrollHeight = null;
|
||||
this.loadingMore.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
private readonly onMessagesChanged = effect(() => {
|
||||
const currentCount = this.channelMessages().length;
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
@@ -170,6 +226,36 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle older-history backfill: messages were prepended, not appended.
|
||||
// Reveal the new rows by widening the display window, and preserve the
|
||||
// user's visual scroll position across the height change. We skip the
|
||||
// sticky-bottom / new-messages-indicator logic entirely for this path.
|
||||
if (this.pendingOlderFetchScrollHeight !== null && currentCount > this.lastMessageCount) {
|
||||
const previousScrollHeight = this.pendingOlderFetchScrollHeight;
|
||||
const previousScrollTop = element.scrollTop;
|
||||
const newlyLoaded = currentCount - this.lastMessageCount;
|
||||
|
||||
this.pendingOlderFetchScrollHeight = null;
|
||||
this.displayLimit.update((limit) => limit + newlyLoaded);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
|
||||
if (container) {
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
|
||||
container.scrollTop = previousScrollTop + (newScrollHeight - previousScrollHeight);
|
||||
}
|
||||
|
||||
this.loadingMore.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
this.lastMessageCount = currentCount;
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
const newMessages = currentCount > this.lastMessageCount;
|
||||
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
|
||||
@@ -232,7 +318,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
if (!messageId)
|
||||
return undefined;
|
||||
|
||||
return this.allMessages().find((message) => message.id === messageId);
|
||||
return this.messagesById().get(messageId);
|
||||
}
|
||||
|
||||
onScroll(): void {
|
||||
@@ -252,32 +338,68 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.stopBottomScrollWatch();
|
||||
}
|
||||
|
||||
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
if (element.scrollTop < 150 && !this.loadingMore()) {
|
||||
const canFetchOlderFromDb =
|
||||
!this.hasMoreMessages()
|
||||
&& !this.conversationExhausted()
|
||||
&& !this.loadingOlder()
|
||||
&& this.channelMessages().length > 0;
|
||||
|
||||
if (this.hasMoreMessages() || canFetchOlderFromDb) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadMore(): void {
|
||||
if (this.loadingMore() || !this.hasMoreMessages())
|
||||
if (this.loadingMore())
|
||||
return;
|
||||
|
||||
this.loadingMore.set(true);
|
||||
// Case 1: there are still in-store messages above the rendered window.
|
||||
// Just widen the display window and preserve scroll position.
|
||||
if (this.hasMoreMessages()) {
|
||||
this.loadingMore.set(true);
|
||||
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
const previousScrollHeight = element?.scrollHeight ?? 0;
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
const previousScrollHeight = element?.scrollHeight ?? 0;
|
||||
|
||||
this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
|
||||
this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
const newScrollHeight = element.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
const newScrollHeight = element.scrollHeight;
|
||||
|
||||
element.scrollTop += newScrollHeight - previousScrollHeight;
|
||||
}
|
||||
element.scrollTop += newScrollHeight - previousScrollHeight;
|
||||
}
|
||||
|
||||
this.loadingMore.set(false);
|
||||
this.loadingMore.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: in-store window is exhausted. Ask the parent to fetch the
|
||||
// next older page from the DB. The parent dispatches loadOlderMessages
|
||||
// and the resulting store update is handled by onMessagesChanged via
|
||||
// pendingOlderFetchScrollHeight (prepend-aware scroll preservation).
|
||||
if (this.loadingOlder() || this.conversationExhausted())
|
||||
return;
|
||||
|
||||
const all = this.channelMessages();
|
||||
|
||||
if (all.length === 0)
|
||||
return;
|
||||
|
||||
const oldest = all[0];
|
||||
const element = this.messagesContainer?.nativeElement;
|
||||
|
||||
this.loadingMore.set(true);
|
||||
this.pendingOlderFetchScrollHeight = element?.scrollHeight ?? 0;
|
||||
this.loadOlderRequested.emit({
|
||||
beforeTimestamp: oldest.timestamp,
|
||||
limit: this.PAGE_SIZE
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,6 +481,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = 0;
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
this.pendingOlderFetchScrollHeight = null;
|
||||
this.loadingMore.set(false);
|
||||
}
|
||||
|
||||
private startBottomScrollWatch(): void {
|
||||
|
||||
Reference in New Issue
Block a user