From 79c6f91cd62f7f11cdde31310cbd45ff916096e2 Mon Sep 17 00:00:00 2001 From: Myx Date: Thu, 11 Jun 2026 11:08:26 +0200 Subject: [PATCH] chore: enforce lint across codebase and ban "maybe" in identifiers Remove member-ordering and complexity eslint-disable comments by reordering class members and applying targeted fixes. Add metoyou/no-maybe-in-naming, type-safe WebRTC e2e harness helpers, and resolve remaining lint errors so npm run lint exits cleanly. Co-authored-by: Cursor --- agents-docs/features/authentication.md | 3 +- e2e/helpers/webrtc-helpers.ts | 97 ++- e2e/helpers/webrtc-test-window.types.ts | 28 + eslint.config.js | 2 + server/src/websocket/broadcast.spec.ts | 4 +- .../websocket/handler-multi-client.spec.ts | 6 +- .../services/notification-audio.service.ts | 85 +- .../app/core/services/time-sync.service.ts | 9 +- .../services/browser-attachment-file-store.ts | 15 +- .../capacitor-attachment-file-store.ts | 12 +- .../electron-attachment-file-store.ts | 12 +- .../services/authentication.service.ts | 71 +- .../services/message-signing.service.ts | 18 +- .../rules/message-revision.builder.rules.ts | 2 - .../domain/rules/message-sync.rules.spec.ts | 5 +- .../chat-image-proxy-fallback.directive.ts | 15 +- .../chat-messages/chat-messages.component.ts | 100 ++- .../chat-message-composer.component.ts | 203 +++-- .../chat-message-item.component.html | 2 +- .../chat-message-item.component.ts | 267 +++--- .../chat-message-markdown.component.ts | 10 +- .../chat-message-list.component.ts | 60 +- .../klipy-gif-picker.component.ts | 44 +- .../typing-indicator.component.ts | 27 +- .../feature/user-list/user-list.component.ts | 22 +- .../application/custom-emoji-sync.effects.ts | 20 +- .../application/custom-emoji.service.ts | 27 +- .../custom-emoji-picker.component.ts | 57 +- .../services/direct-call.service.ts | 97 ++- .../incoming-call-modal.component.ts | 11 +- .../services/direct-message.service.ts | 45 +- .../application/services/friend.service.ts | 21 +- .../services/peer-delivery.service.ts | 15 +- .../feature/dm-chat/dm-chat.component.ts | 70 +- .../feature/dm-rail/dm-rail.component.ts | 24 +- .../dm-conversation-item.component.ts | 39 +- .../dm-conversations-panel.component.ts | 7 +- .../dm-workspace/dm-workspace.component.ts | 31 +- .../find-people/find-people.component.ts | 13 +- .../friend-button/friend-button.component.ts | 11 +- .../user-search-list.component.ts | 23 +- .../effects/notifications.effects.ts | 11 +- .../facades/notifications.facade.ts | 6 +- .../services/notifications.service.ts | 95 ++- .../notifications-settings.component.ts | 9 +- .../plugin-requirement-state.service.ts | 47 +- .../services/plugin-store.service.ts | 238 +++--- .../rules/plugin-local-file.rules.spec.ts | 6 +- .../plugin-action-menu.component.ts | 15 +- .../plugin-manager.component.html | 1 - .../plugin-manager.component.ts | 32 +- .../plugin-store/plugin-store.component.html | 2 +- .../plugin-store/plugin-store.component.ts | 43 +- .../local-plugin-discovery.service.ts | 4 +- .../profile-avatar-editor.component.ts | 16 +- .../screen-share-viewer.component.ts | 84 +- .../facades/server-directory.facade.ts | 8 +- .../create-server-dialog.component.ts | 36 +- .../create-server/create-server.component.ts | 42 +- .../find-servers/find-servers.component.ts | 15 +- .../server-browser.component.ts | 148 ++-- .../theme-settings.component.ts | 89 +- .../services/theme-library-storage.service.ts | 4 +- .../services/voice-activity.service.ts | 92 +- .../facades/voice-session.facade.ts | 17 +- .../services/voice-workspace.service.ts | 31 +- .../floating-voice-controls.component.ts | 33 +- .../voice-controls.component.ts | 192 +++-- .../util/voice-settings-storage.util.ts | 4 +- .../features/dashboard/dashboard.component.ts | 114 +-- .../direct-call/private-call.component.ts | 83 +- .../room/chat-room/chat-room.component.ts | 46 +- .../rooms-side-panel.component.html | 2 +- .../rooms-side-panel.component.ts | 784 ++++++++++-------- ...voice-workspace-stream-tile.component.html | 1 - .../voice-workspace-stream-tile.component.ts | 68 +- .../voice-workspace.component.html | 1 - .../voice-workspace.component.ts | 129 ++- .../servers-rail/servers-rail.component.ts | 93 ++- .../bans-settings/bans-settings.component.ts | 20 +- .../data-settings/data-settings.component.ts | 13 +- .../debugging-settings.component.ts | 21 +- .../general-settings.component.ts | 66 +- .../ice-server-settings.component.ts | 12 +- .../local-api-settings.component.ts | 13 +- .../members-settings.component.ts | 14 +- .../network-settings.component.ts | 25 +- .../permissions-settings.component.ts | 20 +- .../server-settings.component.ts | 29 +- .../settings-modal.component.html | 1 - .../settings-modal.component.ts | 42 +- .../updates-settings.component.ts | 14 +- .../voice-settings.component.ts | 34 +- .../features/settings/settings.component.ts | 24 +- .../native-context-menu.component.ts | 7 + .../shell/title-bar/title-bar.component.html | 1 - .../shell/title-bar/title-bar.component.ts | 130 ++- .../capacitor-mobile-notifications.adapter.ts | 18 +- .../capacitor-mobile-persistence.adapter.ts | 8 +- .../logic/mobile-sqlite-schema.rules.ts | 5 +- .../services/mobile-app-update.service.ts | 20 +- .../services/mobile-persistence.service.ts | 13 +- .../mobile-sqlite-connection.service.ts | 13 +- .../persistence/app-resume.storage.ts | 10 +- .../persistence/database.service.ts | 52 +- .../account-sync/account-sync.effects.ts | 23 +- .../realtime/media/media.manager.ts | 46 +- .../realtime/media/noise-reduction.manager.ts | 12 +- .../realtime/media/screen-share.manager.ts | 2 +- .../peer-connection.manager.ts | 121 +-- .../realtime/realtime-session.service.ts | 290 ++++--- .../realtime/signaling/signaling.manager.ts | 94 ++- .../realtime/state/webrtc-state-controller.ts | 41 +- .../realtime/streams/peer-media-facade.ts | 8 +- .../chat-audio-player.component.ts | 62 +- .../chat-video-player.component.ts | 69 +- .../confirm-dialog.component.ts | 15 +- .../context-menu/context-menu.component.ts | 36 +- .../services/debug-console-resize.service.ts | 49 +- .../leave-server-dialog.component.ts | 7 +- .../modal-backdrop.component.ts | 4 +- .../profile-card-mobile.component.ts | 65 +- .../user-volume-menu.component.ts | 11 +- .../virtual-list/virtual-list.component.ts | 11 + toju-app/src/app/shared/rxjs/idle.ts | 4 +- .../messages/messages-incoming.handlers.ts | 5 +- .../store/messages/messages-sync.effects.ts | 29 +- .../app/store/messages/messages.effects.ts | 130 +-- .../rooms/room-member-identity.rules.spec.ts | 52 ++ .../store/rooms/room-member-identity.rules.ts | 31 + .../store/rooms/room-members-sync.effects.ts | 14 +- .../store/rooms/room-members.helpers.spec.ts | 116 +++ .../app/store/rooms/room-settings.effects.ts | 17 +- .../store/rooms/room-state-sync.effects.ts | 59 +- toju-app/src/app/store/rooms/rooms.effects.ts | 46 +- .../app/store/users/user-avatar.effects.ts | 21 +- toju-app/src/app/store/users/users.effects.ts | 128 +-- tools/eslint-rules.js | 82 ++ 138 files changed, 4286 insertions(+), 2310 deletions(-) create mode 100644 e2e/helpers/webrtc-test-window.types.ts create mode 100644 toju-app/src/app/store/rooms/room-member-identity.rules.spec.ts create mode 100644 toju-app/src/app/store/rooms/room-member-identity.rules.ts create mode 100644 toju-app/src/app/store/rooms/room-members.helpers.spec.ts diff --git a/agents-docs/features/authentication.md b/agents-docs/features/authentication.md index c47964e..e564d15 100644 --- a/agents-docs/features/authentication.md +++ b/agents-docs/features/authentication.md @@ -111,7 +111,8 @@ A per-install **provision secret** enables silent account creation on newly adde |---|---|---| | Home login/register | `authenticateUser` | Resets local state, stores home credential + provision secret | | Foreign login/register | `authorizeSignalServer` | Upserts credential for that URL only; home session unchanged | -| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-`) | +| Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-`) and prefixes the display name with `# #` so same-name accounts stay distinguishable | +| Create/join on foreign server | `RoomsEffects.createRoom$`, invite/join flows | `ensureCredentialForServerUrl` provisions (or reuses) the per-server session token first; REST/WebSocket calls use the **actor user id** for that signal URL, not the home registration id | | Foreign auth failure | `signalServerAuthFailed` | Clears that URL's credential and re-provisions when home token is still valid; global logout only when home server rejects auth | Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges. diff --git a/e2e/helpers/webrtc-helpers.ts b/e2e/helpers/webrtc-helpers.ts index 145fcba..42fcd3e 100644 --- a/e2e/helpers/webrtc-helpers.ts +++ b/e2e/helpers/webrtc-helpers.ts @@ -1,5 +1,16 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { type BrowserContext, type Page } from '@playwright/test'; +import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types'; + +type RtcPeerConnectionArgs = ConstructorParameters; +type AudioContextArgs = ConstructorParameters; + +interface ScreenShareMediaStream extends MediaStream { + __isScreenShare?: boolean; +} + +function webRtcHarnessWindow(scope: Window = window): WebRtcTestHarnessWindow { + return scope as unknown as WebRtcTestHarnessWindow; +} /** * Install RTCPeerConnection monkey-patch on a page BEFORE navigating. @@ -21,11 +32,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom source?: AudioScheduledSourceNode; drawIntervalId?: number; }[] = []; + const harness = webRtcHarnessWindow(); - (window as any).__rtcConnections = connections; - (window as any).__rtcDataChannels = dataChannels; - (window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; - (window as any).__rtcSyntheticMediaResources = syntheticMediaResources; + harness.__rtcConnections = connections; + harness.__rtcDataChannels = dataChannels; + harness.__rtcRemoteTracks = []; + harness.__rtcSyntheticMediaResources = syntheticMediaResources; const OriginalRTCPeerConnection = window.RTCPeerConnection; const trackDataChannel = (channel: RTCDataChannel) => { @@ -36,7 +48,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom dataChannels.push(channel); }; - (window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) { + harness.RTCPeerConnection = function(this: RTCPeerConnection, ...args: RtcPeerConnectionArgs) { const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args); const originalCreateDataChannel = pc.createDataChannel.bind(pc); @@ -50,7 +62,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom }) as RTCPeerConnection['createDataChannel']; pc.addEventListener('connectionstatechange', () => { - (window as any).__lastRtcState = pc.connectionState; + harness.__lastRtcState = pc.connectionState; }); pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => { @@ -58,7 +70,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom }); pc.addEventListener('track', (event: RTCTrackEvent) => { - (window as any).__rtcRemoteTracks.push({ + harness.__rtcRemoteTracks.push({ kind: event.track.kind, id: event.track.id, readyState: event.track.readyState @@ -66,10 +78,10 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom }); return pc; - } as any; + } as typeof RTCPeerConnection; - (window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; - Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection); + harness.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; + Object.setPrototypeOf(harness.RTCPeerConnection, OriginalRTCPeerConnection); // Patch getDisplayMedia to return a synthetic screen share stream // (canvas-based video + 880Hz oscillator audio) so the browser @@ -144,7 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom }, { once: true }); // Tag the stream so tests can identify it - (resultStream as any).__isScreenShare = true; + (resultStream as ScreenShareMediaStream).__isScreenShare = true; return resultStream; }; @@ -169,11 +181,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom export async function installAutoResumeAudioContext(page: Page): Promise { await page.addInitScript(() => { const OrigAudioContext = window.AudioContext; + const audioHarness = webRtcHarnessWindow(); - (window as any).AudioContext = function(this: AudioContext, ...args: any[]) { + audioHarness.AudioContext = function(this: AudioContext, ...args: AudioContextArgs) { const ctx: AudioContext = new OrigAudioContext(...args); // Track all created AudioContexts for test diagnostics - const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[]; + const tracked = audioHarness.__trackedAudioContexts ??= []; tracked.push(ctx); @@ -189,16 +202,16 @@ export async function installAutoResumeAudioContext(page: Page): Promise { }); return ctx; - } as any; + } as typeof AudioContext; - (window as any).AudioContext.prototype = OrigAudioContext.prototype; - Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext); + audioHarness.AudioContext.prototype = OrigAudioContext.prototype; + Object.setPrototypeOf(audioHarness.AudioContext, OrigAudioContext); }); } export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise { await page.waitForFunction( - () => (window as any).__rtcConnections?.some( + () => webRtcHarnessWindow().__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false, undefined, @@ -211,7 +224,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis */ export async function isPeerStillConnected(page: Page): Promise { return page.evaluate( - () => (window as any).__rtcConnections?.some( + () => webRtcHarnessWindow().__rtcConnections?.some( (pc: RTCPeerConnection) => pc.connectionState === 'connected' ) ?? false ); @@ -220,7 +233,7 @@ export async function isPeerStillConnected(page: Page): Promise { /** Returns the number of tracked peer connections in `connected` state. */ export async function getConnectedPeerCount(page: Page): Promise { return page.evaluate( - () => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( + () => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter( (pc) => pc.connectionState === 'connected' ).length ?? 0 ); @@ -229,7 +242,7 @@ export async function getConnectedPeerCount(page: Page): Promise { /** Wait until the expected number of peer connections are `connected`. */ export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise { await page.waitForFunction( - (count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( + (count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter( (pc) => pc.connectionState === 'connected' ).length === count, expectedCount, @@ -240,7 +253,7 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe /** Returns the number of tracked RTCDataChannels in the open state. */ export async function getOpenDataChannelCount(page: Page): Promise { return page.evaluate( - () => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( + () => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( (channel) => channel.readyState === 'open' ).length ?? 0 ); @@ -249,7 +262,7 @@ export async function getOpenDataChannelCount(page: Page): Promise { /** Wait until the expected number of tracked RTCDataChannels are open. */ export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise { await page.waitForFunction( - (count) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( + (count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( (channel) => channel.readyState === 'open' ).length === count, expectedCount, @@ -260,7 +273,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num /** Close every currently-open RTCDataChannel and return how many were closed. */ export async function closeOpenDataChannels(page: Page): Promise { return page.evaluate(() => { - const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; + const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; let closed = 0; @@ -280,7 +293,7 @@ export async function closeOpenDataChannels(page: Page): Promise { /** Dispatch a synthetic data-channel error event on each open channel. */ export async function dispatchDataChannelErrors(page: Page): Promise { return page.evaluate(() => { - const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; + const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; let dispatched = 0; @@ -341,7 +354,7 @@ interface PerPeerAudioStat { /** Get per-peer audio stats for every tracked RTCPeerConnection. */ export async function getPerPeerAudioStats(page: Page): Promise { return page.evaluate(async () => { - const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) { return []; @@ -358,7 +371,7 @@ export async function getPerPeerAudioStats(page: Page): Promise { + stats.forEach((report: RTCStats) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'audio') { @@ -459,7 +472,7 @@ export async function getAudioStats(page: Page): Promise<{ inbound: { bytesReceived: number; packetsReceived: number } | null; }> { return page.evaluate(async () => { - const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return { outbound: null, inbound: null }; @@ -473,8 +486,8 @@ export async function getAudioStats(page: Page): Promise<{ hasInbound: boolean; }; - const hwm: Record = (window as any).__rtcStatsHWM = - ((window as any).__rtcStatsHWM as Record | undefined) ?? {}; + const hwm: Record = webRtcHarnessWindow().__rtcStatsHWM = + (webRtcHarnessWindow().__rtcStatsHWM as Record | undefined) ?? {}; for (let idx = 0; idx < connections.length; idx++) { let stats: RTCStatsReport; @@ -492,7 +505,7 @@ export async function getAudioStats(page: Page): Promise<{ let hasOut = false; let hasIn = false; - stats.forEach((report: any) => { + stats.forEach((report: RTCStats) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'audio') { @@ -583,7 +596,7 @@ export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promis export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise { await page.waitForFunction( async () => { - const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return false; @@ -600,7 +613,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr let hasOut = false; let hasIn = false; - stats.forEach((report: any) => { + stats.forEach((report: RTCStats) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'audio') @@ -692,7 +705,7 @@ export async function getVideoStats(page: Page): Promise<{ inbound: { bytesReceived: number; packetsReceived: number } | null; }> { return page.evaluate(async () => { - const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return { outbound: null, inbound: null }; @@ -706,8 +719,8 @@ export async function getVideoStats(page: Page): Promise<{ hasInbound: boolean; } - const hwm: Record = (window as any).__rtcVideoStatsHWM = - ((window as any).__rtcVideoStatsHWM as Record | undefined) ?? {}; + const hwm: Record = webRtcHarnessWindow().__rtcVideoStatsHWM = + (webRtcHarnessWindow().__rtcVideoStatsHWM as Record | undefined) ?? {}; for (let idx = 0; idx < connections.length; idx++) { let stats: RTCStatsReport; @@ -725,7 +738,7 @@ export async function getVideoStats(page: Page): Promise<{ let hasOut = false; let hasIn = false; - stats.forEach((report: any) => { + stats.forEach((report: RTCStats) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'video') { @@ -791,7 +804,7 @@ export async function getVideoStats(page: Page): Promise<{ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise { await page.waitForFunction( async () => { - const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; if (!connections?.length) return false; @@ -808,7 +821,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr let hasOut = false; let hasIn = false; - stats.forEach((report: any) => { + stats.forEach((report: RTCStats) => { const kind = report.kind ?? report.mediaType; if (report.type === 'outbound-rtp' && kind === 'video') @@ -959,7 +972,7 @@ export async function waitForInboundVideoFlow( */ export async function dumpRtcDiagnostics(page: Page): Promise { return page.evaluate(async () => { - const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; + const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined; if (!conns?.length) return 'No connections tracked'; @@ -984,7 +997,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise { try { const stats = await pc.getStats(); - stats.forEach((report: any) => { + stats.forEach((report: RTCStats) => { if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp') return; @@ -994,7 +1007,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise { lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`); }); - } catch (err: any) { + } catch (err: unknown) { lines.push(` getStats() failed: ${err?.message ?? err}`); } } diff --git a/e2e/helpers/webrtc-test-window.types.ts b/e2e/helpers/webrtc-test-window.types.ts new file mode 100644 index 0000000..34951b7 --- /dev/null +++ b/e2e/helpers/webrtc-test-window.types.ts @@ -0,0 +1,28 @@ +export interface RtcRemoteTrackSnapshot { + kind: string; + id: string; + readyState: string; +} + +export interface RtcSyntheticMediaResource { + audioCtx: AudioContext; + source?: AudioScheduledSourceNode; + drawIntervalId?: number; +} + +export interface WebRtcTestHarnessWindow extends Window { + __rtcConnections: RTCPeerConnection[]; + __rtcDataChannels: RTCDataChannel[]; + __rtcRemoteTracks: RtcRemoteTrackSnapshot[]; + __rtcSyntheticMediaResources: RtcSyntheticMediaResource[]; + __trackedAudioContexts?: AudioContext[]; + __rtcStatsHWM?: Record>; + __rtcVideoStatsHWM?: Record>; + __lastRtcState?: RTCPeerConnectionState; + RTCPeerConnection: typeof RTCPeerConnection; + AudioContext: typeof AudioContext; +} + +export function getWebRtcTestHarnessWindow(): WebRtcTestHarnessWindow { + return window as unknown as WebRtcTestHarnessWindow; +} diff --git a/eslint.config.js b/eslint.config.js index 1db5c22..1867639 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -43,6 +43,7 @@ module.exports = tseslint.config( } ], 'metoyou/no-unicode-symbols': 'error', + 'metoyou/no-maybe-in-naming': 'error', '@typescript-eslint/no-extraneous-class': 'off', '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ], '@angular-eslint/directive-class-suffix': 'error', @@ -177,6 +178,7 @@ module.exports = tseslint.config( extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: { 'metoyou/no-unicode-symbols': 'error', + 'metoyou/no-maybe-in-naming': 'error', // Angular template best practices '@angular-eslint/template/button-has-type': 'warn', '@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }], diff --git a/server/src/websocket/broadcast.spec.ts b/server/src/websocket/broadcast.spec.ts index fa9ae1c..17fe875 100644 --- a/server/src/websocket/broadcast.spec.ts +++ b/server/src/websocket/broadcast.spec.ts @@ -62,7 +62,9 @@ describe('broadcastToServer', () => { expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1); expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1); expect(connectedUsers.get('conn-a1')?.ws).toBeDefined(); - expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0); + const connA1Passive = connectedUsers.get('conn-a1')?.ws as WebSocket & { sentMessages: string[] } | undefined; + + expect(connA1Passive?.sentMessages).toHaveLength(0); }); it('excludes every connection for an identity when excludeIdentityOderId is set', () => { diff --git a/server/src/websocket/handler-multi-client.spec.ts b/server/src/websocket/handler-multi-client.spec.ts index 598c125..1c954f5 100644 --- a/server/src/websocket/handler-multi-client.spec.ts +++ b/server/src/websocket/handler-multi-client.spec.ts @@ -94,12 +94,13 @@ describe('server websocket handler - multi-client sessions', () => { }); it('relays voice_state to other connections for the same user', async () => { - const sender = createConnectedUser('conn-a1', { + createConnectedUser('conn-a1', { authenticated: true, oderId: 'user-1', serverIds: new Set(['server-1']), clientInstanceId: 'device-a' }); + const passive = createConnectedUser('conn-a2', { authenticated: true, oderId: 'user-1', @@ -129,7 +130,7 @@ describe('server websocket handler - multi-client sessions', () => { }); it('forwards RTC offers to the voice-active connection for the target user', async () => { - const sender = createConnectedUser('conn-sender', { + createConnectedUser('conn-sender', { authenticated: true, oderId: 'user-2', serverIds: new Set(['server-1']) @@ -228,6 +229,7 @@ describe('server websocket handler - multi-client sessions', () => { serverIds: new Set(['server-1']), clientInstanceId: 'device-a' }); + const receiver = createConnectedUser('conn-a2', { authenticated: true, oderId: 'user-1', diff --git a/toju-app/src/app/core/services/notification-audio.service.ts b/toju-app/src/app/core/services/notification-audio.service.ts index ec0b5ce..c034c4e 100644 --- a/toju-app/src/app/core/services/notification-audio.service.ts +++ b/toju-app/src/app/core/services/notification-audio.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, signal } from '@angular/core'; /** @@ -34,12 +33,6 @@ const DEFAULT_VOLUME = 0.2; */ @Injectable({ providedIn: 'root' }) export class NotificationAudioService { - /** Pre-loaded audio buffers keyed by {@link AppSound}. */ - private readonly cache = new Map(); - - private readonly sources = new Map(); - - private readonly activeLoops = new Map(); /** Reactive notification volume (0 - 1), persisted to localStorage. */ readonly notificationVolume = signal(this.loadVolume()); @@ -47,45 +40,17 @@ export class NotificationAudioService { /** When true, all sound playback is suppressed (Do Not Disturb). */ readonly dndMuted = signal(false); + /** Pre-loaded audio buffers keyed by {@link AppSound}. */ + private readonly cache = new Map(); + + private readonly sources = new Map(); + + private readonly activeLoops = new Map(); + constructor() { this.preload(); } - /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ - private preload(): void { - for (const sound of Object.values(AppSound)) { - const src = this.resolveAudioUrl(sound); - const audio = new Audio(); - - audio.preload = 'auto'; - audio.src = src; - audio.load(); - - this.sources.set(sound, src); - this.cache.set(sound, audio); - } - } - - private resolveAudioUrl(sound: AppSound): string { - return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString(); - } - - /** Read persisted volume from localStorage, falling back to the default. */ - private loadVolume(): number { - try { - const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME); - - if (raw !== null) { - const parsed = parseFloat(raw); - - if (!isNaN(parsed)) - return Math.max(0, Math.min(1, parsed)); - } - } catch {} - - return DEFAULT_VOLUME; - } - /** * Update the notification volume and persist it. * @@ -178,4 +143,40 @@ export class NotificationAudioService { audio.remove(); this.activeLoops.delete(sound); } + + /** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */ + private preload(): void { + for (const sound of Object.values(AppSound)) { + const src = this.resolveAudioUrl(sound); + const audio = new Audio(); + + audio.preload = 'auto'; + audio.src = src; + audio.load(); + + this.sources.set(sound, src); + this.cache.set(sound, audio); + } + } + + private resolveAudioUrl(sound: AppSound): string { + return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString(); + } + + /** Read persisted volume from localStorage, falling back to the default. */ + private loadVolume(): number { + try { + const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME); + + if (raw !== null) { + const parsed = parseFloat(raw); + + if (!isNaN(parsed)) + return Math.max(0, Math.min(1, parsed)); + } + } catch {} + + return DEFAULT_VOLUME; + } + } diff --git a/toju-app/src/app/core/services/time-sync.service.ts b/toju-app/src/app/core/services/time-sync.service.ts index 59fcd7d..4ba727d 100644 --- a/toju-app/src/app/core/services/time-sync.service.ts +++ b/toju-app/src/app/core/services/time-sync.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, signal, @@ -18,6 +17,10 @@ const DEFAULT_SYNC_TIMEOUT_MS = 5000; */ @Injectable({ providedIn: 'root' }) export class TimeSyncService { + + /** Reactive read-only offset (milliseconds). */ + readonly offset = computed(() => this._offset()); + /** * Internal offset signal: * `serverTime = Date.now() + offset`. @@ -27,9 +30,6 @@ export class TimeSyncService { /** Epoch timestamp of the most recent successful sync. */ private lastSyncTimestamp = 0; - /** Reactive read-only offset (milliseconds). */ - readonly offset = computed(() => this._offset()); - /** * Return a server-adjusted "now" timestamp. * @@ -97,4 +97,5 @@ export class TimeSyncService { // Sync failure is non-fatal; retain the previous offset. } } + } diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts b/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts index e79fc66..d7a4428 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/browser-attachment-file-store.ts @@ -27,17 +27,21 @@ interface StoredFileRecord { @Injectable({ providedIn: 'root' }) export class BrowserAttachmentFileStore implements AttachmentFileStore { readonly maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES; - readonly supportsStreamingToDisk = false; - readonly supportsChunkedReads = true; - readonly providesInlineObjectUrl = false; - private database: IDBDatabase | null = null; - private activeDatabaseName: string | null = null; + readonly supportsStreamingToDisk = false; + + readonly supportsChunkedReads = true; + + readonly providesInlineObjectUrl = false; get isAvailable(): boolean { return typeof indexedDB !== 'undefined'; } + private database: IDBDatabase | null = null; + + private activeDatabaseName: string | null = null; + async getAppDataPath(): Promise { return this.isAvailable ? BROWSER_APP_DATA_ROOT : null; } @@ -225,4 +229,5 @@ export class BrowserAttachmentFileStore implements AttachmentFileStore { transaction.onabort = () => reject(transaction.error); }); } + } diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts b/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts index a58f049..ccac612 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/capacitor-attachment-file-store.ts @@ -16,16 +16,19 @@ const CAPACITOR_APP_DATA_ROOT = 'metoyou'; @Injectable({ providedIn: 'root' }) export class CapacitorAttachmentFileStore implements AttachmentFileStore { readonly maxPersistableBytes = Number.POSITIVE_INFINITY; - readonly supportsStreamingToDisk = true; - readonly supportsChunkedReads = false; - readonly providesInlineObjectUrl = true; - private readonly loadFilesystem: () => Promise = loadCapacitorAttachmentFilesystem; + readonly supportsStreamingToDisk = true; + + readonly supportsChunkedReads = false; + + readonly providesInlineObjectUrl = true; get isAvailable(): boolean { return isCapacitorNativeRuntime(); } + private readonly loadFilesystem: () => Promise = loadCapacitorAttachmentFilesystem; + async getAppDataPath(): Promise { return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null; } @@ -200,4 +203,5 @@ export class CapacitorAttachmentFileStore implements AttachmentFileStore { return null; } } + } diff --git a/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts b/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts index 4efbe83..ff58ac2 100644 --- a/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts +++ b/toju-app/src/app/domains/attachment/infrastructure/services/electron-attachment-file-store.ts @@ -6,11 +6,12 @@ import type { AttachmentFileStore } from './attachment-file-store'; @Injectable({ providedIn: 'root' }) export class ElectronAttachmentFileStore implements AttachmentFileStore { readonly maxPersistableBytes = Number.POSITIVE_INFINITY; - readonly supportsStreamingToDisk = true; - readonly supportsChunkedReads = true; - readonly providesInlineObjectUrl = false; - private readonly electronBridge = inject(ElectronBridgeService); + readonly supportsStreamingToDisk = true; + + readonly supportsChunkedReads = true; + + readonly providesInlineObjectUrl = false; get isAvailable(): boolean { const electronApi = this.electronBridge.getApi(); @@ -18,6 +19,8 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore { return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath; } + private readonly electronBridge = inject(ElectronBridgeService); + async getAppDataPath(): Promise { const electronApi = this.electronBridge.getApi(); @@ -169,4 +172,5 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore { return null; } } + } diff --git a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts index 4d4bf51..6411e4f 100644 --- a/toju-app/src/app/domains/authentication/application/services/authentication.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/authentication.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, tap } from 'rxjs'; @@ -18,47 +17,17 @@ import { MessageSigningService } from './message-signing.service'; @Injectable({ providedIn: 'root' }) export class AuthenticationService { private readonly http = inject(HttpClient); + private readonly serverDirectory = inject(ServerDirectoryFacade); + private readonly authTokenStore = inject(AuthTokenStoreService); + private readonly messageSigning = inject(MessageSigningService); - /** - * Resolve the API base URL for the given server. - * - * @param serverId - Optional server ID to look up. When omitted the - * currently active endpoint is used. - * @returns Fully-qualified API base URL (e.g. `http://host:3001/api`). - */ - private resolveServerUrl(serverId?: string): string { - return this.endpointFor(serverId).replace(/\/api$/, ''); - } - - private persistSessionToken(serverId: string | undefined, response: LoginResponse): void { - const serverUrl = this.resolveServerUrl(serverId); - - this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt); - } - resolveServerUrlFor(serverId?: string): string { return this.resolveServerUrl(serverId); } - private endpointFor(serverId?: string): string { - let endpoint: ServerEndpoint | undefined; - - if (serverId) { - endpoint = this.serverDirectory.servers().find( - (server) => server.id === serverId - ); - } - - const activeEndpoint = endpoint ?? this.serverDirectory.activeServer(); - - return activeEndpoint - ? `${activeEndpoint.url}/api` - : this.serverDirectory.getApiBaseUrl(); - } - /** * Register a new user account on the target server. * @@ -115,4 +84,38 @@ export class AuthenticationService { }) ); } + + /** + * Resolve the API base URL for the given server. + * + * @param serverId - Optional server ID to look up. When omitted the + * currently active endpoint is used. + * @returns Fully-qualified API base URL (e.g. `http://host:3001/api`). + */ + private resolveServerUrl(serverId?: string): string { + return this.endpointFor(serverId).replace(/\/api$/, ''); + } + + private persistSessionToken(serverId: string | undefined, response: LoginResponse): void { + const serverUrl = this.resolveServerUrl(serverId); + + this.authTokenStore.setToken(serverUrl, response.token, response.expiresAt); + } + + private endpointFor(serverId?: string): string { + let endpoint: ServerEndpoint | undefined; + + if (serverId) { + endpoint = this.serverDirectory.servers().find( + (server) => server.id === serverId + ); + } + + const activeEndpoint = endpoint ?? this.serverDirectory.activeServer(); + + return activeEndpoint + ? `${activeEndpoint.url}/api` + : this.serverDirectory.getApiBaseUrl(); + } + } diff --git a/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts b/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts index 2e37037..dc603f4 100644 --- a/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts +++ b/toju-app/src/app/domains/authentication/application/services/message-signing.service.ts @@ -101,7 +101,20 @@ export class MessageSigningService { const stored = this.readStoredKeyPair(); if (stored) { - const [publicKey, privateKey] = await Promise.all([crypto.subtle.importKey('jwk', stored.publicKeyJwk, { name: 'Ed25519' }, true, ['verify']), crypto.subtle.importKey('jwk', stored.privateKeyJwk, { name: 'Ed25519' }, false, ['sign'])]); + const publicKey = await crypto.subtle.importKey( + 'jwk', + stored.publicKeyJwk, + { name: 'Ed25519' }, + true, + ['verify'] + ); + const privateKey = await crypto.subtle.importKey( + 'jwk', + stored.privateKeyJwk, + { name: 'Ed25519' }, + false, + ['sign'] + ); return { publicKey, privateKey }; } @@ -111,7 +124,8 @@ export class MessageSigningService { true, ['sign', 'verify'] ); - const [publicKeyJwk, privateKeyJwk] = await Promise.all([crypto.subtle.exportKey('jwk', generated.publicKey), crypto.subtle.exportKey('jwk', generated.privateKey)]); + const publicKeyJwk = await crypto.subtle.exportKey('jwk', generated.publicKey); + const privateKeyJwk = await crypto.subtle.exportKey('jwk', generated.privateKey); this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk }); diff --git a/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.ts b/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.ts index 4661871..06dad08 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-revision.builder.rules.ts @@ -10,8 +10,6 @@ import { getMessageRevision, resolveMessageRevision } from './message-integrity.rules'; -import { getMessageTimestamp } from './message.rules'; - export interface BuildMessageRevisionInput { message: Message; type: MessageRevisionType; diff --git a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts index e0a656f..4f12f9c 100644 --- a/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts +++ b/toju-app/src/app/domains/chat/domain/rules/message-sync.rules.spec.ts @@ -7,7 +7,10 @@ import { findMissingIds } from './message-sync.rules'; describe('message-sync.rules', () => { it('requests ids with newer revision or mismatched head hash', () => { - const localMap = new Map([['m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }], ['m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }]]); + const localMap = new Map(); + + localMap.set('m1', { ts: 10, rc: 0, ac: 0, revision: 1, headHash: 'aaa' }); + localMap.set('m2', { ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }); const missing = findMissingIds([ { id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' }, { id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }, diff --git a/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts index 3230113..9b9df53 100644 --- a/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts +++ b/toju-app/src/app/domains/chat/feature/chat-image-proxy-fallback.directive.ts @@ -15,11 +15,20 @@ import type { RoomSignalSourceInput } from '../../server-directory'; standalone: true }) export class ChatImageProxyFallbackDirective { + + @HostBinding('src') + get src(): string { + return this.renderedSource(); + } + readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' }); + readonly signalSource = input(null); private readonly klipy = inject(KlipyService); + private readonly renderedSource = signal(''); + private hasAppliedProxyFallback = false; constructor() { @@ -29,11 +38,6 @@ export class ChatImageProxyFallbackDirective { }); } - @HostBinding('src') - get src(): string { - return this.renderedSource(); - } - @HostListener('error') handleError(): void { if (this.hasAppliedProxyFallback) { @@ -49,4 +53,5 @@ export class ChatImageProxyFallbackDirective { this.hasAppliedProxyFallback = true; this.renderedSource.set(proxyUrl); } + } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts index b405a4d..0f37fdb 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/chat-messages.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, @@ -67,42 +66,64 @@ import { }) export class ChatMessagesComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; - @ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent; - private readonly electronBridge = inject(ElectronBridgeService); - private readonly store = inject(Store); - private readonly webrtc = inject(RealtimeSessionFacade); - private readonly attachmentsSvc = inject(AttachmentFacade); - private readonly klipy = inject(KlipyService); - private readonly viewport = inject(ViewportService); + @ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent; readonly isMobile = this.viewport.isMobile; readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages); + readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages); - private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); + readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly loading = this.store.selectSignal(selectMessagesLoading); + readonly syncing = this.store.selectSignal(selectMessagesSyncing); + readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder); + readonly currentUser = this.store.selectSignal(selectCurrentUser); + readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`); + readonly conversationExhausted = toSignal( toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))), { initialValue: false } ); + readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); + readonly composerBottomPadding = signal(140); + readonly klipyGifPickerAnchorRight = signal(16); + readonly replyTo = signal(null); + readonly showKlipyGifPicker = signal(false); + readonly lightboxState = signal(null); + readonly galleryAttachments = signal(null); + readonly imageContextMenu = signal(null); + private readonly electronBridge = inject(ElectronBridgeService); + + private readonly store = inject(Store); + + private readonly webrtc = inject(RealtimeSessionFacade); + + private readonly attachmentsSvc = inject(AttachmentFacade); + + private readonly klipy = inject(KlipyService); + + private readonly viewport = inject(ViewportService); + + private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); + constructor() { effect(() => { void this.klipy.refreshAvailability(this.currentRoom()); @@ -262,36 +283,6 @@ export class ChatMessagesComponent { this.composer?.handleKlipyGifSelected(gif); } - private syncKlipyGifPickerAnchor(): void { - const triggerRect = this.composer?.getKlipyTriggerRect(); - - if (!triggerRect) { - this.klipyGifPickerAnchorRight.set(16); - return; - } - - const viewportWidth = window.innerWidth; - const popupWidth = this.getKlipyGifPickerWidth(viewportWidth); - const preferredRight = viewportWidth - triggerRect.right; - const minRight = 16; - const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); - - this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)); - } - - private getKlipyGifPickerWidth(viewportWidth: number): number { - if (viewportWidth >= 1280) - return 52 * 16; - - if (viewportWidth >= 768) - return 42 * 16; - - if (viewportWidth >= 640) - return 34 * 16; - - return Math.max(0, viewportWidth - 32); - } - openLightbox(event: ChatMessageImageLightboxEvent): void { const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl); const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id); @@ -411,6 +402,36 @@ export class ChatMessagesComponent { } } + private syncKlipyGifPickerAnchor(): void { + const triggerRect = this.composer?.getKlipyTriggerRect(); + + if (!triggerRect) { + this.klipyGifPickerAnchorRight.set(16); + return; + } + + const viewportWidth = window.innerWidth; + const popupWidth = this.getKlipyGifPickerWidth(viewportWidth); + const preferredRight = viewportWidth - triggerRect.right; + const minRight = 16; + const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16); + + this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)); + } + + private getKlipyGifPickerWidth(viewportWidth: number): number { + if (viewportWidth >= 1280) + return 52 * 16; + + if (viewportWidth >= 768) + return 42 * 16; + + if (viewportWidth >= 640) + return 34 * 16; + + return Math.max(0, viewportWidth - 32); + } + private isOwnMessage(message: Message): boolean { return message.senderId === this.currentUser()?.id; } @@ -507,4 +528,5 @@ export class ChatMessagesComponent { this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined); } + } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts index 28dea52..a82d10b 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-composer/chat-message-composer.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { @@ -108,38 +107,43 @@ const DEFAULT_TEXTAREA_HEIGHT = 62; }) export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { @ViewChild('messageInputRef') messageInputRef?: ElementRef; + @ViewChild('composerRoot') composerRoot?: ElementRef; + @ViewChild('klipyTrigger') klipyTrigger?: ElementRef; readonly replyTo = input(null); + readonly showKlipyGifPicker = input(false); + readonly currentUserId = input(null); + readonly klipyEnabled = input(false); + readonly klipySignalSource = input(null); + readonly textareaTestId = input(null); + readonly commandSurface = input('server'); readonly messageSubmitted = output(); + readonly typingStarted = output(); + readonly replyCleared = output(); + readonly heightChanged = output(); + readonly klipyGifPickerToggleRequested = output(); - private readonly klipy = inject(KlipyService); - private readonly markdown = inject(ChatMarkdownService); - private readonly electronBridge = inject(ElectronBridgeService); - private readonly pluginApi = inject(PluginClientApiService); - private readonly pluginUi = inject(PluginUiRegistryService); - private readonly customEmoji = inject(CustomEmojiService); - private readonly mobilePlatform = inject(MobilePlatformService); - private readonly mobileMedia = inject(MobileMediaService); - private readonly viewport = inject(ViewportService); - private readonly appI18n = inject(AppI18nService); - readonly pendingKlipyGif = signal(null); + readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton; + readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile())); + readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled())); + readonly composerTextareaPaddingClass = computed(() => resolveComposerTextareaPaddingClass({ isMobileViewport: this.viewport.isMobile(), @@ -147,38 +151,78 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { klipyEnabled: this.klipyEnabled() }) ); + readonly showComposerMediaMenu = signal(false); + readonly showEmojiPicker = signal(false); + readonly emojiButton = signal('🙂'); + readonly pluginComposerActions = this.pluginUi.composerActionRecords; + readonly slashQuery = signal(null); + readonly slashActiveIndex = signal(0); - private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries( - (text) => this.sendBuiltInSlashText(text), - (key) => this.appI18n.instant(key) - ); + readonly availableSlashCommands = computed(() => selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface()) ); + readonly slashCommandResults = computed(() => { const query = this.slashQuery(); return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query); }); + readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0); + readonly toolbarVisible = signal(false); + readonly dragActive = signal(false); + readonly inputHovered = signal(false); + readonly ctrlHeld = signal(false); + readonly textareaExpanded = signal(false); messageContent = ''; + pendingFiles: File[] = []; + inlineCodeToken = '`'; + private readonly klipy = inject(KlipyService); + + private readonly markdown = inject(ChatMarkdownService); + + private readonly electronBridge = inject(ElectronBridgeService); + + private readonly pluginApi = inject(PluginClientApiService); + + private readonly pluginUi = inject(PluginUiRegistryService); + + private readonly customEmoji = inject(CustomEmojiService); + + private readonly mobilePlatform = inject(MobilePlatformService); + + private readonly mobileMedia = inject(MobileMediaService); + + private readonly viewport = inject(ViewportService); + + private readonly appI18n = inject(AppI18nService); + + private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries( + (text) => this.sendBuiltInSlashText(text), + (key) => this.appI18n.instant(key) + ); + private toolbarHovering = false; + private dragDepth = 0; + private lastTypingSentAt = 0; + private resizeObserver: ResizeObserver | null = null; ngAfterViewInit(): void { @@ -194,7 +238,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { sendMessage(): void { const raw = this.messageContent.trim(); - if (this.maybeRunSlashCommand(raw)) + if (this.runSlashCommandWhenPresent(raw)) return; if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif()) @@ -459,68 +503,6 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { this.resetComposerAfterCommand(); } - private maybeRunSlashCommand(raw: string): boolean { - const parsed = parseSlashCommandInput(raw); - - if (!parsed) - return false; - - const entry = findSlashCommand(this.availableSlashCommands(), parsed.name); - - if (!entry) - return false; - - this.executeSlashCommand(entry, parsed.rawArgs); - this.resetComposerAfterCommand(); - - return true; - } - - private sendBuiltInSlashText(text: string): void { - this.messageSubmitted.emit({ - content: text, - pendingFiles: [] - }); - - this.replyCleared.emit(); - } - - private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void { - const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []); - const context = this.pluginApi.createSlashCommandContext({ - args, - command: entry.contribution.name, - rawArgs - }); - - void Promise.resolve().then(() => entry.contribution.run(context)); - } - - private resetComposerAfterCommand(): void { - this.messageContent = ''; - this.closeSlashCommandMenu(); - - requestAnimationFrame(() => { - this.autoResizeTextarea(); - this.messageInputRef?.nativeElement.focus(); - }); - } - - private moveSlashActive(delta: number): void { - const total = this.slashCommandResults().length; - - if (total === 0) - return; - - this.slashActiveIndex.update((current) => (current + delta + total) % total); - } - - private activeSlashCommand(): SlashCommandEntry | null { - const results = this.slashCommandResults(); - - return results[this.slashActiveIndex()] ?? results[0] ?? null; - } - getKlipyTriggerRect(): DOMRect | null { return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null; } @@ -686,6 +668,68 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { } } + private runSlashCommandWhenPresent(raw: string): boolean { + const parsed = parseSlashCommandInput(raw); + + if (!parsed) + return false; + + const entry = findSlashCommand(this.availableSlashCommands(), parsed.name); + + if (!entry) + return false; + + this.executeSlashCommand(entry, parsed.rawArgs); + this.resetComposerAfterCommand(); + + return true; + } + + private sendBuiltInSlashText(text: string): void { + this.messageSubmitted.emit({ + content: text, + pendingFiles: [] + }); + + this.replyCleared.emit(); + } + + private executeSlashCommand(entry: SlashCommandEntry, rawArgs: string): void { + const args = parseSlashCommandArguments(rawArgs, entry.contribution.options ?? []); + const context = this.pluginApi.createSlashCommandContext({ + args, + command: entry.contribution.name, + rawArgs + }); + + void Promise.resolve().then(() => entry.contribution.run(context)); + } + + private resetComposerAfterCommand(): void { + this.messageContent = ''; + this.closeSlashCommandMenu(); + + requestAnimationFrame(() => { + this.autoResizeTextarea(); + this.messageInputRef?.nativeElement.focus(); + }); + } + + private moveSlashActive(delta: number): void { + const total = this.slashCommandResults().length; + + if (total === 0) + return; + + this.slashActiveIndex.update((current) => (current + delta + total) % total); + } + + private activeSlashCommand(): SlashCommandEntry | null { + const results = this.slashCommandResults(); + + return results[this.slashActiveIndex()] ?? results[0] ?? null; + } + private getSelection(): { start: number; end: number } { const element = this.messageInputRef?.nativeElement; @@ -960,4 +1004,5 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { this.heightChanged.emit(root.offsetHeight); } } + } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index 2a4809e..8448803 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -1,4 +1,4 @@ - + @let msg = message(); @let attachmentsList = attachmentViewModels(); @if (isSystemMessage()) { 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 8d9881f..2dd7a66 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 @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering, */ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { @@ -169,56 +168,61 @@ interface MissingPluginEmbedFallback { }) export class ChatMessageItemComponent implements OnDestroy { @ViewChild('editTextareaRef') editTextareaRef?: ElementRef; + @ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef; - private readonly attachmentsSvc = inject(AttachmentFacade); - private readonly klipy = inject(KlipyService); - private readonly pluginRequirements = inject(PluginRequirementStateService); - private readonly pluginUi = inject(PluginUiRegistryService); - private readonly customEmoji = inject(CustomEmojiService); - private readonly electronBridge = inject(ElectronBridgeService); - private readonly platform = inject(PlatformService); - private readonly experimentalMedia = inject(ExperimentalMediaSettingsService); - private readonly profileCard = inject(ProfileCardService); - private readonly router = inject(Router); - private readonly viewport = inject(ViewportService); - private readonly overlay = inject(Overlay); - private readonly viewContainerRef = inject(ViewContainerRef); - private readonly appI18n = inject(AppI18nService); - private mobileSheetOverlayRef: OverlayRef | null = null; - private longPressTimer: number | null = null; readonly isMobile = this.viewport.isMobile; + readonly mobileSheetOpen = signal(false); - private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); - private readonly experimentalPlayerAttachmentId = signal(null); - private readonly mediaSupportCache = new Map(); readonly message = input.required(); + readonly repliedMessage = input(); + readonly currentUserId = input(null); + readonly isAdmin = input(false); + readonly userLookup = input>(new Map()); readonly replyRequested = output(); + readonly deleteRequested = output(); + readonly editSaved = output(); + readonly reactionAdded = output(); + readonly reactionToggled = output(); + readonly referenceRequested = output(); + readonly downloadRequested = output(); + readonly imageOpened = output(); + readonly imageGalleryOpened = output(); + readonly imageContextMenuRequested = output(); + readonly embedRemoved = output(); readonly emojiShortcuts = this.customEmoji.shortcutEntries; + readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted'); + 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(() => { const msg = this.message(); const found = this.userLookup().get(msg.senderId); @@ -238,26 +242,60 @@ export class ChatMessageItemComponent implements OnDestroy { editContent = ''; - openSenderProfileCard(event: MouseEvent): void { - event.stopPropagation(); - const el = event.currentTarget as HTMLElement; - const user = this.senderUser(); - const editable = user.id === this.currentUserId(); - - this.profileCard.open(el, user, { editable }); - } - readonly attachmentViewModels = computed(() => { void this.attachmentVersion(); return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment)); }); + readonly imageAttachments = computed(() => dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment))) ); + readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment))); + readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage)); + readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length)); + + private readonly attachmentsSvc = inject(AttachmentFacade); + + private readonly klipy = inject(KlipyService); + + private readonly pluginRequirements = inject(PluginRequirementStateService); + + private readonly pluginUi = inject(PluginUiRegistryService); + + private readonly customEmoji = inject(CustomEmojiService); + + private readonly electronBridge = inject(ElectronBridgeService); + + private readonly platform = inject(PlatformService); + + private readonly experimentalMedia = inject(ExperimentalMediaSettingsService); + + private readonly profileCard = inject(ProfileCardService); + + private readonly router = inject(Router); + + private readonly viewport = inject(ViewportService); + + private readonly overlay = inject(Overlay); + + private readonly viewContainerRef = inject(ViewContainerRef); + + private readonly appI18n = inject(AppI18nService); + + private mobileSheetOverlayRef: OverlayRef | null = null; + + private longPressTimer: number | null = null; + + private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); + + private readonly experimentalPlayerAttachmentId = signal(null); + + private readonly mediaSupportCache = new Map(); + private readonly hydrateMessageImages = effect(() => { const messageId = this.message().id; const images = this.imageAttachments(); @@ -282,6 +320,7 @@ export class ChatMessageItemComponent implements OnDestroy { void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId); } }); + private readonly syncAttachmentVersion = effect(() => { const version = this.attachmentsSvc.updated(); @@ -292,6 +331,15 @@ export class ChatMessageItemComponent implements OnDestroy { }); }); + openSenderProfileCard(event: MouseEvent): void { + event.stopPropagation(); + const el = event.currentTarget as HTMLElement; + const user = this.senderUser(); + const editable = user.id === this.currentUserId(); + + this.profileCard.open(el, user, { editable }); + } + openMissingPluginStore(fallback: MissingPluginEmbedFallback): void { const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url; @@ -303,43 +351,6 @@ export class ChatMessageItemComponent implements OnDestroy { }); } - private findPluginEmbeds(token: PluginEmbedToken | null) { - if (!token) { - return []; - } - - const payload = parseEmbedPayload(token.payloadText); - - return this.pluginUi - .embedRecords() - .filter((record) => record.contribution.embedType === token.embedType) - .map((record) => ({ - ...record, - render: () => record.contribution.render(payload) - })); - } - - private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null { - const token = this.pluginEmbedToken(); - - if (!token || this.pluginEmbeds().length > 0) { - return null; - } - - const missingRequirement = - this.pluginRequirements - .missingRequiredRequirements() - .find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ?? - this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ?? - this.pluginRequirements.missingRequiredRequirements()[0]; - const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType); - - return { - pluginName, - searchTerm: pluginName - }; - } - startEdit(): void { this.editContent = this.message().content; this.isEditing.set(true); @@ -453,53 +464,10 @@ export class ChatMessageItemComponent implements OnDestroy { this.clearLongPressTimer(); } - private clearLongPressTimer(): void { - if (this.longPressTimer !== null) { - window.clearTimeout(this.longPressTimer); - this.longPressTimer = null; - } - } - - private isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) { - return false; - } - - return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null; - } - closeMobileActions(): void { this.detachMobileSheet(); } - private openMobileSheet(): void { - if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) { - this.mobileSheetOpen.set(true); - return; - } - - const overlayRef = this.overlay.create({ - positionStrategy: this.overlay.position().global(), - scrollStrategy: this.overlay.scrollStrategies.block(), - hasBackdrop: false, - panelClass: 'metoyou-chat-actions-sheet-pane' - }); - const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef); - - overlayRef.attach(portal); - this.mobileSheetOverlayRef = overlayRef; - this.mobileSheetOpen.set(true); - } - - private detachMobileSheet(): void { - this.mobileSheetOpen.set(false); - - if (this.mobileSheetOverlayRef) { - this.mobileSheetOverlayRef.dispose(); - this.mobileSheetOverlayRef = null; - } - } - ngOnDestroy(): void { this.clearLongPressTimer(); this.detachMobileSheet(); @@ -836,6 +804,86 @@ export class ChatMessageItemComponent implements OnDestroy { this.experimentalPlayerAttachmentId.set(null); } + private findPluginEmbeds(token: PluginEmbedToken | null) { + if (!token) { + return []; + } + + const payload = parseEmbedPayload(token.payloadText); + + return this.pluginUi + .embedRecords() + .filter((record) => record.contribution.embedType === token.embedType) + .map((record) => ({ + ...record, + render: () => record.contribution.render(payload) + })); + } + + private resolveMissingPluginEmbed(): MissingPluginEmbedFallback | null { + const token = this.pluginEmbedToken(); + + if (!token || this.pluginEmbeds().length > 0) { + return null; + } + + const missingRequirement = + this.pluginRequirements + .missingRequiredRequirements() + .find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ?? + this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ?? + this.pluginRequirements.missingRequiredRequirements()[0]; + const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType); + + return { + pluginName, + searchTerm: pluginName + }; + } + + private clearLongPressTimer(): void { + if (this.longPressTimer !== null) { + window.clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + } + + private isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) { + return false; + } + + return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null; + } + + private openMobileSheet(): void { + if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) { + this.mobileSheetOpen.set(true); + return; + } + + const overlayRef = this.overlay.create({ + positionStrategy: this.overlay.position().global(), + scrollStrategy: this.overlay.scrollStrategies.block(), + hasBackdrop: false, + panelClass: 'metoyou-chat-actions-sheet-pane' + }); + const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef); + + overlayRef.attach(portal); + this.mobileSheetOverlayRef = overlayRef; + this.mobileSheetOpen.set(true); + } + + private detachMobileSheet(): void { + this.mobileSheetOpen.set(false); + + if (this.mobileSheetOverlayRef) { + this.mobileSheetOverlayRef.dispose(); + this.mobileSheetOverlayRef = null; + } + } + private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel { const isRawVideo = this.isVideoAttachment(attachment); const isRawAudio = this.isAudioAttachment(attachment); @@ -907,6 +955,7 @@ export class ChatMessageItemComponent implements OnDestroy { return canPlay; } + } function parsePluginEmbedToken(content: string): PluginEmbedToken | null { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts index 0a4eb99..8c449cd 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { Component, @@ -73,15 +72,21 @@ const REMARK_PROCESSOR = unified().use(remarkParse) templateUrl: './chat-message-markdown.component.html' }) export class ChatMessageMarkdownComponent { - private readonly customEmoji = inject(CustomEmojiService); readonly content = input.required(); + readonly displayContent = computed(() => replaceCustomEmojiMessageTokens(this.content(), (id) => this.customEmoji.findEmoji(id))); + readonly largeCustomEmoji = computed(() => isCustomEmojiOnlyMessage(this.content())); + readonly largeUnicodeEmoji = computed(() => isSingleUnicodeEmojiOnlyMessage(this.content())); + readonly remarkProcessor = REMARK_PROCESSOR; + readonly splitTextIntoEmojiSegments = splitTextIntoEmojiSegments; + private readonly customEmoji = inject(CustomEmojiService); + shouldRenderLargeCustomEmoji(url?: string): boolean { return this.isCustomEmojiDataUrl(url) && this.largeCustomEmoji(); } @@ -141,4 +146,5 @@ export class ChatMessageMarkdownComponent { return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized; } + } diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts index dfda817..547d72c 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { AfterViewChecked, @@ -64,37 +63,50 @@ declare global { } }) export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { + private static readonly INITIAL_SETTLE_MS = 1500; + @ViewChild('messagesContainer') messagesContainer?: ElementRef; + @ViewChild('messagesContent') messagesContent?: ElementRef; - private readonly store = inject(Store); - private readonly allUsers = this.store.selectSignal(selectAllUsers); - private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', { - day: 'numeric', - month: 'long', - year: 'numeric' - }); - readonly allMessages = input.required(); + readonly channelMessages = input.required(); + readonly loading = input(false); + readonly syncing = input(false); + readonly currentUserId = input(null); + readonly isAdmin = input(false); + readonly bottomPadding = input(120); + readonly conversationKey = input.required(); + readonly userLookupOverrides = input([]); readonly replyRequested = output(); + readonly deleteRequested = output(); + readonly editSaved = output(); + readonly reactionAdded = output(); + readonly reactionToggled = output(); + readonly downloadRequested = output(); + readonly imageOpened = output(); + readonly imageGalleryOpened = output(); + readonly imageContextMenuRequested = output(); + readonly embedRemoved = output(); + /** * Emitted when the user scrolls up past the in-store window and the * component needs the parent to fetch an older page from the DB. @@ -103,13 +115,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { /** True while a DB-backed older-page fetch dispatched by the parent is in flight. */ readonly loadingOlder = input(false); + /** True once the parent has paginated all the way back to the start of DB history. */ readonly conversationExhausted = input(false); - private readonly PAGE_SIZE = 50; - readonly displayLimit = signal(this.PAGE_SIZE); + readonly loadingMore = signal(false); + readonly showNewMessagesBar = signal(false); readonly messages = computed(() => { @@ -123,6 +136,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { }); readonly initialLoading = computed(() => this.loading() && this.messages().length === 0); + readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0); readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit()); @@ -167,6 +181,18 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { return lookup; }); + private readonly store = inject(Store); + + private readonly allUsers = this.store.selectSignal(selectAllUsers); + + private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); + + private readonly PAGE_SIZE = 50; + /** * O(1) index of messages by id, built once per `allMessages()` change. * Used by `findRepliedMessage` so each rendered row doing a reply lookup @@ -183,13 +209,21 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { }); private contentResizeObserver: ResizeObserver | null = null; + private observedContent: HTMLElement | null = null; + private localSendScrollPending = false; + private localSendScrollTimer: ReturnType | null = null; + private isAutoScrolling = false; + private lastMessageCount = 0; + private initialScrollPending = true; + private prismHighlightScheduled = false; + /** * True while the list should keep auto-pinning to the newest message. Set * when the conversation opens and whenever the user is scrolled near the @@ -199,13 +233,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { * latest message. */ private stickToBottom = true; + /** * Timestamp (ms) until which a freshly opened conversation is still * settling. Inside this window new messages jump instantly instead of * animating, so a channel switch always lands at the bottom. */ private settleUntil = 0; - private static readonly INITIAL_SETTLE_MS = 1500; + /** * Set when an older-page DB fetch is in flight. While true, the * `onMessagesChanged` effect treats incoming message-count growth as a @@ -710,4 +745,5 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { return String(hash); } + } diff --git a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts index f4edaf6..673d1ad 100644 --- a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts +++ b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { AfterViewInit, Component, @@ -55,27 +54,44 @@ const KLIPY_CARD_FALLBACK_SIZE = 160; templateUrl: './klipy-gif-picker.component.html' }) export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy { - readonly signalSource = input(null); - - readonly gifSelected = output(); - readonly closed = output(); @ViewChild('searchInput') searchInput?: ElementRef; - private readonly klipy = inject(KlipyService); - private readonly viewport = inject(ViewportService); - private readonly appI18n = inject(AppI18nService); + readonly signalSource = input(null); + + readonly gifSelected = output(); + + readonly closed = output(); + readonly isMobile = this.viewport.isMobile; - private currentPage = 1; - private searchTimer: ReturnType | null = null; - private requestId = 0; searchQuery = ''; + results = signal([]); + loading = signal(false); + errorMessage = signal(''); + hasNext = signal(false); + private readonly klipy = inject(KlipyService); + + private readonly viewport = inject(ViewportService); + + private readonly appI18n = inject(AppI18nService); + + private currentPage = 1; + + private searchTimer: ReturnType | null = null; + + private requestId = 0; + + @HostListener('document:keydown.escape') + onEscape(): void { + this.close(); + } + ngOnInit(): void { void this.loadResults(true); } @@ -91,11 +107,6 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy this.clearSearchTimer(); } - @HostListener('document:keydown.escape') - onEscape(): void { - this.close(); - } - onSearchQueryChanged(query: string): void { this.searchQuery = query; this.clearSearchTimer(); @@ -206,4 +217,5 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy height: Math.min(KLIPY_CARD_MAX_HEIGHT, Math.max(KLIPY_CARD_MIN_HEIGHT, scaledHeight)) }; } + } diff --git a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts index 7d301b6..d62922b 100644 --- a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts +++ b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering, */ import { Component, computed, @@ -44,17 +43,10 @@ interface TypingSignalingMessage { } }) export class TypingIndicatorComponent { - private readonly typingMap = new Map(); - private readonly store = inject(Store); - private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); - private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - private lastRoomId: string | null = null; - private lastConversationKey: string | null = null; typingDisplay = signal([]); + typingOthersCount = signal(0); - private readonly appI18n = inject(AppI18nService); readonly typingLabel = computed(() => { const names = this.typingDisplay(); @@ -80,6 +72,22 @@ export class TypingIndicatorComponent { return this.appI18n.instant('chat.typing.many', { names: namesText }); }); + private readonly typingMap = new Map(); + + private readonly store = inject(Store); + + private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); + + private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); + + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + + private lastRoomId: string | null = null; + + private lastConversationKey: string | null = null; + + private readonly appI18n = inject(AppI18nService); + constructor() { const webrtc = inject(RealtimeSessionFacade); const destroyRef = inject(DestroyRef); @@ -167,4 +175,5 @@ export class TypingIndicatorComponent { this.typingDisplay.set(names.slice(0, MAX_SHOWN)); this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN)); } + } diff --git a/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts b/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts index d6f5d2c..1edbcd9 100644 --- a/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts +++ b/toju-app/src/app/domains/chat/feature/user-list/user-list.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, @@ -67,20 +66,32 @@ import { DirectMessageService } from '../../../direct-message'; * Displays the list of online users with voice state indicators and admin actions. */ export class UserListComponent { - private store = inject(Store); - private router = inject(Router); - private directMessages = inject(DirectMessageService); onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal; + voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected)); + currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal; + isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); showUserMenu = signal(null); + showBanDialog = signal(false); + userToBan = signal(null); + banReason = ''; - banDuration = '86400000'; // Default 1 day + + banDuration = '86400000'; + + private store = inject(Store); + + private router = inject(Router); + + private directMessages = inject(DirectMessageService); + + // Default 1 day statusLabel(status: User['status']): string { switch (status) { @@ -167,4 +178,5 @@ export class UserListComponent { this.closeBanDialog(); } + } diff --git a/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts b/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts index 928e94c..ec3a9e9 100644 --- a/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts +++ b/toju-app/src/app/domains/custom-emoji/application/custom-emoji-sync.effects.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { createEffect } from '@ngrx/effects'; import { Store } from '@ngrx/store'; @@ -25,13 +24,6 @@ import { CustomEmojiService } from './custom-emoji.service'; @Injectable() export class CustomEmojiSyncEffects { - private readonly customEmoji = inject(CustomEmojiService); - private readonly store = inject(Store); - private readonly webrtc = inject(RealtimeSessionFacade); - private readonly incomingEvents$ = merge( - this.webrtc.onMessageReceived, - this.webrtc.onSignalingMessage as Observable - ); currentUserLoad$ = createEffect( () => this.store.select(selectCurrentUser).pipe( @@ -124,4 +116,16 @@ export class CustomEmojiSyncEffects { ), { dispatch: false } ); + + private readonly customEmoji = inject(CustomEmojiService); + + private readonly store = inject(Store); + + private readonly webrtc = inject(RealtimeSessionFacade); + + private readonly incomingEvents$ = merge( + this.webrtc.onMessageReceived, + this.webrtc.onSignalingMessage as Observable + ); + } diff --git a/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts b/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts index 4cdebde..89eef02 100644 --- a/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts +++ b/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -43,25 +42,34 @@ interface PendingCustomEmojiTransfer { @Injectable({ providedIn: 'root' }) export class CustomEmojiService { - private readonly db = inject(DatabaseService); - private readonly webrtc = inject(RealtimeSessionFacade); - private readonly emojisState = signal([]); - private readonly usageState = signal>(new Map()); - private readonly savedIdsState = signal>(new Set()); - private readonly pendingTransfers = new Map(); - private activeUserId: string | null = null; - private loaded = false; readonly emojis = computed(() => { const savedIds = this.savedIdsState(); return this.emojisState().filter((emoji) => savedIds.has(emoji.id)); }); + readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({ customEmojis: this.emojis(), usage: this.usageState() })); + private readonly db = inject(DatabaseService); + + private readonly webrtc = inject(RealtimeSessionFacade); + + private readonly emojisState = signal([]); + + private readonly usageState = signal>(new Map()); + + private readonly savedIdsState = signal>(new Set()); + + private readonly pendingTransfers = new Map(); + + private activeUserId: string | null = null; + + private loaded = false; + async loadForUser(userId: string | null | undefined): Promise { this.activeUserId = userId ?? null; @@ -574,4 +582,5 @@ export class CustomEmojiService { return baseName || 'emoji'; } + } diff --git a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts index 5168031..a14d760 100644 --- a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts +++ b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { CommonModule } from '@angular/common'; import { Component, @@ -46,41 +45,74 @@ import { templateUrl: './custom-emoji-picker.component.html' }) export class CustomEmojiPickerComponent { - private readonly customEmoji = inject(CustomEmojiService); - private readonly appI18n = inject(AppI18nService); - private readonly host = inject>(ElementRef); readonly currentUserId = input(null); + readonly compact = input(true); + /** Render the picker panel in normal document flow for bottom-sheet embedding. */ readonly inline = input(false); readonly emojiSelected = output(); + readonly dismissed = output(); readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE; + readonly modalOpen = signal(false); + readonly uploadError = signal(null); + readonly uploading = signal(false); + readonly shortcuts = this.customEmoji.shortcutEntries; + readonly customEmojis = this.customEmoji.emojis; + readonly searchQuery = signal(''); + readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries( UNICODE_EMOJI_PICKER_ENTRIES, this.searchQuery() )); + readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker( this.customEmojis(), this.searchQuery() )); + readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0); + readonly showEmptySearchState = computed(() => this.hasActiveSearch() && this.filteredUnicodeEntries().length === 0 && this.filteredCustomEmojis().length === 0); + + private readonly customEmoji = inject(CustomEmojiService); + + private readonly appI18n = inject(AppI18nService); + + private readonly host = inject>(ElementRef); + private readonly loadForUser = effect(() => { void this.customEmoji.loadForUser(this.currentUserId()); }); + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target; + + if (target == null || this.host.nativeElement.contains(target as Node)) { + return; + } + + this.dismiss(); + } + + @HostListener('document:keydown.escape') + onEscape(): void { + this.dismiss(); + } + setSearchQuery(query: string): void { this.searchQuery.set(query); } @@ -114,22 +146,6 @@ export class CustomEmojiPickerComponent { this.modalOpen.set(false); } - @HostListener('document:click', ['$event']) - onDocumentClick(event: MouseEvent): void { - const target = event.target; - - if (target == null || this.host.nativeElement.contains(target as Node)) { - return; - } - - this.dismiss(); - } - - @HostListener('document:keydown.escape') - onEscape(): void { - this.dismiss(); - } - openModal(): void { this.modalOpen.set(true); } @@ -168,4 +184,5 @@ export class CustomEmojiPickerComponent { this.uploading.set(false); } } + } 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 ba05d78..a9df4f4 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 @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -37,31 +36,13 @@ import { toDirectMessageParticipant } from '../../../direct-message'; @Injectable({ providedIn: 'root' }) export class DirectCallService { - private readonly router = inject(Router); - private readonly store = inject(Store); - private readonly delivery = inject(PeerDeliveryService); - private readonly directMessages = inject(DirectMessageService); - private readonly audio = inject(NotificationAudioService); - private readonly voice = inject(VoiceConnectionFacade); - private readonly voiceSession = inject(VoiceSessionFacade); - private readonly realtime = inject(RealtimeSessionFacade); - private readonly voiceActivity = inject(VoiceActivityService); - private readonly playback = inject(VoicePlaybackService); - private readonly viewport = inject(ViewportService); - private readonly mobileNotifications = inject(MobileNotificationsService); - private readonly mobileCallSession = inject(MobileCallSessionService); - private readonly mobileMedia = inject(MobileMediaService); - private readonly i18n = inject(AppI18nService); - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - private readonly users = this.store.selectSignal(selectAllUsers); - private readonly sessionsSignal = signal([]); - private readonly mobileOverlayCallId = signal(null); - private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = []; - private readonly declinedCallIds = new Set(); readonly sessions = computed(() => this.sessionsSignal()); + readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended')); + readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session))); + readonly incomingCall = computed(() => { if (this.isDoNotDisturb()) { return null; @@ -80,8 +61,11 @@ export class DirectCallService { && !session.participants[meId]?.joined && this.hasConnectedParticipant(session)) ?? null; }); + readonly currentSession = signal(null); + readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0); + readonly mobileOverlaySession = computed(() => { const callId = this.mobileOverlayCallId(); @@ -92,6 +76,48 @@ export class DirectCallService { return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null; }); + private readonly router = inject(Router); + + private readonly store = inject(Store); + + private readonly delivery = inject(PeerDeliveryService); + + private readonly directMessages = inject(DirectMessageService); + + private readonly audio = inject(NotificationAudioService); + + private readonly voice = inject(VoiceConnectionFacade); + + private readonly voiceSession = inject(VoiceSessionFacade); + + private readonly realtime = inject(RealtimeSessionFacade); + + private readonly voiceActivity = inject(VoiceActivityService); + + private readonly playback = inject(VoicePlaybackService); + + private readonly viewport = inject(ViewportService); + + private readonly mobileNotifications = inject(MobileNotificationsService); + + private readonly mobileCallSession = inject(MobileCallSessionService); + + private readonly mobileMedia = inject(MobileMediaService); + + private readonly i18n = inject(AppI18nService); + + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + + private readonly users = this.store.selectSignal(selectAllUsers); + + private readonly sessionsSignal = signal([]); + + private readonly mobileOverlayCallId = signal(null); + + private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = []; + + private readonly declinedCallIds = new Set(); + constructor() { this.mobileCallSession.initialize(); this.mobileCallSession.onCallControlAction((intent, callId) => { @@ -392,19 +418,6 @@ export class DirectCallService { } } - private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void { - const action = endForEveryone ? 'end' : 'leave'; - const nextSession = this.markCurrentUserLeft(session, endForEveryone); - - this.audio.stop(AppSound.Call); - this.broadcastCallEvent(action, nextSession); - this.stopLocalMedia(nextSession); - this.upsertSession(nextSession); - - this.currentSession.set(null); - void this.mobileCallSession.endActiveCall(session.callId); - } - async inviteUser(callId: string, user: User): Promise { const session = this.sessionById(callId); @@ -445,6 +458,19 @@ export class DirectCallService { return participant ? participantToUser(participant) : null; } + private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void { + const action = endForEveryone ? 'end' : 'leave'; + const nextSession = this.markCurrentUserLeft(session, endForEveryone); + + this.audio.stop(AppSound.Call); + this.broadcastCallEvent(action, nextSession); + this.stopLocalMedia(nextSession); + this.upsertSession(nextSession); + + this.currentSession.set(null); + void this.mobileCallSession.endActiveCall(session.callId); + } + private async drainPendingIncomingCallPayloads(): Promise { if (this.pendingIncomingCallPayloads.length === 0) { return; @@ -1041,4 +1067,5 @@ export class DirectCallService { return user; } + } diff --git a/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts index 7bf1cb7..586de88 100644 --- a/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts +++ b/toju-app/src/app/domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component.ts @@ -36,10 +36,13 @@ import { DirectCallSession, participantToUser } from '../../domain/models/direct }) export class IncomingCallModalComponent { readonly calls = inject(DirectCallService); - private readonly i18n = inject(AppI18nService); + readonly currentUser = inject(Store).selectSignal(selectCurrentUser); + readonly session = this.calls.incomingCall; + readonly answering = signal(false); + readonly caller = computed(() => { const session = this.session(); @@ -53,10 +56,13 @@ export class IncomingCallModalComponent { return (callerId ? this.calls.userForParticipant(callerId) : null) ?? (participant ? participantToUser(participant) : null); }); + readonly callerName = computed(() => this.caller()?.displayName || this.i18n.instant('call.incoming.someone')); + readonly callerCallingLabel = computed(() => this.i18n.instant('call.incoming.callerCalling', { name: this.callerName() }) ); + readonly callKindLabel = computed(() => { const participantCount = this.session()?.participantIds.length ?? 0; @@ -65,6 +71,8 @@ export class IncomingCallModalComponent { : this.i18n.instant('call.incoming.directCall'); }); + private readonly i18n = inject(AppI18nService); + @HostListener('document:keydown.escape') onEscape(): void { this.decline(); @@ -115,4 +123,5 @@ export class IncomingCallModalComponent { private userKey(user: User): string { return user.oderId || user.id; } + } 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 6a3ee4d..65b5d57 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 @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -62,24 +61,13 @@ interface DirectMessageTypingEntry { @Injectable({ providedIn: 'root' }) export class DirectMessageService { - private readonly repository = inject(DirectMessageRepository); - private readonly offlineQueue = inject(OfflineMessageQueueService); - private readonly delivery = inject(PeerDeliveryService); - private readonly attachments = inject(AttachmentFacade); - private readonly customEmoji = inject(CustomEmojiService); - private readonly store = inject(Store); - private readonly router = inject(Router); - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - private readonly conversationsSignal = signal([]); - private readonly selectedConversationIdSignal = signal(null); - private readonly typingEntriesSignal = signal([]); - private readonly lastSyncRequestAt = new Map(); - private loadedOwnerId: string | null = null; readonly conversations = computed(() => [...this.conversationsSignal()].sort( (firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt )); + readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly(); + readonly selectedConversation = computed(() => { const selectedId = this.selectedConversationIdSignal(); @@ -87,12 +75,40 @@ export class DirectMessageService { ? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null : null; }); + readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce( (total, conversation) => total + conversation.unreadCount, 0 )); + readonly typingEntries = this.typingEntriesSignal.asReadonly(); + private readonly repository = inject(DirectMessageRepository); + + private readonly offlineQueue = inject(OfflineMessageQueueService); + + private readonly delivery = inject(PeerDeliveryService); + + private readonly attachments = inject(AttachmentFacade); + + private readonly customEmoji = inject(CustomEmojiService); + + private readonly store = inject(Store); + + private readonly router = inject(Router); + + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + + private readonly conversationsSignal = signal([]); + + private readonly selectedConversationIdSignal = signal(null); + + private readonly typingEntriesSignal = signal([]); + + private readonly lastSyncRequestAt = new Map(); + + private loadedOwnerId: string | null = null; + constructor() { effect(() => { const ownerId = this.getCurrentUserId(); @@ -991,4 +1007,5 @@ export class DirectMessageService { return ownerId; } + } diff --git a/toju-app/src/app/domains/direct-message/application/services/friend.service.ts b/toju-app/src/app/domains/direct-message/application/services/friend.service.ts index a5b8917..9764136 100644 --- a/toju-app/src/app/domains/direct-message/application/services/friend.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/friend.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -14,16 +13,23 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors'; @Injectable({ providedIn: 'root' }) export class FriendService { - private readonly repository = inject(FriendRepository); - private readonly store = inject(Store); - private readonly webrtc = inject(RealtimeSessionFacade); - private readonly currentUser = this.store.selectSignal(selectCurrentUser); - private readonly friendsSignal = signal([]); - private loadedOwnerId: string | null = null; readonly friends = this.friendsSignal.asReadonly(); + readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId))); + private readonly repository = inject(FriendRepository); + + private readonly store = inject(Store); + + private readonly webrtc = inject(RealtimeSessionFacade); + + private readonly currentUser = this.store.selectSignal(selectCurrentUser); + + private readonly friendsSignal = signal([]); + + private loadedOwnerId: string | null = null; + constructor() { effect(() => { const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null; @@ -116,4 +122,5 @@ export class FriendService { await this.loadForOwner(ownerId); return ownerId; } + } diff --git a/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts b/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts index 24c4b5b..c1c8a44 100644 --- a/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { @@ -13,10 +12,6 @@ import type { ChatEvent, User } from '../../../../shared-kernel'; @Injectable({ providedIn: 'root' }) export class PeerDeliveryService { - private readonly webrtc = inject(RealtimeSessionFacade); - private readonly store = inject(Store); - private readonly users = this.store.selectSignal(selectAllUsers); - private readonly networkRestoredSubject = new Subject(); readonly directMessageEvents$: Observable = merge( this.webrtc.onMessageReceived, @@ -38,8 +33,17 @@ export class PeerDeliveryService { ); readonly peerConnected$ = this.webrtc.onPeerConnected; + readonly networkRestored$ = this.networkRestoredSubject.asObservable(); + private readonly webrtc = inject(RealtimeSessionFacade); + + private readonly store = inject(Store); + + private readonly users = this.store.selectSignal(selectAllUsers); + + private readonly networkRestoredSubject = new Subject(); + constructor() { this.installNetworkTestHooks(); } @@ -174,4 +178,5 @@ export class PeerDeliveryService { this.networkRestoredSubject.next(); }; } + } 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 66a32f1..1c39612 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 @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -84,55 +83,68 @@ interface DmStatusLabel { export class DmChatComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; - private readonly route = inject(ActivatedRoute); - private readonly store = inject(Store); - private readonly electronBridge = inject(ElectronBridgeService); - private readonly attachments = inject(AttachmentFacade); - private readonly klipy = inject(KlipyService); - private readonly linkMetadata = inject(LinkMetadataService); - private readonly profileCard = inject(ProfileCardService); - private readonly viewport = inject(ViewportService); - private readonly metadataRequestKeys = new Set(); - private openedConversationId: string | null = null; - private readonly i18n = inject(AppI18nService); readonly isMobile = this.viewport.isMobile; + readonly directCalls = inject(DirectCallService); + readonly directMessages = inject(DirectMessageService); + readonly currentUser = this.store.selectSignal(selectCurrentUser); + readonly allUsers = this.store.selectSignal(selectAllUsers); + readonly showGifPicker = signal(false); + readonly conversationId = input(null); + readonly showCallButton = input(true); + readonly composerBottomPadding = signal(140); + readonly gifPickerAnchorRight = signal(16); + readonly linkMetadataByMessageId = signal>({}); + readonly replyTo = signal(null); + readonly lightboxState = signal(null); + readonly galleryAttachments = signal(null); + readonly imageContextMenu = signal(null); + readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); + readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId()); + readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); + readonly conversation = this.directMessages.selectedConversation; + readonly klipyEnabled = computed(() => this.klipy.isEnabled(null)); + readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none'); + readonly typingUsers = computed(() => { void this.directMessages.typingEntries(); return this.directMessages.typingUsers(this.conversation()?.id); }); + readonly peerUser = computed(() => { const conversation = this.conversation(); return conversation ? this.peerUserFor(conversation) : null; }); + readonly isGroupConversation = computed(() => { const conversation = this.conversation(); return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2); }); + readonly participantUsers = computed(() => { const conversation = this.conversation(); const knownUsers = this.allUsers(); @@ -164,6 +176,7 @@ export class DmChatComponent { ); }); }); + readonly messageStatuses = computed(() => { const conversation = this.conversation(); const currentUserId = this.currentUserId(); @@ -179,6 +192,7 @@ export class DmChatComponent { status: message.status })); }); + readonly chatMessages = computed(() => { const conversation = this.conversation(); const metadataByMessageId = this.linkMetadataByMessageId(); @@ -196,7 +210,11 @@ export class DmChatComponent { roomId: conversation.id, channelId: 'direct-message', senderId: message.senderId, - senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? this.i18n.instant('common.labels.you') : message.senderId), + senderName: knownUser?.displayName + || participant?.displayName + || (message.senderId === this.currentUserId() + ? this.i18n.instant('common.labels.you') + : message.senderId), content: message.content, timestamp: message.timestamp, kind: message.kind, @@ -209,6 +227,7 @@ export class DmChatComponent { }; }); }); + readonly peerName = computed(() => { const conversation = this.conversation(); const currentUserId = this.currentUserId(); @@ -221,6 +240,7 @@ export class DmChatComponent { return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle'); }); + readonly peerCallIcon = computed(() => { const conversation = this.conversation(); @@ -232,6 +252,7 @@ export class DmChatComponent { return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone'; }); + readonly canCallConversation = computed(() => { const conversation = this.conversation(); @@ -246,6 +267,28 @@ export class DmChatComponent { return !!this.peerUser(); }); + private readonly route = inject(ActivatedRoute); + + private readonly store = inject(Store); + + private readonly electronBridge = inject(ElectronBridgeService); + + private readonly attachments = inject(AttachmentFacade); + + private readonly klipy = inject(KlipyService); + + private readonly linkMetadata = inject(LinkMetadataService); + + private readonly profileCard = inject(ProfileCardService); + + private readonly viewport = inject(ViewportService); + + private readonly metadataRequestKeys = new Set(); + + private openedConversationId: string | null = null; + + private readonly i18n = inject(AppI18nService); + constructor() { effect(() => { const conversationId = this.effectiveConversationId(); @@ -659,4 +702,5 @@ export class DmChatComponent { return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; } + } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts index 54ac4f8..e32b2fb 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-rail/dm-rail.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -60,15 +59,16 @@ const EXIT_ANIMATION_MS = 160; styleUrl: './dm-rail.component.scss' }) export class DmRailComponent implements OnDestroy { - private readonly router = inject(Router); - private readonly store = inject(Store); - private readonly exitTimers = new Map>(); - private readonly i18n = inject(AppI18nService); readonly directMessages = inject(DirectMessageService); + readonly friends = inject(FriendService); + readonly users = this.store.selectSignal(selectAllUsers); + readonly currentUser = this.store.selectSignal(selectCurrentUser); + readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); + readonly activeConversationId = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), @@ -76,11 +76,15 @@ export class DmRailComponent implements OnDestroy { ), { initialValue: this.getConversationIdFromUrl(this.router.url) } ); + readonly friendUsers = computed(() => this.users().filter((user) => this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId() )); + readonly railItems = signal([]); + readonly contextMenu = signal(null); + readonly unreadRailItems = computed(() => { const currentUserId = this.currentUserId(); const items = new Map(); @@ -143,6 +147,7 @@ export class DmRailComponent implements OnDestroy { return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0); }); + readonly isOnDirectMessages = toSignal( this.router.events.pipe( filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), @@ -151,6 +156,14 @@ export class DmRailComponent implements OnDestroy { { initialValue: this.router.url.startsWith('/dm') } ); + private readonly router = inject(Router); + + private readonly store = inject(Store); + + private readonly exitTimers = new Map>(); + + private readonly i18n = inject(AppI18nService); + constructor() { effect(() => { const unreadItems = this.unreadRailItems(); @@ -334,4 +347,5 @@ export class DmRailComponent implements OnDestroy { return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; } + } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts index adf1396..43d9bf1 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversation-item.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -44,26 +43,42 @@ import { DirectMessageService } from '../../application/services/direct-message. templateUrl: './dm-conversation-item.component.html' }) export class DmConversationItemComponent { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly store = inject(Store); - private readonly attachments = inject(AttachmentFacade); - private readonly directMessages = inject(DirectMessageService); - private readonly directCalls = inject(DirectCallService); - private readonly i18n = inject(AppI18nService); readonly conversation = input.required(); + readonly conversationOpened = output(); + readonly users = this.store.selectSignal(selectAllUsers); + readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); + readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id); + readonly peerName = computed(() => this.resolvePeerName(this.conversation())); + readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation())); + readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation())); + readonly canCall = computed(() => this.canCallConversation(this.conversation())); + readonly callIcon = computed(() => this.conversationCallIcon(this.conversation())); + private readonly route = inject(ActivatedRoute); + + private readonly router = inject(Router); + + private readonly store = inject(Store); + + private readonly attachments = inject(AttachmentFacade); + + private readonly directMessages = inject(DirectMessageService); + + private readonly directCalls = inject(DirectCallService); + + private readonly i18n = inject(AppI18nService); + constructor() { effect(() => { const conversation = this.conversation(); @@ -118,7 +133,12 @@ export class DmConversationItemComponent { const peerId = this.peerId(conversation); const knownUser = this.peerUser(conversation); - return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle'); + if (!peerId) + return this.i18n.instant('dm.chat.defaultTitle'); + + return knownUser?.displayName + || conversation.participantProfiles[peerId]?.displayName + || peerId; } private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined { @@ -229,4 +249,5 @@ export class DmConversationItemComponent { ? this.i18n.instant('dm.previews.oneAttachment') : this.i18n.instant('dm.previews.manyAttachments'); } + } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts index ed1866e..e0ee7c1 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-conversations-panel.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -33,12 +32,16 @@ import { DmConversationItemComponent } from './dm-conversation-item.component'; templateUrl: './dm-conversations-panel.component.html' }) export class DmConversationsPanelComponent { - private readonly theme = inject(ThemeService); readonly directMessages = inject(DirectMessageService); + readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel')); + readonly conversationSelected = output(); + private readonly theme = inject(ThemeService); + trackConversationId(index: number, conversation: DirectMessageConversation): string { return conversation.id; } + } diff --git a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts index a1c0ab9..8bb7003 100644 --- a/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/dm-workspace/dm-workspace.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -55,21 +54,18 @@ interface SwiperElement extends HTMLElement { templateUrl: './dm-workspace.component.html' }) export class DmWorkspaceComponent implements OnDestroy { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly theme = inject(ThemeService); - private readonly viewport = inject(ViewportService); - private readonly zone = inject(NgZone); - private readonly directCalls = inject(DirectCallService); - private lastSeenConversationId: string | null = null; - private swiperListenerAttached: SwiperElement | null = null; readonly directMessages = inject(DirectMessageService); + readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { initialValue: this.route.snapshot.paramMap.get('conversationId') }); + readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout')); + readonly isMobile = this.viewport.isMobile; + readonly swiperRef = viewChild>('swiperEl'); + readonly activeCall = computed(() => { const currentSession = this.directCalls.currentSession(); const visibleSessions = this.directCalls.visibleActiveSessions(); @@ -80,6 +76,22 @@ export class DmWorkspaceComponent implements OnDestroy { /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */ readonly mobilePage = signal('conversations'); + private readonly route = inject(ActivatedRoute); + + private readonly router = inject(Router); + + private readonly theme = inject(ThemeService); + + private readonly viewport = inject(ViewportService); + + private readonly zone = inject(NgZone); + + private readonly directCalls = inject(DirectCallService); + + private lastSeenConversationId: string | null = null; + + private swiperListenerAttached: SwiperElement | null = null; + constructor() { effect(() => { const conversationId = this.routeConversationId(); @@ -171,5 +183,6 @@ export class DmWorkspaceComponent implements OnDestroy { ngOnDestroy(): void { this.directMessages.closeConversationView(this.routeConversationId()); } + } diff --git a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts index 2237369..28ca1fc 100644 --- a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -16,7 +15,7 @@ import { lucideUsers } from '@ng-icons/lucide'; -import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; +import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives'; import { UserSearchListComponent } from '../user-search-list/user-search-list.component'; import { selectAllUsers } from '../../../../store/users/users.selectors'; @@ -47,10 +46,7 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; } }) export class FindPeopleComponent { - private store = inject(Store); searchQuery = signal(''); - private users = this.store.selectSignal(selectAllUsers); - private savedRooms = this.store.selectSignal(selectSavedRooms); /** True when the account has any people to surface (known users or server members). */ hasDiscoverablePeople = computed(() => { @@ -61,7 +57,14 @@ export class FindPeopleComponent { return this.savedRooms().some((room) => (room.members?.length ?? 0) > 0); }); + private store = inject(Store); + + private users = this.store.selectSignal(selectAllUsers); + + private savedRooms = this.store.selectSignal(selectSavedRooms); + onSearchChange(query: string): void { this.searchQuery.set(query); } + } diff --git a/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts b/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts index 9ad6191..3d514fd 100644 --- a/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/friend-button/friend-button.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -24,20 +23,26 @@ import type { User } from '../../../../shared-kernel'; templateUrl: './friend-button.component.html' }) export class FriendButtonComponent { - private readonly friends = inject(FriendService); - private readonly i18n = inject(AppI18nService); readonly user = input.required(); + readonly userId = computed(() => this.user().oderId || this.user().id); + readonly isFriend = computed(() => this.friends.isFriend(this.userId())); + readonly ariaLabel = computed(() => this.isFriend() ? this.i18n.instant('dm.friend.remove') : this.i18n.instant('dm.friend.add') ); + private readonly friends = inject(FriendService); + + private readonly i18n = inject(AppI18nService); + toggle(event: Event): void { event.stopPropagation(); void this.friends.toggleFriend(this.userId()); } + } diff --git a/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts b/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts index 722a0e5..592dc76 100644 --- a/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts +++ b/toju-app/src/app/domains/direct-message/feature/user-search-list/user-search-list.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -39,16 +38,18 @@ import type { User } from '../../../../shared-kernel'; templateUrl: './user-search-list.component.html' }) export class UserSearchListComponent { - private readonly store = inject(Store); - private readonly router = inject(Router); - private readonly directMessages = inject(DirectMessageService); readonly directCalls = inject(DirectCallService); + readonly friends = inject(FriendService); - private readonly i18n = inject(AppI18nService); + readonly searchQuery = input(''); + readonly users = this.store.selectSignal(selectAllUsers); + readonly savedRooms = this.store.selectSignal(selectSavedRooms); + readonly currentUser = this.store.selectSignal(selectCurrentUser); + readonly discoveredUsers = computed(() => { const usersById = new Map(); @@ -81,6 +82,7 @@ export class UserSearchListComponent { return Array.from(usersById.values()); }); + readonly matchingUsers = computed(() => { const query = this.normalizedSearchQuery(); const currentUserId = this.currentUserKey(); @@ -90,13 +92,23 @@ export class UserSearchListComponent { .filter((user) => this.matchesQuery(user, query)) .slice(0, 24); }); + readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user)))); + readonly results = computed(() => { const friendIds = this.friends.friendIds(); return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user))); }); + private readonly store = inject(Store); + + private readonly router = inject(Router); + + private readonly directMessages = inject(DirectMessageService); + + private readonly i18n = inject(AppI18nService); + async messageUser(user: User): Promise { const conversation = await this.directMessages.createConversation(user); @@ -151,4 +163,5 @@ export class UserSearchListComponent { .filter((value): value is string => !!value) .some((value) => value.toLowerCase().includes(query)); } + } diff --git a/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts index b946a09..12c59f0 100644 --- a/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts +++ b/toju-app/src/app/domains/notifications/application/effects/notifications.effects.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { Actions, @@ -38,9 +37,6 @@ export function groupMessagesByRoom(messages: Message[]): Map @Injectable() export class NotificationsEffects { - private readonly actions$ = inject(Actions); - private readonly store = inject(Store); - private readonly notifications = inject(NotificationsFacade); syncRoomCatalog$ = createEffect( () => @@ -136,4 +132,11 @@ export class NotificationsEffects { ), { dispatch: false } ); + + private readonly actions$ = inject(Actions); + + private readonly store = inject(Store); + + private readonly notifications = inject(NotificationsFacade); + } diff --git a/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts b/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts index 7dac8ff..ef3fb3d 100644 --- a/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts +++ b/toju-app/src/app/domains/notifications/application/facades/notifications.facade.ts @@ -1,14 +1,15 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { NotificationsService } from '../services/notifications.service'; @Injectable({ providedIn: 'root' }) export class NotificationsFacade { - private readonly service = inject(NotificationsService); readonly settings = this.service.settings; + readonly unread = this.service.unread; + private readonly service = inject(NotificationsService); + initialize( ...args: Parameters ): ReturnType { @@ -98,4 +99,5 @@ export class NotificationsFacade { ): ReturnType { return this.service.setChannelMuted(...args); } + } 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 c71ec69..1f7fd46 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 @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -46,33 +45,54 @@ const MAX_NOTIFIED_MESSAGE_IDS = 500; @Injectable({ providedIn: 'root' }) export class NotificationsService { + + readonly settings = computed(() => this._settings()); + + readonly unread = computed(() => this._unread()); + private readonly store = inject(Store); + private readonly db = inject(DatabaseService); + private readonly audio = inject(NotificationAudioService); + private readonly appI18n = inject(AppI18nService); + private readonly timeSync = inject(TimeSyncService); + private readonly desktopNotifications = inject(DesktopNotificationService); + private readonly storage = inject(NotificationSettingsStorageService); private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); + private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); + private readonly savedRooms = this.store.selectSignal(selectSavedRooms); + private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly _settings = signal(createDefaultNotificationSettings()); - private readonly _unread = signal(createEmptyUnreadState()); - private readonly _windowFocused = signal(typeof document === 'undefined' ? true : document.hasFocus()); - private readonly _documentVisible = signal(typeof document === 'undefined' ? true : document.visibilityState === 'visible'); - private readonly _windowMinimized = signal(false); - private readonly platformKind = detectPlatform(); - private readonly notifiedMessageIds = new Set(); - private readonly notifiedMessageOrder: string[] = []; - private attentionActive = false; - private windowStateCleanup: (() => void) | null = null; - private initialised = false; - readonly settings = computed(() => this._settings()); - readonly unread = computed(() => this._unread()); + private readonly _unread = signal(createEmptyUnreadState()); + + private readonly _windowFocused = signal(typeof document === 'undefined' ? true : document.hasFocus()); + + private readonly _documentVisible = signal(typeof document === 'undefined' ? true : document.visibilityState === 'visible'); + + private readonly _windowMinimized = signal(false); + + private readonly platformKind = detectPlatform(); + + private readonly notifiedMessageIds = new Set(); + + private readonly notifiedMessageOrder: string[] = []; + + private attentionActive = false; + + private windowStateCleanup: (() => void) | null = null; + + private initialised = false; async initialize(): Promise { if (this.initialised) { @@ -311,30 +331,6 @@ export class NotificationsService { }); } - private registerWindowListeners(): void { - if (typeof window === 'undefined' || typeof document === 'undefined') { - return; - } - - window.addEventListener('focus', this.handleWindowFocus); - window.addEventListener('blur', this.handleWindowBlur); - document.addEventListener('visibilitychange', this.handleVisibilityChange); - } - - private registerWindowStateListener(): void { - this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => { - this._windowFocused.set(state.isFocused); - this._windowMinimized.set(state.isMinimized); - - if (state.isFocused && !state.isMinimized && this._documentVisible()) { - this.markCurrentChannelReadIfActive(); - return; - } - - this.syncWindowAttention(); - }); - } - private readonly handleWindowFocus = (): void => { this._windowFocused.set(true); this._windowMinimized.set(false); @@ -359,6 +355,30 @@ export class NotificationsService { this.syncWindowAttention(); }; + private registerWindowListeners(): void { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + window.addEventListener('focus', this.handleWindowFocus); + window.addEventListener('blur', this.handleWindowBlur); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + } + + private registerWindowStateListener(): void { + this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => { + this._windowFocused.set(state.isFocused); + this._windowMinimized.set(state.isMinimized); + + if (state.isFocused && !state.isMinimized && this._documentVisible()) { + this.markCurrentChannelReadIfActive(); + return; + } + + this.syncWindowAttention(); + }); + } + private buildContext(): NotificationDeliveryContext { return { activeChannelId: this.activeChannelId(), @@ -625,6 +645,7 @@ export class NotificationsService { } } } + } function detectPlatform(): DesktopPlatform { diff --git a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts index b77a583..427bd2c 100644 --- a/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts +++ b/toju-app/src/app/domains/notifications/feature/settings/notifications-settings/notifications-settings.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -37,15 +36,20 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n'; templateUrl: './notifications-settings.component.html' }) export class NotificationsSettingsComponent { - private readonly store = inject(Store); readonly notifications = inject(NotificationsFacade); readonly rooms = this.store.selectSignal(selectSavedRooms); + readonly settings = this.notifications.settings; + readonly enabled = computed(() => this.settings().enabled); + readonly showPreview = computed(() => this.settings().showPreview); + readonly respectBusyStatus = computed(() => this.settings().respectBusyStatus); + private readonly store = inject(Store); + trackRoom = (_index: number, room: Room) => room.id; textChannels(room: Room) { @@ -112,4 +116,5 @@ export class NotificationsSettingsComponent { formatUnreadCount(count: number): string { return count > 99 ? '99+' : String(count); } + } diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts index ab1dd44..1328f69 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { DestroyRef, Injectable, @@ -43,27 +42,15 @@ export interface PluginRequirementComparison { @Injectable({ providedIn: 'root' }) export class PluginRequirementStateService { - private readonly destroyRef = inject(DestroyRef); - private readonly pluginRequirements = inject(PluginRequirementService); - private readonly pluginStore = inject(PluginStoreService); - private readonly realtime = inject(RealtimeSessionFacade); - private readonly registry = inject(PluginRegistryService); - private readonly serverDirectory = inject(ServerDirectoryFacade); - private readonly store = inject(Store); - - private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); - private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId); - private readonly snapshotsSignal = signal>({}); - private readonly refreshErrorsSignal = signal>({}); - private readonly sessionDismissedOptionalSignal = signal>({}); - private readonly hiddenOptionalSignal = signal(loadRequirementDismissals()); readonly currentSnapshot = computed(() => { const roomId = this.currentRoomId(); return roomId ? this.snapshotsSignal()[roomId] ?? null : null; }); + readonly refreshErrors = this.refreshErrorsSignal.asReadonly(); + readonly missingInstallableRequirements = computed(() => { if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) { return []; @@ -79,11 +66,14 @@ export class PluginRequirementStateService { return requirements; }); + readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements() .filter((requirement) => requirement.status === 'required')); + readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements() .filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended') .filter((requirement) => !this.isOptionalRequirementDismissed(requirement))); + readonly comparisons = computed(() => { const snapshot = this.currentSnapshot(); const installedEntries = this.registry.entries(); @@ -115,6 +105,32 @@ export class PluginRequirementStateService { return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId)); }); + private readonly destroyRef = inject(DestroyRef); + + private readonly pluginRequirements = inject(PluginRequirementService); + + private readonly pluginStore = inject(PluginStoreService); + + private readonly realtime = inject(RealtimeSessionFacade); + + private readonly registry = inject(PluginRegistryService); + + private readonly serverDirectory = inject(ServerDirectoryFacade); + + private readonly store = inject(Store); + + private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); + + private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId); + + private readonly snapshotsSignal = signal>({}); + + private readonly refreshErrorsSignal = signal>({}); + + private readonly sessionDismissedOptionalSignal = signal>({}); + + private readonly hiddenOptionalSignal = signal(loadRequirementDismissals()); + constructor() { this.realtime.onSignalingMessage .pipe(takeUntilDestroyed(this.destroyRef)) @@ -268,6 +284,7 @@ export class PluginRequirementStateService { return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt; } + } function loadRequirementDismissals(): RequirementDismissalState { diff --git a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts index 6e6b211..72c91aa 100644 --- a/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts +++ b/toju-app/src/app/domains/plugins/application/services/plugin-store.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { DestroyRef, Injectable, @@ -70,52 +69,27 @@ interface ServerInstalledPluginsLoadState { @Injectable({ providedIn: 'root' }) export class PluginStoreService { - private readonly electronBridge = inject(ElectronBridgeService); - private readonly capabilities = inject(PluginCapabilityService); - private readonly desktopState = inject(PluginDesktopStateService); - private readonly destroyRef = inject(DestroyRef); - private readonly host = inject(PluginHostService); - private readonly jsonStorage = jsonStorage; - private readonly pluginRequirements = inject(PluginRequirementService); - private readonly realtime = inject(RealtimeSessionFacade, { optional: true }); - private readonly registry = inject(PluginRegistryService); - private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true }); - private readonly store = inject(Store, { optional: true }); - private readonly appI18n = inject(AppI18nService); - private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null; - private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null; - private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null; - private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null; - private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null; - private readonly sourceUrlsSignal = signal([]); - private readonly sourcesSignal = signal([]); - private readonly clientInstalledPluginsSignal = signal([]); - private readonly serverInstalledPluginsSignal = signal([]); - private readonly serverInstalledPluginsLoadStateSignal = signal({ - actorUserId: null, - loaded: false, - loading: false, - roomId: null - }); - private readonly loadingSignal = signal(false); - private refreshAbortController: AbortController | null = null; - private refreshVersion = 0; - private installedLoadVersion = 0; - private autoUpdateInProgress = false; - private stateMutated = false; readonly sourceUrls = this.sourceUrlsSignal.asReadonly(); + readonly sources = this.sourcesSignal.asReadonly(); + readonly installedPlugins = computed(() => { const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal()); return installedPlugins.sort(sortInstalledPlugins); }); + readonly isLoading = this.loadingSignal.asReadonly(); + readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins)); + readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.()); + readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin]))); + readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device'); + readonly serverInstalledPluginsReadyForCurrentRoom = computed(() => { const roomId = this.currentRoomId?.() ?? null; const actorUserId = this.currentActorUserId(); @@ -132,6 +106,67 @@ export class PluginStoreService { && loadState.actorUserId === actorUserId; }); + private readonly electronBridge = inject(ElectronBridgeService); + + private readonly capabilities = inject(PluginCapabilityService); + + private readonly desktopState = inject(PluginDesktopStateService); + + private readonly destroyRef = inject(DestroyRef); + + private readonly host = inject(PluginHostService); + + private readonly jsonStorage = jsonStorage; + + private readonly pluginRequirements = inject(PluginRequirementService); + + private readonly realtime = inject(RealtimeSessionFacade, { optional: true }); + + private readonly registry = inject(PluginRegistryService); + + private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true }); + + private readonly store = inject(Store, { optional: true }); + + private readonly appI18n = inject(AppI18nService); + + private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null; + + private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null; + + private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null; + + private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null; + + private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null; + + private readonly sourceUrlsSignal = signal([]); + + private readonly sourcesSignal = signal([]); + + private readonly clientInstalledPluginsSignal = signal([]); + + private readonly serverInstalledPluginsSignal = signal([]); + + private readonly serverInstalledPluginsLoadStateSignal = signal({ + actorUserId: null, + loaded: false, + loading: false, + roomId: null + }); + + private readonly loadingSignal = signal(false); + + private refreshAbortController: AbortController | null = null; + + private refreshVersion = 0; + + private installedLoadVersion = 0; + + private autoUpdateInProgress = false; + + private stateMutated = false; + constructor() { const state = this.loadState(); @@ -243,73 +278,6 @@ export class PluginStoreService { return installedPlugin; } - private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null { - if (installScope !== 'server') { - return null; - } - - const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null; - - if (!targetServerId) { - throw new Error('Open a chat server before installing server-scoped plugins'); - } - - return targetServerId; - } - - private async persistInstallResult( - installScope: TojuPluginInstallScope, - targetServerId: string | null, - nextInstalledPlugins: InstalledStorePlugin[], - installedPlugin: InstalledStorePlugin, - options: PluginStoreInstallOptions - ): Promise { - if (installScope === 'server') { - await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required'); - return; - } - - await this.persistInstalledPlugins(nextInstalledPlugins, installScope); - } - - private async registerInstallResult( - installScope: TojuPluginInstallScope, - targetServerId: string | null, - nextInstalledPlugins: InstalledStorePlugin[], - installedPlugin: InstalledStorePlugin, - options: PluginStoreInstallOptions - ): Promise { - if (installScope === 'server' && targetServerId) { - await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins); - } - - if (installScope === 'server' && options.activate) { - this.registry.setEnabled(installedPlugin.manifest.id, true); - } - - if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) { - if (options.activate) { - await this.host.rememberActivation(installedPlugin.manifest.id); - } - - return; - } - - const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl; - - if (sourcePath?.startsWith('file://')) { - await this.ensurePluginSourceReadRoot(sourcePath); - } - - this.host.registerLocalManifest(installedPlugin.manifest, sourcePath); - - this.setInstalledPluginsForScope(installScope, nextInstalledPlugins); - - if (options.activate) { - await this.host.activatePluginById(installedPlugin.manifest.id); - } - } - async loadInstallManifest(plugin: PluginStoreEntry): Promise { if (!plugin.installUrl) { throw new Error('Plugin does not provide an install manifest URL'); @@ -478,6 +446,73 @@ export class PluginStoreService { return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope(); } + private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null { + if (installScope !== 'server') { + return null; + } + + const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null; + + if (!targetServerId) { + throw new Error('Open a chat server before installing server-scoped plugins'); + } + + return targetServerId; + } + + private async persistInstallResult( + installScope: TojuPluginInstallScope, + targetServerId: string | null, + nextInstalledPlugins: InstalledStorePlugin[], + installedPlugin: InstalledStorePlugin, + options: PluginStoreInstallOptions + ): Promise { + if (installScope === 'server') { + await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required'); + return; + } + + await this.persistInstalledPlugins(nextInstalledPlugins, installScope); + } + + private async registerInstallResult( + installScope: TojuPluginInstallScope, + targetServerId: string | null, + nextInstalledPlugins: InstalledStorePlugin[], + installedPlugin: InstalledStorePlugin, + options: PluginStoreInstallOptions + ): Promise { + if (installScope === 'server' && targetServerId) { + await this.writeLocalServerInstalledPlugins(targetServerId, nextInstalledPlugins); + } + + if (installScope === 'server' && options.activate) { + this.registry.setEnabled(installedPlugin.manifest.id, true); + } + + if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) { + if (options.activate) { + await this.host.rememberActivation(installedPlugin.manifest.id); + } + + return; + } + + const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl; + + if (sourcePath?.startsWith('file://')) { + await this.ensurePluginSourceReadRoot(sourcePath); + } + + this.host.registerLocalManifest(installedPlugin.manifest, sourcePath); + + this.setInstalledPluginsForScope(installScope, nextInstalledPlugins); + + if (options.activate) { + await this.host.activatePluginById(installedPlugin.manifest.id); + } + } + private async loadSource(sourceUrl: string, signal: AbortSignal): Promise { try { const sourceValue = await this.fetchJson(sourceUrl, signal); @@ -1068,6 +1103,7 @@ export class PluginStoreService { return null; } + } function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } { diff --git a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts index b6d03ad..980c3b0 100644 --- a/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts +++ b/toju-app/src/app/domains/plugins/domain/rules/plugin-local-file.rules.spec.ts @@ -3,11 +3,7 @@ import { expect, it } from 'vitest'; -import { - collectPluginReadRoots, - fileUrlToPath, - pluginFileParentDir -} from './plugin-local-file.rules'; +import { collectPluginReadRoots, fileUrlToPath } from './plugin-local-file.rules'; describe('plugin-local-file.rules', () => { it('resolves linux file URLs to absolute paths', () => { diff --git a/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts b/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts index 0ae1f32..a402834 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts +++ b/toju-app/src/app/domains/plugins/feature/plugin-action-menu/plugin-action-menu.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, @@ -33,14 +32,17 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; export class PluginActionMenuComponent { readonly closed = output(); - private readonly logger = inject(PluginLoggerService); - private readonly pluginApi = inject(PluginClientApiService); - private readonly pluginRegistry = inject(PluginRegistryService); - private readonly pluginUi = inject(PluginUiRegistryService); - readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()] .sort((left, right) => this.sortActionRecords(left, right))); + private readonly logger = inject(PluginLoggerService); + + private readonly pluginApi = inject(PluginClientApiService); + + private readonly pluginRegistry = inject(PluginRegistryService); + + private readonly pluginUi = inject(PluginUiRegistryService); + @HostListener('document:keydown.escape') close(): void { this.closed.emit(undefined); @@ -95,6 +97,7 @@ export class PluginActionMenuComponent { return left.contribution.label.localeCompare(right.contribution.label); } + } function createInitials(pluginName: string, actionLabel: string): string { diff --git a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html index 4bd0a71..f7832e3 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-manager/plugin-manager.component.html @@ -1,4 +1,3 @@ -
(); + @Output() readonly storeOpened = new EventEmitter(); readonly scope = input('client'); readonly capabilities = inject(PluginCapabilityService); + readonly host = inject(PluginHostService); + readonly logger = inject(PluginLoggerService); + readonly registry = inject(PluginRegistryService); + readonly requirementState = inject(PluginRequirementStateService); + readonly router = inject(Router); + readonly uiRegistry = inject(PluginUiRegistryService); - private readonly appI18n = inject(AppI18nService); + readonly activeTab = signal('installed'); + readonly busyPluginId = signal(null); + readonly busyAll = signal(false); + readonly selectedPluginId = signal(null); + readonly allEntries = this.registry.entries; + readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry))); + readonly managerTitle = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.serverTitle') : this.appI18n.instant('plugins.manager.clientTitle')); + readonly managerDescription = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.serverDescription') : this.appI18n.instant('plugins.manager.clientDescription')); + readonly selectedPlugin = computed(() => { const selectedPluginId = this.selectedPluginId(); return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null; }); + readonly missingCapabilities = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : []; }); + readonly selectedLogs = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id) .slice(-20) : []; }); + readonly extensionCounts = computed(() => ({ appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, @@ -114,6 +132,7 @@ export class PluginManagerComponent { slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length })); + readonly extensionCountItems = computed(() => { const counts = this.extensionCounts(); @@ -129,15 +148,20 @@ export class PluginManagerComponent { { label: this.appI18n.instant('plugins.manager.extensionCounts.embedRenderers'), value: counts.embeds } ]; }); + readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []); + readonly uiConflicts = computed(() => this.uiRegistry.conflicts() .filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId)))); + readonly selectedRequirement = computed(() => { const selectedPlugin = this.selectedPlugin(); return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null; }); + readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null); + readonly selectedSettingsPages = computed(() => { const selectedPlugin = this.selectedPlugin(); @@ -145,12 +169,15 @@ export class PluginManagerComponent { ? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id) : []; }); + readonly emptyTitle = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.empty.serverTitle') : this.appI18n.instant('plugins.manager.empty.clientTitle')); + readonly emptyBody = computed(() => this.scope() === 'server' ? this.appI18n.instant('plugins.manager.empty.serverBody') : this.appI18n.instant('plugins.manager.empty.clientBody')); + readonly selectedDocs = computed(() => { const manifest = this.selectedPlugin()?.manifest; @@ -166,6 +193,8 @@ export class PluginManagerComponent { ].filter((item): item is { label: string; url: string } => typeof item.url === 'string' && item.url.length > 0); }); + private readonly appI18n = inject(AppI18nService); + setTab(tab: PluginManagerTab): void { this.activeTab.set(tab); } @@ -265,4 +294,5 @@ export class PluginManagerComponent { private hasVisiblePlugin(pluginId: string): boolean { return this.entries().some((entry) => entry.manifest.id === pluginId); } + } diff --git a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html index 947cb47..4aefffb 100644 --- a/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html +++ b/toju-app/src/app/domains/plugins/feature/plugin-store/plugin-store.component.html @@ -1,4 +1,4 @@ - +
{ const user = this.currentUser(); @@ -116,8 +121,11 @@ export class PluginStoreComponent implements OnInit { return Array.from(roomsById.values()) .filter((room) => this.canManageServerPlugins(room, user)); }); + readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error)); + readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id))); + readonly filteredPlugins = computed(() => { const searchTerm = this.debouncedSearchTerm().trim() .toLowerCase(); @@ -134,19 +142,25 @@ export class PluginStoreComponent implements OnInit { return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm)); }); + readonly installedCount = computed(() => this.store.installedPlugins().length); + readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length); + readonly sourceCount = computed(() => this.store.sourceUrls().length); + readonly pendingSourceUrls = computed(() => { const loadedUrls = new Set(this.store.sources().map((source) => source.url)); return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl)); }); + readonly selectedReadmePlugin = computed(() => { const readme = this.readme(); return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null; }); + readonly selectedStoreServer = computed(() => { const selectedServerId = this.selectedStoreServerId(); @@ -154,23 +168,41 @@ export class PluginStoreComponent implements OnInit { }); newSourceUrl = ''; + readonly searchTerm = signal(''); + readonly selectedSourceUrl = signal(null); + readonly selectedStoreServerId = signal(null); + readonly selectedServerInstalledPlugins = signal([]); + readonly showInstalledOnly = signal(false); + readonly sourceError = signal(null); + readonly actionError = signal(null); + readonly actionBusyPluginId = signal(null); + readonly readme = signal(null); + readonly readmeRawMode = signal(false); + readonly readmeError = signal(null); + readonly readmeLoadingPluginId = signal(null); + readonly serverInstallDialog = signal(null); + readonly selectedCapabilityIds = signal>(new Set()); + readonly serverInstallOptional = signal(false); + readonly serverInstallError = signal(null); + readonly serverInstallBusy = signal(false); + readonly brokenImageKeys = signal>(new Set()); /** @@ -183,12 +215,20 @@ export class PluginStoreComponent implements OnInit { { initialValue: '' } ); + private readonly appI18n = inject(AppI18nService); + private destroyed = false; + private readonly destroyRef = inject(DestroyRef); + private readonly externalLinks = inject(ExternalLinkService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly settingsModal = inject(SettingsModalService); + private selectedServerLoadVersion = 0; constructor() { @@ -644,6 +684,7 @@ export class PluginStoreComponent implements OnInit { } } } + } function comparePluginVersions(leftVersion: string, rightVersion: string): number { diff --git a/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts index aae98ed..1cfdca3 100644 --- a/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts +++ b/toju-app/src/app/domains/plugins/infrastructure/local-plugin-discovery.service.ts @@ -4,12 +4,13 @@ import type { LocalPluginDiscoveryResult, LocalPluginManifestDescriptor } from ' @Injectable({ providedIn: 'root' }) export class LocalPluginDiscoveryService { - private readonly electronBridge = inject(ElectronBridgeService); get isAvailable(): boolean { return this.electronBridge.isAvailable; } + private readonly electronBridge = inject(ElectronBridgeService); + async getPluginsPath(): Promise { const api = this.electronBridge.getApi(); @@ -43,4 +44,5 @@ export class LocalPluginDiscoveryService { pluginsPath: result.pluginsPath }; } + } diff --git a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts index 2ca2e05..616ee05 100644 --- a/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts +++ b/toju-app/src/app/domains/profile-avatar/feature/profile-avatar-editor/profile-avatar-editor.component.ts @@ -31,21 +31,27 @@ import { templateUrl: './profile-avatar-editor.component.html' }) export class ProfileAvatarEditorComponent { - private readonly appI18n = inject(AppI18nService); - private readonly avatar = inject(ProfileAvatarFacade); readonly source = input.required(); + readonly cancelled = output(); + readonly confirmed = output(); readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE; + readonly processing = signal(false); + readonly errorMessage = signal(null); + readonly preservesAnimation = computed(() => this.source().preservesAnimation); + readonly transform = signal({ zoom: 1, offsetX: 0, offsetY: 0 }); + readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform())); + readonly imageTransform = computed(() => { const source = this.source(); const transform = this.clampedTransform(); @@ -54,7 +60,12 @@ export class ProfileAvatarEditorComponent { return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`; }); + private readonly appI18n = inject(AppI18nService); + + private readonly avatar = inject(ProfileAvatarFacade); + private dragPointerId: number | null = null; + private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null; @HostListener('document:keydown.escape') @@ -161,4 +172,5 @@ export class ProfileAvatarEditorComponent { this.processing.set(false); } } + } diff --git a/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts b/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts index 6563156..a02ee4a 100644 --- a/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts +++ b/toju-app/src/app/domains/screen-share/feature/screen-share-viewer/screen-share-viewer.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ import { Component, inject, @@ -51,51 +50,32 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; export class ScreenShareViewerComponent implements OnDestroy { @ViewChild('screenVideo') videoRef!: ElementRef; - private readonly screenShareService = inject(ScreenShareFacade); - private readonly voicePlayback = inject(VoicePlaybackService); - private readonly store = inject(Store); - private readonly appI18n = inject(AppI18nService); - private remoteStreamSub: Subscription | null = null; - onlineUsers = this.store.selectSignal(selectOnlineUsers); activeScreenSharer = signal(null); - // Track the userId we're currently watching (for detecting when they stop sharing) - private watchingUserId = signal(null); + isFullscreen = signal(false); + hasStream = signal(false); + isLocalShare = signal(false); + screenVolume = signal(DEFAULT_VOLUME); + private readonly screenShareService = inject(ScreenShareFacade); + + private readonly voicePlayback = inject(VoicePlaybackService); + + private readonly store = inject(Store); + + private readonly appI18n = inject(AppI18nService); + + private remoteStreamSub: Subscription | null = null; + + // Track the userId we're currently watching (for detecting when they stop sharing) + private watchingUserId = signal(null); + private streamSubscription: (() => void) | null = null; - private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => { - try { - const userId = evt.detail?.userId; - - if (!userId) - return; - - const stream = this.screenShareService.getRemoteScreenShareStream(userId); - const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null; - - if (stream && stream.getVideoTracks().length > 0) { - if (user) { - this.setRemoteStream(stream, user); - } else if (this.videoRef) { - this.videoRef.nativeElement.srcObject = stream; - this.videoRef.nativeElement.volume = 0; - this.videoRef.nativeElement.muted = true; - this.hasStream.set(true); - this.activeScreenSharer.set(null); - this.watchingUserId.set(userId); - this.screenVolume.set(this.voicePlayback.getUserVolume(userId)); - this.isLocalShare.set(false); - } - } - } catch (_error) { - // Failed to focus viewer on user stream - } - }; constructor() { // React to screen share stream changes @@ -292,4 +272,34 @@ export class ScreenShareViewerComponent implements OnDestroy { } } } + + private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => { + try { + const userId = evt.detail?.userId; + + if (!userId) + return; + + const stream = this.screenShareService.getRemoteScreenShareStream(userId); + const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null; + + if (stream && stream.getVideoTracks().length > 0) { + if (user) { + this.setRemoteStream(stream, user); + } else if (this.videoRef) { + this.videoRef.nativeElement.srcObject = stream; + this.videoRef.nativeElement.volume = 0; + this.videoRef.nativeElement.muted = true; + this.hasStream.set(true); + this.activeScreenSharer.set(null); + this.watchingUserId.set(userId); + this.screenVolume.set(this.voicePlayback.getUserVolume(userId)); + this.isLocalShare.set(false); + } + } + } catch { + // Failed to focus viewer on user stream + } + }; + } diff --git a/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts index 7bf8079..9fd3e28 100644 --- a/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts +++ b/toju-app/src/app/domains/server-directory/application/facades/server-directory.facade.ts @@ -1,16 +1,19 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, inject } from '@angular/core'; import { ServerDirectoryService } from '../services/server-directory.service'; @Injectable({ providedIn: 'root' }) export class ServerDirectoryFacade { - private readonly service = inject(ServerDirectoryService); readonly servers = this.service.servers; + readonly activeServers = this.service.activeServers; + readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers; + readonly activeServer = this.service.activeServer; + private readonly service = inject(ServerDirectoryService); + awaitInitialServerHealthCheck( ...args: Parameters ): ReturnType { @@ -238,4 +241,5 @@ export class ServerDirectoryFacade { ): ReturnType { return this.service.sendHeartbeat(...args); } + } diff --git a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts index d37e5f1..6bf59da 100644 --- a/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/create-server-dialog/create-server-dialog.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, HostListener, @@ -50,34 +49,51 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create- } }) export class CreateServerDialogComponent { - private store = inject(Store); - private router = inject(Router); - private serverDirectory = inject(ServerDirectoryFacade); readonly isMobile = inject(ViewportService).isMobile; readonly created = output(); + readonly cancelled = output(); readonly categories = CATEGORY_PRESETS; + activeEndpoints = this.serverDirectory.activeServers; name = signal(''); + description = signal(''); + topic = signal(''); + selectedCategoryId = signal(null); + isPrivate = signal(false); + password = signal(''); + sourceId = signal(''); + showAdvanced = signal(false); + /** True when the form has enough to create a server. */ + get canCreate(): boolean { + return this.name().trim().length > 0 && this.sourceId().length > 0; + } + + private store = inject(Store); + + private router = inject(Router); + + private serverDirectory = inject(ServerDirectoryFacade); + constructor() { this.sourceId.set(this.activeEndpoints()[0]?.id ?? ''); } - /** True when the form has enough to create a server. */ - get canCreate(): boolean { - return this.name().trim().length > 0 && this.sourceId().length > 0; + @HostListener('document:keydown.escape') + cancel(): void { + this.cancelled.emit(undefined); } selectCategory(category: ServerCategoryPreset): void { @@ -95,11 +111,6 @@ export class CreateServerDialogComponent { this.showAdvanced.update((shown) => !shown); } - @HostListener('document:keydown.escape') - cancel(): void { - this.cancelled.emit(undefined); - } - create(): void { if (!this.canCreate) { return; @@ -128,4 +139,5 @@ export class CreateServerDialogComponent { this.created.emit(undefined); } + } diff --git a/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts index 11429f5..14b628b 100644 --- a/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/create-server/create-server.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, inject, @@ -59,32 +58,44 @@ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [ templateUrl: './create-server.component.html' }) export class CreateServerComponent implements OnInit { - private store = inject(Store); - private router = inject(Router); - private serverDirectory = inject(ServerDirectoryFacade); - private currentUser = this.store.selectSignal(selectCurrentUser); readonly categories = CATEGORY_PRESETS; + activeEndpoints = this.serverDirectory.activeServers; name = signal(''); - description = signal(''); - topic = signal(''); - selectedCategoryId = signal(null); - isPrivate = signal(false); - password = signal(''); - sourceId = ''; - showAdvanced = signal(false); - ngOnInit(): void { - this.sourceId = this.activeEndpoints()[0]?.id ?? ''; - } + description = signal(''); + + topic = signal(''); + + selectedCategoryId = signal(null); + + isPrivate = signal(false); + + password = signal(''); + + sourceId = ''; + + showAdvanced = signal(false); /** True when the form has enough to create a server. */ get canCreate(): boolean { return this.name().trim().length > 0 && this.sourceId.length > 0; } + private store = inject(Store); + + private router = inject(Router); + + private serverDirectory = inject(ServerDirectoryFacade); + + private currentUser = this.store.selectSignal(selectCurrentUser); + + ngOnInit(): void { + this.sourceId = this.activeEndpoints()[0]?.id ?? ''; + } + selectCategory(category: ServerCategoryPreset): void { if (this.selectedCategoryId() === category.id) { this.selectedCategoryId.set(null); @@ -133,4 +144,5 @@ export class CreateServerComponent implements OnInit { }) ); } + } diff --git a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts index 851d069..8fa08b6 100644 --- a/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/find-servers/find-servers.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, computed, @@ -44,15 +43,16 @@ const RECENT_SERVER_LIMIT = 6; } }) export class FindServersComponent implements OnInit { - private store = inject(Store); - private serverDirectory = inject(ServerDirectoryFacade); - private readonly i18n = inject(AppI18nService); featured = signal([]); + trending = signal([]); + savedRooms = this.store.selectSignal(selectSavedRooms); readonly searchPlaceholder = this.i18n.instant('servers.find.searchPlaceholder'); + readonly emptyStateTitle = this.i18n.instant('servers.find.emptyTitle'); + readonly emptyStateMessage = this.i18n.instant('servers.find.emptyMessage'); /** Discovery sections shown when the user is not actively searching. */ @@ -95,6 +95,12 @@ export class FindServersComponent implements OnInit { /** True when there is nothing to recommend (a brand-new account). */ isNewUser = computed(() => this.discoverySections().length === 0); + private store = inject(Store); + + private serverDirectory = inject(ServerDirectoryFacade); + + private readonly i18n = inject(AppI18nService); + ngOnInit(): void { this.serverDirectory.getFeaturedServers().subscribe((servers) => this.featured.set(servers)); this.serverDirectory.getTrendingServers().subscribe((servers) => this.trending.set(servers)); @@ -120,4 +126,5 @@ export class FindServersComponent implements OnInit { sourceUrl: room.sourceUrl }; } + } diff --git a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts index 2116196..245fdb8 100644 --- a/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/server-browser/server-browser.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, effect, @@ -115,28 +114,19 @@ export interface ServerDiscoverySection { templateUrl: './server-browser.component.html' }) export class ServerBrowserComponent implements OnInit { - private store = inject(Store); - private router = inject(Router); - private db = inject(DatabaseService); - private externalLinks = inject(ExternalLinkService); - private serverDirectory = inject(ServerDirectoryFacade); - private webrtc = inject(RealtimeSessionFacade); - private pluginRequirements = inject(PluginRequirementService); - private pluginStore = inject(PluginStoreService); - private injector = inject(Injector); - private readonly i18n = inject(AppI18nService); - private readonly signalServerAuthorize = inject(SignalServerAuthorizeService); - private searchSubject = new Subject(); - private banLookupRequestVersion = 0; /** Discovery sections shown when the search query is empty. */ @Input() discoverySections: ServerDiscoverySection[] = []; + /** Title for the onboarding empty state when there is nothing to show. */ @Input() emptyStateTitle?: string; + /** Supporting copy for the onboarding empty state. */ @Input() emptyStateMessage?: string; + /** Placeholder for the search input. */ @Input() searchPlaceholder?: string; + /** Whether the My Servers quick bar is shown. */ @Input() showMyServers = true; @@ -152,6 +142,95 @@ export class ServerBrowserComponent implements OnInit { return this.searchPlaceholder ?? this.i18n.instant('servers.browser.search.placeholder'); } + searchQuery = ''; + + searchResults = this.store.selectSignal(selectSearchResults); + + isSearching = this.store.selectSignal(selectIsSearching); + + error = this.store.selectSignal(selectRoomsError); + + savedRooms = this.store.selectSignal(selectSavedRooms); + + currentUser = this.store.selectSignal(selectCurrentUser); + + activeEndpoints = this.serverDirectory.activeServers; + + bannedServerLookup = signal>({}); + + bannedServerName = signal(''); + + showBannedDialog = signal(false); + + showPasswordDialog = signal(false); + + passwordPromptServer = signal(null); + + joinPassword = signal(''); + + joinPasswordError = signal(null); + + joinErrorMessage = signal(null); + + joinedServerMenuId = signal(null); + + leaveDialogRoom = signal(null); + + pluginConsentDialog = signal(null); + + selectedOptionalPluginIds = signal>(new Set()); + + pluginConsentBusy = signal(false); + + pluginConsentError = signal(null); + + pluginConsentReadme = signal(null); + + pluginConsentReadmeLoadingId = signal(null); + + pluginConsentReadmeError = signal(null); + + /** True while the user is actively searching (non-empty query). */ + get isSearchMode(): boolean { + return this.searchQuery.trim().length > 0; + } + + /** Discovery sections that actually contain servers. */ + get visibleSections(): ServerDiscoverySection[] { + return this.discoverySections.filter((section) => section.servers.length > 0); + } + + /** True when there is nothing to render outside of search mode. */ + get showEmptyState(): boolean { + return !this.isSearchMode && this.visibleSections.length === 0; + } + + private store = inject(Store); + + private router = inject(Router); + + private db = inject(DatabaseService); + + private externalLinks = inject(ExternalLinkService); + + private serverDirectory = inject(ServerDirectoryFacade); + + private webrtc = inject(RealtimeSessionFacade); + + private pluginRequirements = inject(PluginRequirementService); + + private pluginStore = inject(PluginStoreService); + + private injector = inject(Injector); + + private readonly i18n = inject(AppI18nService); + + private readonly signalServerAuthorize = inject(SignalServerAuthorizeService); + + private searchSubject = new Subject(); + + private banLookupRequestVersion = 0; + serverCardTitle(server: ServerInfo): string { return this.isJoinedServer(server) ? this.i18n.instant('servers.browser.card.doubleClickOpen', { name: server.name }) @@ -200,31 +279,6 @@ export class ServerBrowserComponent implements OnInit { : this.i18n.instant('servers.plugins.readme'); } - searchQuery = ''; - searchResults = this.store.selectSignal(selectSearchResults); - isSearching = this.store.selectSignal(selectIsSearching); - error = this.store.selectSignal(selectRoomsError); - savedRooms = this.store.selectSignal(selectSavedRooms); - currentUser = this.store.selectSignal(selectCurrentUser); - activeEndpoints = this.serverDirectory.activeServers; - bannedServerLookup = signal>({}); - bannedServerName = signal(''); - showBannedDialog = signal(false); - showPasswordDialog = signal(false); - passwordPromptServer = signal(null); - joinPassword = signal(''); - joinPasswordError = signal(null); - joinErrorMessage = signal(null); - joinedServerMenuId = signal(null); - leaveDialogRoom = signal(null); - pluginConsentDialog = signal(null); - selectedOptionalPluginIds = signal>(new Set()); - pluginConsentBusy = signal(false); - pluginConsentError = signal(null); - pluginConsentReadme = signal(null); - pluginConsentReadmeLoadingId = signal(null); - pluginConsentReadmeError = signal(null); - // The reactive effect is created in ngOnInit with an explicit injector so the // component can be instantiated outside a change-detection context (e.g. unit tests). ngOnInit(): void { @@ -247,21 +301,6 @@ export class ServerBrowserComponent implements OnInit { }); } - /** True while the user is actively searching (non-empty query). */ - get isSearchMode(): boolean { - return this.searchQuery.trim().length > 0; - } - - /** Discovery sections that actually contain servers. */ - get visibleSections(): ServerDiscoverySection[] { - return this.discoverySections.filter((section) => section.servers.length > 0); - } - - /** True when there is nothing to render outside of search mode. */ - get showEmptyState(): boolean { - return !this.isSearchMode && this.visibleSections.length === 0; - } - onSearchChange(query: string): void { this.searchSubject.next(query); } @@ -724,4 +763,5 @@ export class ServerBrowserComponent implements OnInit { return hasRoomBanForUser(bans, currentUser, currentUserId); } + } diff --git a/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts index 8f40f49..35db590 100644 --- a/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts +++ b/toju-app/src/app/domains/theme/feature/settings/theme-settings/theme-settings.component.ts @@ -49,28 +49,45 @@ type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout'; }) export class ThemeSettingsComponent { readonly modal = inject(SettingsModalService); + readonly theme = inject(ThemeService); + readonly themeLibrary = inject(ThemeLibraryService); + readonly registry = inject(ThemeRegistryService); + readonly picker = inject(ElementPickerService); + readonly layoutSync = inject(LayoutSyncService); - private readonly appI18n = inject(AppI18nService); readonly editorRef = viewChild('jsonEditorRef'); readonly draftText = this.theme.draftText; + readonly draftErrors = this.theme.draftErrors; + readonly draftIsValid = this.theme.draftIsValid; + readonly statusMessage = this.theme.statusMessage; + readonly isDraftDirty = this.theme.isDraftDirty; + readonly isFullscreen = this.modal.themeStudioFullscreen; + readonly activeTheme = this.theme.activeTheme; + readonly builtInPresets = this.theme.builtInPresets; + readonly draftTheme = this.theme.draftTheme; + readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS; + readonly animationKeys = this.theme.knownAnimationClasses; + readonly layoutContainers = this.layoutSync.containers(); + readonly themeEntries = this.registry.entries(); + readonly workspaceTabs = computed(() => [ { key: 'editor' as const, @@ -88,15 +105,23 @@ export class ThemeSettingsComponent { description: this.appI18n.instant('theme.studio.workspaces.layout.description') } ]); + readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts()); + readonly activeWorkspace = signal('editor'); + readonly activeEditorTab = signal('json'); + readonly cssOnlyText = signal(''); + readonly explorerQuery = signal(''); readonly selectedContainer = signal('roomLayout'); + readonly selectedElementKey = signal('chatRoomMainPanel'); + readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey())); + readonly selectedElementCapabilities = computed(() => { const selected = this.selectedElement(); @@ -111,24 +136,31 @@ export class ThemeSettingsComponent { selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null ].filter((value): value is string => value !== null); }); + readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer())); + readonly selectedLayoutContainer = computed(() => { return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0]; }); + readonly selectedElementGrid = computed(() => { return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null; }); + readonly activeWorkspaceInfo = computed(() => { return this.workspaceTabs().find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs()[0]; }); + readonly localizedFilteredEntries = computed(() => this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry)) ); + readonly localizedSelectedElement = computed(() => { const selected = this.selectedElement(); return selected ? this.localizeRegistryEntry(selected) : null; }); + readonly visiblePropertyHints = computed(() => { const selected = this.selectedElement(); @@ -148,9 +180,11 @@ export class ThemeSettingsComponent { return true; }); }); + readonly mountedEntries = computed(() => { return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable); }); + readonly filteredEntries = computed(() => { const query = this.explorerQuery().trim() .toLowerCase(); @@ -165,17 +199,29 @@ export class ThemeSettingsComponent { return haystack.includes(query); }); }); + readonly draftLineCount = computed(() => this.draftText().split('\n').length); + readonly draftCharacterCount = computed(() => this.draftText().length); + readonly draftErrorCount = computed(() => this.draftErrors().length); + readonly mountedEntryCount = computed(() => this.mountedEntries().length); + readonly llmGuideCopyMessage = signal(null); + readonly savedThemesAvailable = this.themeLibrary.isAvailable; + readonly savedThemes = this.themeLibrary.entries; + readonly savedThemesBusy = this.themeLibrary.isBusy; + readonly savedThemesPath = this.themeLibrary.savedThemesPath; + readonly selectedSavedTheme = this.themeLibrary.selectedEntry; + private readonly appI18n = inject(AppI18nService); + private llmGuideCopyTimeoutId: ReturnType | null = null; constructor() { @@ -485,6 +531,26 @@ export class ThemeSettingsComponent { return (this.mountedKeyCounts()[entry.key] ?? 0) > 0; } + presetDisplayName(presetKey: string, fallbackName: string): string { + const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`); + + return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized; + } + + presetDescription(presetKey: string, fallbackDescription?: string): string | undefined { + if (!fallbackDescription) { + return undefined; + } + + const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`); + + return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized; + } + + isDefaultPresetName(name: string): boolean { + return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name'); + } + private focusEditor(): void { this.withEditorReady((editor) => { editor.focus(); @@ -815,26 +881,6 @@ export class ThemeSettingsComponent { }, 4000); } - presetDisplayName(presetKey: string, fallbackName: string): string { - const localized = this.appI18n.instant(`theme.presets.${presetKey}.name`); - - return localized === `theme.presets.${presetKey}.name` ? fallbackName : localized; - } - - presetDescription(presetKey: string, fallbackDescription?: string): string | undefined { - if (!fallbackDescription) { - return undefined; - } - - const localized = this.appI18n.instant(`theme.presets.${presetKey}.description`); - - return localized === `theme.presets.${presetKey}.description` ? fallbackDescription : localized; - } - - isDefaultPresetName(name: string): boolean { - return name === this.appI18n.instant('theme.presets.toju-default-dark-11.name'); - } - private localizeRegistryEntry(entry: ThemeRegistryEntry): ThemeRegistryEntry { return { ...entry, @@ -853,4 +899,5 @@ export class ThemeSettingsComponent { return text.indexOf(`"${key}"`, sectionIndex); } + } diff --git a/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts b/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts index 7a9a73f..b68daa2 100644 --- a/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts +++ b/toju-app/src/app/domains/theme/infrastructure/services/theme-library-storage.service.ts @@ -49,12 +49,13 @@ async function withTimeout(operation: Promise, label: string): Promise @Injectable({ providedIn: 'root' }) export class ThemeLibraryStorageService { - private readonly electronBridge = inject(ElectronBridgeService); get isAvailable(): boolean { return this.electronBridge.isAvailable; } + private readonly electronBridge = inject(ElectronBridgeService); + async getSavedThemesPath(): Promise { const electronApi = this.electronBridge.getApi(); @@ -218,4 +219,5 @@ export class ThemeLibraryStorageService { }; } } + } diff --git a/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts b/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts index 38b81a4..ce1f9b2 100644 --- a/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts +++ b/toju-app/src/app/domains/voice-connection/application/services/voice-activity.service.ts @@ -27,8 +27,6 @@ import { import { Subscription } from 'rxjs'; import { VoiceConnectionFacade } from '../facades/voice-connection.facade'; import { DebuggingService } from '../../../../core/services/debugging.service'; -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */ - const SPEAKING_THRESHOLD = 0.015; const SILENT_FRAME_GRACE = 8; const FFT_SIZE = 256; @@ -46,15 +44,20 @@ interface TrackedStream { @Injectable({ providedIn: 'root' }) export class VoiceActivityService implements OnDestroy { + + readonly speakingMap: Signal> = this._speakingMap; + private readonly voiceConnection = inject(VoiceConnectionFacade); + private readonly debugging = inject(DebuggingService); private readonly tracked = new Map(); - private animFrameId: number | null = null; - private readonly subs: Subscription[] = []; - private readonly _speakingMap = signal>(new Map()); - readonly speakingMap: Signal> = this._speakingMap; + private animFrameId: number | null = null; + + private readonly subs: Subscription[] = []; + + private readonly _speakingMap = signal>(new Map()); constructor() { this.subs.push( @@ -176,30 +179,11 @@ export class VoiceActivityService implements OnDestroy { this.stopPolling(); } - private ensureAllRemoteStreamsTracked(): void { - const peers = this.voiceConnection.getConnectedPeers(); - - for (const peerId of peers) { - const stream = this.voiceConnection.getRemoteVoiceStream(peerId); - - if (stream) { - this.trackStream(peerId, stream); - } - } - } - - private ensurePolling(): void { - if (this.animFrameId !== null) - return; - - this.poll(); - } - - private stopPolling(): void { - if (this.animFrameId !== null) { - cancelAnimationFrame(this.animFrameId); - this.animFrameId = null; - } + ngOnDestroy(): void { + this.stopPolling(); + this.tracked.forEach((entry) => this.disposeEntry(entry)); + this.tracked.clear(); + this.subs.forEach((subscription) => subscription.unsubscribe()); } private poll = (): void => { @@ -212,8 +196,8 @@ export class VoiceActivityService implements OnDestroy { let sumSquares = 0; - for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) { - const normalised = (dataArray[sampleIndex] - 128) / 128; + for (const sample of dataArray) { + const normalised = (sample - 128) / 128; sumSquares += normalised * normalised; } @@ -249,6 +233,32 @@ export class VoiceActivityService implements OnDestroy { this.animFrameId = requestAnimationFrame(this.poll); }; + private ensureAllRemoteStreamsTracked(): void { + const peers = this.voiceConnection.getConnectedPeers(); + + for (const peerId of peers) { + const stream = this.voiceConnection.getRemoteVoiceStream(peerId); + + if (stream) { + this.trackStream(peerId, stream); + } + } + } + + private ensurePolling(): void { + if (this.animFrameId !== null) + return; + + this.poll(); + } + + private stopPolling(): void { + if (this.animFrameId !== null) { + cancelAnimationFrame(this.animFrameId); + this.animFrameId = null; + } + } + private publishSpeakingMap(): void { const map = new Map(); @@ -269,16 +279,18 @@ export class VoiceActivityService implements OnDestroy { private disposeEntry(entry: TrackedStream): void { entry.sources.forEach((source) => { - try { source.disconnect(); } catch { /* already disconnected */ } + try { + source.disconnect(); + } catch { + /* already disconnected */ + } }); - try { entry.ctx.close(); } catch { /* already closed */ } + try { + entry.ctx.close(); + } catch { + /* already closed */ + } } - ngOnDestroy(): void { - this.stopPolling(); - this.tracked.forEach((entry) => this.disposeEntry(entry)); - this.tracked.clear(); - this.subs.forEach((subscription) => subscription.unsubscribe()); - } } diff --git a/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts b/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts index c6e0970..f568119 100644 --- a/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts +++ b/toju-app/src/app/domains/voice-session/application/facades/voice-session.facade.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering, */ import { Injectable, signal, @@ -20,13 +19,6 @@ import type { VoiceSessionInfo } from '../../domain/models/voice-session.model'; */ @Injectable({ providedIn: 'root' }) export class VoiceSessionFacade { - private readonly store = inject(Store); - - /** Current voice session metadata, or `null` when disconnected. */ - private readonly _voiceSession = signal(null); - - /** Whether the user is currently viewing the voice-connected server. */ - private readonly _isViewingVoiceServer = signal(true); /** Reactive read-only voice session. */ readonly voiceSession = computed(() => this._voiceSession()); @@ -43,6 +35,14 @@ export class VoiceSessionFacade { () => this._voiceSession() !== null && !this._isViewingVoiceServer() ); + private readonly store = inject(Store); + + /** Current voice session metadata, or `null` when disconnected. */ + private readonly _voiceSession = signal(null); + + /** Whether the user is currently viewing the voice-connected server. */ + private readonly _isViewingVoiceServer = signal(true); + /** * Begin tracking a voice session. * Called when the user joins a voice channel. @@ -111,4 +111,5 @@ export class VoiceSessionFacade { getVoiceServerId(): string | null { return this._voiceSession()?.serverId ?? null; } + } diff --git a/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts b/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts index cc2a30c..df2ca5f 100644 --- a/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts +++ b/toju-app/src/app/domains/voice-session/application/services/voice-workspace.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Injectable, computed, @@ -23,15 +22,6 @@ const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = { @Injectable({ providedIn: 'root' }) export class VoiceWorkspaceService { - private readonly voiceSession = inject(VoiceSessionFacade); - - private readonly _mode = signal('hidden'); - private readonly _focusedStreamId = signal(null); - private readonly _connectRemoteShares = signal(false); - private readonly _miniWindowPosition = signal( - DEFAULT_MINI_WINDOW_POSITION - ); - private readonly _hasCustomMiniWindowPosition = signal(false); readonly mode = computed(() => { if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) { @@ -42,15 +32,35 @@ export class VoiceWorkspaceService { }); readonly isExpanded = computed(() => this.mode() === 'expanded'); + readonly isMinimized = computed(() => this.mode() === 'minimized'); + readonly isVisible = computed(() => this.mode() !== 'hidden'); + readonly focusedStreamId = computed(() => this._focusedStreamId()); + readonly shouldConnectRemoteShares = computed( () => this.isVisible() && this._connectRemoteShares() ); + readonly miniWindowPosition = computed(() => this._miniWindowPosition()); + readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition()); + private readonly voiceSession = inject(VoiceSessionFacade); + + private readonly _mode = signal('hidden'); + + private readonly _focusedStreamId = signal(null); + + private readonly _connectRemoteShares = signal(false); + + private readonly _miniWindowPosition = signal( + DEFAULT_MINI_WINDOW_POSITION + ); + + private readonly _hasCustomMiniWindowPosition = signal(false); + constructor() { effect(() => { if (this.voiceSession.voiceSession()) { @@ -125,4 +135,5 @@ export class VoiceWorkspaceService { this._connectRemoteShares.set(false); this.resetMiniWindowPosition(); } + } diff --git a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts index df4caa2..0f430d1 100644 --- a/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts +++ b/toju-app/src/app/domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ import { Component, inject, @@ -60,30 +59,45 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n'; * Provides mute, deafen, screen-share, and disconnect actions in a compact overlay. */ export class FloatingVoiceControlsComponent implements OnInit { - private readonly webrtcService = inject(VoiceConnectionFacade); - private readonly screenShareService = inject(ScreenShareFacade); - private readonly viewport = inject(ViewportService); readonly isMobile = this.viewport.isMobile; - private readonly voiceSessionService = inject(VoiceSessionFacade); - private readonly voicePlayback = inject(VoicePlaybackService); - private readonly store = inject(Store); - private readonly appI18n = inject(AppI18nService); currentUser = this.store.selectSignal(selectCurrentUser); // Voice state from services showFloatingControls = this.voiceSessionService.showFloatingControls; + voiceSession = this.voiceSessionService.voiceSession; isConnected = computed(() => this.webrtcService.isVoiceConnected()); + isMuted = signal(false); + isDeafened = signal(false); + isScreenSharing = this.screenShareService.isScreenSharing; + includeSystemAudio = signal(false); + screenShareQuality = signal('balanced'); + askScreenShareQuality = signal(true); + showScreenShareQualityDialog = signal(false); + private readonly webrtcService = inject(VoiceConnectionFacade); + + private readonly screenShareService = inject(ScreenShareFacade); + + private readonly viewport = inject(ViewportService); + + private readonly voiceSessionService = inject(VoiceSessionFacade); + + private readonly voicePlayback = inject(VoicePlaybackService); + + private readonly store = inject(Store); + + private readonly appI18n = inject(AppI18nService); + /** Sync local mute/deafen state from the WebRTC service on init. */ ngOnInit(): void { // Sync mute/deafen state from webrtc service @@ -300,8 +314,9 @@ export class FloatingVoiceControlsComponent implements OnInit { includeSystemAudio: this.includeSystemAudio(), quality }); - } catch (_error) { + } catch { // Screen share request was denied or failed } } + } diff --git a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts index 4d3637f..43d7c28 100644 --- a/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts +++ b/toju-app/src/app/domains/voice-session/feature/voice-controls/voice-controls.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */ import { Component, ElementRef, @@ -75,23 +74,15 @@ interface AudioDevice { templateUrl: './voice-controls.component.html' }) export class VoiceControlsComponent implements OnInit, OnDestroy { - private readonly webrtcService = inject(VoiceConnectionFacade); - private readonly screenShareService = inject(ScreenShareFacade); - private readonly voiceSessionService = inject(VoiceSessionFacade); - private readonly voiceActivity = inject(VoiceActivityService); - private readonly voicePlayback = inject(VoicePlaybackService); - private readonly store = inject(Store); - private readonly settingsModal = inject(SettingsModalService); - private readonly hostEl = inject(ElementRef); - private readonly profileCard = inject(ProfileCardService); - private readonly mobileMedia = inject(MobileMediaService); - private readonly appI18n = inject(AppI18nService); currentUser = this.store.selectSignal(selectCurrentUser); + currentRoom = this.store.selectSignal(selectCurrentRoom); isConnected = computed(() => this.webrtcService.isVoiceConnected()); + showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError()); + connectionErrorMessage = computed(() => { const message = this.webrtcService.connectionErrorMessage(); @@ -101,12 +92,65 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { return this.appI18n.instant(message); }); + isMuted = signal(false); + isDeafened = signal(false); + isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled()); + isScreenSharing = this.screenShareService.isScreenSharing; + showSettings = signal(false); + inputDevices = signal([]); + + outputDevices = signal([]); + + selectedInputDevice = signal(''); + + selectedOutputDevice = signal(''); + + inputVolume = signal(100); + + outputVolume = signal(100); + + audioBitrate = signal(96); + + latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); + + includeSystemAudio = signal(false); + + noiseReduction = signal(true); + + screenShareQuality = signal('balanced'); + + askScreenShareQuality = signal(true); + + showScreenShareQualityDialog = signal(false); + + private readonly webrtcService = inject(VoiceConnectionFacade); + + private readonly screenShareService = inject(ScreenShareFacade); + + private readonly voiceSessionService = inject(VoiceSessionFacade); + + private readonly voiceActivity = inject(VoiceActivityService); + + private readonly voicePlayback = inject(VoicePlaybackService); + + private readonly store = inject(Store); + + private readonly settingsModal = inject(SettingsModalService); + + private readonly hostEl = inject(ElementRef); + + private readonly profileCard = inject(ProfileCardService); + + private readonly mobileMedia = inject(MobileMediaService); + + private readonly appI18n = inject(AppI18nService); + toggleProfileCard(): void { const user = this.currentUser(); @@ -116,27 +160,6 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true }); } - inputDevices = signal([]); - outputDevices = signal([]); - selectedInputDevice = signal(''); - selectedOutputDevice = signal(''); - inputVolume = signal(100); - outputVolume = signal(100); - audioBitrate = signal(96); - latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); - includeSystemAudio = signal(false); - noiseReduction = signal(true); - screenShareQuality = signal('balanced'); - askScreenShareQuality = signal(true); - showScreenShareQualityDialog = signal(false); - - private playbackOptions(): PlaybackOptions { - return { - isConnected: this.isConnected(), - outputVolume: this.outputVolume() / 100, - isDeafened: this.isDeafened() - }; - } async ngOnInit(): Promise { await this.loadAudioDevices(); @@ -166,7 +189,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.outputDevices.set( devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label })) ); - } catch (_error) {} + } catch { /* ignore device enumeration errors */ } } async connect(): Promise { @@ -263,7 +286,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { async retryConnection(): Promise { try { await this.webrtcService.ensureSignalingConnected(10000); - } catch (_error) {} + } catch { /* ignore device enumeration errors */ } } disconnect(): void { @@ -438,7 +461,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { }) ); } - } catch (_error) {} + } catch { /* ignore device enumeration errors */ } } async toggleScreenShare(): Promise { @@ -547,6 +570,58 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.saveSettings(); } + getMuteButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isMuted()) { + return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } + + getDeafenButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isDeafened()) { + return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } + + getCameraButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isCameraEnabled()) { + return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } + + getScreenShareButtonClass(): string { + const base = + 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; + + if (this.isScreenSharing()) { + return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; + } + + return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; + } + + private playbackOptions(): PlaybackOptions { + return { + isConnected: this.isConnected(), + outputVolume: this.outputVolume() / 100, + isDeafened: this.isDeafened() + }; + } + private loadSettings(): void { const settings = loadVoiceSettingsFromStorage(); @@ -614,50 +689,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { includeSystemAudio: this.includeSystemAudio(), quality }); - } catch (_error) {} + } catch { /* ignore device enumeration errors */ } } - getMuteButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isMuted()) { - return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } - - getDeafenButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isDeafened()) { - return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } - - getCameraButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isCameraEnabled()) { - return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } - - getScreenShareButtonClass(): string { - const base = - 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50'; - - if (this.isScreenSharing()) { - return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`; - } - - return `${base} border-border bg-card text-foreground hover:bg-secondary/70`; - } } diff --git a/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts b/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts index 19e4824..87c59b7 100644 --- a/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts +++ b/toju-app/src/app/domains/voice-session/infrastructure/util/voice-settings-storage.util.ts @@ -104,11 +104,11 @@ function schedulePersist(settings: VoiceSettings): void { timeRemaining(): number; } type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle; - interface MaybeIdleGlobal { + interface IdleCallbackGlobal { requestIdleCallback?: IdleRequest; } - const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as MaybeIdleGlobal; + const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as IdleCallbackGlobal; if (typeof idleGlobal.requestIdleCallback === 'function') { idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 }); diff --git a/toju-app/src/app/features/dashboard/dashboard.component.ts b/toju-app/src/app/features/dashboard/dashboard.component.ts index e6b0ad1..05ec28a 100644 --- a/toju-app/src/app/features/dashboard/dashboard.component.ts +++ b/toju-app/src/app/features/dashboard/dashboard.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { Component, ElementRef, @@ -100,24 +99,22 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches'; } }) export class DashboardComponent implements OnInit { - private readonly appI18n = inject(AppI18nService); - private store = inject(Store); - private router = inject(Router); - private serverDirectory = inject(ServerDirectoryFacade); - private friendsService = inject(FriendService); - private readonly viewport = inject(ViewportService); - private searchSubject = new Subject(); - private readonly searchInputRef = viewChild>('searchInput'); readonly isMobile = this.viewport.isMobile; + searchQuery = signal(''); + serverResults = this.store.selectSignal(selectSearchResults); + isSearching = this.store.selectSignal(selectIsSearching); + savedRooms = this.store.selectSignal(selectSavedRooms); + currentUser = this.store.selectSignal(selectCurrentUser); + popularServers = signal([]); + recentSearches = signal(this.loadRecentSearches()); - private users = this.store.selectSignal(selectAllUsers); /** True while the user is actively typing a query. */ isSearchMode = computed(() => this.searchQuery().trim().length > 0); @@ -125,37 +122,6 @@ export class DashboardComponent implements OnInit { /** Server matches limited for the quick-search list. */ topServerResults = computed(() => this.serverResults().slice(0, QUICK_RESULT_LIMIT)); - /** Every distinct person known to the account (known users plus saved-room members), excluding self. */ - private discoveredPeople = computed(() => { - const currentKey = this.currentUserKey(); - const byKey = new Map(); - - for (const user of this.users()) { - byKey.set(user.oderId || user.id, user); - } - - for (const room of this.savedRooms()) { - for (const member of room.members ?? []) { - const key = member.oderId || member.id; - - if (byKey.has(key)) { - continue; - } - - byKey.set(key, { - id: member.id, - oderId: key, - username: member.username, - displayName: member.displayName, - avatarUrl: member.avatarUrl, - status: 'disconnected' - } as User); - } - } - - return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey); - }); - /** People matches derived from known users and saved-room members. */ topPeopleResults = computed(() => { const query = this.searchQuery().trim() @@ -200,6 +166,63 @@ export class DashboardComponent implements OnInit { /** True for a brand-new account with no servers and no known people. */ isNewUser = computed(() => this.savedRooms().length === 0 && this.users().length === 0); + private readonly appI18n = inject(AppI18nService); + + private store = inject(Store); + + private router = inject(Router); + + private serverDirectory = inject(ServerDirectoryFacade); + + private friendsService = inject(FriendService); + + private readonly viewport = inject(ViewportService); + + private searchSubject = new Subject(); + + private readonly searchInputRef = viewChild>('searchInput'); + + private users = this.store.selectSignal(selectAllUsers); + + /** Every distinct person known to the account (known users plus saved-room members), excluding self. */ + private discoveredPeople = computed(() => { + const currentKey = this.currentUserKey(); + const byKey = new Map(); + + for (const user of this.users()) { + byKey.set(user.oderId || user.id, user); + } + + for (const room of this.savedRooms()) { + for (const member of room.members ?? []) { + const key = member.oderId || member.id; + + if (byKey.has(key)) { + continue; + } + + byKey.set(key, { + id: member.id, + oderId: key, + username: member.username, + displayName: member.displayName, + avatarUrl: member.avatarUrl, + status: 'disconnected' + } as User); + } + } + + return Array.from(byKey.values()).filter((user) => (user.oderId || user.id) !== currentKey); + }); + + @HostListener('document:keydown', ['$event']) + onGlobalKeydown(event: KeyboardEvent): void { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + this.searchInputRef()?.nativeElement.focus(); + } + } + ngOnInit(): void { this.store.dispatch(RoomsActions.loadRooms()); @@ -217,14 +240,6 @@ export class DashboardComponent implements OnInit { }); } - @HostListener('document:keydown', ['$event']) - onGlobalKeydown(event: KeyboardEvent): void { - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { - event.preventDefault(); - this.searchInputRef()?.nativeElement.focus(); - } - } - onSearchChange(query: string): void { this.searchQuery.set(query); this.searchSubject.next(query); @@ -357,4 +372,5 @@ export class DashboardComponent implements OnInit { .filter((value): value is string => typeof value === 'string') .some((value) => value.toLowerCase().includes(query)); } + } diff --git a/toju-app/src/app/features/direct-call/private-call.component.ts b/toju-app/src/app/features/direct-call/private-call.component.ts index 8db019a..89c2420 100644 --- a/toju-app/src/app/features/direct-call/private-call.component.ts +++ b/toju-app/src/app/features/direct-call/private-call.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -82,34 +81,29 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant- templateUrl: './private-call.component.html' }) export class PrivateCallComponent { - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly destroyRef = inject(DestroyRef); - private readonly store = inject(Store); - private readonly calls = inject(DirectCallService); - private readonly realtime = inject(RealtimeSessionFacade); - private readonly voice = inject(VoiceConnectionFacade); - private readonly voiceActivity = inject(VoiceActivityService); - private readonly playback = inject(VoicePlaybackService); - private readonly screenShare = inject(ScreenShareFacade); - private readonly viewport = inject(ViewportService); - private readonly mobilePlatform = inject(MobilePlatformService); - private readonly mobileMedia = inject(MobileMediaService); - private chatResizing = false; - private readonly i18n = inject(AppI18nService); readonly allUsers = this.store.selectSignal(selectAllUsers); + readonly currentUser = this.store.selectSignal(selectCurrentUser); + readonly isMobile = this.viewport.isMobile; + readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile()); + readonly speakerphoneEnabled = signal(true); + readonly callIdInput = input(null); + readonly overlayMode = input(false); + readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), { initialValue: this.route.snapshot.paramMap.get('callId') }); + readonly callId = computed(() => this.callIdInput() ?? this.routeCallId()); + readonly session = computed(() => this.calls.sessionById(this.callId())); + readonly participantUsers = computed(() => { const session = this.session(); @@ -121,25 +115,40 @@ export class PrivateCallComponent { .map((participantId) => this.userForSessionParticipant(session, participantId)) .filter((user): user is User => !!user); }); + readonly isConnected = computed(() => { const session = this.session(); const currentUserId = this.currentUserKey(); return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined; }); + readonly isMuted = this.voice.isMuted; + readonly isDeafened = this.voice.isDeafened; + readonly isCameraEnabled = this.voice.isCameraEnabled; + readonly isScreenSharing = this.screenShare.isScreenSharing; + readonly remoteStreamRevision = signal(0); + readonly includeSystemAudio = signal(false); + readonly screenShareQuality = signal('balanced'); + readonly askScreenShareQuality = signal(true); + readonly showScreenShareQualityDialog = signal(false); + readonly inviteUserId = signal(''); + readonly focusedStreamId = signal(null); + readonly showAllStreamsMode = signal(false); + readonly chatWidthPx = signal(384); + readonly inviteCandidates = computed(() => { const participantIds = new Set(this.session()?.participantIds ?? []); const currentUserId = this.currentUserKey(); @@ -150,6 +159,7 @@ export class PrivateCallComponent { return userId !== currentUserId && !participantIds.has(userId); }); }); + readonly activeShares = computed(() => { this.remoteStreamRevision(); @@ -193,8 +203,11 @@ export class PrivateCallComponent { return shares; }); + readonly featuredShare = computed(() => this.activeShares()[0] ?? null); + readonly hasMultipleShares = computed(() => this.activeShares().length > 1); + readonly focusedShareId = computed(() => { const requested = this.focusedStreamId(); const activeShares = this.activeShares(); @@ -213,7 +226,9 @@ export class PrivateCallComponent { return null; }); + readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null); + readonly thumbnailShares = computed(() => { const focusedShareId = this.focusedShareId(); @@ -223,6 +238,37 @@ export class PrivateCallComponent { return this.activeShares().filter((share) => share.id !== focusedShareId); }); + + private readonly route = inject(ActivatedRoute); + + private readonly router = inject(Router); + + private readonly destroyRef = inject(DestroyRef); + + private readonly store = inject(Store); + + private readonly calls = inject(DirectCallService); + + private readonly realtime = inject(RealtimeSessionFacade); + + private readonly voice = inject(VoiceConnectionFacade); + + private readonly voiceActivity = inject(VoiceActivityService); + + private readonly playback = inject(VoicePlaybackService); + + private readonly screenShare = inject(ScreenShareFacade); + + private readonly viewport = inject(ViewportService); + + private readonly mobilePlatform = inject(MobilePlatformService); + + private readonly mobileMedia = inject(MobileMediaService); + + private chatResizing = false; + + private readonly i18n = inject(AppI18nService); + constructor() { effect(() => { const callId = this.callId(); @@ -300,6 +346,8 @@ export class PrivateCallComponent { this.chatResizing = false; } + readonly trackUserKey = (index: number, user: User): string => this.userKey(user); + async join(): Promise { const session = this.session(); @@ -508,8 +556,6 @@ export class PrivateCallComponent { return user.oderId || user.id; } - readonly trackUserKey = (index: number, user: User): string => this.userKey(user); - private currentUserKey(): string { const user = this.currentUser(); @@ -659,4 +705,5 @@ export class PrivateCallComponent { private bumpRemoteStreamRevision(): void { this.remoteStreamRevision.update((value) => value + 1); } + } diff --git a/toju-app/src/app/features/room/chat-room/chat-room.component.ts b/toju-app/src/app/features/room/chat-room/chat-room.component.ts index c781536..11f1baa 100644 --- a/toju-app/src/app/features/room/chat-room/chat-room.component.ts +++ b/toju-app/src/app/features/room/chat-room/chat-room.component.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/member-ordering */ import { CUSTOM_ELEMENTS_SCHEMA, Component, @@ -97,28 +96,25 @@ interface SwiperElement extends HTMLElement { * remains the source of truth and stays in sync with the active slide. */ export class ChatRoomComponent { - private readonly store = inject(Store); - private readonly settingsModal = inject(SettingsModalService); - private readonly theme = inject(ThemeService); - private readonly viewport = inject(ViewportService); - private readonly directCalls = inject(DirectCallService); - private readonly zone = inject(NgZone); - private voiceWorkspace = inject(VoiceWorkspaceService); - private lastSeenChannelId: string | null = null; - private lastSeenRoomId: string | null = null; - private swiperListenerAttached: SwiperElement | null = null; showMenu = signal(false); + showAdminPanel = signal(false); /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */ readonly mobilePage = signal('channels'); + readonly isMobile = this.viewport.isMobile; + readonly swiperRef = viewChild>('swiperEl'); currentRoom = this.store.selectSignal(selectCurrentRoom); + isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); + textChannels = this.store.selectSignal(selectTextChannels); + activeChannelId = this.store.selectSignal(selectActiveChannelId); + /** * Resolved channel object for `activeChannelId`. Used on mobile to title the main pane * with the selected channel name instead of the room name. @@ -132,19 +128,46 @@ export class ChatRoomComponent { return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null; }); + isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; + hasTextChannels = computed(() => this.textChannels().length > 0); + activeCall = computed(() => { const currentSession = this.directCalls.currentSession(); const visibleSessions = this.directCalls.visibleActiveSessions(); return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null; }); + roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout')); + channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel')); + mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel')); + membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel')); + private readonly store = inject(Store); + + private readonly settingsModal = inject(SettingsModalService); + + private readonly theme = inject(ThemeService); + + private readonly viewport = inject(ViewportService); + + private readonly directCalls = inject(DirectCallService); + + private readonly zone = inject(NgZone); + + private voiceWorkspace = inject(VoiceWorkspaceService); + + private lastSeenChannelId: string | null = null; + + private lastSeenRoomId: string | null = null; + + private swiperListenerAttached: SwiperElement | null = null; + constructor() { // When entering a server, always land on the channels list ("first page") on mobile, even // if a default channel is pre-selected. Once inside the server, *changing* channels @@ -237,5 +260,6 @@ export class ChatRoomComponent { this.settingsModal.open('server', room.id); } } + } diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html index 5855696..6b8897e 100644 --- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html +++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.html @@ -1,4 +1,4 @@ - +