perf: server navigation

This commit is contained in:
2026-05-18 19:38:08 +02:00
parent 0152ed9dd2
commit afb64520ed
12 changed files with 212 additions and 90 deletions

View File

@@ -16,11 +16,12 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
const rows = await repo.find({ const rows = await repo.find({
where: { roomId, ownerUserId: currentUserId }, where: { roomId, ownerUserId: currentUserId },
order: { timestamp: 'ASC' }, order: { timestamp: 'DESC' },
take: limit, take: limit,
skip: offset skip: offset
}); });
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id)); const chronologicalRows = [...rows].reverse();
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, chronologicalRows.map((row) => row.id));
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? [])); return chronologicalRows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
} }

View File

@@ -35,6 +35,7 @@ export class AttachmentManagerService {
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false; private isDatabaseInitialised = false;
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
constructor() { constructor() {
effect(() => { effect(() => {
@@ -79,27 +80,23 @@ export class AttachmentManagerService {
} }
async requestAutoDownloadsForRoom(roomId: string): Promise<void> { async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!roomId || !this.isRoomWatched(roomId)) if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0)
return; return;
if (this.database.isReady()) { const activeRequest = this.autoDownloadRequestsByRoom.get(roomId);
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) { if (activeRequest) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId); return activeRequest;
await this.requestAutoDownloadsForMessage(message.id);
} }
return; const request = this.runAutoDownloadsForRoom(roomId).finally(() => {
if (this.autoDownloadRequestsByRoom.get(roomId) === request) {
this.autoDownloadRequestsByRoom.delete(roomId);
} }
});
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) { this.autoDownloadRequestsByRoom.set(roomId, request);
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId); return request;
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
} }
async deleteForMessage(messageId: string): Promise<void> { async deleteForMessage(messageId: string): Promise<void> {
@@ -180,6 +177,31 @@ export class AttachmentManagerService {
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file); await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
} }
private async runAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!this.isRoomWatched(roomId)) {
return;
}
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
await this.requestAutoDownloadsForMessage(message.id);
}
return;
}
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
}
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> { private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
if (!messageId) if (!messageId)
return; return;

View File

@@ -80,6 +80,7 @@ export class ServersRailComponent {
menuX = signal(72); menuX = signal(72);
menuY = signal(100); menuY = signal(100);
contextRoom = signal<Room | null>(null); contextRoom = signal<Room | null>(null);
optimisticSelectedRoomId = signal<string | null>(null);
showLeaveConfirm = signal(false); showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers); onlineUsers = this.store.selectSignal(selectOnlineUsers);
@@ -192,6 +193,18 @@ export class ServersRailComponent {
void this.refreshBannedLookup(rooms, currentUser ?? null); void this.refreshBannedLookup(rooms, currentUser ?? null);
}); });
effect(() => {
const optimisticRoomId = this.optimisticSelectedRoomId();
if (!optimisticRoomId) {
return;
}
if (this.currentRoom()?.id === optimisticRoomId && !this.isOnDirectMessage() && !this.isOnCall()) {
this.optimisticSelectedRoomId.set(null);
}
});
this.savedRoomJoinRequests this.savedRoomJoinRequests
.pipe( .pipe(
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)), switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
@@ -214,6 +227,8 @@ export class ServersRailComponent {
createServer(): void { createServer(): void {
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
this.optimisticSelectedRoomId.set(null);
if (voiceServerId) { if (voiceServerId) {
this.voiceSession.setViewingVoiceServer(false); this.voiceSession.setViewingVoiceServer(false);
} }
@@ -235,11 +250,13 @@ export class ServersRailComponent {
return; return;
} }
this.optimisticSelectedRoomId.set(room.id);
this.activateSavedRoom(room); this.activateSavedRoom(room);
this.savedRoomJoinRequests.next({ room }); this.savedRoomJoinRequests.next({ room });
} }
openCall(callId: string): void { openCall(callId: string): void {
this.optimisticSelectedRoomId.set(null);
void this.router.navigate(['/call', callId]); void this.router.navigate(['/call', callId]);
} }
@@ -335,6 +352,7 @@ export class ServersRailComponent {
); );
if (isCurrentRoom) { if (isCurrentRoom) {
this.optimisticSelectedRoomId.set(null);
this.router.navigate(['/search']); this.router.navigate(['/search']);
} }
@@ -374,6 +392,12 @@ export class ServersRailComponent {
} }
isSelectedRoom(room: Room): boolean { isSelectedRoom(room: Room): boolean {
const optimisticRoomId = this.optimisticSelectedRoomId();
if (optimisticRoomId) {
return optimisticRoomId === room.id;
}
if (this.isOnDirectMessage() || this.isOnCall()) { if (this.isOnDirectMessage() || this.isOnCall()) {
return false; return false;
} }
@@ -492,6 +516,7 @@ export class ServersRailComponent {
if (errorCode === 'BANNED') { if (errorCode === 'BANNED') {
this.closePasswordDialog(); this.closePasswordDialog();
this.optimisticSelectedRoomId.set(null);
this.bannedRoomLookup.update((lookup) => ({ this.bannedRoomLookup.update((lookup) => ({
...lookup, ...lookup,
[room.id]: true [room.id]: true

View File

@@ -66,31 +66,32 @@ export class BrowserDatabaseService {
} }
/** /**
* Retrieve messages for a room, sorted oldest-first. * Retrieve the latest messages for a room, sorted oldest-first for display.
* @param roomId - Target room. * @param roomId - Target room.
* @param limit - Maximum number of messages to return. * @param limit - Maximum number of messages to return.
* @param offset - Number of messages to skip (for pagination). * @param offset - Number of newer messages to skip (for pagination).
*/ */
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> { async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>( const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId STORE_MESSAGES, 'roomId', roomId
); );
const sortedMessages = allRoomMessages.sort((first, second) => first.timestamp - second.timestamp);
const endIndex = Math.max(sortedMessages.length - offset, 0);
const startIndex = Math.max(endIndex - limit, 0);
const messages = sortedMessages.slice(startIndex, endIndex);
return allRoomMessages return this.hydrateMessages(messages);
.sort((first, second) => first.timestamp - second.timestamp)
.slice(offset, offset + limit)
.map((message) => this.normaliseMessage(message));
} }
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> { async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>( const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId STORE_MESSAGES, 'roomId', roomId
); );
const messages = allRoomMessages
return allRoomMessages
.filter((message) => message.timestamp > sinceTimestamp) .filter((message) => message.timestamp > sinceTimestamp)
.sort((first, second) => first.timestamp - second.timestamp) .sort((first, second) => first.timestamp - second.timestamp);
.map((message) => this.normaliseMessage(message));
return this.hydrateMessages(messages);
} }
/** Delete a message by its ID. */ /** Delete a message by its ID. */
@@ -112,7 +113,11 @@ export class BrowserDatabaseService {
async getMessageById(messageId: string): Promise<Message | null> { async getMessageById(messageId: string): Promise<Message | null> {
const message = await this.get<Message>(STORE_MESSAGES, messageId); const message = await this.get<Message>(STORE_MESSAGES, messageId);
return message ? this.normaliseMessage(message) : null; if (!message) {
return null;
}
return (await this.hydrateMessages([message]))[0] ?? null;
} }
/** Remove every message belonging to a room. */ /** Remove every message belonging to a room. */
@@ -520,6 +525,47 @@ export class BrowserDatabaseService {
await this.awaitTransaction(transaction); await this.awaitTransaction(transaction);
} }
private async hydrateMessages(messages: Message[]): Promise<Message[]> {
if (messages.length === 0) {
return [];
}
const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id));
return messages.map((message) => this.normaliseMessage({
...message,
reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? []
}));
}
private async loadReactionsForMessages(messageIds: readonly string[]): Promise<Map<string, Reaction[]>> {
const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0));
const reactionsByMessageId = new Map<string, Reaction[]>();
if (messageIdSet.size === 0) {
return reactionsByMessageId;
}
const allReactions = await this.getAll<Reaction>(STORE_REACTIONS);
for (const reaction of allReactions) {
if (!messageIdSet.has(reaction.messageId)) {
continue;
}
const reactions = reactionsByMessageId.get(reaction.messageId) ?? [];
reactions.push(reaction);
reactionsByMessageId.set(reaction.messageId, reactions);
}
for (const reactions of reactionsByMessageId.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
}
return reactionsByMessageId;
}
private normaliseMessage(message: Message): Message { private normaliseMessage(message: Message): Message {
if (message.content === DELETED_MESSAGE_CONTENT) { if (message.content === DELETED_MESSAGE_CONTENT) {
return { ...message, return { ...message,

View File

@@ -49,7 +49,7 @@ export class DatabaseService {
/** Persist a single chat message. */ /** Persist a single chat message. */
saveMessage(message: Message) { return this.backend.saveMessage(message); } saveMessage(message: Message) { return this.backend.saveMessage(message); }
/** Retrieve messages for a room with optional pagination. */ /** Retrieve the latest messages for a room with optional pagination. */
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); } getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
/** Retrieve messages newer than a given timestamp for a room. */ /** Retrieve messages newer than a given timestamp for a room. */

View File

@@ -37,11 +37,11 @@ export class ElectronDatabaseService {
} }
/** /**
* Retrieve messages for a room, sorted oldest-first. * Retrieve the latest messages for a room, sorted oldest-first for display.
* *
* @param roomId - Target room. * @param roomId - Target room.
* @param limit - Maximum number of messages to return. * @param limit - Maximum number of messages to return.
* @param offset - Number of messages to skip (for pagination). * @param offset - Number of newer messages to skip (for pagination).
*/ */
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> { getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } }); return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } });

View File

@@ -111,20 +111,25 @@ export class MessagesSyncEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess), ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(this.store.select(selectCurrentRoom)),
mergeMap(([{ room }, currentRoom]) => { switchMap(([{ room }, currentRoom]) => {
const activeRoom = currentRoom || room; const requestedRoomId = room.id;
if (!activeRoom) return timer(75).pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
switchMap(([, latestCurrentRoom]) => {
const activeRoom = latestCurrentRoom ?? currentRoom ?? room;
const peers = this.webrtc.getConnectedPeers();
if (!activeRoom || activeRoom.id !== requestedRoomId || peers.length === 0) {
return EMPTY; return EMPTY;
}
return from( return from(this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)).pipe(
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)
).pipe(
tap((messages) => { tap((messages) => {
const count = messages.length; const count = messages.length;
const lastUpdated = getLatestTimestamp(messages); const lastUpdated = getLatestTimestamp(messages);
for (const pid of this.webrtc.getConnectedPeers()) { for (const pid of peers) {
try { try {
this.webrtc.sendToPeer(pid, { this.webrtc.sendToPeer(pid, {
type: 'chat-sync-summary', type: 'chat-sync-summary',
@@ -148,6 +153,8 @@ export class MessagesSyncEffects {
}) })
); );
}) })
);
})
), ),
{ dispatch: false } { dispatch: false }
); );

View File

@@ -50,6 +50,8 @@ import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
import { resolveRoomPermission } from '../../domains/access-control'; import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers'; import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
@Injectable() @Injectable()
export class MessagesEffects { export class MessagesEffects {
private readonly actions$ = inject(Actions); private readonly actions$ = inject(Actions);
@@ -66,7 +68,7 @@ export class MessagesEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(MessagesActions.loadMessages), ofType(MessagesActions.loadMessages),
switchMap(({ roomId }) => switchMap(({ roomId }) =>
from(this.db.getMessages(roomId)).pipe( from(this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0)).pipe(
mergeMap(async (messages) => { mergeMap(async (messages) => {
const hydrated = await hydrateMessages(messages, this.db); const hydrated = await hydrateMessages(messages, this.db);

View File

@@ -29,23 +29,20 @@ export type { InventoryItem } from '../../domains/chat/domain/rules/message-sync
/** Hydrates a single message with its reactions from the database. */ /** Hydrates a single message with its reactions from the database. */
export async function hydrateMessage( export async function hydrateMessage(
msg: Message, msg: Message,
db: DatabaseService _db: DatabaseService
): Promise<Message> { ): Promise<Message> {
if (msg.isDeleted) if (msg.isDeleted)
return normaliseDeletedMessage(msg); return normaliseDeletedMessage(msg);
const reactions = await db.getReactionsForMessage(msg.id); return msg;
return reactions.length > 0 ? { ...msg,
reactions } : msg;
} }
/** Hydrates an array of messages with their reactions. */ /** Hydrates an array of messages with their reactions. */
export async function hydrateMessages( export async function hydrateMessages(
messages: Message[], messages: Message[],
db: DatabaseService _db: DatabaseService
): Promise<Message[]> { ): Promise<Message[]> {
return Promise.all(messages.map((msg) => hydrateMessage(msg, db))); return messages.map((msg) => msg.isDeleted ? normaliseDeletedMessage(msg) : msg);
} }
/** Builds a sync inventory item from a message and its reaction count. */ /** Builds a sync inventory item from a message and its reaction count. */

View File

@@ -340,11 +340,12 @@ export class RoomMembersSyncEffects {
const role = room.hostId === currentUser.id const role = room.hostId === currentUser.id
? 'host' ? 'host'
: (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member'); : (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member');
const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now();
return { return {
...roomMemberFromUser(currentUser, Date.now(), role), ...roomMemberFromUser(currentUser, seenAt, role),
id: existingMember?.id ?? currentUser.id, id: existingMember?.id ?? currentUser.id,
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? Date.now(), joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt,
avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl, avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl,
role role
}; };

View File

@@ -12,7 +12,8 @@ import {
of, of,
from, from,
EMPTY, EMPTY,
merge merge,
timer
} from 'rxjs'; } from 'rxjs';
import { import {
map, map,
@@ -60,6 +61,8 @@ type BlockedRoomAccessAction =
| ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof RoomsActions.joinRoomFailure>; | ReturnType<typeof RoomsActions.joinRoomFailure>;
const VIEW_SERVER_LOAD_DELAY_MS = 75;
@Injectable() @Injectable()
export class RoomsEffects { export class RoomsEffects {
private actions$ = inject(Actions); private actions$ = inject(Actions);
@@ -608,7 +611,12 @@ export class RoomsEffects {
navigationRequestVersion navigationRequestVersion
}); });
this.router.navigate(['/room', room.id]); window.setTimeout(() => {
if (this.signalingConnection.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
void this.router.navigate(['/room', room.id]);
}
}, 0);
return of(RoomsActions.viewServerSuccess({ room })); return of(RoomsActions.viewServerSuccess({ room }));
}; };
@@ -634,7 +642,9 @@ export class RoomsEffects {
onViewServerSuccess$ = createEffect(() => onViewServerSuccess$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(RoomsActions.viewServerSuccess), ofType(RoomsActions.viewServerSuccess),
mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()]) switchMap(({ room }) => timer(VIEW_SERVER_LOAD_DELAY_MS).pipe(
mergeMap(() => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
))
) )
); );

View File

@@ -42,6 +42,20 @@ function getDefaultTextChannelId(room: Room): string {
return resolveActiveTextChannelId(enrichRoom(room).channels, 'general'); return resolveActiveTextChannelId(enrichRoom(room).channels, 'general');
} }
function activateRoomView(state: RoomsState, room: Room, isConnecting: boolean): RoomsState {
const enriched = enrichRoom(room);
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: getDefaultTextChannelId(enriched)
};
}
/** Upsert a room into a saved-rooms list (add or replace by id) */ /** Upsert a room into a saved-rooms list (add or replace by id) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] { function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const normalizedRoom = enrichRoom(room); const normalizedRoom = enrichRoom(room);
@@ -220,27 +234,24 @@ export const roomsReducer = createReducer(
})), })),
// View server - just switch the viewed room, stay connected // View server - just switch the viewed room, stay connected
on(RoomsActions.viewServer, (state) => ({ on(RoomsActions.viewServer, (state, { room, skipBanCheck }) => {
if (skipBanCheck) {
return {
...activateRoomView(state, room, true),
error: null
};
}
return {
...state, ...state,
isConnecting: true, isConnecting: true,
signalServerCompatibilityError: null, signalServerCompatibilityError: null,
error: null error: null
})),
on(RoomsActions.viewServerSuccess, (state, { room }) => {
const enriched = enrichRoom(room);
return {
...state,
currentRoom: enriched,
savedRooms: upsertRoom(state.savedRooms, enriched),
isConnecting: false,
signalServerCompatibilityError: null,
isConnected: true,
activeChannelId: getDefaultTextChannelId(enriched)
}; };
}), }),
on(RoomsActions.viewServerSuccess, (state, { room }) => activateRoomView(state, room, false)),
// Update room settings // Update room settings
on(RoomsActions.updateRoomSettings, (state) => ({ on(RoomsActions.updateRoomSettings, (state) => ({
...state, ...state,