diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index 03a50f8..a0b58ee 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -25,6 +25,20 @@ Durable rules for AI agents working on this project. Read this file at session s ## Lessons +### Disambiguate nested chat cards [chat] [ui] + +- **Trigger:** removing a visual treatment from chat history when a system message has both an outer row wrapper and an inner pill/card. +- **Rule:** preserve the intended inner timeline pill unless the user explicitly targets it; render system messages outside the themed `chatMessageBubble` wrapper and keep `data-message-id` off direct child `div`s. +- **Why:** PM call-started history should stay as a compact centered pill, while theme CSS such as `app-chat-message-item > div[data-message-id]` can turn the full-width row around it into the unnecessary card. +- **Example:** In `chat-message-item.component.html`, keep `data-testid="chat-system-message"` with `rounded-full border bg-secondary/45`, put `appThemeNode="chatMessageBubble"` only on the non-system branch, and place `[attr.data-message-id]` on the nested pill instead of the system row wrapper. + +### Use terminal Vitest when the test tool hangs [testing] + +- **Trigger:** VS Code test execution stays at "Starting test run..." without producing Vitest output. +- **Rule:** run the focused spec through the terminal with `cd toju-app && npx vitest run ` and report the direct Vitest result. +- **Why:** the test integration can hang before starting the runner, while the terminal Vitest command returns quickly and gives actionable failures. +- **Example:** `cd toju-app && npx vitest run src/app/domains/game-activity/application/game-activity.service.spec.ts`. + ### Do not add fake chrome around screenshots [website] [design] - **Trigger:** wrapping a real product screenshot in decorative titlebar/window chrome or placing oversized marketing headings beside copy without checking overlap. diff --git a/toju-app/src/app/domains/README.md b/toju-app/src/app/domains/README.md index 2f48828..0bbd47b 100644 --- a/toju-app/src/app/domains/README.md +++ b/toju-app/src/app/domains/README.md @@ -15,7 +15,7 @@ infrastructure adapters and UI. | **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` | | **direct-call** | Direct and small-group private calls initiated from people cards and direct messages | `DirectCallService` | | **experimental-media** | Optional media playback experiments kept isolated from the default attachment path | `ExperimentalMediaSettingsService` | -| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` | +| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), focused-window scan suppression in Electron, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` | | **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` | diff --git a/toju-app/src/app/domains/attachment/README.md b/toju-app/src/app/domains/attachment/README.md index ede367f..ce4f0b2 100644 --- a/toju-app/src/app/domains/attachment/README.md +++ b/toju-app/src/app/domains/attachment/README.md @@ -133,6 +133,8 @@ When the user navigates to a room, the manager watches the route and decides whi The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`. +Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media. + Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player. An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path. 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 4480a53..69c1998 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 @@ -6,8 +6,11 @@ import { import { NavigationEnd, Router } from '@angular/router'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { DatabaseService } from '../../../../infrastructure/persistence'; -import { ROOM_URL_PATTERN } from '../../../../core/constants'; -import { shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic'; +import { + getWatchedAttachmentRoomIdFromUrl, + isDirectMessageAttachmentRoomId, + shouldAutoRequestWhenWatched +} from '../../domain/logic/attachment.logic'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import type { FileAnnouncePayload, @@ -182,6 +185,11 @@ export class AttachmentManagerService { return; } + if (isDirectMessageAttachmentRoomId(roomId)) { + await this.requestAutoDownloadsForRuntimeRoom(roomId); + return; + } + if (this.database.isReady()) { const messages = await this.database.getMessages(roomId, 500, 0); @@ -193,6 +201,10 @@ export class AttachmentManagerService { return; } + await this.requestAutoDownloadsForRuntimeRoom(roomId); + } + + private async requestAutoDownloadsForRuntimeRoom(roomId: string): Promise { for (const [messageId] of this.runtimeStore.getAttachmentEntries()) { const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId); @@ -235,9 +247,7 @@ export class AttachmentManagerService { } private extractWatchedRoomId(url: string): string | null { - const roomMatch = url.match(ROOM_URL_PATTERN); - - return roomMatch ? roomMatch[1] : null; + return getWatchedAttachmentRoomIdFromUrl(url); } private isRoomWatched(roomId: string | null | undefined): boolean { diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts new file mode 100644 index 0000000..41d56e3 --- /dev/null +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.spec.ts @@ -0,0 +1,24 @@ +import { getWatchedAttachmentRoomIdFromUrl, isDirectMessageAttachmentRoomId } from './attachment.logic'; + +describe('attachment logic', () => { + it('extracts watched server room ids from room URLs', () => { + expect(getWatchedAttachmentRoomIdFromUrl('/room/general')).toBe('general'); + expect(getWatchedAttachmentRoomIdFromUrl('/room/general/chat')).toBe('general'); + }); + + it('extracts watched direct-message storage ids from DM URLs', () => { + expect(getWatchedAttachmentRoomIdFromUrl('/dm/alice%3Abob')).toBe('direct-message:alice:bob'); + expect(getWatchedAttachmentRoomIdFromUrl('/pm/dm-group-1?tab=chat')).toBe('direct-message:dm-group-1'); + }); + + it('ignores non-message URLs', () => { + expect(getWatchedAttachmentRoomIdFromUrl('/settings')).toBeNull(); + expect(getWatchedAttachmentRoomIdFromUrl('/dm')).toBeNull(); + }); + + it('identifies direct-message attachment storage ids', () => { + expect(isDirectMessageAttachmentRoomId('direct-message:alice:bob')).toBe(true); + expect(isDirectMessageAttachmentRoomId('room-1')).toBe(false); + expect(isDirectMessageAttachmentRoomId(null)).toBe(false); + }); +}); diff --git a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts index 5ead998..630ff1f 100644 --- a/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts +++ b/toju-app/src/app/domains/attachment/domain/logic/attachment.logic.ts @@ -1,6 +1,10 @@ import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants'; import type { Attachment } from '../models/attachment.model'; +const ROOM_URL_PATTERN = /\/room\/([^/]+)/; +const DIRECT_MESSAGE_URL_PATTERN = /^\/(?:dm|pm)\/([^/]+)/; +const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:'; + export function isAttachmentMedia(attachment: Pick): boolean { return attachment.mime.startsWith('image/') || attachment.mime.startsWith('video/') || @@ -17,3 +21,28 @@ export function shouldPersistDownloadedAttachment(attachment: Pick @let msg = message(); @let attachmentsList = attachmentViewModels(); +@if (isSystemMessage()) { +
+
+ {{ msg.content }} + {{ formatTimestamp(msg.timestamp) }} +
+
+} @else {
+} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index 9111aa1..7b85a2a 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -203,6 +203,7 @@ export class ChatMessageItemComponent implements OnDestroy { readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content)); readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken())); readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed()); + readonly isSystemMessage = computed(() => this.message().kind === 'system'); readonly isEditing = signal(false); readonly showEmojiPicker = signal(false); readonly senderUser = computed(() => { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.template.spec.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.template.spec.ts new file mode 100644 index 0000000..8e52af8 --- /dev/null +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.template.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; + +const template = readFileSync(new URL('./chat-message-item.component.html', import.meta.url), 'utf8'); + +describe('ChatMessageItemComponent template', () => { + it('keeps system messages in a centered pill without full-row card styling', () => { + const systemMessageIndex = template.indexOf('data-testid="chat-system-message"'); + const systemWrapperBlock = template.slice(Math.max(0, systemMessageIndex - 220), systemMessageIndex); + const messageRowBlock = template.match(/appThemeNode="chatMessageBubble"[\s\S]*?class="([^"]+)"/); + const systemMessageBlock = template.match(/data-testid="chat-system-message"[\s\S]*?class="([^"]+)"/); + + expect(systemMessageIndex).toBeGreaterThan(-1); + expect(systemWrapperBlock).not.toContain('appThemeNode="chatMessageBubble"'); + expect(systemWrapperBlock).not.toContain('[attr.data-message-id]'); + expect(systemWrapperBlock).toContain('justify-center'); + expect(messageRowBlock?.[1]).toBeDefined(); + expect(messageRowBlock?.[1]).toMatch(/\brounded-lg\b/); + expect(messageRowBlock?.[1]).toMatch(/\bhover:bg-secondary\/30\b/); + expect(systemMessageBlock?.[1]).toBeDefined(); + expect(systemMessageBlock?.[1]).toMatch(/\brounded-full\b/); + expect(systemMessageBlock?.[1]).toMatch(/\bborder\b/); + expect(systemMessageBlock?.[1]).toMatch(/\bbg-secondary\/45\b/); + }); +}); diff --git a/toju-app/src/app/domains/direct-call/README.md b/toju-app/src/app/domains/direct-call/README.md index 1b36b9a..ec49ab3 100644 --- a/toju-app/src/app/domains/direct-call/README.md +++ b/toju-app/src/app/domains/direct-call/README.md @@ -6,12 +6,13 @@ Direct calls coordinate private voice sessions started from people cards, direct 1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session. 2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel. -3. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. The ring stops when the recipient joins, declines, leaves, or the call ends. -4. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel. -5. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat. -6. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant. -7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages. -8. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view. +3. The caller and recipient both record a direct-message `call-started` system entry for the call's conversation, so the chat history shows who started the call without creating a normal text message. +4. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. Ring events received before the current user identity is hydrated are queued and replayed once identity is available. The ring stops when the recipient joins, declines, leaves, or the call ends; stale duplicate ring events for a locally ended call are ignored. +5. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel. +6. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat. +7. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant. +8. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages. +9. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view. Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification. diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts index a501510..499041b 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.spec.ts @@ -9,6 +9,7 @@ import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { Subject } from 'rxjs'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; +import { ViewportService } from '../../../../core/platform'; import { VoiceActivityService, VoiceConnectionFacade, @@ -136,6 +137,87 @@ describe('DirectCallService', () => { expect(context.service.incomingCall()).toBeNull(); }); + it('does not start ringing after declining while incoming ring setup is still pending', async () => { + let releaseCallLog: (() => void) | null = null; + + const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); + + context.directMessages.recordCallStarted.mockImplementationOnce(async () => new Promise((resolve) => { + releaseCallLog = resolve; + })); + + context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob'])); + + await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob')); + await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalled()); + context.service.declineIncomingCall('dm-alice-bob'); + releaseCallLog?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(context.service.incomingCall()).toBeNull(); + expect(context.audio.playLoop).not.toHaveBeenCalled(); + expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call); + }); + + it('logs a system call-started entry when starting a direct PM call', async () => { + const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] }); + + await context.service.startCall(bob); + + expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith( + 'dm-alice-bob', + expect.objectContaining({ userId: 'alice' }), + expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]), + expect.any(Number) + ); + }); + + it('logs a system call-started entry when receiving a PM ring', async () => { + const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); + + context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob'])); + + await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob')); + await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith( + 'dm-alice-bob', + expect.objectContaining({ userId: 'alice' }), + expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]), + 10 + )); + }); + + it('does not restart the call sound when a stale ring arrives after declining', async () => { + const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); + + context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob'])); + + await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob')); + context.service.declineIncomingCall('dm-alice-bob'); + context.audio.playLoop.mockClear(); + + context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob'])); + + await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended')); + expect(context.service.incomingCall()).toBeNull(); + expect(context.audio.playLoop).not.toHaveBeenCalled(); + }); + + it('shows a pending incoming call once the current user hydrates', async () => { + const context = createServiceContext({ currentUser: null, allUsers: [alice, bob] }); + + context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob'])); + + await Promise.resolve(); + expect(context.service.sessionById('dm-alice-bob')).toBeNull(); + + context.currentUser.set(bob); + context.effectScheduler.flush(); + + await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob')); + await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call)); + }); + it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => { const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] }); const session = createSession('connected', true); @@ -298,7 +380,7 @@ describe('DirectCallService', () => { interface ServiceContextOptions { allUsers: User[]; - currentUser: User; + currentUser: User | null; } interface ServiceContext { @@ -306,6 +388,7 @@ interface ServiceContext { playLoop: ReturnType; stop: ReturnType; }; + currentUser: ReturnType>; delivery: { sendCallEvent: ReturnType; }; @@ -314,6 +397,10 @@ interface ServiceContext { createConversation: ReturnType; createGroupConversation: ReturnType; openConversation: ReturnType; + recordCallStarted: ReturnType; + }; + effectScheduler: { + flush: ReturnType; }; router: { navigate: ReturnType; @@ -351,12 +438,13 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext { }) }; const directMessages = { - createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)), + createConversation: vi.fn(async (user: User) => createDirectConversation(currentUser() ?? bob, user)), createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({ ...createGroupConversation(conversationId, participants.map(participantToUser)), title })), - openConversation: vi.fn(async () => undefined) + openConversation: vi.fn(async () => undefined), + recordCallStarted: vi.fn(async () => undefined) }; const delivery = { directCallEvents$: directCallEvents.asObservable(), @@ -366,6 +454,25 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext { playLoop: vi.fn(), stop: vi.fn() }; + const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>(); + const effectScheduler = { + add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => { + scheduledEffects.add(scheduledEffect); + }), + flush: vi.fn(() => { + for (const scheduledEffect of scheduledEffects) { + if (scheduledEffect.dirty) { + scheduledEffect.run(); + } + } + }), + remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => { + scheduledEffects.delete(scheduledEffect); + }), + schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => { + scheduledEffects.add(scheduledEffect); + }) + }; const voice = { broadcastMessage: vi.fn(), disableVoice: vi.fn(), @@ -391,12 +498,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext { }, { provide: EffectScheduler, - useValue: { - add: vi.fn(), - flush: vi.fn(), - remove: vi.fn(), - schedule: vi.fn() - } + useValue: effectScheduler }, { provide: DirectMessageService, @@ -439,15 +541,23 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext { playPendingStreams: vi.fn(), teardownAll: vi.fn() } + }, + { + provide: ViewportService, + useValue: { + isMobile: vi.fn(() => false) + } } ] }); return { audio, + currentUser, delivery, directCallEvents, directMessages, + effectScheduler, router, service: runInInjectionContext(injector, () => new DirectCallService()), voice, diff --git a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts index 16fbde8..6835607 100644 --- a/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts +++ b/toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts @@ -44,6 +44,7 @@ export class DirectCallService { private readonly users = this.store.selectSignal(selectAllUsers); private readonly sessionsSignal = signal([]); private readonly mobileOverlayCallId = signal(null); + private readonly pendingIncomingCallEvents: DirectCallEventPayload[] = []; readonly sessions = computed(() => this.sessionsSignal()); readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended')); @@ -85,6 +86,18 @@ export class DirectCallService { } }); + effect(() => { + if (!this.currentUserId() || this.pendingIncomingCallEvents.length === 0) { + return; + } + + const pendingEvents = this.pendingIncomingCallEvents.splice(0); + + for (const payload of pendingEvents) { + void this.handleIncomingCallEvent(payload); + } + }); + effect(() => { const session = this.currentSession(); @@ -159,7 +172,7 @@ export class DirectCallService { } const existing = this.sessionById(conversation.id); - const session = existing ?? this.createSession({ + const session = existing && existing.status !== 'ended' ? existing : this.createSession({ callId: conversation.id, conversationId: conversation.id, createdAt: Date.now(), @@ -171,6 +184,14 @@ export class DirectCallService { this.upsertSession(session); this.currentSession.set(session); + + await this.directMessages.recordCallStarted( + session.conversationId, + meParticipant, + Object.values(session.participants).map((participant) => participant.profile), + session.createdAt + ); + await this.joinCall(session.callId, false); this.sendCallEvent(peerParticipant.userId, 'ring', session); await this.openCallView(session.callId); @@ -236,6 +257,8 @@ export class DirectCallService { } declineIncomingCall(callId: string): void { + this.audio.stop(AppSound.Call); + const session = this.sessionById(callId); if (!session || session.status === 'ended') { @@ -253,8 +276,6 @@ export class DirectCallService { status: 'ended' as const }; - this.audio.stop(AppSound.Call); - if (meId) { this.broadcastCallEvent('leave', session); } @@ -385,18 +406,29 @@ export class DirectCallService { } private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise { + const currentUserIds = this.currentUserIds(); const meId = this.currentUserId(); - if (!meId || payload.sender.userId === meId) { + if (!meId) { + this.pendingIncomingCallEvents.push(payload); return; } - if (!this.callPayloadIncludesParticipant(payload, meId)) { + if (currentUserIds.has(payload.sender.userId)) { + return; + } + + if (!this.callPayloadIncludesAnyParticipant(payload, currentUserIds)) { return; } const participants = this.callParticipantsFromPayload(payload); const existing = this.sessionById(payload.callId); + + if (this.isStaleRingForEndedSession(payload, existing)) { + return; + } + const incomingSession = this.createSession({ callId: payload.callId, conversationId: payload.conversationId, @@ -424,14 +456,22 @@ export class DirectCallService { if (payload.action === 'ring') { await this.ensureCallConversation(session); + await this.directMessages.recordCallStarted( + session.conversationId, + payload.sender, + Object.values(session.participants).map((participant) => participant.profile), + session.createdAt + ); - if (this.shouldAlertIncomingCall(session)) { + const latestSession = this.sessionById(session.callId); + + if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) { this.audio.playLoop(AppSound.Call); } else { this.audio.stop(AppSound.Call); } - if (this.shouldAlertIncomingCall(session)) { + if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) { await this.showIncomingNotification(payload.sender.displayName, payload.callId); } @@ -475,6 +515,14 @@ export class DirectCallService { this.upsertSession(session); this.currentSession.set(session); + + await this.directMessages.recordCallStarted( + session.conversationId, + meParticipant, + Object.values(session.participants).map((participant) => participant.profile), + session.createdAt + ); + await this.joinCall(session.callId, false); this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session); await this.router.navigate(['/call', session.callId]); @@ -711,9 +759,15 @@ export class DirectCallService { ]); } - private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean { - return payload.participantIds.includes(participantId) - || (payload.participants ?? []).some((participant) => participant.userId === participantId); + private callPayloadIncludesAnyParticipant(payload: DirectCallEventPayload, participantIds: Set): boolean { + return payload.participantIds.some((participantId) => participantIds.has(participantId)) + || (payload.participants ?? []).some((participant) => participantIds.has(participant.userId)); + } + + private isStaleRingForEndedSession(payload: DirectCallEventPayload, existing: DirectCallSession | null): boolean { + return payload.action === 'ring' + && existing?.status === 'ended' + && payload.createdAt <= existing.createdAt; } private groupConversationTitle(session: DirectCallSession): string { @@ -867,6 +921,10 @@ export class DirectCallService { return session.status !== 'connected' && !this.isDoNotDisturb(); } + private shouldPlayIncomingCallAlert(session: DirectCallSession): boolean { + return this.shouldAlertIncomingCall(session) && this.incomingCall()?.callId === session.callId; + } + private isDoNotDisturb(): boolean { return this.currentUser()?.status === 'busy'; } @@ -923,6 +981,25 @@ export class DirectCallService { return user ? this.userKey(user) : null; } + private currentUserIds(): Set { + const ids = new Set(); + const user = this.currentUser(); + + if (user?.id) { + ids.add(user.id); + } + + if (user?.oderId) { + ids.add(user.oderId); + } + + if (user?.peerId) { + ids.add(user.peerId); + } + + return ids; + } + private requireCurrentUser(): User { const user = this.currentUser(); diff --git a/toju-app/src/app/domains/direct-message/README.md b/toju-app/src/app/domains/direct-message/README.md index d65358e..22230a7 100644 --- a/toju-app/src/app/domains/direct-message/README.md +++ b/toju-app/src/app/domains/direct-message/README.md @@ -23,6 +23,8 @@ direct-message/ 5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back. 6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event. +Unread counts are idempotent by message id: re-receiving or syncing a message that already exists can update status/content metadata but must not increment the conversation unread count again. + Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history. Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`. @@ -37,6 +39,8 @@ When a private call grows beyond two participants, the direct-call domain create The DM header and conversation list can start calls from both one-to-one and group conversations. Group calls reuse the group conversation id as the call id and send the same ring notification to every other participant. +Starting or receiving a PM/group call records a local `system` direct-message event with `systemEvent: "call-started"`. These entries are stored with deterministic ids based on the conversation and call timestamp, do not increment unread counts, and are rendered by the shared chat list as compact timeline rows instead of editable/reactable text messages. + Typing state is DM-owned as well. The composer emits `direct-message-typing` events, and the chat view renders the active peer names with a short TTL so the embedded private-call chat has the same typing feedback as a standalone PM. When a conversation opens, a peer reconnects, or network service is restored, the selected conversation requests a bounded `direct-message-sync` snapshot from the peer. Incoming snapshots merge the newest messages by id instead of replacing local history, which lets clients backfill older PMs when their local stores drift. diff --git a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.spec.ts b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.spec.ts index a7d3327..28e8cd8 100644 --- a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.spec.ts +++ b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.spec.ts @@ -4,6 +4,7 @@ import { createGroupConversation, directMessageEventIncludesUser, directMessageSyncIncludesUser, + createDirectCallStartedMessage, getDirectConversationId, isGroupDirectConversation, updateMessageStatusInConversation, @@ -88,6 +89,36 @@ describe('DirectMessageService domain flow', () => { expect(updatedConversation.messages[0].recipientIds).toEqual(recipientIds); }); + it('does not increment unread when an existing message is upserted again', () => { + const conversation = createDirectConversation(alice, bob, 10); + const message = createMessage('message-1', 'SENT'); + const withUnreadMessage = upsertDirectMessage(conversation, message, true); + const withDuplicateMessage = upsertDirectMessage(withUnreadMessage, { ...message, status: 'DELIVERED' }, true); + + expect(withDuplicateMessage.messages).toHaveLength(1); + expect(withDuplicateMessage.unreadCount).toBe(1); + expect(withDuplicateMessage.messages[0].status).toBe('DELIVERED'); + }); + + it('does not increment unread for call-started system messages', () => { + const conversation = createDirectConversation(alice, bob, 0); + const message = createDirectCallStartedMessage(conversation.id, alice, ['bob'], 123); + const withSystemMessage = upsertDirectMessage(conversation, message, true); + + expect(withSystemMessage.unreadCount).toBe(0); + expect(withSystemMessage.messages).toHaveLength(1); + }); + + it('creates call-started system messages that do not read like normal text', () => { + const message = createDirectCallStartedMessage(getDirectConversationId('alice', 'bob'), alice, ['bob'], 123); + + expect(message.id).toBe(`dm-call-started-${getDirectConversationId('alice', 'bob')}-123`); + expect(message.kind).toBe('system'); + expect(message.systemEvent).toBe('call-started'); + expect(message.content).toBe('Alice started a call'); + expect(message.status).toBe('DELIVERED'); + }); + it('should update status correctly', () => { expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT'); expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED'); diff --git a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts index 9da8952..e17c3d0 100644 --- a/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts @@ -17,6 +17,7 @@ import { advanceDirectMessageStatus, createDirectConversation, createGroupConversation, + createDirectCallStartedMessage, directMessageConversationIncludesUser, directMessageEventIncludesUser, directMessageSyncIncludesUser, @@ -244,6 +245,37 @@ export class DirectMessageService { return message; } + async recordCallStarted( + conversationId: string, + caller: DirectMessageParticipant, + participants: DirectMessageParticipant[], + timestamp = Date.now() + ): Promise { + const ownerId = this.getCurrentUserIdOrThrow(); + const currentUser = this.requireCurrentUser(); + const currentParticipant = toDirectMessageParticipant(currentUser); + const allParticipants = this.uniqueParticipants([ + currentParticipant, + caller, + ...participants + ]); + + await this.loadForOwner(ownerId); + + const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId) + ?? await this.repository.getConversation(ownerId, conversationId) + ?? this.createConversationForSystemEvent(conversationId, currentParticipant, caller, allParticipants, timestamp); + const conversation = this.mergeConversationParticipants(existingConversation, allParticipants); + const message = createDirectCallStartedMessage( + conversation.id, + caller, + conversation.participants.filter((participantId) => participantId !== caller.userId), + timestamp + ); + + await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false)); + } + async editMessage(conversationId: string, messageId: string, content: string): Promise { const normalizedContent = content.trim(); @@ -863,6 +895,25 @@ export class DirectMessageService { }; } + private createConversationForSystemEvent( + conversationId: string, + currentParticipant: DirectMessageParticipant, + caller: DirectMessageParticipant, + participants: DirectMessageParticipant[], + timestamp: number + ): DirectMessageConversation { + if (participants.length > 2) { + return createGroupConversation(conversationId, participants, timestamp); + } + + const peer = participants.find((participant) => participant.userId !== currentParticipant.userId) ?? caller; + + return { + ...createDirectConversation(currentParticipant, peer, timestamp), + id: conversationId + }; + } + private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] { if (!conversation || !currentUserId) { return []; diff --git a/toju-app/src/app/domains/direct-message/domain/logic/direct-message.logic.ts b/toju-app/src/app/domains/direct-message/domain/logic/direct-message.logic.ts index 1a7b538..d3a3843 100644 --- a/toju-app/src/app/domains/direct-message/domain/logic/direct-message.logic.ts +++ b/toju-app/src/app/domains/direct-message/domain/logic/direct-message.logic.ts @@ -72,6 +72,28 @@ export function createGroupConversation( }; } +export function createDirectCallStartedMessage( + conversationId: string, + caller: DirectMessageParticipant, + recipientIds: string[], + timestamp: number +): DirectMessage { + return { + id: `dm-call-started-${conversationId}-${timestamp}`, + conversationId, + senderId: caller.userId, + recipientId: recipientIds[0] ?? caller.userId, + recipientIds, + content: `${caller.displayName || caller.username || caller.userId} started a call`, + timestamp, + status: 'DELIVERED', + kind: 'system', + systemEvent: 'call-started', + reactions: [], + isDeleted: false + }; +} + export function isGroupDirectConversation(conversation: DirectMessageConversation): boolean { return conversation.kind === 'group' || conversation.participants.length > 2; } @@ -104,6 +126,7 @@ export function upsertDirectMessage( ): DirectMessageConversation { const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id); const messages = [...conversation.messages]; + const isNewMessage = existingIndex < 0; if (existingIndex >= 0) { const existing = messages[existingIndex]; @@ -119,11 +142,13 @@ export function upsertDirectMessage( messages.sort((firstMessage, secondMessage) => firstMessage.timestamp - secondMessage.timestamp); + const shouldIncrementUnread = incrementUnread && isNewMessage && message.kind !== 'system'; + return { ...conversation, messages, lastMessageAt: Math.max(conversation.lastMessageAt, message.timestamp), - unreadCount: incrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount + unreadCount: shouldIncrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount }; } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts index 606516f..8ea88c7 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-chat/dm-chat.component.ts @@ -193,6 +193,8 @@ export class DmChatComponent { senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId), content: message.content, timestamp: message.timestamp, + kind: message.kind, + systemEvent: message.systemEvent, editedAt: message.editedAt, reactions: message.reactions ?? [], isDeleted: !!message.isDeleted, diff --git a/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts b/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts index 053630e..04a21ea 100644 --- a/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts +++ b/toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts @@ -8,6 +8,7 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Subject, of } from 'rxjs'; import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import type { ActiveGameCandidateResult } from '../../../core/platform/electron/electron-api.models'; import { RealtimeSessionFacade } from '../../../core/realtime'; import { ServerDirectoryFacade } from '../../server-directory'; import { UsersActions } from '../../../store/users/users.actions'; @@ -117,12 +118,38 @@ describe('GameActivityService sync', () => { }) })); }); + + it('does not ask Electron for running games while the app window is focused', () => { + const getActiveGameCandidate = vi.fn<() => Promise>(async () => ({ + candidate: null, + fallbackProcessNames: ['Hades.exe'] + })); + const getRunningProcessNames = vi.fn(async () => ['Hades.exe']); + const context = createServiceContext({ + currentUser: alice, + allUsers: [alice], + documentHasFocus: true, + electronApi: { + getActiveGameCandidate, + getRunningProcessNames + } + }); + + context.service.start(); + + expect(getActiveGameCandidate).not.toHaveBeenCalled(); + expect(getRunningProcessNames).not.toHaveBeenCalled(); + }); }); interface ServiceContextOptions { currentUser: User; allUsers: User[]; - electronApi?: { getRunningProcessNames: () => Promise } | null; + electronApi?: { + getActiveGameCandidate?: () => Promise; + getRunningProcessNames?: () => Promise; + } | null; + documentHasFocus?: boolean; processNames?: string[]; gameMatchResponse?: GameMatchResponse; } @@ -141,6 +168,8 @@ interface ServiceContext { } function createServiceContext(options: ServiceContextOptions): ServiceContext { + vi.stubGlobal('document', { hasFocus: () => options.documentHasFocus ?? false }); + const currentUser = signal(options.currentUser); const allUsers = signal(options.allUsers); const incomingMessages = new Subject(); diff --git a/toju-app/src/app/domains/game-activity/application/game-activity.service.ts b/toju-app/src/app/domains/game-activity/application/game-activity.service.ts index da333c3..a4dca0d 100644 --- a/toju-app/src/app/domains/game-activity/application/game-activity.service.ts +++ b/toju-app/src/app/domains/game-activity/application/game-activity.service.ts @@ -160,6 +160,13 @@ export class GameActivityService implements OnDestroy { return; } + if (this.isAppWindowFocused()) { + this.lastProcessHash = ''; + this.ngZone.run(() => this.applyMatchedGame(null)); + + return; + } + this.scanInFlight = true; try { @@ -596,6 +603,12 @@ export class GameActivityService implements OnDestroy { .join('|'); } + private isAppWindowFocused(): boolean { + return typeof document !== 'undefined' + && typeof document.hasFocus === 'function' + && document.hasFocus(); + } + private getScanIntervalMs(): number { const storedValue = Number.parseInt(localStorage.getItem(SCAN_INTERVAL_STORAGE_KEY) ?? '', 10); const interval = Number.isFinite(storedValue) ? storedValue : DEFAULT_SCAN_INTERVAL_MS; diff --git a/toju-app/src/app/domains/notifications/README.md b/toju-app/src/app/domains/notifications/README.md index c031266..16b4222 100644 --- a/toju-app/src/app/domains/notifications/README.md +++ b/toju-app/src/app/domains/notifications/README.md @@ -149,6 +149,7 @@ The domain must avoid marking an entire historical backlog as unread the first t - When `syncRoomCatalog()` sees a room for the first time, its baseline is set to `Date.now()`. Old stored messages stay treated as historical backlog. - When a live message arrives before the room has been catalogued, `ensureRoomTracking()` uses `message.timestamp - 1` so that the current live message still counts as unread. +- When startup room metadata has not loaded channels yet, existing channel read markers are preserved until the real text-channel catalog arrives, preventing previously read messages from reappearing as unread after reload. ### Channel scope diff --git a/toju-app/src/app/domains/notifications/application/services/notifications.service.spec.ts b/toju-app/src/app/domains/notifications/application/services/notifications.service.spec.ts new file mode 100644 index 0000000..18b472a --- /dev/null +++ b/toju-app/src/app/domains/notifications/application/services/notifications.service.spec.ts @@ -0,0 +1,156 @@ +import { + Injector, + runInInjectionContext, + signal +} from '@angular/core'; +import { Store } from '@ngrx/store'; +import type { + Message, + Room, + User +} from '../../../../shared-kernel'; +import { NotificationAudioService } from '../../../../core/services/notification-audio.service'; +import { DatabaseService } from '../../../../infrastructure/persistence'; +import { + selectActiveChannelId, + selectCurrentRoom, + selectSavedRooms +} from '../../../../store/rooms/rooms.selectors'; +import { selectCurrentUser } from '../../../../store/users/users.selectors'; +import { DesktopNotificationService } from '../../infrastructure/services/desktop-notification.service'; +import { NotificationSettingsStorageService } from '../../infrastructure/services/notification-settings-storage.service'; +import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model'; +import { NotificationsService } from './notifications.service'; + +const alice: User = { + id: 'alice', + oderId: 'alice', + username: 'alice', + displayName: 'Alice', + status: 'online', + role: 'member', + joinedAt: 1 +}; + +describe('NotificationsService', () => { + it('keeps channel read markers when startup room metadata has no channels yet', async () => { + const roomWithoutChannels = createRoom({ channels: [] }); + const context = createServiceContext({ + currentUser: alice, + savedRooms: [roomWithoutChannels], + settings: { + ...createDefaultNotificationSettings(), + roomBaselines: { 'room-1': 10 }, + lastReadByChannel: { + 'room-1': { + 'channel-1': 100 + } + } + } + }); + + await context.service.initialize(); + + expect(context.service.settings().lastReadByChannel['room-1']?.['channel-1']).toBe(100); + }); +}); + +interface ServiceContextOptions { + currentUser: User | null; + savedRooms: Room[]; + settings: NotificationsSettings; +} + +interface ServiceContext { + service: NotificationsService; +} + +function createServiceContext(options: ServiceContextOptions): ServiceContext { + const currentUser = signal(options.currentUser); + const savedRooms = signal(options.savedRooms); + const currentRoom = signal(null); + const activeChannelId = signal(null); + + let storedSettings = options.settings; + + const injector = Injector.create({ + providers: [ + { + provide: DatabaseService, + useValue: { + getMessagesSince: vi.fn(async (): Promise => []) + } + }, + { + provide: DesktopNotificationService, + useValue: { + clearAttention: vi.fn(), + onWindowStateChanged: vi.fn(() => () => undefined), + requestAttention: vi.fn(), + showNotification: vi.fn(async () => undefined) + } + }, + { + provide: NotificationAudioService, + useValue: { + play: vi.fn() + } + }, + { + provide: NotificationSettingsStorageService, + useValue: { + load: vi.fn(() => storedSettings), + save: vi.fn((settings: NotificationsSettings) => { + storedSettings = settings; + }) + } + }, + { + provide: Store, + useValue: { + selectSignal: vi.fn((selector: unknown) => { + if (selector === selectCurrentRoom) { + return currentRoom; + } + + if (selector === selectActiveChannelId) { + return activeChannelId; + } + + if (selector === selectSavedRooms) { + return savedRooms; + } + + if (selector === selectCurrentUser) { + return currentUser; + } + + throw new Error('Unexpected selector requested by NotificationsService test.'); + }) + } + } + ] + }); + + return { + service: runInInjectionContext(injector, () => new NotificationsService()) + }; +} + +function createRoom(overrides: Partial = {}): Room { + return { + id: 'room-1', + name: 'Room 1', + description: '', + channels: [ + { + id: 'channel-1', + name: 'general', + type: 'text' + } + ], + members: [], + roles: [], + ...overrides + } as Room; +} diff --git a/toju-app/src/app/domains/notifications/application/services/notifications.service.ts b/toju-app/src/app/domains/notifications/application/services/notifications.service.ts index 17dd5a8..db3df8a 100644 --- a/toju-app/src/app/domains/notifications/application/services/notifications.service.ts +++ b/toju-app/src/app/domains/notifications/application/services/notifications.service.ts @@ -102,19 +102,24 @@ export class NotificationsService { nextSettings.mutedRooms[room.id] = currentSettings.mutedRooms[room.id] === true; nextSettings.roomBaselines[room.id] = currentSettings.roomBaselines[room.id] ?? now; + const hasChannelCatalog = (room.channels?.length ?? 0) > 0; const textChannelIds = new Set(getRoomTextChannelIds(room)); const mutedChannels = currentSettings.mutedChannels[room.id] ?? {}; const lastReadByChannel = currentSettings.lastReadByChannel[room.id] ?? {}; - nextSettings.mutedChannels[room.id] = Object.fromEntries( - Object.entries(mutedChannels) - .filter(([channelId, muted]) => textChannelIds.has(channelId) && muted === true) - ); + nextSettings.mutedChannels[room.id] = hasChannelCatalog + ? Object.fromEntries( + Object.entries(mutedChannels) + .filter(([channelId, muted]) => textChannelIds.has(channelId) && muted === true) + ) + : { ...mutedChannels }; - nextSettings.lastReadByChannel[room.id] = Object.fromEntries( - Object.entries(lastReadByChannel) - .filter((entry): entry is [string, number] => textChannelIds.has(entry[0]) && typeof entry[1] === 'number') - ); + nextSettings.lastReadByChannel[room.id] = hasChannelCatalog + ? Object.fromEntries( + Object.entries(lastReadByChannel) + .filter((entry): entry is [string, number] => textChannelIds.has(entry[0]) && typeof entry[1] === 'number') + ) + : { ...lastReadByChannel }; } this.setSettings(nextSettings); diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html index c7d7c3b..5ac3792 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.html @@ -153,10 +153,10 @@ />
-
+

Servers

{{ searchResults().length }} found

@@ -164,11 +164,11 @@
@if (isSearching()) { -
+
} @else if (searchResults().length === 0) { -
+
No servers found

} @else { - - -
-
+ @for (server of searchResults(); track server.id) { +
-
-
-
+ } +
}
diff --git a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts index 24901a1..5b6ee27 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-search/server-search.component.ts @@ -58,7 +58,6 @@ import { import { ChatMessageMarkdownComponent } from '../../../chat'; import { hasRoomBanForUser } from '../../../access-control'; import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component'; -import { VirtualListComponent } from '../../../../shared/components/virtual-list'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { PluginRequirementService, @@ -83,8 +82,7 @@ interface JoinPluginConsentDialog { ChatMessageMarkdownComponent, ConfirmDialogComponent, LeaveServerDialogComponent, - UserSearchListComponent, - VirtualListComponent + UserSearchListComponent ], viewProviders: [ provideIcons({ @@ -189,9 +187,6 @@ export class ServerSearchComponent implements OnInit { this.searchSubject.next(query); } - /** Stable trackBy reference for the virtualized server results list. */ - readonly trackServerById = (_index: number, server: ServerInfo): string => server.id; - /** Join a server from the search results. Redirects to login if unauthenticated. */ async joinServer(server: ServerInfo): Promise { const currentUserId = localStorage.getItem('metoyou_currentUserId'); diff --git a/toju-app/src/app/shared-kernel/direct-message-contracts.ts b/toju-app/src/app/shared-kernel/direct-message-contracts.ts index 7abbfc1..8628c0b 100644 --- a/toju-app/src/app/shared-kernel/direct-message-contracts.ts +++ b/toju-app/src/app/shared-kernel/direct-message-contracts.ts @@ -1,7 +1,9 @@ import type { Reaction } from './message.models'; export type DirectMessageStatus = 'QUEUED' | 'SENT' | 'DELIVERED' | 'ACKNOWLEDGED'; +export type DirectMessageKind = 'user' | 'system'; export type DirectMessageMutationType = 'edit' | 'delete' | 'reaction-add' | 'reaction-remove'; +export type DirectMessageSystemEvent = 'call-started'; export interface DirectMessageParticipant { userId: string; @@ -24,6 +26,8 @@ export interface DirectMessage { content: string; timestamp: number; status: DirectMessageStatus; + kind?: DirectMessageKind; + systemEvent?: DirectMessageSystemEvent; editedAt?: number; isDeleted?: boolean; reactions?: Reaction[]; diff --git a/toju-app/src/app/shared-kernel/message.models.ts b/toju-app/src/app/shared-kernel/message.models.ts index 371d194..f2d0ed2 100644 --- a/toju-app/src/app/shared-kernel/message.models.ts +++ b/toju-app/src/app/shared-kernel/message.models.ts @@ -17,6 +17,8 @@ export interface Message { senderName: string; content: string; timestamp: number; + kind?: 'user' | 'system'; + systemEvent?: 'call-started'; editedAt?: number; reactions: Reaction[]; isDeleted: boolean; diff --git a/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts index 52d9e64..54ed0fd 100644 --- a/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts +++ b/toju-app/src/app/shared/components/profile-card/profile-card-mobile.component.ts @@ -1,14 +1,13 @@ /* eslint-disable @typescript-eslint/member-ordering */ import { Component, - DestroyRef, computed, effect, inject, + OnDestroy, output, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -33,7 +32,6 @@ import { FriendService } from '../../../domains/direct-message/application/servi import { DirectCallService } from '../../../domains/direct-call/application/services/direct-call.service'; import { formatGameActivityElapsed } from '../../../domains/game-activity'; import { ExternalLinkService } from '../../../core/platform/external-link.service'; -import { visibilityAwareInterval$ } from '../../rxjs'; import { UserStatusService } from '../../../core/services/user-status.service'; import { EditableProfileAvatarSource, @@ -66,7 +64,7 @@ import { ], templateUrl: './profile-card-mobile.component.html' }) -export class ProfileCardMobileComponent { +export class ProfileCardMobileComponent implements OnDestroy { readonly user = signal({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 }); readonly editable = signal(false); readonly closed = output(); @@ -120,10 +118,7 @@ export class ProfileCardMobileComponent { readonly activityNow = signal(Date.now()); readonly busy = signal(false); - private readonly destroyRef = inject(DestroyRef); - private readonly activityTimerSub = visibilityAwareInterval$(1_000) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.activityNow.set(Date.now())); + private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000); private readonly syncProfileDrafts = effect( () => { const user = this.displayedUser(); @@ -140,6 +135,10 @@ export class ProfileCardMobileComponent { { allowSignalWrites: true } ); + ngOnDestroy(): void { + clearInterval(this.activityTimer); + } + currentStatusColor(): string { switch (this.displayedUser().status) { case 'online': diff --git a/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts index 43d8a84..c5769b3 100644 --- a/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts +++ b/toju-app/src/app/store/messages/messages-incoming.handlers.spec.ts @@ -20,7 +20,8 @@ function createMessage(overrides: Partial = {}): Message { function createContext(overrides: Record = {}) { return { db: { - getMessages: vi.fn() + getMessages: vi.fn(), + getRoomMessageStats: vi.fn(async () => ({ count: 0, lastUpdated: 0 })) }, webrtc: { sendToPeer: vi.fn() @@ -36,12 +37,12 @@ function createContext(overrides: Record = {}) { describe('dispatchIncomingMessage room-scoped sync', () => { it('requests sync for event room even when another room is viewed', async () => { - const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b' - ? [createMessage({ roomId: 'room-b', timestamp: 10, editedAt: 10 })] - : [createMessage({ roomId: 'room-a', timestamp: 100, editedAt: 100 })]); + const getRoomMessageStats = vi.fn(async (roomId: string) => roomId === 'room-b' + ? { count: 1, lastUpdated: 10 } + : { count: 1, lastUpdated: 100 }); const sendToPeer = vi.fn(); const context = createContext({ - db: { getMessages }, + db: { getMessages: vi.fn(), getRoomMessageStats }, webrtc: { sendToPeer }, currentRoom: { id: 'room-a' }, savedRooms: [{ id: 'room-b' }] @@ -60,7 +61,7 @@ describe('dispatchIncomingMessage room-scoped sync', () => { ).pipe(defaultIfEmpty(null)) ); - expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0); + expect(getRoomMessageStats).toHaveBeenCalledWith('room-b'); expect(sendToPeer).toHaveBeenCalledWith('peer-1', { type: 'chat-sync-request', roomId: 'room-b'