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({
where: { roomId, ownerUserId: currentUserId },
order: { timestamp: 'ASC' },
order: { timestamp: 'DESC' },
take: limit,
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 isDatabaseInitialised = false;
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
constructor() {
effect(() => {
@@ -79,27 +80,23 @@ export class AttachmentManagerService {
}
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!roomId || !this.isRoomWatched(roomId))
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0)
return;
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
const activeRequest = this.autoDownloadRequestsByRoom.get(roomId);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
await this.requestAutoDownloadsForMessage(message.id);
if (activeRequest) {
return activeRequest;
}
return;
const request = this.runAutoDownloadsForRoom(roomId).finally(() => {
if (this.autoDownloadRequestsByRoom.get(roomId) === request) {
this.autoDownloadRequestsByRoom.delete(roomId);
}
});
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
this.autoDownloadRequestsByRoom.set(roomId, request);
return request;
}
async deleteForMessage(messageId: string): Promise<void> {
@@ -180,6 +177,31 @@ export class AttachmentManagerService {
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> {
if (!messageId)
return;

View File

@@ -80,6 +80,7 @@ export class ServersRailComponent {
menuX = signal(72);
menuY = signal(100);
contextRoom = signal<Room | null>(null);
optimisticSelectedRoomId = signal<string | null>(null);
showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
@@ -192,6 +193,18 @@ export class ServersRailComponent {
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
.pipe(
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
@@ -214,6 +227,8 @@ export class ServersRailComponent {
createServer(): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
this.optimisticSelectedRoomId.set(null);
if (voiceServerId) {
this.voiceSession.setViewingVoiceServer(false);
}
@@ -235,11 +250,13 @@ export class ServersRailComponent {
return;
}
this.optimisticSelectedRoomId.set(room.id);
this.activateSavedRoom(room);
this.savedRoomJoinRequests.next({ room });
}
openCall(callId: string): void {
this.optimisticSelectedRoomId.set(null);
void this.router.navigate(['/call', callId]);
}
@@ -335,6 +352,7 @@ export class ServersRailComponent {
);
if (isCurrentRoom) {
this.optimisticSelectedRoomId.set(null);
this.router.navigate(['/search']);
}
@@ -374,6 +392,12 @@ export class ServersRailComponent {
}
isSelectedRoom(room: Room): boolean {
const optimisticRoomId = this.optimisticSelectedRoomId();
if (optimisticRoomId) {
return optimisticRoomId === room.id;
}
if (this.isOnDirectMessage() || this.isOnCall()) {
return false;
}
@@ -492,6 +516,7 @@ export class ServersRailComponent {
if (errorCode === 'BANNED') {
this.closePasswordDialog();
this.optimisticSelectedRoomId.set(null);
this.bannedRoomLookup.update((lookup) => ({
...lookup,
[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 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[]> {
const allRoomMessages = await this.getAllFromIndex<Message>(
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
.sort((first, second) => first.timestamp - second.timestamp)
.slice(offset, offset + limit)
.map((message) => this.normaliseMessage(message));
return this.hydrateMessages(messages);
}
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId
);
return allRoomMessages
const messages = allRoomMessages
.filter((message) => message.timestamp > sinceTimestamp)
.sort((first, second) => first.timestamp - second.timestamp)
.map((message) => this.normaliseMessage(message));
.sort((first, second) => first.timestamp - second.timestamp);
return this.hydrateMessages(messages);
}
/** Delete a message by its ID. */
@@ -112,7 +113,11 @@ export class BrowserDatabaseService {
async getMessageById(messageId: string): Promise<Message | null> {
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. */
@@ -520,6 +525,47 @@ export class BrowserDatabaseService {
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 {
if (message.content === DELETED_MESSAGE_CONTENT) {
return { ...message,

View File

@@ -49,7 +49,7 @@ export class DatabaseService {
/** Persist a single chat 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); }
/** 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 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[]> {
return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } });

View File

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

View File

@@ -50,6 +50,8 @@ import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
import { resolveRoomPermission } from '../../domains/access-control';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
@Injectable()
export class MessagesEffects {
private readonly actions$ = inject(Actions);
@@ -66,7 +68,7 @@ export class MessagesEffects {
this.actions$.pipe(
ofType(MessagesActions.loadMessages),
switchMap(({ roomId }) =>
from(this.db.getMessages(roomId)).pipe(
from(this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0)).pipe(
mergeMap(async (messages) => {
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. */
export async function hydrateMessage(
msg: Message,
db: DatabaseService
_db: DatabaseService
): Promise<Message> {
if (msg.isDeleted)
return normaliseDeletedMessage(msg);
const reactions = await db.getReactionsForMessage(msg.id);
return reactions.length > 0 ? { ...msg,
reactions } : msg;
return msg;
}
/** Hydrates an array of messages with their reactions. */
export async function hydrateMessages(
messages: Message[],
db: DatabaseService
_db: DatabaseService
): 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. */

View File

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

View File

@@ -12,7 +12,8 @@ import {
of,
from,
EMPTY,
merge
merge,
timer
} from 'rxjs';
import {
map,
@@ -60,6 +61,8 @@ type BlockedRoomAccessAction =
| ReturnType<typeof RoomsActions.forgetRoom>
| ReturnType<typeof RoomsActions.joinRoomFailure>;
const VIEW_SERVER_LOAD_DELAY_MS = 75;
@Injectable()
export class RoomsEffects {
private actions$ = inject(Actions);
@@ -608,7 +611,12 @@ export class RoomsEffects {
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 }));
};
@@ -634,7 +642,9 @@ export class RoomsEffects {
onViewServerSuccess$ = createEffect(() =>
this.actions$.pipe(
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');
}
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) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const normalizedRoom = enrichRoom(room);
@@ -220,27 +234,24 @@ export const roomsReducer = createReducer(
})),
// 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,
isConnecting: true,
signalServerCompatibilityError: 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
on(RoomsActions.updateRoomSettings, (state) => ({
...state,