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

This commit is contained in:
2026-05-18 23:21:09 +02:00
parent 94428ed170
commit 54e8b9a5e4
19 changed files with 542 additions and 86 deletions

View File

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

View File

@@ -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

View File

@@ -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();

View File

@@ -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;'
}

View File

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

View File

@@ -17,6 +17,7 @@ import type {
User
} from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { CHUNK_SIZE, chunkArray } from '../../../../store/messages/messages.helpers';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import {
@@ -27,6 +28,8 @@ import {
} from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { defaultChannels } from '../../../../store/rooms/room-channels.defaults';
import { isChannelNameTaken, normalizeChannelName } from '../../../../store/rooms/room-channels.rules';
import type {
PluginApiAvatarUpdate,
PluginApiActionContext,
@@ -77,11 +80,11 @@ export class PluginClientApiService {
channels: {
addAudioChannel: (request) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'voice') }));
this.addPluginManagedChannel(pluginId, createChannel(request, 'voice'));
},
addTextChannel: (request) => {
requireCapability('channels.manage');
this.store.dispatch(RoomsActions.addChannel({ channel: createChannel(request, 'text') }));
this.addPluginManagedChannel(pluginId, createChannel(request, 'text'));
},
addVideoChannel: (request) => {
requireCapability('channels.manage');
@@ -743,9 +746,86 @@ export class PluginClientApiService {
}
this.store.dispatch(MessagesActions.syncMessages({ messages: normalizedMessages }));
// Broadcast imported history to peers in CHUNK_SIZE batches so they don't
// depend on the inventory-limited background sync to discover bulk imports.
for (const chunk of chunkArray(normalizedMessages, CHUNK_SIZE)) {
this.voice.broadcastMessage({
type: 'chat-sync-batch',
roomId,
messages: chunk
} as unknown as ChatEvent);
}
this.logger.info(pluginId, 'Historical messages imported', { count: normalizedMessages.length });
}
private addPluginManagedChannel(pluginId: string, channel: Channel): void {
const room = this.currentRoom();
const currentUser = this.currentUser();
if (!room || !currentUser) {
return;
}
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const isServerAdmin = currentUser.role === 'admin' || currentUser.role === 'host';
const canManageChannels = resolveRoomPermission(room, currentUser, 'manageChannels');
if (!isOwner && !isServerAdmin && !canManageChannels) {
this.logger.warn(pluginId, 'Plugin channel creation denied by room permissions', {
channelId: channel.id,
roomId: room.id
});
return;
}
const existingChannels = room.channels ?? defaultChannels();
const normalizedName = normalizeChannelName(channel.name);
const channelExists = existingChannels.some((entry) => entry.id === channel.id) ||
isChannelNameTaken(existingChannels, normalizedName, channel.type);
if (!normalizedName || channelExists) {
return;
}
const channels = [
...existingChannels,
{ ...channel,
name: normalizedName }
];
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
changes: { channels } }));
void this.db.updateRoom(room.id, { channels }).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin-created channel', error);
});
this.realtime.broadcastMessage({
type: 'channels-update',
roomId: room.id,
channels
});
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
channels,
currentOwnerId: currentUser.id
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
this.logger.info(pluginId, 'Plugin channel created', {
channelId: channel.id,
roomId: room.id
});
}
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);