diff --git a/electron/cqrs/queries/handlers/getMessages.ts b/electron/cqrs/queries/handlers/getMessages.ts index 27b0bac..b5a3286 100644 --- a/electron/cqrs/queries/handlers/getMessages.ts +++ b/electron/cqrs/queries/handlers/getMessages.ts @@ -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) ?? [])); } diff --git a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts index e3534f3..4480a53 100644 --- a/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts +++ b/toju-app/src/app/domains/attachment/application/services/attachment-manager.service.ts @@ -35,6 +35,7 @@ export class AttachmentManagerService { private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); private isDatabaseInitialised = false; + private autoDownloadRequestsByRoom = new Map>(); constructor() { effect(() => { @@ -79,27 +80,23 @@ export class AttachmentManagerService { } async requestAutoDownloadsForRoom(roomId: string): Promise { - 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); - } - - return; + if (activeRequest) { + return activeRequest; } - for (const [messageId] of this.runtimeStore.getAttachmentEntries()) { - const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId); - - if (attachmentRoomId === roomId) { - await this.requestAutoDownloadsForMessage(messageId); + const request = this.runAutoDownloadsForRoom(roomId).finally(() => { + if (this.autoDownloadRequestsByRoom.get(roomId) === request) { + this.autoDownloadRequestsByRoom.delete(roomId); } - } + }); + + this.autoDownloadRequestsByRoom.set(roomId, request); + return request; } async deleteForMessage(messageId: string): Promise { @@ -180,6 +177,31 @@ export class AttachmentManagerService { await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file); } + private async runAutoDownloadsForRoom(roomId: string): Promise { + 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 { if (!messageId) return; diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts index 80548d8..84bff04 100644 --- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts +++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts @@ -80,6 +80,7 @@ export class ServersRailComponent { menuX = signal(72); menuY = signal(100); contextRoom = signal(null); + optimisticSelectedRoomId = signal(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 diff --git a/toju-app/src/app/infrastructure/persistence/browser-database.service.ts b/toju-app/src/app/infrastructure/persistence/browser-database.service.ts index 1d177d0..93b7993 100644 --- a/toju-app/src/app/infrastructure/persistence/browser-database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/browser-database.service.ts @@ -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 { const allRoomMessages = await this.getAllFromIndex( 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 { const allRoomMessages = await this.getAllFromIndex( 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 { const message = await this.get(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 { + 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> { + const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0)); + const reactionsByMessageId = new Map(); + + if (messageIdSet.size === 0) { + return reactionsByMessageId; + } + + const allReactions = await this.getAll(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, diff --git a/toju-app/src/app/infrastructure/persistence/database.service.ts b/toju-app/src/app/infrastructure/persistence/database.service.ts index 07fd8cf..10f5498 100644 --- a/toju-app/src/app/infrastructure/persistence/database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/database.service.ts @@ -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. */ diff --git a/toju-app/src/app/infrastructure/persistence/electron-database.service.ts b/toju-app/src/app/infrastructure/persistence/electron-database.service.ts index 4eb5aab..7949e16 100644 --- a/toju-app/src/app/infrastructure/persistence/electron-database.service.ts +++ b/toju-app/src/app/infrastructure/persistence/electron-database.service.ts @@ -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 { return this.api.query({ type: 'get-messages', payload: { roomId, limit, offset } }); diff --git a/toju-app/src/app/store/messages/messages-sync.effects.ts b/toju-app/src/app/store/messages/messages-sync.effects.ts index 9137782..018fe87 100644 --- a/toju-app/src/app/store/messages/messages-sync.effects.ts +++ b/toju-app/src/app/store/messages/messages-sync.effects.ts @@ -111,40 +111,47 @@ 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 EMPTY; + return timer(75).pipe( + withLatestFrom(this.store.select(selectCurrentRoom)), + switchMap(([, latestCurrentRoom]) => { + const activeRoom = latestCurrentRoom ?? currentRoom ?? room; + const peers = this.webrtc.getConnectedPeers(); - 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()) { - try { - this.webrtc.sendToPeer(pid, { - type: 'chat-sync-summary', - roomId: activeRoom.id, - count, - lastUpdated - }); - - this.webrtc.sendToPeer(pid, { - type: 'chat-inventory-request', - roomId: activeRoom.id - }); - } catch (error) { - this.debugging.warn('messages', 'Failed to kick off room sync for peer', { - error, - peerId: pid, - roomId: activeRoom.id - }); - } + if (!activeRoom || activeRoom.id !== requestedRoomId || peers.length === 0) { + return EMPTY; } + + 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 peers) { + try { + this.webrtc.sendToPeer(pid, { + type: 'chat-sync-summary', + roomId: activeRoom.id, + count, + lastUpdated + }); + + this.webrtc.sendToPeer(pid, { + type: 'chat-inventory-request', + roomId: activeRoom.id + }); + } catch (error) { + this.debugging.warn('messages', 'Failed to kick off room sync for peer', { + error, + peerId: pid, + roomId: activeRoom.id + }); + } + } + }) + ); }) ); }) diff --git a/toju-app/src/app/store/messages/messages.effects.ts b/toju-app/src/app/store/messages/messages.effects.ts index 49c8038..068c611 100644 --- a/toju-app/src/app/store/messages/messages.effects.ts +++ b/toju-app/src/app/store/messages/messages.effects.ts @@ -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); diff --git a/toju-app/src/app/store/messages/messages.helpers.ts b/toju-app/src/app/store/messages/messages.helpers.ts index 4a3fc2b..f4ea065 100644 --- a/toju-app/src/app/store/messages/messages.helpers.ts +++ b/toju-app/src/app/store/messages/messages.helpers.ts @@ -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 { 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 { - 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. */ diff --git a/toju-app/src/app/store/rooms/room-members-sync.effects.ts b/toju-app/src/app/store/rooms/room-members-sync.effects.ts index 279d097..baba652 100644 --- a/toju-app/src/app/store/rooms/room-members-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-members-sync.effects.ts @@ -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 }; diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts index e8f5d6f..3a79c8f 100644 --- a/toju-app/src/app/store/rooms/rooms.effects.ts +++ b/toju-app/src/app/store/rooms/rooms.effects.ts @@ -12,7 +12,8 @@ import { of, from, EMPTY, - merge + merge, + timer } from 'rxjs'; import { map, @@ -60,6 +61,8 @@ type BlockedRoomAccessAction = | ReturnType | ReturnType; +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()]) + )) ) ); diff --git a/toju-app/src/app/store/rooms/rooms.reducer.ts b/toju-app/src/app/store/rooms/rooms.reducer.ts index 63e1b23..abe4002 100644 --- a/toju-app/src/app/store/rooms/rooms.reducer.ts +++ b/toju-app/src/app/store/rooms/rooms.reducer.ts @@ -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) => ({ - ...state, - isConnecting: true, - signalServerCompatibilityError: null, - error: null - })), - - on(RoomsActions.viewServerSuccess, (state, { room }) => { - const enriched = enrichRoom(room); + on(RoomsActions.viewServer, (state, { room, skipBanCheck }) => { + if (skipBanCheck) { + return { + ...activateRoomView(state, room, true), + error: null + }; + } return { ...state, - currentRoom: enriched, - savedRooms: upsertRoom(state.savedRooms, enriched), - isConnecting: false, + isConnecting: true, signalServerCompatibilityError: null, - isConnected: true, - activeChannelId: getDefaultTextChannelId(enriched) + error: null }; }), + on(RoomsActions.viewServerSuccess, (state, { room }) => activateRoomView(state, room, false)), + // Update room settings on(RoomsActions.updateRoomSettings, (state) => ({ ...state,