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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 11:08:26 +02:00
parent b630bacdc6
commit 79c6f91cd6
138 changed files with 4286 additions and 2310 deletions

View File

@@ -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 | | 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 | | 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-<homeUserIdPrefix>`) | | Auto-provision | `SignalServerProvisionerService` | Registers or logs in on foreign server using provision secret; on username collision tries suffixed username (`alice-<homeUserIdPrefix>`) and prefixes the display name with `#<homeUserIdPrefix> #<signalServerTag>` 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 | | 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. Authorize UI: `/login?mode=authorize&serverId=…&returnUrl=…` (also supported on `/register`). Settings → Network shows per-endpoint `Authorized` / `Needs sign-in` badges.

View File

@@ -1,5 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type BrowserContext, type Page } from '@playwright/test'; import { type BrowserContext, type Page } from '@playwright/test';
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
type RtcPeerConnectionArgs = ConstructorParameters<typeof RTCPeerConnection>;
type AudioContextArgs = ConstructorParameters<typeof AudioContext>;
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. * Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
@@ -21,11 +32,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
source?: AudioScheduledSourceNode; source?: AudioScheduledSourceNode;
drawIntervalId?: number; drawIntervalId?: number;
}[] = []; }[] = [];
const harness = webRtcHarnessWindow();
(window as any).__rtcConnections = connections; harness.__rtcConnections = connections;
(window as any).__rtcDataChannels = dataChannels; harness.__rtcDataChannels = dataChannels;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; harness.__rtcRemoteTracks = [];
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources; harness.__rtcSyntheticMediaResources = syntheticMediaResources;
const OriginalRTCPeerConnection = window.RTCPeerConnection; const OriginalRTCPeerConnection = window.RTCPeerConnection;
const trackDataChannel = (channel: RTCDataChannel) => { const trackDataChannel = (channel: RTCDataChannel) => {
@@ -36,7 +48,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
dataChannels.push(channel); 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 pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
const originalCreateDataChannel = pc.createDataChannel.bind(pc); const originalCreateDataChannel = pc.createDataChannel.bind(pc);
@@ -50,7 +62,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
}) as RTCPeerConnection['createDataChannel']; }) as RTCPeerConnection['createDataChannel'];
pc.addEventListener('connectionstatechange', () => { pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState; harness.__lastRtcState = pc.connectionState;
}); });
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => { pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
@@ -58,7 +70,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
}); });
pc.addEventListener('track', (event: RTCTrackEvent) => { pc.addEventListener('track', (event: RTCTrackEvent) => {
(window as any).__rtcRemoteTracks.push({ harness.__rtcRemoteTracks.push({
kind: event.track.kind, kind: event.track.kind,
id: event.track.id, id: event.track.id,
readyState: event.track.readyState readyState: event.track.readyState
@@ -66,10 +78,10 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
}); });
return pc; return pc;
} as any; } as typeof RTCPeerConnection;
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype; harness.RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection); Object.setPrototypeOf(harness.RTCPeerConnection, OriginalRTCPeerConnection);
// Patch getDisplayMedia to return a synthetic screen share stream // Patch getDisplayMedia to return a synthetic screen share stream
// (canvas-based video + 880Hz oscillator audio) so the browser // (canvas-based video + 880Hz oscillator audio) so the browser
@@ -144,7 +156,7 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
}, { once: true }); }, { once: true });
// Tag the stream so tests can identify it // Tag the stream so tests can identify it
(resultStream as any).__isScreenShare = true; (resultStream as ScreenShareMediaStream).__isScreenShare = true;
return resultStream; return resultStream;
}; };
@@ -169,11 +181,12 @@ export async function installWebRTCTracking(target: BrowserContext | Page): Prom
export async function installAutoResumeAudioContext(page: Page): Promise<void> { export async function installAutoResumeAudioContext(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
const OrigAudioContext = window.AudioContext; 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); const ctx: AudioContext = new OrigAudioContext(...args);
// Track all created AudioContexts for test diagnostics // Track all created AudioContexts for test diagnostics
const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[]; const tracked = audioHarness.__trackedAudioContexts ??= [];
tracked.push(ctx); tracked.push(ctx);
@@ -189,16 +202,16 @@ export async function installAutoResumeAudioContext(page: Page): Promise<void> {
}); });
return ctx; return ctx;
} as any; } as typeof AudioContext;
(window as any).AudioContext.prototype = OrigAudioContext.prototype; audioHarness.AudioContext.prototype = OrigAudioContext.prototype;
Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext); Object.setPrototypeOf(audioHarness.AudioContext, OrigAudioContext);
}); });
} }
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> { export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
() => (window as any).__rtcConnections?.some( () => webRtcHarnessWindow().__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected' (pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false, ) ?? false,
undefined, undefined,
@@ -211,7 +224,7 @@ export async function waitForPeerConnected(page: Page, timeout = 30_000): Promis
*/ */
export async function isPeerStillConnected(page: Page): Promise<boolean> { export async function isPeerStillConnected(page: Page): Promise<boolean> {
return page.evaluate( return page.evaluate(
() => (window as any).__rtcConnections?.some( () => webRtcHarnessWindow().__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected' (pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false ) ?? false
); );
@@ -220,7 +233,7 @@ export async function isPeerStillConnected(page: Page): Promise<boolean> {
/** Returns the number of tracked peer connections in `connected` state. */ /** Returns the number of tracked peer connections in `connected` state. */
export async function getConnectedPeerCount(page: Page): Promise<number> { export async function getConnectedPeerCount(page: Page): Promise<number> {
return page.evaluate( return page.evaluate(
() => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( () => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected' (pc) => pc.connectionState === 'connected'
).length ?? 0 ).length ?? 0
); );
@@ -229,7 +242,7 @@ export async function getConnectedPeerCount(page: Page): Promise<number> {
/** Wait until the expected number of peer connections are `connected`. */ /** Wait until the expected number of peer connections are `connected`. */
export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> { export async function waitForConnectedPeerCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
(count) => ((window as any).__rtcConnections as RTCPeerConnection[] | undefined)?.filter( (count) => (webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined)?.filter(
(pc) => pc.connectionState === 'connected' (pc) => pc.connectionState === 'connected'
).length === count, ).length === count,
expectedCount, expectedCount,
@@ -240,7 +253,7 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe
/** Returns the number of tracked RTCDataChannels in the open state. */ /** Returns the number of tracked RTCDataChannels in the open state. */
export async function getOpenDataChannelCount(page: Page): Promise<number> { export async function getOpenDataChannelCount(page: Page): Promise<number> {
return page.evaluate( return page.evaluate(
() => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( () => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open' (channel) => channel.readyState === 'open'
).length ?? 0 ).length ?? 0
); );
@@ -249,7 +262,7 @@ export async function getOpenDataChannelCount(page: Page): Promise<number> {
/** Wait until the expected number of tracked RTCDataChannels are open. */ /** Wait until the expected number of tracked RTCDataChannels are open. */
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> { export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
(count) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter( (count) => (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open' (channel) => channel.readyState === 'open'
).length === count, ).length === count,
expectedCount, expectedCount,
@@ -260,7 +273,7 @@ export async function waitForOpenDataChannelCount(page: Page, expectedCount: num
/** Close every currently-open RTCDataChannel and return how many were closed. */ /** Close every currently-open RTCDataChannel and return how many were closed. */
export async function closeOpenDataChannels(page: Page): Promise<number> { export async function closeOpenDataChannels(page: Page): Promise<number> {
return page.evaluate(() => { return page.evaluate(() => {
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let closed = 0; let closed = 0;
@@ -280,7 +293,7 @@ export async function closeOpenDataChannels(page: Page): Promise<number> {
/** Dispatch a synthetic data-channel error event on each open channel. */ /** Dispatch a synthetic data-channel error event on each open channel. */
export async function dispatchDataChannelErrors(page: Page): Promise<number> { export async function dispatchDataChannelErrors(page: Page): Promise<number> {
return page.evaluate(() => { return page.evaluate(() => {
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? []; const channels = (webRtcHarnessWindow().__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let dispatched = 0; let dispatched = 0;
@@ -341,7 +354,7 @@ interface PerPeerAudioStat {
/** Get per-peer audio stats for every tracked RTCPeerConnection. */ /** Get per-peer audio stats for every tracked RTCPeerConnection. */
export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> { export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat[]> {
return page.evaluate(async () => { return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) { if (!connections?.length) {
return []; return [];
@@ -358,7 +371,7 @@ export async function getPerPeerAudioStats(page: Page): Promise<PerPeerAudioStat
try { try {
const stats = await pc.getStats(); const stats = await pc.getStats();
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') { if (report.type === 'outbound-rtp' && kind === 'audio') {
@@ -459,7 +472,7 @@ export async function getAudioStats(page: Page): Promise<{
inbound: { bytesReceived: number; packetsReceived: number } | null; inbound: { bytesReceived: number; packetsReceived: number } | null;
}> { }> {
return page.evaluate(async () => { return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return { outbound: null, inbound: null }; return { outbound: null, inbound: null };
@@ -473,8 +486,8 @@ export async function getAudioStats(page: Page): Promise<{
hasInbound: boolean; hasInbound: boolean;
}; };
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM = const hwm: Record<number, HWMEntry> = webRtcHarnessWindow().__rtcStatsHWM =
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {}; (webRtcHarnessWindow().__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) { for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport; let stats: RTCStatsReport;
@@ -492,7 +505,7 @@ export async function getAudioStats(page: Page): Promise<{
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') { 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<void> { export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
async () => { async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return false; return false;
@@ -600,7 +613,7 @@ export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Pr
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') if (report.type === 'outbound-rtp' && kind === 'audio')
@@ -692,7 +705,7 @@ export async function getVideoStats(page: Page): Promise<{
inbound: { bytesReceived: number; packetsReceived: number } | null; inbound: { bytesReceived: number; packetsReceived: number } | null;
}> { }> {
return page.evaluate(async () => { return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return { outbound: null, inbound: null }; return { outbound: null, inbound: null };
@@ -706,8 +719,8 @@ export async function getVideoStats(page: Page): Promise<{
hasInbound: boolean; hasInbound: boolean;
} }
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM = const hwm: Record<number, VHWM> = webRtcHarnessWindow().__rtcVideoStatsHWM =
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {}; (webRtcHarnessWindow().__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) { for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport; let stats: RTCStatsReport;
@@ -725,7 +738,7 @@ export async function getVideoStats(page: Page): Promise<{
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'video') { 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<void> { export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction( await page.waitForFunction(
async () => { async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const connections = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) if (!connections?.length)
return false; return false;
@@ -808,7 +821,7 @@ export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Pr
let hasOut = false; let hasOut = false;
let hasIn = false; let hasIn = false;
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
const kind = report.kind ?? report.mediaType; const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'video') if (report.type === 'outbound-rtp' && kind === 'video')
@@ -959,7 +972,7 @@ export async function waitForInboundVideoFlow(
*/ */
export async function dumpRtcDiagnostics(page: Page): Promise<string> { export async function dumpRtcDiagnostics(page: Page): Promise<string> {
return page.evaluate(async () => { return page.evaluate(async () => {
const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined; const conns = webRtcHarnessWindow().__rtcConnections as RTCPeerConnection[] | undefined;
if (!conns?.length) if (!conns?.length)
return 'No connections tracked'; return 'No connections tracked';
@@ -984,7 +997,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
try { try {
const stats = await pc.getStats(); const stats = await pc.getStats();
stats.forEach((report: any) => { stats.forEach((report: RTCStats) => {
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp') if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
return; return;
@@ -994,7 +1007,7 @@ export async function dumpRtcDiagnostics(page: Page): Promise<string> {
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`); lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
}); });
} catch (err: any) { } catch (err: unknown) {
lines.push(` getStats() failed: ${err?.message ?? err}`); lines.push(` getStats() failed: ${err?.message ?? err}`);
} }
} }

View File

@@ -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<number, Record<string, number | boolean>>;
__rtcVideoStatsHWM?: Record<number, Record<string, number | boolean>>;
__lastRtcState?: RTCPeerConnectionState;
RTCPeerConnection: typeof RTCPeerConnection;
AudioContext: typeof AudioContext;
}
export function getWebRtcTestHarnessWindow(): WebRtcTestHarnessWindow {
return window as unknown as WebRtcTestHarnessWindow;
}

View File

@@ -43,6 +43,7 @@ module.exports = tseslint.config(
} }
], ],
'metoyou/no-unicode-symbols': 'error', 'metoyou/no-unicode-symbols': 'error',
'metoyou/no-maybe-in-naming': 'error',
'@typescript-eslint/no-extraneous-class': 'off', '@typescript-eslint/no-extraneous-class': 'off',
'@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ], '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ],
'@angular-eslint/directive-class-suffix': 'error', '@angular-eslint/directive-class-suffix': 'error',
@@ -177,6 +178,7 @@ module.exports = tseslint.config(
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: { rules: {
'metoyou/no-unicode-symbols': 'error', 'metoyou/no-unicode-symbols': 'error',
'metoyou/no-maybe-in-naming': 'error',
// Angular template best practices // Angular template best practices
'@angular-eslint/template/button-has-type': 'warn', '@angular-eslint/template/button-has-type': 'warn',
'@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }], '@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }],

View File

@@ -62,7 +62,9 @@ describe('broadcastToServer', () => {
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1); expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
expect((connB.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).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', () => { it('excludes every connection for an identity when excludeIdentityOderId is set', () => {

View File

@@ -94,12 +94,13 @@ describe('server websocket handler - multi-client sessions', () => {
}); });
it('relays voice_state to other connections for the same user', async () => { it('relays voice_state to other connections for the same user', async () => {
const sender = createConnectedUser('conn-a1', { createConnectedUser('conn-a1', {
authenticated: true, authenticated: true,
oderId: 'user-1', oderId: 'user-1',
serverIds: new Set(['server-1']), serverIds: new Set(['server-1']),
clientInstanceId: 'device-a' clientInstanceId: 'device-a'
}); });
const passive = createConnectedUser('conn-a2', { const passive = createConnectedUser('conn-a2', {
authenticated: true, authenticated: true,
oderId: 'user-1', 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 () => { it('forwards RTC offers to the voice-active connection for the target user', async () => {
const sender = createConnectedUser('conn-sender', { createConnectedUser('conn-sender', {
authenticated: true, authenticated: true,
oderId: 'user-2', oderId: 'user-2',
serverIds: new Set(['server-1']) serverIds: new Set(['server-1'])
@@ -228,6 +229,7 @@ describe('server websocket handler - multi-client sessions', () => {
serverIds: new Set(['server-1']), serverIds: new Set(['server-1']),
clientInstanceId: 'device-a' clientInstanceId: 'device-a'
}); });
const receiver = createConnectedUser('conn-a2', { const receiver = createConnectedUser('conn-a2', {
authenticated: true, authenticated: true,
oderId: 'user-1', oderId: 'user-1',

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
/** /**
@@ -34,12 +33,6 @@ const DEFAULT_VOLUME = 0.2;
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class NotificationAudioService { export class NotificationAudioService {
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>();
private readonly sources = new Map<AppSound, string>();
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
@@ -47,45 +40,17 @@ export class NotificationAudioService {
/** When true, all sound playback is suppressed (Do Not Disturb). */ /** When true, all sound playback is suppressed (Do Not Disturb). */
readonly dndMuted = signal(false); readonly dndMuted = signal(false);
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>();
private readonly sources = new Map<AppSound, string>();
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
constructor() { constructor() {
this.preload(); 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. * Update the notification volume and persist it.
* *
@@ -178,4 +143,40 @@ export class NotificationAudioService {
audio.remove(); audio.remove();
this.activeLoops.delete(sound); 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;
}
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
signal, signal,
@@ -18,6 +17,10 @@ const DEFAULT_SYNC_TIMEOUT_MS = 5000;
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TimeSyncService { export class TimeSyncService {
/** Reactive read-only offset (milliseconds). */
readonly offset = computed(() => this._offset());
/** /**
* Internal offset signal: * Internal offset signal:
* `serverTime = Date.now() + offset`. * `serverTime = Date.now() + offset`.
@@ -27,9 +30,6 @@ export class TimeSyncService {
/** Epoch timestamp of the most recent successful sync. */ /** Epoch timestamp of the most recent successful sync. */
private lastSyncTimestamp = 0; private lastSyncTimestamp = 0;
/** Reactive read-only offset (milliseconds). */
readonly offset = computed(() => this._offset());
/** /**
* Return a server-adjusted "now" timestamp. * Return a server-adjusted "now" timestamp.
* *
@@ -97,4 +97,5 @@ export class TimeSyncService {
// Sync failure is non-fatal; retain the previous offset. // Sync failure is non-fatal; retain the previous offset.
} }
} }
} }

View File

@@ -27,17 +27,21 @@ interface StoredFileRecord {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class BrowserAttachmentFileStore implements AttachmentFileStore { export class BrowserAttachmentFileStore implements AttachmentFileStore {
readonly maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES; readonly maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES;
readonly supportsStreamingToDisk = false;
readonly supportsChunkedReads = true;
readonly providesInlineObjectUrl = false;
private database: IDBDatabase | null = null; readonly supportsStreamingToDisk = false;
private activeDatabaseName: string | null = null;
readonly supportsChunkedReads = true;
readonly providesInlineObjectUrl = false;
get isAvailable(): boolean { get isAvailable(): boolean {
return typeof indexedDB !== 'undefined'; return typeof indexedDB !== 'undefined';
} }
private database: IDBDatabase | null = null;
private activeDatabaseName: string | null = null;
async getAppDataPath(): Promise<string | null> { async getAppDataPath(): Promise<string | null> {
return this.isAvailable ? BROWSER_APP_DATA_ROOT : null; return this.isAvailable ? BROWSER_APP_DATA_ROOT : null;
} }
@@ -225,4 +229,5 @@ export class BrowserAttachmentFileStore implements AttachmentFileStore {
transaction.onabort = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error);
}); });
} }
} }

View File

@@ -16,16 +16,19 @@ const CAPACITOR_APP_DATA_ROOT = 'metoyou';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class CapacitorAttachmentFileStore implements AttachmentFileStore { export class CapacitorAttachmentFileStore implements AttachmentFileStore {
readonly maxPersistableBytes = Number.POSITIVE_INFINITY; readonly maxPersistableBytes = Number.POSITIVE_INFINITY;
readonly supportsStreamingToDisk = true;
readonly supportsChunkedReads = false;
readonly providesInlineObjectUrl = true;
private readonly loadFilesystem: () => Promise<CapacitorAttachmentFilesystem | null> = loadCapacitorAttachmentFilesystem; readonly supportsStreamingToDisk = true;
readonly supportsChunkedReads = false;
readonly providesInlineObjectUrl = true;
get isAvailable(): boolean { get isAvailable(): boolean {
return isCapacitorNativeRuntime(); return isCapacitorNativeRuntime();
} }
private readonly loadFilesystem: () => Promise<CapacitorAttachmentFilesystem | null> = loadCapacitorAttachmentFilesystem;
async getAppDataPath(): Promise<string | null> { async getAppDataPath(): Promise<string | null> {
return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null; return this.isAvailable ? CAPACITOR_APP_DATA_ROOT : null;
} }
@@ -200,4 +203,5 @@ export class CapacitorAttachmentFileStore implements AttachmentFileStore {
return null; return null;
} }
} }
} }

View File

@@ -6,11 +6,12 @@ import type { AttachmentFileStore } from './attachment-file-store';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ElectronAttachmentFileStore implements AttachmentFileStore { export class ElectronAttachmentFileStore implements AttachmentFileStore {
readonly maxPersistableBytes = Number.POSITIVE_INFINITY; 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 { get isAvailable(): boolean {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
@@ -18,6 +19,8 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath; return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
} }
private readonly electronBridge = inject(ElectronBridgeService);
async getAppDataPath(): Promise<string | null> { async getAppDataPath(): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
@@ -169,4 +172,5 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
return null; return null;
} }
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, tap } from 'rxjs'; import { Observable, tap } from 'rxjs';
@@ -18,47 +17,17 @@ import { MessageSigningService } from './message-signing.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthenticationService { export class AuthenticationService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly authTokenStore = inject(AuthTokenStoreService); private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly messageSigning = inject(MessageSigningService); 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 { resolveServerUrlFor(serverId?: string): string {
return this.resolveServerUrl(serverId); 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. * 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();
}
} }

View File

@@ -101,7 +101,20 @@ export class MessageSigningService {
const stored = this.readStoredKeyPair(); const stored = this.readStoredKeyPair();
if (stored) { 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 }; return { publicKey, privateKey };
} }
@@ -111,7 +124,8 @@ export class MessageSigningService {
true, true,
['sign', 'verify'] ['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 }); this.writeStoredKeyPair({ publicKeyJwk, privateKeyJwk });

View File

@@ -10,8 +10,6 @@ import {
getMessageRevision, getMessageRevision,
resolveMessageRevision resolveMessageRevision
} from './message-integrity.rules'; } from './message-integrity.rules';
import { getMessageTimestamp } from './message.rules';
export interface BuildMessageRevisionInput { export interface BuildMessageRevisionInput {
message: Message; message: Message;
type: MessageRevisionType; type: MessageRevisionType;

View File

@@ -7,7 +7,10 @@ import { findMissingIds } from './message-sync.rules';
describe('message-sync.rules', () => { describe('message-sync.rules', () => {
it('requests ids with newer revision or mismatched head hash', () => { 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<string, { ts: number; rc: number; ac: number; revision: number; headHash: string }>();
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([ const missing = findMissingIds([
{ id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' }, { id: 'm1', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'ccc' },
{ id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' }, { id: 'm2', ts: 10, rc: 0, ac: 0, revision: 2, headHash: 'bbb' },

View File

@@ -15,11 +15,20 @@ import type { RoomSignalSourceInput } from '../../server-directory';
standalone: true standalone: true
}) })
export class ChatImageProxyFallbackDirective { export class ChatImageProxyFallbackDirective {
@HostBinding('src')
get src(): string {
return this.renderedSource();
}
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' }); readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
readonly signalSource = input<RoomSignalSourceInput | null>(null); readonly signalSource = input<RoomSignalSourceInput | null>(null);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly renderedSource = signal(''); private readonly renderedSource = signal('');
private hasAppliedProxyFallback = false; private hasAppliedProxyFallback = false;
constructor() { constructor() {
@@ -29,11 +38,6 @@ export class ChatImageProxyFallbackDirective {
}); });
} }
@HostBinding('src')
get src(): string {
return this.renderedSource();
}
@HostListener('error') @HostListener('error')
handleError(): void { handleError(): void {
if (this.hasAppliedProxyFallback) { if (this.hasAppliedProxyFallback) {
@@ -49,4 +53,5 @@ export class ChatImageProxyFallbackDirective {
this.hasAppliedProxyFallback = true; this.hasAppliedProxyFallback = true;
this.renderedSource.set(proxyUrl); this.renderedSource.set(proxyUrl);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
HostListener, HostListener,
@@ -67,42 +66,64 @@ import {
}) })
export class ChatMessagesComponent { export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
private readonly electronBridge = inject(ElectronBridgeService); @ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
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);
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages); readonly roomMessages = this.store.selectSignal(selectCurrentRoomMessages);
readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages); readonly channelMessages = this.store.selectSignal(selectActiveChannelMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly loading = this.store.selectSignal(selectMessagesLoading); readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing); readonly syncing = this.store.selectSignal(selectMessagesSyncing);
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder); readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`); readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly conversationExhausted = toSignal( readonly conversationExhausted = toSignal(
toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))), toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))),
{ initialValue: false } { initialValue: false }
); );
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly composerBottomPadding = signal(140); readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16); readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null); readonly replyTo = signal<Message | null>(null);
readonly showKlipyGifPicker = signal(false); readonly showKlipyGifPicker = signal(false);
readonly lightboxState = signal<ChatLightboxState | null>(null); readonly lightboxState = signal<ChatLightboxState | null>(null);
readonly galleryAttachments = signal<Attachment[] | null>(null); readonly galleryAttachments = signal<Attachment[] | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null); readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(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() { constructor() {
effect(() => { effect(() => {
void this.klipy.refreshAvailability(this.currentRoom()); void this.klipy.refreshAvailability(this.currentRoom());
@@ -262,36 +283,6 @@ export class ChatMessagesComponent {
this.composer?.handleKlipyGifSelected(gif); 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 { openLightbox(event: ChatMessageImageLightboxEvent): void {
const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl); const attachments = event.attachments.filter((attachment) => attachment.available && attachment.objectUrl);
const index = attachments.findIndex((attachment) => attachment.id === event.attachment.id); 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 { private isOwnMessage(message: Message): boolean {
return message.senderId === this.currentUser()?.id; return message.senderId === this.currentUser()?.id;
} }
@@ -507,4 +528,5 @@ export class ChatMessagesComponent {
this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined); this.attachmentsSvc.publishAttachments(messageId, pendingFiles, currentUserId || undefined);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
@@ -108,38 +107,43 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
}) })
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>; @ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>; @ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
readonly replyTo = input<Message | null>(null); readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false); readonly showKlipyGifPicker = input(false);
readonly currentUserId = input<string | null>(null); readonly currentUserId = input<string | null>(null);
readonly klipyEnabled = input(false); readonly klipyEnabled = input(false);
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null); readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
readonly textareaTestId = input<string | null>(null); readonly textareaTestId = input<string | null>(null);
readonly commandSurface = input<SlashCommandSurface>('server'); readonly commandSurface = input<SlashCommandSurface>('server');
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>(); readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output(); readonly typingStarted = output();
readonly replyCleared = output(); readonly replyCleared = output();
readonly heightChanged = output<number>(); readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = 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<KlipyGif | null>(null); readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton; readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton;
readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile())); readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile()));
readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled())); readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled()));
readonly composerTextareaPaddingClass = computed(() => readonly composerTextareaPaddingClass = computed(() =>
resolveComposerTextareaPaddingClass({ resolveComposerTextareaPaddingClass({
isMobileViewport: this.viewport.isMobile(), isMobileViewport: this.viewport.isMobile(),
@@ -147,38 +151,78 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
klipyEnabled: this.klipyEnabled() klipyEnabled: this.klipyEnabled()
}) })
); );
readonly showComposerMediaMenu = signal(false); readonly showComposerMediaMenu = signal(false);
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);
readonly emojiButton = signal('🙂'); readonly emojiButton = signal('🙂');
readonly pluginComposerActions = this.pluginUi.composerActionRecords; readonly pluginComposerActions = this.pluginUi.composerActionRecords;
readonly slashQuery = signal<string | null>(null); readonly slashQuery = signal<string | null>(null);
readonly slashActiveIndex = signal(0); readonly slashActiveIndex = signal(0);
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries(
(text) => this.sendBuiltInSlashText(text),
(key) => this.appI18n.instant(key)
);
readonly availableSlashCommands = computed(() => readonly availableSlashCommands = computed(() =>
selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface()) selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface())
); );
readonly slashCommandResults = computed(() => { readonly slashCommandResults = computed(() => {
const query = this.slashQuery(); const query = this.slashQuery();
return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query); return query === null ? [] : filterSlashCommands(this.availableSlashCommands(), query);
}); });
readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0); readonly slashMenuOpen = computed(() => this.slashCommandResults().length > 0);
readonly toolbarVisible = signal(false); readonly toolbarVisible = signal(false);
readonly dragActive = signal(false); readonly dragActive = signal(false);
readonly inputHovered = signal(false); readonly inputHovered = signal(false);
readonly ctrlHeld = signal(false); readonly ctrlHeld = signal(false);
readonly textareaExpanded = signal(false); readonly textareaExpanded = signal(false);
messageContent = ''; messageContent = '';
pendingFiles: File[] = []; pendingFiles: File[] = [];
inlineCodeToken = '`'; 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 toolbarHovering = false;
private dragDepth = 0; private dragDepth = 0;
private lastTypingSentAt = 0; private lastTypingSentAt = 0;
private resizeObserver: ResizeObserver | null = null; private resizeObserver: ResizeObserver | null = null;
ngAfterViewInit(): void { ngAfterViewInit(): void {
@@ -194,7 +238,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
sendMessage(): void { sendMessage(): void {
const raw = this.messageContent.trim(); const raw = this.messageContent.trim();
if (this.maybeRunSlashCommand(raw)) if (this.runSlashCommandWhenPresent(raw))
return; return;
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif()) if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
@@ -459,68 +503,6 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.resetComposerAfterCommand(); 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 { getKlipyTriggerRect(): DOMRect | null {
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? 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 } { private getSelection(): { start: number; end: number } {
const element = this.messageInputRef?.nativeElement; const element = this.messageInputRef?.nativeElement;
@@ -960,4 +1004,5 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
this.heightChanged.emit(root.offsetHeight); this.heightChanged.emit(root.offsetHeight);
} }
} }
} }

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc --> <!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
@let msg = message(); @let msg = message();
@let attachmentsList = attachmentViewModels(); @let attachmentsList = attachmentViewModels();
@if (isSystemMessage()) { @if (isSystemMessage()) {

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
@@ -169,56 +168,61 @@ interface MissingPluginEmbedFallback {
}) })
export class ChatMessageItemComponent implements OnDestroy { export class ChatMessageItemComponent implements OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>; @ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
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 isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false); readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
private readonly mediaSupportCache = new Map<string, boolean>();
readonly message = input.required<Message>(); readonly message = input.required<Message>();
readonly repliedMessage = input<Message | undefined>(); readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null); readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false); readonly isAdmin = input(false);
readonly userLookup = input<ReadonlyMap<string, User>>(new Map()); readonly userLookup = input<ReadonlyMap<string, User>>(new Map());
readonly replyRequested = output<ChatMessageReplyEvent>(); readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>(); readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>(); readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>(); readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>(); readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly referenceRequested = output<string>(); readonly referenceRequested = output<string>();
readonly downloadRequested = output<Attachment>(); readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<ChatMessageImageLightboxEvent>(); readonly imageOpened = output<ChatMessageImageLightboxEvent>();
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>(); readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>(); readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>(); readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
readonly emojiShortcuts = this.customEmoji.shortcutEntries; readonly emojiShortcuts = this.customEmoji.shortcutEntries;
readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted'); readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted');
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content)); readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken())); readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed()); readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
readonly isSystemMessage = computed(() => this.message().kind === 'system'); readonly isSystemMessage = computed(() => this.message().kind === 'system');
readonly isEditing = signal(false); readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false); readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => { readonly senderUser = computed<User>(() => {
const msg = this.message(); const msg = this.message();
const found = this.userLookup().get(msg.senderId); const found = this.userLookup().get(msg.senderId);
@@ -238,26 +242,60 @@ export class ChatMessageItemComponent implements OnDestroy {
editContent = ''; 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<ChatMessageAttachmentViewModel[]>(() => { readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion(); void this.attachmentVersion();
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment)); return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
}); });
readonly imageAttachments = computed(() => readonly imageAttachments = computed(() =>
dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment))) dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment)))
); );
readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment))); readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment)));
readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage)); readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage));
readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length)); 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<string | null>(null);
private readonly mediaSupportCache = new Map<string, boolean>();
private readonly hydrateMessageImages = effect(() => { private readonly hydrateMessageImages = effect(() => {
const messageId = this.message().id; const messageId = this.message().id;
const images = this.imageAttachments(); const images = this.imageAttachments();
@@ -282,6 +320,7 @@ export class ChatMessageItemComponent implements OnDestroy {
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId); void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
} }
}); });
private readonly syncAttachmentVersion = effect(() => { private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated(); 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 { openMissingPluginStore(fallback: MissingPluginEmbedFallback): void {
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/dashboard' : this.router.url; 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 { startEdit(): void {
this.editContent = this.message().content; this.editContent = this.message().content;
this.isEditing.set(true); this.isEditing.set(true);
@@ -453,53 +464,10 @@ export class ChatMessageItemComponent implements OnDestroy {
this.clearLongPressTimer(); 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 { closeMobileActions(): void {
this.detachMobileSheet(); 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 { ngOnDestroy(): void {
this.clearLongPressTimer(); this.clearLongPressTimer();
this.detachMobileSheet(); this.detachMobileSheet();
@@ -836,6 +804,86 @@ export class ChatMessageItemComponent implements OnDestroy {
this.experimentalPlayerAttachmentId.set(null); 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 { private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isRawVideo = this.isVideoAttachment(attachment); const isRawVideo = this.isVideoAttachment(attachment);
const isRawAudio = this.isAudioAttachment(attachment); const isRawAudio = this.isAudioAttachment(attachment);
@@ -907,6 +955,7 @@ export class ChatMessageItemComponent implements OnDestroy {
return canPlay; return canPlay;
} }
} }
function parsePluginEmbedToken(content: string): PluginEmbedToken | null { function parsePluginEmbedToken(content: string): PluginEmbedToken | null {

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
Component, Component,
@@ -73,15 +72,21 @@ const REMARK_PROCESSOR = unified().use(remarkParse)
templateUrl: './chat-message-markdown.component.html' templateUrl: './chat-message-markdown.component.html'
}) })
export class ChatMessageMarkdownComponent { export class ChatMessageMarkdownComponent {
private readonly customEmoji = inject(CustomEmojiService);
readonly content = input.required<string>(); readonly content = input.required<string>();
readonly displayContent = computed(() => replaceCustomEmojiMessageTokens(this.content(), (id) => this.customEmoji.findEmoji(id))); readonly displayContent = computed(() => replaceCustomEmojiMessageTokens(this.content(), (id) => this.customEmoji.findEmoji(id)));
readonly largeCustomEmoji = computed(() => isCustomEmojiOnlyMessage(this.content())); readonly largeCustomEmoji = computed(() => isCustomEmojiOnlyMessage(this.content()));
readonly largeUnicodeEmoji = computed(() => isSingleUnicodeEmojiOnlyMessage(this.content())); readonly largeUnicodeEmoji = computed(() => isSingleUnicodeEmojiOnlyMessage(this.content()));
readonly remarkProcessor = REMARK_PROCESSOR; readonly remarkProcessor = REMARK_PROCESSOR;
readonly splitTextIntoEmojiSegments = splitTextIntoEmojiSegments; readonly splitTextIntoEmojiSegments = splitTextIntoEmojiSegments;
private readonly customEmoji = inject(CustomEmojiService);
shouldRenderLargeCustomEmoji(url?: string): boolean { shouldRenderLargeCustomEmoji(url?: string): boolean {
return this.isCustomEmojiDataUrl(url) && this.largeCustomEmoji(); return this.isCustomEmojiDataUrl(url) && this.largeCustomEmoji();
} }
@@ -141,4 +146,5 @@ export class ChatMessageMarkdownComponent {
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized; return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
AfterViewChecked, AfterViewChecked,
@@ -64,37 +63,50 @@ declare global {
} }
}) })
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy { export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
private static readonly INITIAL_SETTLE_MS = 1500;
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>; @ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
@ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>; @ViewChild('messagesContent') messagesContent?: ElementRef<HTMLDivElement>;
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<Message[]>(); readonly allMessages = input.required<Message[]>();
readonly channelMessages = input.required<Message[]>(); readonly channelMessages = input.required<Message[]>();
readonly loading = input(false); readonly loading = input(false);
readonly syncing = input(false); readonly syncing = input(false);
readonly currentUserId = input<string | null>(null); readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false); readonly isAdmin = input(false);
readonly bottomPadding = input(120); readonly bottomPadding = input(120);
readonly conversationKey = input.required<string>(); readonly conversationKey = input.required<string>();
readonly userLookupOverrides = input<User[]>([]); readonly userLookupOverrides = input<User[]>([]);
readonly replyRequested = output<ChatMessageReplyEvent>(); readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>(); readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>(); readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>(); readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>(); readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly downloadRequested = output<Attachment>(); readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<ChatMessageImageLightboxEvent>(); readonly imageOpened = output<ChatMessageImageLightboxEvent>();
readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>(); readonly imageGalleryOpened = output<ChatMessageImageGalleryEvent>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>(); readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>(); readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
/** /**
* Emitted when the user scrolls up past the in-store window and the * 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. * 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. */ /** True while a DB-backed older-page fetch dispatched by the parent is in flight. */
readonly loadingOlder = input(false); readonly loadingOlder = input(false);
/** True once the parent has paginated all the way back to the start of DB history. */ /** True once the parent has paginated all the way back to the start of DB history. */
readonly conversationExhausted = input(false); readonly conversationExhausted = input(false);
private readonly PAGE_SIZE = 50;
readonly displayLimit = signal(this.PAGE_SIZE); readonly displayLimit = signal(this.PAGE_SIZE);
readonly loadingMore = signal(false); readonly loadingMore = signal(false);
readonly showNewMessagesBar = signal(false); readonly showNewMessagesBar = signal(false);
readonly messages = computed(() => { readonly messages = computed(() => {
@@ -123,6 +136,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}); });
readonly initialLoading = computed(() => this.loading() && this.messages().length === 0); readonly initialLoading = computed(() => this.loading() && this.messages().length === 0);
readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0); readonly refreshLoading = computed(() => this.loading() && this.messages().length > 0);
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit()); readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
@@ -167,6 +181,18 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return lookup; 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. * O(1) index of messages by id, built once per `allMessages()` change.
* Used by `findRepliedMessage` so each rendered row doing a reply lookup * 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 contentResizeObserver: ResizeObserver | null = null;
private observedContent: HTMLElement | null = null; private observedContent: HTMLElement | null = null;
private localSendScrollPending = false; private localSendScrollPending = false;
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null; private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
private isAutoScrolling = false; private isAutoScrolling = false;
private lastMessageCount = 0; private lastMessageCount = 0;
private initialScrollPending = true; private initialScrollPending = true;
private prismHighlightScheduled = false; private prismHighlightScheduled = false;
/** /**
* True while the list should keep auto-pinning to the newest message. Set * 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 * when the conversation opens and whenever the user is scrolled near the
@@ -199,13 +233,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
* latest message. * latest message.
*/ */
private stickToBottom = true; private stickToBottom = true;
/** /**
* Timestamp (ms) until which a freshly opened conversation is still * Timestamp (ms) until which a freshly opened conversation is still
* settling. Inside this window new messages jump instantly instead of * settling. Inside this window new messages jump instantly instead of
* animating, so a channel switch always lands at the bottom. * animating, so a channel switch always lands at the bottom.
*/ */
private settleUntil = 0; private settleUntil = 0;
private static readonly INITIAL_SETTLE_MS = 1500;
/** /**
* Set when an older-page DB fetch is in flight. While true, the * Set when an older-page DB fetch is in flight. While true, the
* `onMessagesChanged` effect treats incoming message-count growth as a * `onMessagesChanged` effect treats incoming message-count growth as a
@@ -710,4 +745,5 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return String(hash); return String(hash);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
AfterViewInit, AfterViewInit,
Component, Component,
@@ -55,27 +54,44 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
templateUrl: './klipy-gif-picker.component.html' templateUrl: './klipy-gif-picker.component.html'
}) })
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy { export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
readonly signalSource = input<RoomSignalSourceInput | null>(null);
readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService); readonly signalSource = input<RoomSignalSourceInput | null>(null);
private readonly viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService); readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0;
searchQuery = ''; searchQuery = '';
results = signal<KlipyGif[]>([]); results = signal<KlipyGif[]>([]);
loading = signal(false); loading = signal(false);
errorMessage = signal(''); errorMessage = signal('');
hasNext = signal(false); hasNext = signal(false);
private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0;
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
ngOnInit(): void { ngOnInit(): void {
void this.loadResults(true); void this.loadResults(true);
} }
@@ -91,11 +107,6 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
this.clearSearchTimer(); this.clearSearchTimer();
} }
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
onSearchQueryChanged(query: string): void { onSearchQueryChanged(query: string): void {
this.searchQuery = query; this.searchQuery = query;
this.clearSearchTimer(); 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)) height: Math.min(KLIPY_CARD_MAX_HEIGHT, Math.max(KLIPY_CARD_MIN_HEIGHT, scaledHeight))
}; };
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, */
import { import {
Component, Component,
computed, computed,
@@ -44,17 +43,10 @@ interface TypingSignalingMessage {
} }
}) })
export class TypingIndicatorComponent { export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; channelId: string; expiresAt: number }>();
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<string[]>([]); typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0); typingOthersCount = signal<number>(0);
private readonly appI18n = inject(AppI18nService);
readonly typingLabel = computed(() => { readonly typingLabel = computed(() => {
const names = this.typingDisplay(); const names = this.typingDisplay();
@@ -80,6 +72,22 @@ export class TypingIndicatorComponent {
return this.appI18n.instant('chat.typing.many', { names: namesText }); return this.appI18n.instant('chat.typing.many', { names: namesText });
}); });
private readonly typingMap = new Map<string, { name: string; channelId: string; expiresAt: number }>();
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() { constructor() {
const webrtc = inject(RealtimeSessionFacade); const webrtc = inject(RealtimeSessionFacade);
const destroyRef = inject(DestroyRef); const destroyRef = inject(DestroyRef);
@@ -167,4 +175,5 @@ export class TypingIndicatorComponent {
this.typingDisplay.set(names.slice(0, MAX_SHOWN)); this.typingDisplay.set(names.slice(0, MAX_SHOWN));
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN)); this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -67,20 +66,32 @@ import { DirectMessageService } from '../../../direct-message';
* Displays the list of online users with voice state indicators and admin actions. * Displays the list of online users with voice state indicators and admin actions.
*/ */
export class UserListComponent { 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<User[]>; onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected)); voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>; currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
showUserMenu = signal<string | null>(null); showUserMenu = signal<string | null>(null);
showBanDialog = signal(false); showBanDialog = signal(false);
userToBan = signal<User | null>(null); userToBan = signal<User | null>(null);
banReason = ''; 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 { statusLabel(status: User['status']): string {
switch (status) { switch (status) {
@@ -167,4 +178,5 @@ export class UserListComponent {
this.closeBanDialog(); this.closeBanDialog();
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { createEffect } from '@ngrx/effects'; import { createEffect } from '@ngrx/effects';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -25,13 +24,6 @@ import { CustomEmojiService } from './custom-emoji.service';
@Injectable() @Injectable()
export class CustomEmojiSyncEffects { 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<ChatEvent>
);
currentUserLoad$ = createEffect( currentUserLoad$ = createEffect(
() => this.store.select(selectCurrentUser).pipe( () => this.store.select(selectCurrentUser).pipe(
@@ -124,4 +116,16 @@ export class CustomEmojiSyncEffects {
), ),
{ dispatch: false } { 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<ChatEvent>
);
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -43,25 +42,34 @@ interface PendingCustomEmojiTransfer {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class CustomEmojiService { export class CustomEmojiService {
private readonly db = inject(DatabaseService);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly emojisState = signal<CustomEmoji[]>([]);
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
private readonly savedIdsState = signal<ReadonlySet<string>>(new Set());
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
private activeUserId: string | null = null;
private loaded = false;
readonly emojis = computed(() => { readonly emojis = computed(() => {
const savedIds = this.savedIdsState(); const savedIds = this.savedIdsState();
return this.emojisState().filter((emoji) => savedIds.has(emoji.id)); return this.emojisState().filter((emoji) => savedIds.has(emoji.id));
}); });
readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({ readonly shortcutEntries = computed(() => selectEmojiShortcutEntries({
customEmojis: this.emojis(), customEmojis: this.emojis(),
usage: this.usageState() usage: this.usageState()
})); }));
private readonly db = inject(DatabaseService);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly emojisState = signal<CustomEmoji[]>([]);
private readonly usageState = signal<ReadonlyMap<string, number>>(new Map());
private readonly savedIdsState = signal<ReadonlySet<string>>(new Set());
private readonly pendingTransfers = new Map<string, PendingCustomEmojiTransfer>();
private activeUserId: string | null = null;
private loaded = false;
async loadForUser(userId: string | null | undefined): Promise<void> { async loadForUser(userId: string | null | undefined): Promise<void> {
this.activeUserId = userId ?? null; this.activeUserId = userId ?? null;
@@ -574,4 +582,5 @@ export class CustomEmojiService {
return baseName || 'emoji'; return baseName || 'emoji';
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
Component, Component,
@@ -46,41 +45,74 @@ import {
templateUrl: './custom-emoji-picker.component.html' templateUrl: './custom-emoji-picker.component.html'
}) })
export class CustomEmojiPickerComponent { export class CustomEmojiPickerComponent {
private readonly customEmoji = inject(CustomEmojiService);
private readonly appI18n = inject(AppI18nService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly currentUserId = input<string | null>(null); readonly currentUserId = input<string | null>(null);
readonly compact = input(true); readonly compact = input(true);
/** Render the picker panel in normal document flow for bottom-sheet embedding. */ /** Render the picker panel in normal document flow for bottom-sheet embedding. */
readonly inline = input(false); readonly inline = input(false);
readonly emojiSelected = output<string>(); readonly emojiSelected = output<string>();
readonly dismissed = output(); readonly dismissed = output();
readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE; readonly acceptAttribute = CUSTOM_EMOJI_ACCEPT_ATTRIBUTE;
readonly modalOpen = signal(false); readonly modalOpen = signal(false);
readonly uploadError = signal<string | null>(null); readonly uploadError = signal<string | null>(null);
readonly uploading = signal(false); readonly uploading = signal(false);
readonly shortcuts = this.customEmoji.shortcutEntries; readonly shortcuts = this.customEmoji.shortcutEntries;
readonly customEmojis = this.customEmoji.emojis; readonly customEmojis = this.customEmoji.emojis;
readonly searchQuery = signal(''); readonly searchQuery = signal('');
readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries( readonly filteredUnicodeEntries = computed(() => filterUnicodeEmojiPickerEntries(
UNICODE_EMOJI_PICKER_ENTRIES, UNICODE_EMOJI_PICKER_ENTRIES,
this.searchQuery() this.searchQuery()
)); ));
readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker( readonly filteredCustomEmojis = computed(() => filterCustomEmojisForPicker(
this.customEmojis(), this.customEmojis(),
this.searchQuery() this.searchQuery()
)); ));
readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0); readonly hasActiveSearch = computed(() => normalizeEmojiPickerSearchQuery(this.searchQuery()).length > 0);
readonly showEmptySearchState = computed(() => this.hasActiveSearch() readonly showEmptySearchState = computed(() => this.hasActiveSearch()
&& this.filteredUnicodeEntries().length === 0 && this.filteredUnicodeEntries().length === 0
&& this.filteredCustomEmojis().length === 0); && this.filteredCustomEmojis().length === 0);
private readonly customEmoji = inject(CustomEmojiService);
private readonly appI18n = inject(AppI18nService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly loadForUser = effect(() => { private readonly loadForUser = effect(() => {
void this.customEmoji.loadForUser(this.currentUserId()); 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 { setSearchQuery(query: string): void {
this.searchQuery.set(query); this.searchQuery.set(query);
} }
@@ -114,22 +146,6 @@ export class CustomEmojiPickerComponent {
this.modalOpen.set(false); 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 { openModal(): void {
this.modalOpen.set(true); this.modalOpen.set(true);
} }
@@ -168,4 +184,5 @@ export class CustomEmojiPickerComponent {
this.uploading.set(false); this.uploading.set(false);
} }
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -37,31 +36,13 @@ import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DirectCallService { 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<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
private readonly declinedCallIds = new Set<string>();
readonly sessions = computed(() => this.sessionsSignal()); readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended')); readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session))); readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
readonly incomingCall = computed<DirectCallSession | null>(() => { readonly incomingCall = computed<DirectCallSession | null>(() => {
if (this.isDoNotDisturb()) { if (this.isDoNotDisturb()) {
return null; return null;
@@ -80,8 +61,11 @@ export class DirectCallService {
&& !session.participants[meId]?.joined && !session.participants[meId]?.joined
&& this.hasConnectedParticipant(session)) ?? null; && this.hasConnectedParticipant(session)) ?? null;
}); });
readonly currentSession = signal<DirectCallSession | null>(null); readonly currentSession = signal<DirectCallSession | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0); readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => { readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId(); const callId = this.mobileOverlayCallId();
@@ -92,6 +76,48 @@ export class DirectCallService {
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null; 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<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
private readonly pendingIncomingCallPayloads: DirectCallEventPayload[] = [];
private readonly declinedCallIds = new Set<string>();
constructor() { constructor() {
this.mobileCallSession.initialize(); this.mobileCallSession.initialize();
this.mobileCallSession.onCallControlAction((intent, callId) => { 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<void> { async inviteUser(callId: string, user: User): Promise<void> {
const session = this.sessionById(callId); const session = this.sessionById(callId);
@@ -445,6 +458,19 @@ export class DirectCallService {
return participant ? participantToUser(participant) : null; 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<void> { private async drainPendingIncomingCallPayloads(): Promise<void> {
if (this.pendingIncomingCallPayloads.length === 0) { if (this.pendingIncomingCallPayloads.length === 0) {
return; return;
@@ -1041,4 +1067,5 @@ export class DirectCallService {
return user; return user;
} }
} }

View File

@@ -36,10 +36,13 @@ import { DirectCallSession, participantToUser } from '../../domain/models/direct
}) })
export class IncomingCallModalComponent { export class IncomingCallModalComponent {
readonly calls = inject(DirectCallService); readonly calls = inject(DirectCallService);
private readonly i18n = inject(AppI18nService);
readonly currentUser = inject(Store).selectSignal(selectCurrentUser); readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly session = this.calls.incomingCall; readonly session = this.calls.incomingCall;
readonly answering = signal(false); readonly answering = signal(false);
readonly caller = computed(() => { readonly caller = computed(() => {
const session = this.session(); const session = this.session();
@@ -53,10 +56,13 @@ export class IncomingCallModalComponent {
return (callerId ? this.calls.userForParticipant(callerId) : null) return (callerId ? this.calls.userForParticipant(callerId) : null)
?? (participant ? participantToUser(participant) : null); ?? (participant ? participantToUser(participant) : null);
}); });
readonly callerName = computed(() => this.caller()?.displayName || this.i18n.instant('call.incoming.someone')); readonly callerName = computed(() => this.caller()?.displayName || this.i18n.instant('call.incoming.someone'));
readonly callerCallingLabel = computed(() => readonly callerCallingLabel = computed(() =>
this.i18n.instant('call.incoming.callerCalling', { name: this.callerName() }) this.i18n.instant('call.incoming.callerCalling', { name: this.callerName() })
); );
readonly callKindLabel = computed(() => { readonly callKindLabel = computed(() => {
const participantCount = this.session()?.participantIds.length ?? 0; const participantCount = this.session()?.participantIds.length ?? 0;
@@ -65,6 +71,8 @@ export class IncomingCallModalComponent {
: this.i18n.instant('call.incoming.directCall'); : this.i18n.instant('call.incoming.directCall');
}); });
private readonly i18n = inject(AppI18nService);
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
onEscape(): void { onEscape(): void {
this.decline(); this.decline();
@@ -115,4 +123,5 @@ export class IncomingCallModalComponent {
private userKey(user: User): string { private userKey(user: User): string {
return user.oderId || user.id; return user.oderId || user.id;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -62,24 +61,13 @@ interface DirectMessageTypingEntry {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DirectMessageService { 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<DirectMessageConversation[]>([]);
private readonly selectedConversationIdSignal = signal<string | null>(null);
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
private readonly lastSyncRequestAt = new Map<string, number>();
private loadedOwnerId: string | null = null;
readonly conversations = computed(() => [...this.conversationsSignal()].sort( readonly conversations = computed(() => [...this.conversationsSignal()].sort(
(firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt (firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt
)); ));
readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly(); readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly();
readonly selectedConversation = computed(() => { readonly selectedConversation = computed(() => {
const selectedId = this.selectedConversationIdSignal(); const selectedId = this.selectedConversationIdSignal();
@@ -87,12 +75,40 @@ export class DirectMessageService {
? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null ? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null
: null; : null;
}); });
readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce( readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce(
(total, conversation) => total + conversation.unreadCount, (total, conversation) => total + conversation.unreadCount,
0 0
)); ));
readonly typingEntries = this.typingEntriesSignal.asReadonly(); 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<DirectMessageConversation[]>([]);
private readonly selectedConversationIdSignal = signal<string | null>(null);
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
private readonly lastSyncRequestAt = new Map<string, number>();
private loadedOwnerId: string | null = null;
constructor() { constructor() {
effect(() => { effect(() => {
const ownerId = this.getCurrentUserId(); const ownerId = this.getCurrentUserId();
@@ -991,4 +1007,5 @@ export class DirectMessageService {
return ownerId; return ownerId;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -14,16 +13,23 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FriendService { 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<Friend[]>([]);
private loadedOwnerId: string | null = null;
readonly friends = this.friendsSignal.asReadonly(); readonly friends = this.friendsSignal.asReadonly();
readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId))); 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<Friend[]>([]);
private loadedOwnerId: string | null = null;
constructor() { constructor() {
effect(() => { effect(() => {
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null; const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null;
@@ -116,4 +122,5 @@ export class FriendService {
await this.loadForOwner(ownerId); await this.loadForOwner(ownerId);
return ownerId; return ownerId;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { import {
@@ -13,10 +12,6 @@ import type { ChatEvent, User } from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PeerDeliveryService { 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<void>();
readonly directMessageEvents$: Observable<ChatEvent> = merge( readonly directMessageEvents$: Observable<ChatEvent> = merge(
this.webrtc.onMessageReceived, this.webrtc.onMessageReceived,
@@ -38,8 +33,17 @@ export class PeerDeliveryService {
); );
readonly peerConnected$ = this.webrtc.onPeerConnected; readonly peerConnected$ = this.webrtc.onPeerConnected;
readonly networkRestored$ = this.networkRestoredSubject.asObservable(); 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<void>();
constructor() { constructor() {
this.installNetworkTestHooks(); this.installNetworkTestHooks();
} }
@@ -174,4 +178,5 @@ export class PeerDeliveryService {
this.networkRestoredSubject.next(); this.networkRestoredSubject.next();
}; };
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -84,55 +83,68 @@ interface DmStatusLabel {
export class DmChatComponent { export class DmChatComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @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<string>();
private openedConversationId: string | null = null;
private readonly i18n = inject(AppI18nService);
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService); readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly allUsers = this.store.selectSignal(selectAllUsers); readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly showGifPicker = signal(false); readonly showGifPicker = signal(false);
readonly conversationId = input<string | null>(null); readonly conversationId = input<string | null>(null);
readonly showCallButton = input(true); readonly showCallButton = input(true);
readonly composerBottomPadding = signal(140); readonly composerBottomPadding = signal(140);
readonly gifPickerAnchorRight = signal(16); readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({}); readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
readonly replyTo = signal<Message | null>(null); readonly replyTo = signal<Message | null>(null);
readonly lightboxState = signal<ChatLightboxState | null>(null); readonly lightboxState = signal<ChatLightboxState | null>(null);
readonly galleryAttachments = signal<Attachment[] | null>(null); readonly galleryAttachments = signal<Attachment[] | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null); readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId') initialValue: this.route.snapshot.paramMap.get('conversationId')
}); });
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId()); readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly conversation = this.directMessages.selectedConversation; readonly conversation = this.directMessages.selectedConversation;
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null)); readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none'); readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
readonly typingUsers = computed(() => { readonly typingUsers = computed(() => {
void this.directMessages.typingEntries(); void this.directMessages.typingEntries();
return this.directMessages.typingUsers(this.conversation()?.id); return this.directMessages.typingUsers(this.conversation()?.id);
}); });
readonly peerUser = computed(() => { readonly peerUser = computed(() => {
const conversation = this.conversation(); const conversation = this.conversation();
return conversation ? this.peerUserFor(conversation) : null; return conversation ? this.peerUserFor(conversation) : null;
}); });
readonly isGroupConversation = computed(() => { readonly isGroupConversation = computed(() => {
const conversation = this.conversation(); const conversation = this.conversation();
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2); return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
}); });
readonly participantUsers = computed<User[]>(() => { readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const knownUsers = this.allUsers(); const knownUsers = this.allUsers();
@@ -164,6 +176,7 @@ export class DmChatComponent {
); );
}); });
}); });
readonly messageStatuses = computed<DmStatusLabel[]>(() => { readonly messageStatuses = computed<DmStatusLabel[]>(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const currentUserId = this.currentUserId(); const currentUserId = this.currentUserId();
@@ -179,6 +192,7 @@ export class DmChatComponent {
status: message.status status: message.status
})); }));
}); });
readonly chatMessages = computed<Message[]>(() => { readonly chatMessages = computed<Message[]>(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const metadataByMessageId = this.linkMetadataByMessageId(); const metadataByMessageId = this.linkMetadataByMessageId();
@@ -196,7 +210,11 @@ export class DmChatComponent {
roomId: conversation.id, roomId: conversation.id,
channelId: 'direct-message', channelId: 'direct-message',
senderId: message.senderId, 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, content: message.content,
timestamp: message.timestamp, timestamp: message.timestamp,
kind: message.kind, kind: message.kind,
@@ -209,6 +227,7 @@ export class DmChatComponent {
}; };
}); });
}); });
readonly peerName = computed(() => { readonly peerName = computed(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const currentUserId = this.currentUserId(); const currentUserId = this.currentUserId();
@@ -221,6 +240,7 @@ export class DmChatComponent {
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle'); return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : this.i18n.instant('dm.chat.defaultTitle');
}); });
readonly peerCallIcon = computed(() => { readonly peerCallIcon = computed(() => {
const conversation = this.conversation(); const conversation = this.conversation();
@@ -232,6 +252,7 @@ export class DmChatComponent {
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone'; return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
}); });
readonly canCallConversation = computed(() => { readonly canCallConversation = computed(() => {
const conversation = this.conversation(); const conversation = this.conversation();
@@ -246,6 +267,28 @@ export class DmChatComponent {
return !!this.peerUser(); 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<string>();
private openedConversationId: string | null = null;
private readonly i18n = inject(AppI18nService);
constructor() { constructor() {
effect(() => { effect(() => {
const conversationId = this.effectiveConversationId(); const conversationId = this.effectiveConversationId();
@@ -659,4 +702,5 @@ export class DmChatComponent {
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -60,15 +59,16 @@ const EXIT_ANIMATION_MS = 160;
styleUrl: './dm-rail.component.scss' styleUrl: './dm-rail.component.scss'
}) })
export class DmRailComponent implements OnDestroy { export class DmRailComponent implements OnDestroy {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly i18n = inject(AppI18nService);
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
readonly friends = inject(FriendService); readonly friends = inject(FriendService);
readonly users = this.store.selectSignal(selectAllUsers); readonly users = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly activeConversationId = toSignal( readonly activeConversationId = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -76,11 +76,15 @@ export class DmRailComponent implements OnDestroy {
), ),
{ initialValue: this.getConversationIdFromUrl(this.router.url) } { initialValue: this.getConversationIdFromUrl(this.router.url) }
); );
readonly friendUsers = computed(() => this.users().filter((user) => readonly friendUsers = computed(() => this.users().filter((user) =>
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId() this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
)); ));
readonly railItems = signal<DmRailItem[]>([]); readonly railItems = signal<DmRailItem[]>([]);
readonly contextMenu = signal<DmRailContextMenuState | null>(null); readonly contextMenu = signal<DmRailContextMenuState | null>(null);
readonly unreadRailItems = computed<DmRailItem[]>(() => { readonly unreadRailItems = computed<DmRailItem[]>(() => {
const currentUserId = this.currentUserId(); const currentUserId = this.currentUserId();
const items = new Map<string, DmRailItem>(); const items = new Map<string, DmRailItem>();
@@ -143,6 +147,7 @@ export class DmRailComponent implements OnDestroy {
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0); return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
}); });
readonly isOnDirectMessages = toSignal( readonly isOnDirectMessages = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -151,6 +156,14 @@ export class DmRailComponent implements OnDestroy {
{ initialValue: this.router.url.startsWith('/dm') } { initialValue: this.router.url.startsWith('/dm') }
); );
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly i18n = inject(AppI18nService);
constructor() { constructor() {
effect(() => { effect(() => {
const unreadItems = this.unreadRailItems(); const unreadItems = this.unreadRailItems();
@@ -334,4 +347,5 @@ export class DmRailComponent implements OnDestroy {
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`; return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -44,26 +43,42 @@ import { DirectMessageService } from '../../application/services/direct-message.
templateUrl: './dm-conversation-item.component.html' templateUrl: './dm-conversation-item.component.html'
}) })
export class DmConversationItemComponent { 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<DirectMessageConversation>(); readonly conversation = input.required<DirectMessageConversation>();
readonly conversationOpened = output<string>(); readonly conversationOpened = output<string>();
readonly users = this.store.selectSignal(selectAllUsers); readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId') initialValue: this.route.snapshot.paramMap.get('conversationId')
}); });
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id); readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
readonly peerName = computed(() => this.resolvePeerName(this.conversation())); readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation())); readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation())); readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
readonly canCall = computed(() => this.canCallConversation(this.conversation())); readonly canCall = computed(() => this.canCallConversation(this.conversation()));
readonly callIcon = computed(() => this.conversationCallIcon(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() { constructor() {
effect(() => { effect(() => {
const conversation = this.conversation(); const conversation = this.conversation();
@@ -118,7 +133,12 @@ export class DmConversationItemComponent {
const peerId = this.peerId(conversation); const peerId = this.peerId(conversation);
const knownUser = this.peerUser(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 { private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
@@ -229,4 +249,5 @@ export class DmConversationItemComponent {
? this.i18n.instant('dm.previews.oneAttachment') ? this.i18n.instant('dm.previews.oneAttachment')
: this.i18n.instant('dm.previews.manyAttachments'); : this.i18n.instant('dm.previews.manyAttachments');
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -33,12 +32,16 @@ import { DmConversationItemComponent } from './dm-conversation-item.component';
templateUrl: './dm-conversations-panel.component.html' templateUrl: './dm-conversations-panel.component.html'
}) })
export class DmConversationsPanelComponent { export class DmConversationsPanelComponent {
private readonly theme = inject(ThemeService);
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel')); readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly conversationSelected = output<string>(); readonly conversationSelected = output<string>();
private readonly theme = inject(ThemeService);
trackConversationId(index: number, conversation: DirectMessageConversation): string { trackConversationId(index: number, conversation: DirectMessageConversation): string {
return conversation.id; return conversation.id;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
@@ -55,21 +54,18 @@ interface SwiperElement extends HTMLElement {
templateUrl: './dm-workspace.component.html' templateUrl: './dm-workspace.component.html'
}) })
export class DmWorkspaceComponent implements OnDestroy { 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 directMessages = inject(DirectMessageService);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId') initialValue: this.route.snapshot.paramMap.get('conversationId')
}); });
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout')); readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('dmLayout'));
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl'); readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
readonly activeCall = computed(() => { readonly activeCall = computed(() => {
const currentSession = this.directCalls.currentSession(); const currentSession = this.directCalls.currentSession();
const visibleSessions = this.directCalls.visibleActiveSessions(); 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. */ /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<DmWorkspaceMobilePage>('conversations'); readonly mobilePage = signal<DmWorkspaceMobilePage>('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() { constructor() {
effect(() => { effect(() => {
const conversationId = this.routeConversationId(); const conversationId = this.routeConversationId();
@@ -171,5 +183,6 @@ export class DmWorkspaceComponent implements OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.directMessages.closeConversationView(this.routeConversationId()); this.directMessages.closeConversationView(this.routeConversationId());
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -16,7 +15,7 @@ import {
lucideUsers lucideUsers
} from '@ng-icons/lucide'; } 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 { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
import { UserSearchListComponent } from '../user-search-list/user-search-list.component'; import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
import { selectAllUsers } from '../../../../store/users/users.selectors'; import { selectAllUsers } from '../../../../store/users/users.selectors';
@@ -47,10 +46,7 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
} }
}) })
export class FindPeopleComponent { export class FindPeopleComponent {
private store = inject(Store);
searchQuery = signal(''); 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). */ /** True when the account has any people to surface (known users or server members). */
hasDiscoverablePeople = computed(() => { hasDiscoverablePeople = computed(() => {
@@ -61,7 +57,14 @@ export class FindPeopleComponent {
return this.savedRooms().some((room) => (room.members?.length ?? 0) > 0); 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 { onSearchChange(query: string): void {
this.searchQuery.set(query); this.searchQuery.set(query);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -24,20 +23,26 @@ import type { User } from '../../../../shared-kernel';
templateUrl: './friend-button.component.html' templateUrl: './friend-button.component.html'
}) })
export class FriendButtonComponent { export class FriendButtonComponent {
private readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
readonly user = input.required<User>(); readonly user = input.required<User>();
readonly userId = computed(() => this.user().oderId || this.user().id); readonly userId = computed(() => this.user().oderId || this.user().id);
readonly isFriend = computed(() => this.friends.isFriend(this.userId())); readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
readonly ariaLabel = computed(() => readonly ariaLabel = computed(() =>
this.isFriend() this.isFriend()
? this.i18n.instant('dm.friend.remove') ? this.i18n.instant('dm.friend.remove')
: this.i18n.instant('dm.friend.add') : this.i18n.instant('dm.friend.add')
); );
private readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
toggle(event: Event): void { toggle(event: Event): void {
event.stopPropagation(); event.stopPropagation();
void this.friends.toggleFriend(this.userId()); void this.friends.toggleFriend(this.userId());
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -39,16 +38,18 @@ import type { User } from '../../../../shared-kernel';
templateUrl: './user-search-list.component.html' templateUrl: './user-search-list.component.html'
}) })
export class UserSearchListComponent { export class UserSearchListComponent {
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
readonly directCalls = inject(DirectCallService); readonly directCalls = inject(DirectCallService);
readonly friends = inject(FriendService); readonly friends = inject(FriendService);
private readonly i18n = inject(AppI18nService);
readonly searchQuery = input(''); readonly searchQuery = input('');
readonly users = this.store.selectSignal(selectAllUsers); readonly users = this.store.selectSignal(selectAllUsers);
readonly savedRooms = this.store.selectSignal(selectSavedRooms); readonly savedRooms = this.store.selectSignal(selectSavedRooms);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly discoveredUsers = computed(() => { readonly discoveredUsers = computed(() => {
const usersById = new Map<string, User>(); const usersById = new Map<string, User>();
@@ -81,6 +82,7 @@ export class UserSearchListComponent {
return Array.from(usersById.values()); return Array.from(usersById.values());
}); });
readonly matchingUsers = computed(() => { readonly matchingUsers = computed(() => {
const query = this.normalizedSearchQuery(); const query = this.normalizedSearchQuery();
const currentUserId = this.currentUserKey(); const currentUserId = this.currentUserKey();
@@ -90,13 +92,23 @@ export class UserSearchListComponent {
.filter((user) => this.matchesQuery(user, query)) .filter((user) => this.matchesQuery(user, query))
.slice(0, 24); .slice(0, 24);
}); });
readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user)))); readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user))));
readonly results = computed(() => { readonly results = computed(() => {
const friendIds = this.friends.friendIds(); const friendIds = this.friends.friendIds();
return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user))); 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<void> { async messageUser(user: User): Promise<void> {
const conversation = await this.directMessages.createConversation(user); const conversation = await this.directMessages.createConversation(user);
@@ -151,4 +163,5 @@ export class UserSearchListComponent {
.filter((value): value is string => !!value) .filter((value): value is string => !!value)
.some((value) => value.toLowerCase().includes(query)); .some((value) => value.toLowerCase().includes(query));
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import {
Actions, Actions,
@@ -38,9 +37,6 @@ export function groupMessagesByRoom(messages: Message[]): Map<string, Message[]>
@Injectable() @Injectable()
export class NotificationsEffects { export class NotificationsEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly notifications = inject(NotificationsFacade);
syncRoomCatalog$ = createEffect( syncRoomCatalog$ = createEffect(
() => () =>
@@ -136,4 +132,11 @@ export class NotificationsEffects {
), ),
{ dispatch: false } { dispatch: false }
); );
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly notifications = inject(NotificationsFacade);
} }

View File

@@ -1,14 +1,15 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { NotificationsService } from '../services/notifications.service'; import { NotificationsService } from '../services/notifications.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class NotificationsFacade { export class NotificationsFacade {
private readonly service = inject(NotificationsService);
readonly settings = this.service.settings; readonly settings = this.service.settings;
readonly unread = this.service.unread; readonly unread = this.service.unread;
private readonly service = inject(NotificationsService);
initialize( initialize(
...args: Parameters<NotificationsService['initialize']> ...args: Parameters<NotificationsService['initialize']>
): ReturnType<NotificationsService['initialize']> { ): ReturnType<NotificationsService['initialize']> {
@@ -98,4 +99,5 @@ export class NotificationsFacade {
): ReturnType<NotificationsService['setChannelMuted']> { ): ReturnType<NotificationsService['setChannelMuted']> {
return this.service.setChannelMuted(...args); return this.service.setChannelMuted(...args);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -46,33 +45,54 @@ const MAX_NOTIFIED_MESSAGE_IDS = 500;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class NotificationsService { export class NotificationsService {
readonly settings = computed(() => this._settings());
readonly unread = computed(() => this._unread());
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly db = inject(DatabaseService); private readonly db = inject(DatabaseService);
private readonly audio = inject(NotificationAudioService); private readonly audio = inject(NotificationAudioService);
private readonly appI18n = inject(AppI18nService); private readonly appI18n = inject(AppI18nService);
private readonly timeSync = inject(TimeSyncService); private readonly timeSync = inject(TimeSyncService);
private readonly desktopNotifications = inject(DesktopNotificationService); private readonly desktopNotifications = inject(DesktopNotificationService);
private readonly storage = inject(NotificationSettingsStorageService); private readonly storage = inject(NotificationSettingsStorageService);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly savedRooms = this.store.selectSignal(selectSavedRooms); private readonly savedRooms = this.store.selectSignal(selectSavedRooms);
private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly _settings = signal<NotificationsSettings>(createDefaultNotificationSettings()); private readonly _settings = signal<NotificationsSettings>(createDefaultNotificationSettings());
private readonly _unread = signal<NotificationsUnreadState>(createEmptyUnreadState());
private readonly _windowFocused = signal<boolean>(typeof document === 'undefined' ? true : document.hasFocus());
private readonly _documentVisible = signal<boolean>(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
private readonly _windowMinimized = signal<boolean>(false);
private readonly platformKind = detectPlatform();
private readonly notifiedMessageIds = new Set<string>();
private readonly notifiedMessageOrder: string[] = [];
private attentionActive = false;
private windowStateCleanup: (() => void) | null = null;
private initialised = false;
readonly settings = computed(() => this._settings()); private readonly _unread = signal<NotificationsUnreadState>(createEmptyUnreadState());
readonly unread = computed(() => this._unread());
private readonly _windowFocused = signal<boolean>(typeof document === 'undefined' ? true : document.hasFocus());
private readonly _documentVisible = signal<boolean>(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
private readonly _windowMinimized = signal<boolean>(false);
private readonly platformKind = detectPlatform();
private readonly notifiedMessageIds = new Set<string>();
private readonly notifiedMessageOrder: string[] = [];
private attentionActive = false;
private windowStateCleanup: (() => void) | null = null;
private initialised = false;
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.initialised) { 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 => { private readonly handleWindowFocus = (): void => {
this._windowFocused.set(true); this._windowFocused.set(true);
this._windowMinimized.set(false); this._windowMinimized.set(false);
@@ -359,6 +355,30 @@ export class NotificationsService {
this.syncWindowAttention(); 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 { private buildContext(): NotificationDeliveryContext {
return { return {
activeChannelId: this.activeChannelId(), activeChannelId: this.activeChannelId(),
@@ -625,6 +645,7 @@ export class NotificationsService {
} }
} }
} }
} }
function detectPlatform(): DesktopPlatform { function detectPlatform(): DesktopPlatform {

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -37,15 +36,20 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../../core/i18n';
templateUrl: './notifications-settings.component.html' templateUrl: './notifications-settings.component.html'
}) })
export class NotificationsSettingsComponent { export class NotificationsSettingsComponent {
private readonly store = inject(Store);
readonly notifications = inject(NotificationsFacade); readonly notifications = inject(NotificationsFacade);
readonly rooms = this.store.selectSignal(selectSavedRooms); readonly rooms = this.store.selectSignal(selectSavedRooms);
readonly settings = this.notifications.settings; readonly settings = this.notifications.settings;
readonly enabled = computed(() => this.settings().enabled); readonly enabled = computed(() => this.settings().enabled);
readonly showPreview = computed(() => this.settings().showPreview); readonly showPreview = computed(() => this.settings().showPreview);
readonly respectBusyStatus = computed(() => this.settings().respectBusyStatus); readonly respectBusyStatus = computed(() => this.settings().respectBusyStatus);
private readonly store = inject(Store);
trackRoom = (_index: number, room: Room) => room.id; trackRoom = (_index: number, room: Room) => room.id;
textChannels(room: Room) { textChannels(room: Room) {
@@ -112,4 +116,5 @@ export class NotificationsSettingsComponent {
formatUnreadCount(count: number): string { formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count); return count > 99 ? '99+' : String(count);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
DestroyRef, DestroyRef,
Injectable, Injectable,
@@ -43,27 +42,15 @@ export interface PluginRequirementComparison {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PluginRequirementStateService { 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<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
readonly currentSnapshot = computed(() => { readonly currentSnapshot = computed(() => {
const roomId = this.currentRoomId(); const roomId = this.currentRoomId();
return roomId ? this.snapshotsSignal()[roomId] ?? null : null; return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
}); });
readonly refreshErrors = this.refreshErrorsSignal.asReadonly(); readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
readonly missingInstallableRequirements = computed(() => { readonly missingInstallableRequirements = computed(() => {
if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) { if (!this.pluginStore.serverInstalledPluginsReadyForCurrentRoom()) {
return []; return [];
@@ -79,11 +66,14 @@ export class PluginRequirementStateService {
return requirements; return requirements;
}); });
readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements() readonly missingRequiredRequirements = computed(() => this.missingInstallableRequirements()
.filter((requirement) => requirement.status === 'required')); .filter((requirement) => requirement.status === 'required'));
readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements() readonly visibleOptionalRequirements = computed(() => this.missingInstallableRequirements()
.filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended') .filter((requirement) => requirement.status === 'optional' || requirement.status === 'recommended')
.filter((requirement) => !this.isOptionalRequirementDismissed(requirement))); .filter((requirement) => !this.isOptionalRequirementDismissed(requirement)));
readonly comparisons = computed<PluginRequirementComparison[]>(() => { readonly comparisons = computed<PluginRequirementComparison[]>(() => {
const snapshot = this.currentSnapshot(); const snapshot = this.currentSnapshot();
const installedEntries = this.registry.entries(); const installedEntries = this.registry.entries();
@@ -115,6 +105,32 @@ export class PluginRequirementStateService {
return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId)); 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<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
private readonly sessionDismissedOptionalSignal = signal<Record<string, string[]>>({});
private readonly hiddenOptionalSignal = signal<RequirementDismissalState>(loadRequirementDismissals());
constructor() { constructor() {
this.realtime.onSignalingMessage this.realtime.onSignalingMessage
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -268,6 +284,7 @@ export class PluginRequirementStateService {
return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt; return typeof hiddenAt === 'number' && hiddenAt >= requirement.updatedAt;
} }
} }
function loadRequirementDismissals(): RequirementDismissalState { function loadRequirementDismissals(): RequirementDismissalState {

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
DestroyRef, DestroyRef,
Injectable, Injectable,
@@ -70,52 +69,27 @@ interface ServerInstalledPluginsLoadState {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PluginStoreService { 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<string[]>([]);
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsLoadStateSignal = signal<ServerInstalledPluginsLoadState>({
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 sourceUrls = this.sourceUrlsSignal.asReadonly();
readonly sources = this.sourcesSignal.asReadonly(); readonly sources = this.sourcesSignal.asReadonly();
readonly installedPlugins = computed(() => { readonly installedPlugins = computed(() => {
const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal()); const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal());
return installedPlugins.sort(sortInstalledPlugins); return installedPlugins.sort(sortInstalledPlugins);
}); });
readonly isLoading = this.loadingSignal.asReadonly(); readonly isLoading = this.loadingSignal.asReadonly();
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins)); readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.()); readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin]))); readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device'); readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
readonly serverInstalledPluginsReadyForCurrentRoom = computed(() => { readonly serverInstalledPluginsReadyForCurrentRoom = computed(() => {
const roomId = this.currentRoomId?.() ?? null; const roomId = this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId(); const actorUserId = this.currentActorUserId();
@@ -132,6 +106,67 @@ export class PluginStoreService {
&& loadState.actorUserId === actorUserId; && 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<string[]>([]);
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsLoadStateSignal = signal<ServerInstalledPluginsLoadState>({
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() { constructor() {
const state = this.loadState(); const state = this.loadState();
@@ -243,73 +278,6 @@ export class PluginStoreService {
return installedPlugin; 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<void> {
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<void> {
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<TojuPluginManifest> { async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
if (!plugin.installUrl) { if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL'); throw new Error('Plugin does not provide an install manifest URL');
@@ -478,6 +446,73 @@ export class PluginStoreService {
return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope(); 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<void> {
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<void> {
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<PluginStoreSourceResult> { private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
try { try {
const sourceValue = await this.fetchJson(sourceUrl, signal); const sourceValue = await this.fetchJson(sourceUrl, signal);
@@ -1068,6 +1103,7 @@ export class PluginStoreService {
return null; return null;
} }
} }
function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } { function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } {

View File

@@ -3,11 +3,7 @@ import {
expect, expect,
it it
} from 'vitest'; } from 'vitest';
import { import { collectPluginReadRoots, fileUrlToPath } from './plugin-local-file.rules';
collectPluginReadRoots,
fileUrlToPath,
pluginFileParentDir
} from './plugin-local-file.rules';
describe('plugin-local-file.rules', () => { describe('plugin-local-file.rules', () => {
it('resolves linux file URLs to absolute paths', () => { it('resolves linux file URLs to absolute paths', () => {

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
HostListener, HostListener,
@@ -33,14 +32,17 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
export class PluginActionMenuComponent { export class PluginActionMenuComponent {
readonly closed = output<undefined>(); readonly closed = output<undefined>();
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()] readonly actions = computed(() => [...this.pluginUi.toolbarActionRecords()]
.sort((left, right) => this.sortActionRecords(left, right))); .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') @HostListener('document:keydown.escape')
close(): void { close(): void {
this.closed.emit(undefined); this.closed.emit(undefined);
@@ -95,6 +97,7 @@ export class PluginActionMenuComponent {
return left.contribution.label.localeCompare(right.contribution.label); return left.contribution.label.localeCompare(right.contribution.label);
} }
} }
function createInitials(pluginName: string, actionLabel: string): string { function createInitials(pluginName: string, actionLabel: string): string {

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<section <section
class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0" class="flex min-h-full flex-col bg-background text-foreground md:h-full md:min-h-0"
data-testid="plugin-manager" data-testid="plugin-manager"

View File

@@ -63,46 +63,64 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
}) })
export class PluginManagerComponent { export class PluginManagerComponent {
@Output() readonly closed = new EventEmitter<void>(); @Output() readonly closed = new EventEmitter<void>();
@Output() readonly storeOpened = new EventEmitter<void>(); @Output() readonly storeOpened = new EventEmitter<void>();
readonly scope = input<TojuPluginInstallScope>('client'); readonly scope = input<TojuPluginInstallScope>('client');
readonly capabilities = inject(PluginCapabilityService); readonly capabilities = inject(PluginCapabilityService);
readonly host = inject(PluginHostService); readonly host = inject(PluginHostService);
readonly logger = inject(PluginLoggerService); readonly logger = inject(PluginLoggerService);
readonly registry = inject(PluginRegistryService); readonly registry = inject(PluginRegistryService);
readonly requirementState = inject(PluginRequirementStateService); readonly requirementState = inject(PluginRequirementStateService);
readonly router = inject(Router); readonly router = inject(Router);
readonly uiRegistry = inject(PluginUiRegistryService); readonly uiRegistry = inject(PluginUiRegistryService);
private readonly appI18n = inject(AppI18nService);
readonly activeTab = signal<PluginManagerTab>('installed'); readonly activeTab = signal<PluginManagerTab>('installed');
readonly busyPluginId = signal<string | null>(null); readonly busyPluginId = signal<string | null>(null);
readonly busyAll = signal(false); readonly busyAll = signal(false);
readonly selectedPluginId = signal<string | null>(null); readonly selectedPluginId = signal<string | null>(null);
readonly allEntries = this.registry.entries; readonly allEntries = this.registry.entries;
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry))); readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
readonly managerTitle = computed(() => this.scope() === 'server' readonly managerTitle = computed(() => this.scope() === 'server'
? this.appI18n.instant('plugins.manager.serverTitle') ? this.appI18n.instant('plugins.manager.serverTitle')
: this.appI18n.instant('plugins.manager.clientTitle')); : this.appI18n.instant('plugins.manager.clientTitle'));
readonly managerDescription = computed(() => this.scope() === 'server' readonly managerDescription = computed(() => this.scope() === 'server'
? this.appI18n.instant('plugins.manager.serverDescription') ? this.appI18n.instant('plugins.manager.serverDescription')
: this.appI18n.instant('plugins.manager.clientDescription')); : this.appI18n.instant('plugins.manager.clientDescription'));
readonly selectedPlugin = computed(() => { readonly selectedPlugin = computed(() => {
const selectedPluginId = this.selectedPluginId(); const selectedPluginId = this.selectedPluginId();
return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null; return this.entries().find((entry) => entry.manifest.id === selectedPluginId) ?? this.entries()[0] ?? null;
}); });
readonly missingCapabilities = computed(() => { readonly missingCapabilities = computed(() => {
const selectedPlugin = this.selectedPlugin(); const selectedPlugin = this.selectedPlugin();
return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : []; return selectedPlugin ? this.capabilities.missing(selectedPlugin.manifest) : [];
}); });
readonly selectedLogs = computed(() => { readonly selectedLogs = computed(() => {
const selectedPlugin = this.selectedPlugin(); const selectedPlugin = this.selectedPlugin();
return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id) return selectedPlugin ? this.logger.entries().filter((entry) => entry.pluginId === selectedPlugin.manifest.id)
.slice(-20) : []; .slice(-20) : [];
}); });
readonly extensionCounts = computed(() => ({ readonly extensionCounts = computed(() => ({
appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length, appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
channelSections: this.uiRegistry.channelSectionRecords().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, slashCommands: this.uiRegistry.slashCommandRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
})); }));
readonly extensionCountItems = computed(() => { readonly extensionCountItems = computed(() => {
const counts = this.extensionCounts(); const counts = this.extensionCounts();
@@ -129,15 +148,20 @@ export class PluginManagerComponent {
{ label: this.appI18n.instant('plugins.manager.extensionCounts.embedRenderers'), value: counts.embeds } { label: this.appI18n.instant('plugins.manager.extensionCounts.embedRenderers'), value: counts.embeds }
]; ];
}); });
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []); readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
readonly uiConflicts = computed(() => this.uiRegistry.conflicts() readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId)))); .filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
readonly selectedRequirement = computed(() => { readonly selectedRequirement = computed(() => {
const selectedPlugin = this.selectedPlugin(); const selectedPlugin = this.selectedPlugin();
return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null; return selectedPlugin ? this.requirementState.comparisonFor(selectedPlugin.manifest.id) : null;
}); });
readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null); readonly selectedSettingsSchema = computed(() => this.selectedPlugin()?.manifest.settings ?? null);
readonly selectedSettingsPages = computed(() => { readonly selectedSettingsPages = computed(() => {
const selectedPlugin = this.selectedPlugin(); const selectedPlugin = this.selectedPlugin();
@@ -145,12 +169,15 @@ export class PluginManagerComponent {
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id) ? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
: []; : [];
}); });
readonly emptyTitle = computed(() => this.scope() === 'server' readonly emptyTitle = computed(() => this.scope() === 'server'
? this.appI18n.instant('plugins.manager.empty.serverTitle') ? this.appI18n.instant('plugins.manager.empty.serverTitle')
: this.appI18n.instant('plugins.manager.empty.clientTitle')); : this.appI18n.instant('plugins.manager.empty.clientTitle'));
readonly emptyBody = computed(() => this.scope() === 'server' readonly emptyBody = computed(() => this.scope() === 'server'
? this.appI18n.instant('plugins.manager.empty.serverBody') ? this.appI18n.instant('plugins.manager.empty.serverBody')
: this.appI18n.instant('plugins.manager.empty.clientBody')); : this.appI18n.instant('plugins.manager.empty.clientBody'));
readonly selectedDocs = computed(() => { readonly selectedDocs = computed(() => {
const manifest = this.selectedPlugin()?.manifest; 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); ].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 { setTab(tab: PluginManagerTab): void {
this.activeTab.set(tab); this.activeTab.set(tab);
} }
@@ -265,4 +294,5 @@ export class PluginManagerComponent {
private hasVisiblePlugin(pluginId: string): boolean { private hasVisiblePlugin(pluginId: string): boolean {
return this.entries().some((entry) => entry.manifest.id === pluginId); return this.entries().some((entry) => entry.manifest.id === pluginId);
} }
} }

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc --> <!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<main <main
class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6" class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6"
data-testid="plugin-store-page" data-testid="plugin-store-page"

View File

@@ -93,12 +93,17 @@ interface ServerPluginInstallDialog {
}) })
export class PluginStoreComponent implements OnInit { export class PluginStoreComponent implements OnInit {
readonly store = inject(PluginStoreService); readonly store = inject(PluginStoreService);
readonly capabilities = inject(PluginCapabilityService); readonly capabilities = inject(PluginCapabilityService);
private readonly appI18n = inject(AppI18nService);
readonly ngrxStore = inject(NgRxStore); readonly ngrxStore = inject(NgRxStore);
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms); readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom); readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser); readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
readonly manageableServers = computed(() => { readonly manageableServers = computed(() => {
const user = this.currentUser(); const user = this.currentUser();
@@ -116,8 +121,11 @@ export class PluginStoreComponent implements OnInit {
return Array.from(roomsById.values()) return Array.from(roomsById.values())
.filter((room) => this.canManageServerPlugins(room, user)); .filter((room) => this.canManageServerPlugins(room, user));
}); });
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error)); readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id))); readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
readonly filteredPlugins = computed(() => { readonly filteredPlugins = computed(() => {
const searchTerm = this.debouncedSearchTerm().trim() const searchTerm = this.debouncedSearchTerm().trim()
.toLowerCase(); .toLowerCase();
@@ -134,19 +142,25 @@ export class PluginStoreComponent implements OnInit {
return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm)); return plugins.filter((plugin) => this.matchesSearch(plugin, searchTerm));
}); });
readonly installedCount = computed(() => this.store.installedPlugins().length); readonly installedCount = computed(() => this.store.installedPlugins().length);
readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length); readonly totalSourcePlugins = computed(() => this.store.availablePlugins().length);
readonly sourceCount = computed(() => this.store.sourceUrls().length); readonly sourceCount = computed(() => this.store.sourceUrls().length);
readonly pendingSourceUrls = computed(() => { readonly pendingSourceUrls = computed(() => {
const loadedUrls = new Set(this.store.sources().map((source) => source.url)); const loadedUrls = new Set(this.store.sources().map((source) => source.url));
return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl)); return this.store.sourceUrls().filter((sourceUrl) => !loadedUrls.has(sourceUrl));
}); });
readonly selectedReadmePlugin = computed(() => { readonly selectedReadmePlugin = computed(() => {
const readme = this.readme(); const readme = this.readme();
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null; return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
}); });
readonly selectedStoreServer = computed(() => { readonly selectedStoreServer = computed(() => {
const selectedServerId = this.selectedStoreServerId(); const selectedServerId = this.selectedStoreServerId();
@@ -154,23 +168,41 @@ export class PluginStoreComponent implements OnInit {
}); });
newSourceUrl = ''; newSourceUrl = '';
readonly searchTerm = signal(''); readonly searchTerm = signal('');
readonly selectedSourceUrl = signal<string | null>(null); readonly selectedSourceUrl = signal<string | null>(null);
readonly selectedStoreServerId = signal<string | null>(null); readonly selectedStoreServerId = signal<string | null>(null);
readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]); readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]);
readonly showInstalledOnly = signal(false); readonly showInstalledOnly = signal(false);
readonly sourceError = signal<string | null>(null); readonly sourceError = signal<string | null>(null);
readonly actionError = signal<string | null>(null); readonly actionError = signal<string | null>(null);
readonly actionBusyPluginId = signal<string | null>(null); readonly actionBusyPluginId = signal<string | null>(null);
readonly readme = signal<PluginStoreReadme | null>(null); readonly readme = signal<PluginStoreReadme | null>(null);
readonly readmeRawMode = signal(false); readonly readmeRawMode = signal(false);
readonly readmeError = signal<string | null>(null); readonly readmeError = signal<string | null>(null);
readonly readmeLoadingPluginId = signal<string | null>(null); readonly readmeLoadingPluginId = signal<string | null>(null);
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null); readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set()); readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
readonly serverInstallOptional = signal(false); readonly serverInstallOptional = signal(false);
readonly serverInstallError = signal<string | null>(null); readonly serverInstallError = signal<string | null>(null);
readonly serverInstallBusy = signal(false); readonly serverInstallBusy = signal(false);
readonly brokenImageKeys = signal<Set<string>>(new Set()); readonly brokenImageKeys = signal<Set<string>>(new Set());
/** /**
@@ -183,12 +215,20 @@ export class PluginStoreComponent implements OnInit {
{ initialValue: '' } { initialValue: '' }
); );
private readonly appI18n = inject(AppI18nService);
private destroyed = false; private destroyed = false;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly externalLinks = inject(ExternalLinkService); private readonly externalLinks = inject(ExternalLinkService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly settingsModal = inject(SettingsModalService); private readonly settingsModal = inject(SettingsModalService);
private selectedServerLoadVersion = 0; private selectedServerLoadVersion = 0;
constructor() { constructor() {
@@ -644,6 +684,7 @@ export class PluginStoreComponent implements OnInit {
} }
} }
} }
} }
function comparePluginVersions(leftVersion: string, rightVersion: string): number { function comparePluginVersions(leftVersion: string, rightVersion: string): number {

View File

@@ -4,12 +4,13 @@ import type { LocalPluginDiscoveryResult, LocalPluginManifestDescriptor } from '
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LocalPluginDiscoveryService { export class LocalPluginDiscoveryService {
private readonly electronBridge = inject(ElectronBridgeService);
get isAvailable(): boolean { get isAvailable(): boolean {
return this.electronBridge.isAvailable; return this.electronBridge.isAvailable;
} }
private readonly electronBridge = inject(ElectronBridgeService);
async getPluginsPath(): Promise<string | null> { async getPluginsPath(): Promise<string | null> {
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();
@@ -43,4 +44,5 @@ export class LocalPluginDiscoveryService {
pluginsPath: result.pluginsPath pluginsPath: result.pluginsPath
}; };
} }
} }

View File

@@ -31,21 +31,27 @@ import {
templateUrl: './profile-avatar-editor.component.html' templateUrl: './profile-avatar-editor.component.html'
}) })
export class ProfileAvatarEditorComponent { export class ProfileAvatarEditorComponent {
private readonly appI18n = inject(AppI18nService);
private readonly avatar = inject(ProfileAvatarFacade);
readonly source = input.required<EditableProfileAvatarSource>(); readonly source = input.required<EditableProfileAvatarSource>();
readonly cancelled = output<undefined>(); readonly cancelled = output<undefined>();
readonly confirmed = output<ProcessedProfileAvatar>(); readonly confirmed = output<ProcessedProfileAvatar>();
readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE; readonly frameSize = PROFILE_AVATAR_EDITOR_FRAME_SIZE;
readonly processing = signal(false); readonly processing = signal(false);
readonly errorMessage = signal<string | null>(null); readonly errorMessage = signal<string | null>(null);
readonly preservesAnimation = computed(() => this.source().preservesAnimation); readonly preservesAnimation = computed(() => this.source().preservesAnimation);
readonly transform = signal<ProfileAvatarTransform>({ zoom: 1, readonly transform = signal<ProfileAvatarTransform>({ zoom: 1,
offsetX: 0, offsetX: 0,
offsetY: 0 }); offsetY: 0 });
readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform())); readonly clampedTransform = computed(() => clampProfileAvatarTransform(this.source(), this.transform()));
readonly imageTransform = computed(() => { readonly imageTransform = computed(() => {
const source = this.source(); const source = this.source();
const transform = this.clampedTransform(); const transform = this.clampedTransform();
@@ -54,7 +60,12 @@ export class ProfileAvatarEditorComponent {
return `translate(-50%, -50%) translate(${transform.offsetX}px, ${transform.offsetY}px) scale(${scale})`; 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 dragPointerId: number | null = null;
private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null; private dragOrigin: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
@@ -161,4 +172,5 @@ export class ProfileAvatarEditorComponent {
this.processing.set(false); this.processing.set(false);
} }
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { import {
Component, Component,
inject, inject,
@@ -51,51 +50,32 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
export class ScreenShareViewerComponent implements OnDestroy { export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>; @ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
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); onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeScreenSharer = signal<User | null>(null); activeScreenSharer = signal<User | null>(null);
// Track the userId we're currently watching (for detecting when they stop sharing)
private watchingUserId = signal<string | null>(null);
isFullscreen = signal(false); isFullscreen = signal(false);
hasStream = signal(false); hasStream = signal(false);
isLocalShare = signal(false); isLocalShare = signal(false);
screenVolume = signal(DEFAULT_VOLUME); 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<string | null>(null);
private streamSubscription: (() => void) | null = 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() { constructor() {
// React to screen share stream changes // 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
}
};
} }

View File

@@ -1,16 +1,19 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ServerDirectoryService } from '../services/server-directory.service'; import { ServerDirectoryService } from '../services/server-directory.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ServerDirectoryFacade { export class ServerDirectoryFacade {
private readonly service = inject(ServerDirectoryService);
readonly servers = this.service.servers; readonly servers = this.service.servers;
readonly activeServers = this.service.activeServers; readonly activeServers = this.service.activeServers;
readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers; readonly hasMissingDefaultServers = this.service.hasMissingDefaultServers;
readonly activeServer = this.service.activeServer; readonly activeServer = this.service.activeServer;
private readonly service = inject(ServerDirectoryService);
awaitInitialServerHealthCheck( awaitInitialServerHealthCheck(
...args: Parameters<ServerDirectoryService['awaitInitialServerHealthCheck']> ...args: Parameters<ServerDirectoryService['awaitInitialServerHealthCheck']>
): ReturnType<ServerDirectoryService['awaitInitialServerHealthCheck']> { ): ReturnType<ServerDirectoryService['awaitInitialServerHealthCheck']> {
@@ -238,4 +241,5 @@ export class ServerDirectoryFacade {
): ReturnType<ServerDirectoryService['sendHeartbeat']> { ): ReturnType<ServerDirectoryService['sendHeartbeat']> {
return this.service.sendHeartbeat(...args); return this.service.sendHeartbeat(...args);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
HostListener, HostListener,
@@ -50,34 +49,51 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-
} }
}) })
export class CreateServerDialogComponent { export class CreateServerDialogComponent {
private store = inject(Store);
private router = inject(Router);
private serverDirectory = inject(ServerDirectoryFacade);
readonly isMobile = inject(ViewportService).isMobile; readonly isMobile = inject(ViewportService).isMobile;
readonly created = output<undefined>(); readonly created = output<undefined>();
readonly cancelled = output<undefined>(); readonly cancelled = output<undefined>();
readonly categories = CATEGORY_PRESETS; readonly categories = CATEGORY_PRESETS;
activeEndpoints = this.serverDirectory.activeServers; activeEndpoints = this.serverDirectory.activeServers;
name = signal(''); name = signal('');
description = signal(''); description = signal('');
topic = signal(''); topic = signal('');
selectedCategoryId = signal<string | null>(null); selectedCategoryId = signal<string | null>(null);
isPrivate = signal(false); isPrivate = signal(false);
password = signal(''); password = signal('');
sourceId = signal(''); sourceId = signal('');
showAdvanced = signal(false); 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() { constructor() {
this.sourceId.set(this.activeEndpoints()[0]?.id ?? ''); this.sourceId.set(this.activeEndpoints()[0]?.id ?? '');
} }
/** True when the form has enough to create a server. */ @HostListener('document:keydown.escape')
get canCreate(): boolean { cancel(): void {
return this.name().trim().length > 0 && this.sourceId().length > 0; this.cancelled.emit(undefined);
} }
selectCategory(category: ServerCategoryPreset): void { selectCategory(category: ServerCategoryPreset): void {
@@ -95,11 +111,6 @@ export class CreateServerDialogComponent {
this.showAdvanced.update((shown) => !shown); this.showAdvanced.update((shown) => !shown);
} }
@HostListener('document:keydown.escape')
cancel(): void {
this.cancelled.emit(undefined);
}
create(): void { create(): void {
if (!this.canCreate) { if (!this.canCreate) {
return; return;
@@ -128,4 +139,5 @@ export class CreateServerDialogComponent {
this.created.emit(undefined); this.created.emit(undefined);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -59,32 +58,44 @@ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [
templateUrl: './create-server.component.html' templateUrl: './create-server.component.html'
}) })
export class CreateServerComponent implements OnInit { 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; readonly categories = CATEGORY_PRESETS;
activeEndpoints = this.serverDirectory.activeServers; activeEndpoints = this.serverDirectory.activeServers;
name = signal(''); name = signal('');
description = signal('');
topic = signal('');
selectedCategoryId = signal<string | null>(null);
isPrivate = signal(false);
password = signal('');
sourceId = '';
showAdvanced = signal(false);
ngOnInit(): void { description = signal('');
this.sourceId = this.activeEndpoints()[0]?.id ?? '';
} topic = signal('');
selectedCategoryId = signal<string | null>(null);
isPrivate = signal(false);
password = signal('');
sourceId = '';
showAdvanced = signal(false);
/** True when the form has enough to create a server. */ /** True when the form has enough to create a server. */
get canCreate(): boolean { get canCreate(): boolean {
return this.name().trim().length > 0 && this.sourceId.length > 0; 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 { selectCategory(category: ServerCategoryPreset): void {
if (this.selectedCategoryId() === category.id) { if (this.selectedCategoryId() === category.id) {
this.selectedCategoryId.set(null); this.selectedCategoryId.set(null);
@@ -133,4 +144,5 @@ export class CreateServerComponent implements OnInit {
}) })
); );
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -44,15 +43,16 @@ const RECENT_SERVER_LIMIT = 6;
} }
}) })
export class FindServersComponent implements OnInit { export class FindServersComponent implements OnInit {
private store = inject(Store);
private serverDirectory = inject(ServerDirectoryFacade);
private readonly i18n = inject(AppI18nService);
featured = signal<ServerInfo[]>([]); featured = signal<ServerInfo[]>([]);
trending = signal<ServerInfo[]>([]); trending = signal<ServerInfo[]>([]);
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
readonly searchPlaceholder = this.i18n.instant('servers.find.searchPlaceholder'); readonly searchPlaceholder = this.i18n.instant('servers.find.searchPlaceholder');
readonly emptyStateTitle = this.i18n.instant('servers.find.emptyTitle'); readonly emptyStateTitle = this.i18n.instant('servers.find.emptyTitle');
readonly emptyStateMessage = this.i18n.instant('servers.find.emptyMessage'); readonly emptyStateMessage = this.i18n.instant('servers.find.emptyMessage');
/** Discovery sections shown when the user is not actively searching. */ /** 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). */ /** True when there is nothing to recommend (a brand-new account). */
isNewUser = computed(() => this.discoverySections().length === 0); isNewUser = computed(() => this.discoverySections().length === 0);
private store = inject(Store);
private serverDirectory = inject(ServerDirectoryFacade);
private readonly i18n = inject(AppI18nService);
ngOnInit(): void { ngOnInit(): void {
this.serverDirectory.getFeaturedServers().subscribe((servers) => this.featured.set(servers)); this.serverDirectory.getFeaturedServers().subscribe((servers) => this.featured.set(servers));
this.serverDirectory.getTrendingServers().subscribe((servers) => this.trending.set(servers)); this.serverDirectory.getTrendingServers().subscribe((servers) => this.trending.set(servers));
@@ -120,4 +126,5 @@ export class FindServersComponent implements OnInit {
sourceUrl: room.sourceUrl sourceUrl: room.sourceUrl
}; };
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
effect, effect,
@@ -115,28 +114,19 @@ export interface ServerDiscoverySection {
templateUrl: './server-browser.component.html' templateUrl: './server-browser.component.html'
}) })
export class ServerBrowserComponent implements OnInit { 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<string>();
private banLookupRequestVersion = 0;
/** Discovery sections shown when the search query is empty. */ /** Discovery sections shown when the search query is empty. */
@Input() discoverySections: ServerDiscoverySection[] = []; @Input() discoverySections: ServerDiscoverySection[] = [];
/** Title for the onboarding empty state when there is nothing to show. */ /** Title for the onboarding empty state when there is nothing to show. */
@Input() emptyStateTitle?: string; @Input() emptyStateTitle?: string;
/** Supporting copy for the onboarding empty state. */ /** Supporting copy for the onboarding empty state. */
@Input() emptyStateMessage?: string; @Input() emptyStateMessage?: string;
/** Placeholder for the search input. */ /** Placeholder for the search input. */
@Input() searchPlaceholder?: string; @Input() searchPlaceholder?: string;
/** Whether the My Servers quick bar is shown. */ /** Whether the My Servers quick bar is shown. */
@Input() showMyServers = true; @Input() showMyServers = true;
@@ -152,6 +142,95 @@ export class ServerBrowserComponent implements OnInit {
return this.searchPlaceholder ?? this.i18n.instant('servers.browser.search.placeholder'); 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<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptServer = signal<ServerInfo | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
joinErrorMessage = signal<string | null>(null);
joinedServerMenuId = signal<string | null>(null);
leaveDialogRoom = signal<Room | null>(null);
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
selectedOptionalPluginIds = signal<Set<string>>(new Set());
pluginConsentBusy = signal(false);
pluginConsentError = signal<string | null>(null);
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
pluginConsentReadmeLoadingId = signal<string | null>(null);
pluginConsentReadmeError = signal<string | null>(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<string>();
private banLookupRequestVersion = 0;
serverCardTitle(server: ServerInfo): string { serverCardTitle(server: ServerInfo): string {
return this.isJoinedServer(server) return this.isJoinedServer(server)
? this.i18n.instant('servers.browser.card.doubleClickOpen', { name: server.name }) ? 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'); : 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<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptServer = signal<ServerInfo | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
joinErrorMessage = signal<string | null>(null);
joinedServerMenuId = signal<string | null>(null);
leaveDialogRoom = signal<Room | null>(null);
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
selectedOptionalPluginIds = signal<Set<string>>(new Set());
pluginConsentBusy = signal(false);
pluginConsentError = signal<string | null>(null);
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
pluginConsentReadmeLoadingId = signal<string | null>(null);
pluginConsentReadmeError = signal<string | null>(null);
// The reactive effect is created in ngOnInit with an explicit injector so the // 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). // component can be instantiated outside a change-detection context (e.g. unit tests).
ngOnInit(): void { 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 { onSearchChange(query: string): void {
this.searchSubject.next(query); this.searchSubject.next(query);
} }
@@ -724,4 +763,5 @@ export class ServerBrowserComponent implements OnInit {
return hasRoomBanForUser(bans, currentUser, currentUserId); return hasRoomBanForUser(bans, currentUser, currentUserId);
} }
} }

View File

@@ -49,28 +49,45 @@ type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
}) })
export class ThemeSettingsComponent { export class ThemeSettingsComponent {
readonly modal = inject(SettingsModalService); readonly modal = inject(SettingsModalService);
readonly theme = inject(ThemeService); readonly theme = inject(ThemeService);
readonly themeLibrary = inject(ThemeLibraryService); readonly themeLibrary = inject(ThemeLibraryService);
readonly registry = inject(ThemeRegistryService); readonly registry = inject(ThemeRegistryService);
readonly picker = inject(ElementPickerService); readonly picker = inject(ElementPickerService);
readonly layoutSync = inject(LayoutSyncService); readonly layoutSync = inject(LayoutSyncService);
private readonly appI18n = inject(AppI18nService);
readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('jsonEditorRef'); readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('jsonEditorRef');
readonly draftText = this.theme.draftText; readonly draftText = this.theme.draftText;
readonly draftErrors = this.theme.draftErrors; readonly draftErrors = this.theme.draftErrors;
readonly draftIsValid = this.theme.draftIsValid; readonly draftIsValid = this.theme.draftIsValid;
readonly statusMessage = this.theme.statusMessage; readonly statusMessage = this.theme.statusMessage;
readonly isDraftDirty = this.theme.isDraftDirty; readonly isDraftDirty = this.theme.isDraftDirty;
readonly isFullscreen = this.modal.themeStudioFullscreen; readonly isFullscreen = this.modal.themeStudioFullscreen;
readonly activeTheme = this.theme.activeTheme; readonly activeTheme = this.theme.activeTheme;
readonly builtInPresets = this.theme.builtInPresets; readonly builtInPresets = this.theme.builtInPresets;
readonly draftTheme = this.theme.draftTheme; readonly draftTheme = this.theme.draftTheme;
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS; readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
readonly animationKeys = this.theme.knownAnimationClasses; readonly animationKeys = this.theme.knownAnimationClasses;
readonly layoutContainers = this.layoutSync.containers(); readonly layoutContainers = this.layoutSync.containers();
readonly themeEntries = this.registry.entries(); readonly themeEntries = this.registry.entries();
readonly workspaceTabs = computed(() => [ readonly workspaceTabs = computed(() => [
{ {
key: 'editor' as const, key: 'editor' as const,
@@ -88,15 +105,23 @@ export class ThemeSettingsComponent {
description: this.appI18n.instant('theme.studio.workspaces.layout.description') description: this.appI18n.instant('theme.studio.workspaces.layout.description')
} }
]); ]);
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts()); readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor'); readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
readonly activeEditorTab = signal<ThemeEditorTab>('json'); readonly activeEditorTab = signal<ThemeEditorTab>('json');
readonly cssOnlyText = signal(''); readonly cssOnlyText = signal('');
readonly explorerQuery = signal(''); readonly explorerQuery = signal('');
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout'); readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
readonly selectedElementKey = signal<string>('chatRoomMainPanel'); readonly selectedElementKey = signal<string>('chatRoomMainPanel');
readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey())); readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey()));
readonly selectedElementCapabilities = computed(() => { readonly selectedElementCapabilities = computed(() => {
const selected = this.selectedElement(); const selected = this.selectedElement();
@@ -111,24 +136,31 @@ export class ThemeSettingsComponent {
selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null selected.supportsIcon ? this.appI18n.instant('theme.studio.capabilities.iconSlot') : null
].filter((value): value is string => value !== null); ].filter((value): value is string => value !== null);
}); });
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer())); readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
readonly selectedLayoutContainer = computed(() => { readonly selectedLayoutContainer = computed(() => {
return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0]; return this.layoutContainers.find((container) => container.key === this.selectedContainer()) ?? this.layoutContainers[0];
}); });
readonly selectedElementGrid = computed(() => { readonly selectedElementGrid = computed(() => {
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null; return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
}); });
readonly activeWorkspaceInfo = computed(() => { readonly activeWorkspaceInfo = computed(() => {
return this.workspaceTabs().find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs()[0]; return this.workspaceTabs().find((workspace) => workspace.key === this.activeWorkspace()) ?? this.workspaceTabs()[0];
}); });
readonly localizedFilteredEntries = computed(() => readonly localizedFilteredEntries = computed(() =>
this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry)) this.filteredEntries().map((entry) => this.localizeRegistryEntry(entry))
); );
readonly localizedSelectedElement = computed(() => { readonly localizedSelectedElement = computed(() => {
const selected = this.selectedElement(); const selected = this.selectedElement();
return selected ? this.localizeRegistryEntry(selected) : null; return selected ? this.localizeRegistryEntry(selected) : null;
}); });
readonly visiblePropertyHints = computed(() => { readonly visiblePropertyHints = computed(() => {
const selected = this.selectedElement(); const selected = this.selectedElement();
@@ -148,9 +180,11 @@ export class ThemeSettingsComponent {
return true; return true;
}); });
}); });
readonly mountedEntries = computed(() => { readonly mountedEntries = computed(() => {
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable); return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
}); });
readonly filteredEntries = computed(() => { readonly filteredEntries = computed(() => {
const query = this.explorerQuery().trim() const query = this.explorerQuery().trim()
.toLowerCase(); .toLowerCase();
@@ -165,17 +199,29 @@ export class ThemeSettingsComponent {
return haystack.includes(query); return haystack.includes(query);
}); });
}); });
readonly draftLineCount = computed(() => this.draftText().split('\n').length); readonly draftLineCount = computed(() => this.draftText().split('\n').length);
readonly draftCharacterCount = computed(() => this.draftText().length); readonly draftCharacterCount = computed(() => this.draftText().length);
readonly draftErrorCount = computed(() => this.draftErrors().length); readonly draftErrorCount = computed(() => this.draftErrors().length);
readonly mountedEntryCount = computed(() => this.mountedEntries().length); readonly mountedEntryCount = computed(() => this.mountedEntries().length);
readonly llmGuideCopyMessage = signal<string | null>(null); readonly llmGuideCopyMessage = signal<string | null>(null);
readonly savedThemesAvailable = this.themeLibrary.isAvailable; readonly savedThemesAvailable = this.themeLibrary.isAvailable;
readonly savedThemes = this.themeLibrary.entries; readonly savedThemes = this.themeLibrary.entries;
readonly savedThemesBusy = this.themeLibrary.isBusy; readonly savedThemesBusy = this.themeLibrary.isBusy;
readonly savedThemesPath = this.themeLibrary.savedThemesPath; readonly savedThemesPath = this.themeLibrary.savedThemesPath;
readonly selectedSavedTheme = this.themeLibrary.selectedEntry; readonly selectedSavedTheme = this.themeLibrary.selectedEntry;
private readonly appI18n = inject(AppI18nService);
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null; private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
@@ -485,6 +531,26 @@ export class ThemeSettingsComponent {
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0; 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 { private focusEditor(): void {
this.withEditorReady((editor) => { this.withEditorReady((editor) => {
editor.focus(); editor.focus();
@@ -815,26 +881,6 @@ export class ThemeSettingsComponent {
}, 4000); }, 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 { private localizeRegistryEntry(entry: ThemeRegistryEntry): ThemeRegistryEntry {
return { return {
...entry, ...entry,
@@ -853,4 +899,5 @@ export class ThemeSettingsComponent {
return text.indexOf(`"${key}"`, sectionIndex); return text.indexOf(`"${key}"`, sectionIndex);
} }
} }

View File

@@ -49,12 +49,13 @@ async function withTimeout<T>(operation: Promise<T>, label: string): Promise<T>
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ThemeLibraryStorageService { export class ThemeLibraryStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
get isAvailable(): boolean { get isAvailable(): boolean {
return this.electronBridge.isAvailable; return this.electronBridge.isAvailable;
} }
private readonly electronBridge = inject(ElectronBridgeService);
async getSavedThemesPath(): Promise<string | null> { async getSavedThemesPath(): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
@@ -218,4 +219,5 @@ export class ThemeLibraryStorageService {
}; };
} }
} }
} }

View File

@@ -27,8 +27,6 @@ import {
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { VoiceConnectionFacade } from '../facades/voice-connection.facade'; import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
import { DebuggingService } from '../../../../core/services/debugging.service'; 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 SPEAKING_THRESHOLD = 0.015;
const SILENT_FRAME_GRACE = 8; const SILENT_FRAME_GRACE = 8;
const FFT_SIZE = 256; const FFT_SIZE = 256;
@@ -46,15 +44,20 @@ interface TrackedStream {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoiceActivityService implements OnDestroy { export class VoiceActivityService implements OnDestroy {
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
private readonly voiceConnection = inject(VoiceConnectionFacade); private readonly voiceConnection = inject(VoiceConnectionFacade);
private readonly debugging = inject(DebuggingService); private readonly debugging = inject(DebuggingService);
private readonly tracked = new Map<string, TrackedStream>(); private readonly tracked = new Map<string, TrackedStream>();
private animFrameId: number | null = null;
private readonly subs: Subscription[] = [];
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap; private animFrameId: number | null = null;
private readonly subs: Subscription[] = [];
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
constructor() { constructor() {
this.subs.push( this.subs.push(
@@ -176,30 +179,11 @@ export class VoiceActivityService implements OnDestroy {
this.stopPolling(); this.stopPolling();
} }
private ensureAllRemoteStreamsTracked(): void { ngOnDestroy(): void {
const peers = this.voiceConnection.getConnectedPeers(); this.stopPolling();
this.tracked.forEach((entry) => this.disposeEntry(entry));
for (const peerId of peers) { this.tracked.clear();
const stream = this.voiceConnection.getRemoteVoiceStream(peerId); this.subs.forEach((subscription) => subscription.unsubscribe());
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 poll = (): void => { private poll = (): void => {
@@ -212,8 +196,8 @@ export class VoiceActivityService implements OnDestroy {
let sumSquares = 0; let sumSquares = 0;
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) { for (const sample of dataArray) {
const normalised = (dataArray[sampleIndex] - 128) / 128; const normalised = (sample - 128) / 128;
sumSquares += normalised * normalised; sumSquares += normalised * normalised;
} }
@@ -249,6 +233,32 @@ export class VoiceActivityService implements OnDestroy {
this.animFrameId = requestAnimationFrame(this.poll); 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 { private publishSpeakingMap(): void {
const map = new Map<string, boolean>(); const map = new Map<string, boolean>();
@@ -269,16 +279,18 @@ export class VoiceActivityService implements OnDestroy {
private disposeEntry(entry: TrackedStream): void { private disposeEntry(entry: TrackedStream): void {
entry.sources.forEach((source) => { 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());
}
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, */
import { import {
Injectable, Injectable,
signal, signal,
@@ -20,13 +19,6 @@ import type { VoiceSessionInfo } from '../../domain/models/voice-session.model';
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoiceSessionFacade { export class VoiceSessionFacade {
private readonly store = inject(Store);
/** Current voice session metadata, or `null` when disconnected. */
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
/** Whether the user is currently viewing the voice-connected server. */
private readonly _isViewingVoiceServer = signal<boolean>(true);
/** Reactive read-only voice session. */ /** Reactive read-only voice session. */
readonly voiceSession = computed(() => this._voiceSession()); readonly voiceSession = computed(() => this._voiceSession());
@@ -43,6 +35,14 @@ export class VoiceSessionFacade {
() => this._voiceSession() !== null && !this._isViewingVoiceServer() () => this._voiceSession() !== null && !this._isViewingVoiceServer()
); );
private readonly store = inject(Store);
/** Current voice session metadata, or `null` when disconnected. */
private readonly _voiceSession = signal<VoiceSessionInfo | null>(null);
/** Whether the user is currently viewing the voice-connected server. */
private readonly _isViewingVoiceServer = signal<boolean>(true);
/** /**
* Begin tracking a voice session. * Begin tracking a voice session.
* Called when the user joins a voice channel. * Called when the user joins a voice channel.
@@ -111,4 +111,5 @@ export class VoiceSessionFacade {
getVoiceServerId(): string | null { getVoiceServerId(): string | null {
return this._voiceSession()?.serverId ?? null; return this._voiceSession()?.serverId ?? null;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -23,15 +22,6 @@ const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class VoiceWorkspaceService { export class VoiceWorkspaceService {
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
private readonly _focusedStreamId = signal<string | null>(null);
private readonly _connectRemoteShares = signal(false);
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
DEFAULT_MINI_WINDOW_POSITION
);
private readonly _hasCustomMiniWindowPosition = signal(false);
readonly mode = computed<VoiceWorkspaceMode>(() => { readonly mode = computed<VoiceWorkspaceMode>(() => {
if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) { if (!this.voiceSession.voiceSession() || !this.voiceSession.isViewingVoiceServer()) {
@@ -42,15 +32,35 @@ export class VoiceWorkspaceService {
}); });
readonly isExpanded = computed(() => this.mode() === 'expanded'); readonly isExpanded = computed(() => this.mode() === 'expanded');
readonly isMinimized = computed(() => this.mode() === 'minimized'); readonly isMinimized = computed(() => this.mode() === 'minimized');
readonly isVisible = computed(() => this.mode() !== 'hidden'); readonly isVisible = computed(() => this.mode() !== 'hidden');
readonly focusedStreamId = computed(() => this._focusedStreamId()); readonly focusedStreamId = computed(() => this._focusedStreamId());
readonly shouldConnectRemoteShares = computed( readonly shouldConnectRemoteShares = computed(
() => this.isVisible() && this._connectRemoteShares() () => this.isVisible() && this._connectRemoteShares()
); );
readonly miniWindowPosition = computed(() => this._miniWindowPosition()); readonly miniWindowPosition = computed(() => this._miniWindowPosition());
readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition()); readonly hasCustomMiniWindowPosition = computed(() => this._hasCustomMiniWindowPosition());
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly _mode = signal<VoiceWorkspaceMode>('hidden');
private readonly _focusedStreamId = signal<string | null>(null);
private readonly _connectRemoteShares = signal(false);
private readonly _miniWindowPosition = signal<VoiceWorkspacePosition>(
DEFAULT_MINI_WINDOW_POSITION
);
private readonly _hasCustomMiniWindowPosition = signal(false);
constructor() { constructor() {
effect(() => { effect(() => {
if (this.voiceSession.voiceSession()) { if (this.voiceSession.voiceSession()) {
@@ -125,4 +135,5 @@ export class VoiceWorkspaceService {
this._connectRemoteShares.set(false); this._connectRemoteShares.set(false);
this.resetMiniWindowPosition(); this.resetMiniWindowPosition();
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import { import {
Component, Component,
inject, 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. * Provides mute, deafen, screen-share, and disconnect actions in a compact overlay.
*/ */
export class FloatingVoiceControlsComponent implements OnInit { 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; 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); currentUser = this.store.selectSignal(selectCurrentUser);
// Voice state from services // Voice state from services
showFloatingControls = this.voiceSessionService.showFloatingControls; showFloatingControls = this.voiceSessionService.showFloatingControls;
voiceSession = this.voiceSessionService.voiceSession; voiceSession = this.voiceSessionService.voiceSession;
isConnected = computed(() => this.webrtcService.isVoiceConnected()); isConnected = computed(() => this.webrtcService.isVoiceConnected());
isMuted = signal(false); isMuted = signal(false);
isDeafened = signal(false); isDeafened = signal(false);
isScreenSharing = this.screenShareService.isScreenSharing; isScreenSharing = this.screenShareService.isScreenSharing;
includeSystemAudio = signal(false); includeSystemAudio = signal(false);
screenShareQuality = signal<ScreenShareQuality>('balanced'); screenShareQuality = signal<ScreenShareQuality>('balanced');
askScreenShareQuality = signal(true); askScreenShareQuality = signal(true);
showScreenShareQualityDialog = signal(false); 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. */ /** Sync local mute/deafen state from the WebRTC service on init. */
ngOnInit(): void { ngOnInit(): void {
// Sync mute/deafen state from webrtc service // Sync mute/deafen state from webrtc service
@@ -300,8 +314,9 @@ export class FloatingVoiceControlsComponent implements OnInit {
includeSystemAudio: this.includeSystemAudio(), includeSystemAudio: this.includeSystemAudio(),
quality quality
}); });
} catch (_error) { } catch {
// Screen share request was denied or failed // Screen share request was denied or failed
} }
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, complexity */
import { import {
Component, Component,
ElementRef, ElementRef,
@@ -75,23 +74,15 @@ interface AudioDevice {
templateUrl: './voice-controls.component.html' templateUrl: './voice-controls.component.html'
}) })
export class VoiceControlsComponent implements OnInit, OnDestroy { 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); currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
isConnected = computed(() => this.webrtcService.isVoiceConnected()); isConnected = computed(() => this.webrtcService.isVoiceConnected());
showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError()); showConnectionError = computed(() => this.webrtcService.shouldShowConnectionError());
connectionErrorMessage = computed(() => { connectionErrorMessage = computed(() => {
const message = this.webrtcService.connectionErrorMessage(); const message = this.webrtcService.connectionErrorMessage();
@@ -101,12 +92,65 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
return this.appI18n.instant(message); return this.appI18n.instant(message);
}); });
isMuted = signal(false); isMuted = signal(false);
isDeafened = signal(false); isDeafened = signal(false);
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled()); isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
isScreenSharing = this.screenShareService.isScreenSharing; isScreenSharing = this.screenShareService.isScreenSharing;
showSettings = signal(false); showSettings = signal(false);
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>('');
inputVolume = signal(100);
outputVolume = signal(100);
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(true);
screenShareQuality = signal<ScreenShareQuality>('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 { toggleProfileCard(): void {
const user = this.currentUser(); 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 }); this.profileCard.open(this.hostEl.nativeElement, user, { placement: 'above', editable: true });
} }
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>('');
inputVolume = signal(100);
outputVolume = signal(100);
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(true);
screenShareQuality = signal<ScreenShareQuality>('balanced');
askScreenShareQuality = signal(true);
showScreenShareQualityDialog = signal(false);
private playbackOptions(): PlaybackOptions {
return {
isConnected: this.isConnected(),
outputVolume: this.outputVolume() / 100,
isDeafened: this.isDeafened()
};
}
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
await this.loadAudioDevices(); await this.loadAudioDevices();
@@ -166,7 +189,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.outputDevices.set( this.outputDevices.set(
devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label })) devices.filter((device) => device.kind === 'audiooutput').map((device) => ({ deviceId: device.deviceId, label: device.label }))
); );
} catch (_error) {} } catch { /* ignore device enumeration errors */ }
} }
async connect(): Promise<void> { async connect(): Promise<void> {
@@ -263,7 +286,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
async retryConnection(): Promise<void> { async retryConnection(): Promise<void> {
try { try {
await this.webrtcService.ensureSignalingConnected(10000); await this.webrtcService.ensureSignalingConnected(10000);
} catch (_error) {} } catch { /* ignore device enumeration errors */ }
} }
disconnect(): void { disconnect(): void {
@@ -438,7 +461,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}) })
); );
} }
} catch (_error) {} } catch { /* ignore device enumeration errors */ }
} }
async toggleScreenShare(): Promise<void> { async toggleScreenShare(): Promise<void> {
@@ -547,6 +570,58 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.saveSettings(); 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 { private loadSettings(): void {
const settings = loadVoiceSettingsFromStorage(); const settings = loadVoiceSettingsFromStorage();
@@ -614,50 +689,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
includeSystemAudio: this.includeSystemAudio(), includeSystemAudio: this.includeSystemAudio(),
quality 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`;
}
} }

View File

@@ -104,11 +104,11 @@ function schedulePersist(settings: VoiceSettings): void {
timeRemaining(): number; timeRemaining(): number;
} }
type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle; type IdleRequest = (cb: (deadline: IdleDeadline) => void, opts?: { timeout: number }) => IdleCallbackHandle;
interface MaybeIdleGlobal { interface IdleCallbackGlobal {
requestIdleCallback?: IdleRequest; requestIdleCallback?: IdleRequest;
} }
const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as MaybeIdleGlobal; const idleGlobal = (typeof globalThis === 'undefined' ? {} : globalThis) as IdleCallbackGlobal;
if (typeof idleGlobal.requestIdleCallback === 'function') { if (typeof idleGlobal.requestIdleCallback === 'function') {
idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 }); idleGlobal.requestIdleCallback(() => runner(), { timeout: 1000 });

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
ElementRef, ElementRef,
@@ -100,24 +99,22 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
} }
}) })
export class DashboardComponent implements OnInit { 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<string>();
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
searchQuery = signal(''); searchQuery = signal('');
serverResults = this.store.selectSignal(selectSearchResults); serverResults = this.store.selectSignal(selectSearchResults);
isSearching = this.store.selectSignal(selectIsSearching); isSearching = this.store.selectSignal(selectIsSearching);
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
popularServers = signal<ServerInfo[]>([]); popularServers = signal<ServerInfo[]>([]);
recentSearches = signal<string[]>(this.loadRecentSearches()); recentSearches = signal<string[]>(this.loadRecentSearches());
private users = this.store.selectSignal(selectAllUsers);
/** True while the user is actively typing a query. */ /** True while the user is actively typing a query. */
isSearchMode = computed(() => this.searchQuery().trim().length > 0); isSearchMode = computed(() => this.searchQuery().trim().length > 0);
@@ -125,37 +122,6 @@ export class DashboardComponent implements OnInit {
/** Server matches limited for the quick-search list. */ /** Server matches limited for the quick-search list. */
topServerResults = computed(() => this.serverResults().slice(0, QUICK_RESULT_LIMIT)); 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<User[]>(() => {
const currentKey = this.currentUserKey();
const byKey = new Map<string, User>();
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. */ /** People matches derived from known users and saved-room members. */
topPeopleResults = computed<User[]>(() => { topPeopleResults = computed<User[]>(() => {
const query = this.searchQuery().trim() 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. */ /** True for a brand-new account with no servers and no known people. */
isNewUser = computed(() => this.savedRooms().length === 0 && this.users().length === 0); 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<string>();
private readonly searchInputRef = viewChild<ElementRef<HTMLInputElement>>('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<User[]>(() => {
const currentKey = this.currentUserKey();
const byKey = new Map<string, User>();
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 { ngOnInit(): void {
this.store.dispatch(RoomsActions.loadRooms()); 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 { onSearchChange(query: string): void {
this.searchQuery.set(query); this.searchQuery.set(query);
this.searchSubject.next(query); this.searchSubject.next(query);
@@ -357,4 +372,5 @@ export class DashboardComponent implements OnInit {
.filter((value): value is string => typeof value === 'string') .filter((value): value is string => typeof value === 'string')
.some((value) => value.toLowerCase().includes(query)); .some((value) => value.toLowerCase().includes(query));
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
@@ -82,34 +81,29 @@ import { PrivateCallParticipantCardComponent } from './private-call-participant-
templateUrl: './private-call.component.html' templateUrl: './private-call.component.html'
}) })
export class PrivateCallComponent { 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 allUsers = this.store.selectSignal(selectAllUsers);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile()); readonly showSpeakerphoneButton = computed(() => this.mobilePlatform.isNativeMobile());
readonly speakerphoneEnabled = signal(true); readonly speakerphoneEnabled = signal(true);
readonly callIdInput = input<string | null>(null); readonly callIdInput = input<string | null>(null);
readonly overlayMode = input(false); readonly overlayMode = input(false);
readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), { readonly routeCallId = toSignal(this.route.paramMap.pipe(map((params) => params.get('callId'))), {
initialValue: this.route.snapshot.paramMap.get('callId') initialValue: this.route.snapshot.paramMap.get('callId')
}); });
readonly callId = computed(() => this.callIdInput() ?? this.routeCallId()); readonly callId = computed(() => this.callIdInput() ?? this.routeCallId());
readonly session = computed(() => this.calls.sessionById(this.callId())); readonly session = computed(() => this.calls.sessionById(this.callId()));
readonly participantUsers = computed(() => { readonly participantUsers = computed(() => {
const session = this.session(); const session = this.session();
@@ -121,25 +115,40 @@ export class PrivateCallComponent {
.map((participantId) => this.userForSessionParticipant(session, participantId)) .map((participantId) => this.userForSessionParticipant(session, participantId))
.filter((user): user is User => !!user); .filter((user): user is User => !!user);
}); });
readonly isConnected = computed(() => { readonly isConnected = computed(() => {
const session = this.session(); const session = this.session();
const currentUserId = this.currentUserKey(); const currentUserId = this.currentUserKey();
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined; return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined;
}); });
readonly isMuted = this.voice.isMuted; readonly isMuted = this.voice.isMuted;
readonly isDeafened = this.voice.isDeafened; readonly isDeafened = this.voice.isDeafened;
readonly isCameraEnabled = this.voice.isCameraEnabled; readonly isCameraEnabled = this.voice.isCameraEnabled;
readonly isScreenSharing = this.screenShare.isScreenSharing; readonly isScreenSharing = this.screenShare.isScreenSharing;
readonly remoteStreamRevision = signal(0); readonly remoteStreamRevision = signal(0);
readonly includeSystemAudio = signal(false); readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced'); readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
readonly askScreenShareQuality = signal(true); readonly askScreenShareQuality = signal(true);
readonly showScreenShareQualityDialog = signal(false); readonly showScreenShareQualityDialog = signal(false);
readonly inviteUserId = signal(''); readonly inviteUserId = signal('');
readonly focusedStreamId = signal<string | null>(null); readonly focusedStreamId = signal<string | null>(null);
readonly showAllStreamsMode = signal(false); readonly showAllStreamsMode = signal(false);
readonly chatWidthPx = signal(384); readonly chatWidthPx = signal(384);
readonly inviteCandidates = computed(() => { readonly inviteCandidates = computed(() => {
const participantIds = new Set(this.session()?.participantIds ?? []); const participantIds = new Set(this.session()?.participantIds ?? []);
const currentUserId = this.currentUserKey(); const currentUserId = this.currentUserKey();
@@ -150,6 +159,7 @@ export class PrivateCallComponent {
return userId !== currentUserId && !participantIds.has(userId); return userId !== currentUserId && !participantIds.has(userId);
}); });
}); });
readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => { readonly activeShares = computed<VoiceWorkspaceStreamItem[]>(() => {
this.remoteStreamRevision(); this.remoteStreamRevision();
@@ -193,8 +203,11 @@ export class PrivateCallComponent {
return shares; return shares;
}); });
readonly featuredShare = computed(() => this.activeShares()[0] ?? null); readonly featuredShare = computed(() => this.activeShares()[0] ?? null);
readonly hasMultipleShares = computed(() => this.activeShares().length > 1); readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
readonly focusedShareId = computed(() => { readonly focusedShareId = computed(() => {
const requested = this.focusedStreamId(); const requested = this.focusedStreamId();
const activeShares = this.activeShares(); const activeShares = this.activeShares();
@@ -213,7 +226,9 @@ export class PrivateCallComponent {
return null; return null;
}); });
readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null); readonly focusedShare = computed(() => this.activeShares().find((share) => share.id === this.focusedShareId()) ?? null);
readonly thumbnailShares = computed(() => { readonly thumbnailShares = computed(() => {
const focusedShareId = this.focusedShareId(); const focusedShareId = this.focusedShareId();
@@ -223,6 +238,37 @@ export class PrivateCallComponent {
return this.activeShares().filter((share) => share.id !== focusedShareId); 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() { constructor() {
effect(() => { effect(() => {
const callId = this.callId(); const callId = this.callId();
@@ -300,6 +346,8 @@ export class PrivateCallComponent {
this.chatResizing = false; this.chatResizing = false;
} }
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
async join(): Promise<void> { async join(): Promise<void> {
const session = this.session(); const session = this.session();
@@ -508,8 +556,6 @@ export class PrivateCallComponent {
return user.oderId || user.id; return user.oderId || user.id;
} }
readonly trackUserKey = (index: number, user: User): string => this.userKey(user);
private currentUserKey(): string { private currentUserKey(): string {
const user = this.currentUser(); const user = this.currentUser();
@@ -659,4 +705,5 @@ export class PrivateCallComponent {
private bumpRemoteStreamRevision(): void { private bumpRemoteStreamRevision(): void {
this.remoteStreamRevision.update((value) => value + 1); this.remoteStreamRevision.update((value) => value + 1);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
@@ -97,28 +96,25 @@ interface SwiperElement extends HTMLElement {
* remains the source of truth and stays in sync with the active slide. * remains the source of truth and stays in sync with the active slide.
*/ */
export class ChatRoomComponent { 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); showMenu = signal(false);
showAdminPanel = signal(false); showAdminPanel = signal(false);
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */ /** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
readonly mobilePage = signal<ChatRoomMobilePage>('channels'); readonly mobilePage = signal<ChatRoomMobilePage>('channels');
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl'); readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
textChannels = this.store.selectSignal(selectTextChannels); textChannels = this.store.selectSignal(selectTextChannels);
activeChannelId = this.store.selectSignal(selectActiveChannelId); activeChannelId = this.store.selectSignal(selectActiveChannelId);
/** /**
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane * Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
* with the selected channel name instead of the room name. * 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; return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null;
}); });
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
hasTextChannels = computed(() => this.textChannels().length > 0); hasTextChannels = computed(() => this.textChannels().length > 0);
activeCall = computed(() => { activeCall = computed(() => {
const currentSession = this.directCalls.currentSession(); const currentSession = this.directCalls.currentSession();
const visibleSessions = this.directCalls.visibleActiveSessions(); const visibleSessions = this.directCalls.visibleActiveSessions();
return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null; return visibleSessions.find((session) => session.callId === currentSession?.callId) ?? visibleSessions[0] ?? null;
}); });
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout')); roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel')); channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel')); mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel')); 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() { constructor() {
// When entering a server, always land on the channels list ("first page") on mobile, even // 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 // 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); this.settingsModal.open('server', room.id);
} }
} }
} }

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity --> <!-- eslint-disable @angular-eslint/template/button-has-type -->
<aside class="flex h-full min-h-0 flex-col bg-card"> <aside class="flex h-full min-h-0 flex-col bg-card">
<div <div
appThemeNode="roomPanelHeader" appThemeNode="roomPanelHeader"

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
DestroyRef, DestroyRef,
@@ -141,52 +140,53 @@ const SKELETON_REVEAL_DELAY_MS = 180;
templateUrl: './rooms-side-panel.component.html' templateUrl: './rooms-side-panel.component.html'
}) })
export class RoomsSidePanelComponent implements OnDestroy { export class RoomsSidePanelComponent implements OnDestroy {
private store = inject(Store);
private signalServerAuth = inject(SignalServerAuthService);
private router = inject(Router);
private realtime = inject(RealtimeSessionFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private screenShare = inject(ScreenShareFacade);
private notifications = inject(NotificationsFacade);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
private directCalls = inject(DirectCallService);
private profileCard = inject(ProfileCardService);
private directMessages = inject(DirectMessageService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly pluginActionMenu = inject(PluginActionMenuService);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly appI18n = inject(AppI18nService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
private readonly destroyRef = inject(DestroyRef);
readonly panelMode = input<PanelMode>('channels'); readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true); readonly showVoiceControls = input(true);
readonly textChannelSelected = output<string>(); readonly textChannelSelected = output<string>();
showFloatingControls = this.voiceSessionService.showFloatingControls; showFloatingControls = this.voiceSessionService.showFloatingControls;
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
onlineUsers = this.store.selectSignal(selectOnlineUsers); onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
isConnecting = this.store.selectSignal(selectIsConnecting); isConnecting = this.store.selectSignal(selectIsConnecting);
messagesLoading = this.store.selectSignal(selectMessagesLoading); messagesLoading = this.store.selectSignal(selectMessagesLoading);
activeChannelId = this.store.selectSignal(selectActiveChannelId); activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels); textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels);
pluginChannelSections = this.pluginUi.channelSectionRecords; pluginChannelSections = this.pluginUi.channelSectionRecords;
pluginMenuActions = this.pluginUi.toolbarActionRecords; pluginMenuActions = this.pluginUi.toolbarActionRecords;
pluginSidePanels = this.pluginUi.sidePanelRecords; pluginSidePanels = this.pluginUi.sidePanelRecords;
panelHydrating = computed(() => this.panelMode() === 'channels' && (this.isConnecting() || this.messagesLoading())); panelHydrating = computed(() => this.panelMode() === 'channels' && (this.isConnecting() || this.messagesLoading()));
delayedPanelHydrating = signal(false); delayedPanelHydrating = signal(false);
showTextChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.textChannels().length === 0); showTextChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.textChannels().length === 0);
showVoiceChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.voiceEnabled() && this.voiceChannels().length === 0); showVoiceChannelSkeleton = computed(() => this.delayedPanelHydrating() && this.voiceEnabled() && this.voiceChannels().length === 0);
showPluginSkeleton = computed(() => this.delayedPanelHydrating() && !this.hasPluginPanelContent()); showPluginSkeleton = computed(() => this.delayedPanelHydrating() && !this.hasPluginPanelContent());
localUserHasDesync = this.voiceConnectivity.localUserHasDesync; localUserHasDesync = this.voiceConnectivity.localUserHasDesync;
roomMembers = computed(() => this.currentRoom()?.members ?? []); roomMembers = computed(() => this.currentRoom()?.members ?? []);
roomMemberIdentifiers = computed(() => { roomMemberIdentifiers = computed(() => {
const identifiers = new Set<string>(); const identifiers = new Set<string>();
@@ -196,6 +196,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
return identifiers; return identifiers;
}); });
onlineRoomUsers = computed(() => { onlineRoomUsers = computed(() => {
const memberIdentifiers = this.roomMemberIdentifiers(); const memberIdentifiers = this.roomMemberIdentifiers();
const roomId = this.currentRoom()?.id; const roomId = this.currentRoom()?.id;
@@ -204,6 +205,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
(user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId) (user) => !this.isCurrentUserIdentity(user) && this.matchesIdentifiers(memberIdentifiers, user) && this.isUserPresentInRoom(user, roomId)
); );
}); });
offlineRoomMembers = computed(() => { offlineRoomMembers = computed(() => {
const onlineIdentifiers = new Set<string>(); const onlineIdentifiers = new Set<string>();
@@ -215,6 +217,7 @@ export class RoomsSidePanelComponent implements OnDestroy {
return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member)); return this.roomMembers().filter((member) => !this.matchesIdentifiers(onlineIdentifiers, member));
}); });
knownUserCount = computed(() => { knownUserCount = computed(() => {
const memberIds = new Set( const memberIds = new Set(
this.roomMembers() this.roomMembers()
@@ -230,36 +233,92 @@ export class RoomsSidePanelComponent implements OnDestroy {
return memberIds.size; return memberIds.size;
}); });
private hasPluginPanelContent(): boolean {
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
}
showChannelMenu = signal(false); showChannelMenu = signal(false);
channelMenuX = signal(0); channelMenuX = signal(0);
channelMenuY = signal(0); channelMenuY = signal(0);
contextChannel = signal<Channel | null>(null); contextChannel = signal<Channel | null>(null);
renamingChannelId = signal<string | null>(null); renamingChannelId = signal<string | null>(null);
channelNameError = signal<string | null>(null); channelNameError = signal<string | null>(null);
showCreateChannelDialog = signal(false); showCreateChannelDialog = signal(false);
createChannelType = signal<'text' | 'voice'>('text'); createChannelType = signal<'text' | 'voice'>('text');
newChannelName = ''; newChannelName = '';
showUserMenu = signal(false); showUserMenu = signal(false);
userMenuX = signal(0); userMenuX = signal(0);
userMenuY = signal(0); userMenuY = signal(0);
contextMenuUser = signal<User | null>(null); contextMenuUser = signal<User | null>(null);
showVolumeMenu = signal(false); showVolumeMenu = signal(false);
volumeMenuX = signal(0); volumeMenuX = signal(0);
volumeMenuY = signal(0); volumeMenuY = signal(0);
volumeMenuPeerId = signal(''); volumeMenuPeerId = signal('');
volumeMenuDisplayName = signal(''); volumeMenuDisplayName = signal('');
draggedVoiceUserId = signal<string | null>(null); draggedVoiceUserId = signal<string | null>(null);
dragTargetVoiceChannelId = signal<string | null>(null); dragTargetVoiceChannelId = signal<string | null>(null);
activityNow = signal(Date.now()); activityNow = signal(Date.now());
private store = inject(Store);
private signalServerAuth = inject(SignalServerAuthService);
private router = inject(Router);
private realtime = inject(RealtimeSessionFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private screenShare = inject(ScreenShareFacade);
private notifications = inject(NotificationsFacade);
private voiceSessionService = inject(VoiceSessionFacade);
private voiceWorkspace = inject(VoiceWorkspaceService);
private voicePlayback = inject(VoicePlaybackService);
private directCalls = inject(DirectCallService);
private profileCard = inject(ProfileCardService);
private directMessages = inject(DirectMessageService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly pluginActionMenu = inject(PluginActionMenuService);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private readonly pluginUi = inject(PluginUiRegistryService);
private readonly appI18n = inject(AppI18nService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
private skeletonRevealTimer: ReturnType<typeof setTimeout> | null = null;
private readonly destroyRef = inject(DestroyRef);
constructor() { constructor() {
effect(() => { effect(() => {
if (!this.panelHydrating()) { if (!this.panelHydrating()) {
@@ -292,15 +351,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.pluginActionMenu.close(); this.pluginActionMenu.close();
} }
private clearSkeletonRevealTimer(): void {
if (!this.skeletonRevealTimer) {
return;
}
clearTimeout(this.skeletonRevealTimer);
this.skeletonRevealTimer = null;
}
gameActivityElapsed(user: User | null | undefined): string { gameActivityElapsed(user: User | null | undefined): string {
const activity = user?.gameActivity; const activity = user?.gameActivity;
@@ -361,102 +411,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
await this.openDirectMessage(event, this.roomMemberToUser(member)); await this.openDirectMessage(event, this.roomMemberToUser(member));
} }
private roomMemberToUser(member: RoomMember): User {
return {
id: member.id,
oderId: member.oderId || member.id,
username: member.username,
displayName: member.displayName,
description: member.description,
profileUpdatedAt: member.profileUpdatedAt,
avatarUrl: member.avatarUrl,
avatarHash: member.avatarHash,
avatarMime: member.avatarMime,
avatarUpdatedAt: member.avatarUpdatedAt,
status: 'disconnected',
role: member.role,
joinedAt: member.joinedAt
};
}
private roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
if (!entity)
return;
if (entity.id) {
identifiers.add(entity.id);
}
if (entity.oderId) {
identifiers.add(entity.oderId);
}
}
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
}
private isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean {
if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) {
return true;
}
return entity.presenceServerIds.includes(roomId);
}
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
const current = this.currentUser();
if (!current) {
return false;
}
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
current,
this.currentRoom()?.sourceUrl
);
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
}
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
const current = this.currentUser();
if (!current) {
return;
}
this.addIdentifiers(identifiers, current);
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
current,
this.currentRoom()?.sourceUrl
)) {
identifiers.add(selfId);
}
}
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
this.cancelQueuedProfileCardOpen();
this.profileCardOpenTimer = setTimeout(() => {
this.profileCardOpenTimer = null;
this.profileCard.open(anchor, user, { placement: 'left', editable });
}, 180);
}
private cancelQueuedProfileCardOpen(): void {
if (!this.profileCardOpenTimer) {
return;
}
clearTimeout(this.profileCardOpenTimer);
this.profileCardOpenTimer = null;
}
hasConnectivityIssue(user: User): boolean { hasConnectivityIssue(user: User): boolean {
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id); return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
} }
@@ -643,25 +597,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
} }
} }
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
if (!name) {
return 'room.channel.nameRequired';
}
const channels = this.currentRoom()?.channels ?? [];
const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType();
if (!channelType) {
return null;
}
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
return 'room.channel.nameUnique';
}
return null;
}
openUserContextMenu(evt: MouseEvent, user: User) { openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault(); evt.preventDefault();
@@ -722,21 +657,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
} }
} }
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
if (
!room
|| !current?.voiceState?.isConnected
|| current.voiceState.roomId !== roomId
|| current.voiceState.serverId !== room.id
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
) {
return false;
}
this.voiceWorkspace.open(null, { connectRemoteShares: true });
return true;
}
isPassiveInVoiceRoom(roomId: string): boolean { isPassiveInVoiceRoom(roomId: string): boolean {
const current = this.currentUser(); const current = this.currentUser();
const room = this.currentRoom(); const room = this.currentRoom();
@@ -778,24 +698,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
return this.appI18n.instant('room.panel.joinVoiceChannel'); return this.appI18n.instant('room.panel.joinVoiceChannel');
} }
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
}
private prepareVoiceJoin(room: Room, current: User | null): void {
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
return;
}
this.disconnectCurrentVoiceTarget(current);
}
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
}
joinVoice(roomId: string) { joinVoice(roomId: string) {
const room = this.currentRoom(); const room = this.currentRoom();
const current = this.currentUser(); const current = this.currentUser();
@@ -833,94 +735,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
startJoin(); startJoin();
} }
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
this.voiceConnection.clearConnectionError();
this.updateVoiceStateStore(roomId, room, current);
this.trackCurrentUserMic();
this.startVoiceHeartbeat(roomId, room);
this.broadcastVoiceConnected(roomId, room, current);
this.startVoiceSession(roomId, room);
}
private handleVoiceJoinFailure(error: unknown): void {
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
this.voiceConnection.reportConnectionError(message);
}
private trackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
const micStream = this.voiceConnection.getRawMicStream();
if (userId && micStream) {
this.voiceActivity.trackLocalMic(userId, micStream);
}
}
private untrackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
}
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
if (!current?.id)
return;
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: true,
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id,
clientInstanceId: this.realtime.getClientInstanceId()
}
})
);
}
private startVoiceHeartbeat(roomId: string, room: Room): void {
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
}
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
const clientInstanceId = this.realtime.getClientInstanceId();
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id,
clientInstanceId
}
});
}
private startVoiceSession(roomId: string, room: Room): void {
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
}
leaveVoice(roomId: string) { leaveVoice(roomId: string) {
const current = this.currentUser(); const current = this.currentUser();
@@ -930,53 +744,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.disconnectCurrentVoiceTarget(current); this.disconnectCurrentVoiceTarget(current);
} }
private disconnectCurrentVoiceTarget(current: User | null): void {
const previousVoiceState = current?.voiceState;
this.voiceConnection.stopVoiceHeartbeat();
this.untrackCurrentUserMic();
this.voiceConnection.disableVoice();
if (current?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
clientInstanceId: undefined
}
})
);
this.store.dispatch(
UsersActions.updateCameraState({
userId: current.id,
cameraState: { isEnabled: false }
})
);
}
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: previousVoiceState?.roomId,
serverId: previousVoiceState?.serverId,
clientInstanceId: undefined
}
});
this.voiceSessionService.endSession();
}
voiceOccupancy(roomId: string): number { voiceOccupancy(roomId: string): number {
return this.voiceUsersInRoom(roomId).length; return this.voiceUsersInRoom(roomId).length;
} }
@@ -1059,47 +826,6 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.moveVoiceUserToChannel(draggedUserId, channelId); this.moveVoiceUserToChannel(draggedUserId, channelId);
} }
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
const room = this.currentRoom();
const actor = this.currentUser();
if (!room || !actor || !this.canMoveVoiceUsers()) {
return;
}
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
return;
}
const movedVoiceState: Partial<User['voiceState']> = {
isConnected: true,
isMuted: targetUser.voiceState.isMuted,
isDeafened: targetUser.voiceState.isDeafened,
isSpeaking: targetUser.voiceState.isSpeaking,
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
volume: targetUser.voiceState.volume,
roomId: channelId,
serverId: room.id
};
this.store.dispatch(
UsersActions.updateVoiceState({
userId: targetUser.id,
voiceState: movedVoiceState
})
);
this.realtime.broadcastMessage({
type: 'voice-channel-move',
roomId: room.id,
targetUserId: targetUser.oderId || targetUser.id,
voiceState: movedVoiceState,
displayName: targetUser.displayName
});
}
isUserLocallyMuted(user: User): boolean { isUserLocallyMuted(user: User): boolean {
const peerId = user.oderId || user.id; const peerId = user.oderId || user.id;
@@ -1299,6 +1025,343 @@ export class RoomsSidePanelComponent implements OnDestroy {
return this.appI18n.instant('room.panel.messageUser', { name: displayName }); return this.appI18n.instant('room.panel.messageUser', { name: displayName });
} }
private hasPluginPanelContent(): boolean {
return this.pluginChannelSections().length > 0 || this.pluginMenuActions().length > 0 || this.pluginSidePanels().length > 0;
}
private clearSkeletonRevealTimer(): void {
if (!this.skeletonRevealTimer) {
return;
}
clearTimeout(this.skeletonRevealTimer);
this.skeletonRevealTimer = null;
}
private roomMemberToUser(member: RoomMember): User {
return {
id: member.id,
oderId: member.oderId || member.id,
username: member.username,
displayName: member.displayName,
description: member.description,
profileUpdatedAt: member.profileUpdatedAt,
avatarUrl: member.avatarUrl,
avatarHash: member.avatarHash,
avatarMime: member.avatarMime,
avatarUpdatedAt: member.avatarUpdatedAt,
status: 'disconnected',
role: member.role,
joinedAt: member.joinedAt
};
}
private roomMemberKey(member: RoomMember): string {
return member.oderId || member.id;
}
private addIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string } | null | undefined): void {
if (!entity)
return;
if (entity.id) {
identifiers.add(entity.id);
}
if (entity.oderId) {
identifiers.add(entity.oderId);
}
}
private matchesIdentifiers(identifiers: Set<string>, entity: { id?: string; oderId?: string }): boolean {
return !!((entity.id && identifiers.has(entity.id)) || (entity.oderId && identifiers.has(entity.oderId)));
}
private isUserPresentInRoom(entity: { presenceServerIds?: string[] }, roomId: string | undefined): boolean {
if (!roomId || !Array.isArray(entity.presenceServerIds) || entity.presenceServerIds.length === 0) {
return true;
}
return entity.presenceServerIds.includes(roomId);
}
private isCurrentUserIdentity(entity: { id?: string; oderId?: string }): boolean {
const current = this.currentUser();
if (!current) {
return false;
}
const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
current,
this.currentRoom()?.sourceUrl
);
return isSelfPresenceUserId(entity.oderId, selfIds) || isSelfPresenceUserId(entity.id, selfIds);
}
private addSelfPresenceIdentifiers(identifiers: Set<string>): void {
const current = this.currentUser();
if (!current) {
return;
}
this.addIdentifiers(identifiers, current);
for (const selfId of this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(
current,
this.currentRoom()?.sourceUrl
)) {
identifiers.add(selfId);
}
}
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
this.cancelQueuedProfileCardOpen();
this.profileCardOpenTimer = setTimeout(() => {
this.profileCardOpenTimer = null;
this.profileCard.open(anchor, user, { placement: 'left', editable });
}, 180);
}
private cancelQueuedProfileCardOpen(): void {
if (!this.profileCardOpenTimer) {
return;
}
clearTimeout(this.profileCardOpenTimer);
this.profileCardOpenTimer = null;
}
private getChannelNameError(name: string, excludeChannelId?: string): string | null {
if (!name) {
return 'room.channel.nameRequired';
}
const channels = this.currentRoom()?.channels ?? [];
const channelType = excludeChannelId ? channels.find((channel) => channel.id === excludeChannelId)?.type : this.createChannelType();
if (!channelType) {
return null;
}
if (isChannelNameTaken(channels, name, channelType, excludeChannelId)) {
return 'room.channel.nameUnique';
}
return null;
}
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
if (
!room
|| !current?.voiceState?.isConnected
|| current.voiceState.roomId !== roomId
|| current.voiceState.serverId !== room.id
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
) {
return false;
}
this.voiceWorkspace.open(null, { connectRemoteShares: true });
return true;
}
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
}
private prepareVoiceJoin(room: Room, current: User | null): void {
if (!current?.voiceState?.isConnected || current.voiceState.serverId === room.id) {
return;
}
this.disconnectCurrentVoiceTarget(current);
}
private enableVoiceForJoin(room: Room, current: User | null, roomId: string): Promise<void> {
const isSwitchingChannels = !!current?.voiceState?.isConnected && current.voiceState.serverId === room.id && current.voiceState.roomId !== roomId;
return isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice().then(() => undefined);
}
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
this.voiceConnection.clearConnectionError();
this.updateVoiceStateStore(roomId, room, current);
this.trackCurrentUserMic();
this.startVoiceHeartbeat(roomId, room);
this.broadcastVoiceConnected(roomId, room, current);
this.startVoiceSession(roomId, room);
}
private handleVoiceJoinFailure(error: unknown): void {
const message = error instanceof Error ? error.message : 'room.voiceJoin.failed';
this.voiceConnection.reportConnectionError(message);
}
private trackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
const micStream = this.voiceConnection.getRawMicStream();
if (userId && micStream) {
this.voiceActivity.trackLocalMic(userId, micStream);
}
}
private untrackCurrentUserMic(): void {
const userId = this.currentUser()?.oderId || this.currentUser()?.id;
if (userId) {
this.voiceActivity.untrackLocalMic(userId);
}
}
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
if (!current?.id)
return;
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: true,
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id,
clientInstanceId: this.realtime.getClientInstanceId()
}
})
);
}
private startVoiceHeartbeat(roomId: string, room: Room): void {
this.voiceConnection.startVoiceHeartbeat(roomId, room.id);
}
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
const clientInstanceId = this.realtime.getClientInstanceId();
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: true,
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId,
serverId: room.id,
clientInstanceId
}
});
}
private startVoiceSession(roomId: string, room: Room): void {
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
this.voiceSessionService.startSession({
serverId: room.id,
serverName: room.name,
roomId,
roomName: voiceRoomName,
serverIcon: room.icon,
serverDescription: room.description,
serverRoute: `/room/${room.id}`
});
}
private disconnectCurrentVoiceTarget(current: User | null): void {
const previousVoiceState = current?.voiceState;
this.voiceConnection.stopVoiceHeartbeat();
this.untrackCurrentUserMic();
this.voiceConnection.disableVoice();
if (current?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: current.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
clientInstanceId: undefined
}
})
);
this.store.dispatch(
UsersActions.updateCameraState({
userId: current.id,
cameraState: { isEnabled: false }
})
);
}
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
displayName: current?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: previousVoiceState?.roomId,
serverId: previousVoiceState?.serverId,
clientInstanceId: undefined
}
});
this.voiceSessionService.endSession();
}
private moveVoiceUserToChannel(draggedUserId: string, channelId: string): void {
const room = this.currentRoom();
const actor = this.currentUser();
if (!room || !actor || !this.canMoveVoiceUsers()) {
return;
}
const targetUser = this.onlineUsers().find((user) => user.id === draggedUserId || user.oderId === draggedUserId);
if (!targetUser?.voiceState?.isConnected || targetUser.voiceState.serverId !== room.id || targetUser.voiceState.roomId === channelId) {
return;
}
const movedVoiceState: Partial<User['voiceState']> = {
isConnected: true,
isMuted: targetUser.voiceState.isMuted,
isDeafened: targetUser.voiceState.isDeafened,
isSpeaking: targetUser.voiceState.isSpeaking,
isMutedByAdmin: targetUser.voiceState.isMutedByAdmin,
volume: targetUser.voiceState.volume,
roomId: channelId,
serverId: room.id
};
this.store.dispatch(
UsersActions.updateVoiceState({
userId: targetUser.id,
voiceState: movedVoiceState
})
);
this.realtime.broadcastMessage({
type: 'voice-channel-move',
roomId: room.id,
targetUserId: targetUser.oderId || targetUser.id,
voiceState: movedVoiceState,
displayName: targetUser.displayName
});
}
private isVoiceUserSpeaking(user: User): boolean { private isVoiceUserSpeaking(user: User): boolean {
const userKey = user.oderId || user.id; const userKey = user.oderId || user.id;
@@ -1345,4 +1408,5 @@ export class RoomsSidePanelComponent implements OnDestroy {
private hasActiveVideoStream(stream: MediaStream | null): boolean { private hasActiveVideoStream(stream: MediaStream | null): boolean {
return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live'); return !!stream && stream.getVideoTracks().some((track) => track.readyState === 'live');
} }
} }

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<div <div
#tileRoot #tileRoot
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200" class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
Component, Component,
@@ -56,29 +55,47 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
} }
}) })
export class VoiceWorkspaceStreamTileComponent implements OnDestroy { export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
private readonly appI18n = inject(AppI18nService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly item = input.required<VoiceWorkspaceStreamItem>(); readonly item = input.required<VoiceWorkspaceStreamItem>();
readonly focused = input(false); readonly focused = input(false);
readonly featured = input(false); readonly featured = input(false);
readonly compact = input(false); readonly compact = input(false);
readonly mini = input(false); readonly mini = input(false);
readonly immersive = input(false); readonly immersive = input(false);
readonly focusRequested = output<string>(); readonly focusRequested = output<string>();
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot'); readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo'); readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
readonly isFullscreen = signal(false); readonly isFullscreen = signal(false);
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
readonly showFullscreenHeader = signal(true); readonly showFullscreenHeader = signal(true);
readonly volume = signal(100); readonly volume = signal(100);
readonly muted = signal(false); readonly muted = signal(false);
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
private readonly mobilePictureInPicture = inject(MobilePictureInPictureService);
private readonly appI18n = inject(AppI18nService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
void this.mobileLifecycle.initialize(); void this.mobileLifecycle.initialize();
this.mobileLifecycle.onAppStateChange((isActive) => { this.mobileLifecycle.onAppStateChange((isActive) => {
@@ -172,24 +189,6 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
this.unlockOrientation(); this.unlockOrientation();
} }
private async handleAppStateChange(isActive: boolean): Promise<void> {
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
if (isActive) {
await this.mobilePictureInPicture.exit();
}
return;
}
const video = this.videoRef()?.nativeElement;
if (!video || !this.item().stream) {
return;
}
await this.mobilePictureInPicture.enter(video);
}
canToggleFullscreen(): boolean { canToggleFullscreen(): boolean {
return !this.mini() && !this.compact(); return !this.mini() && !this.compact();
} }
@@ -374,6 +373,24 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
return !item.isLocal && item.hasAudio; return !item.isLocal && item.hasAudio;
} }
private async handleAppStateChange(isActive: boolean): Promise<void> {
if (isActive || !this.focused() || !this.mobilePictureInPicture.isSupported()) {
if (isActive) {
await this.mobilePictureInPicture.exit();
}
return;
}
const video = this.videoRef()?.nativeElement;
if (!video || !this.item().stream) {
return;
}
await this.mobilePictureInPicture.enter(video);
}
private async enterFullscreen(): Promise<void> { private async enterFullscreen(): Promise<void> {
const tile = this.tileRef()?.nativeElement; const tile = this.tileRef()?.nativeElement;
@@ -425,6 +442,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
clearTimeout(this.fullscreenHeaderHideTimeoutId); clearTimeout(this.fullscreenHeaderHideTimeoutId);
this.fullscreenHeaderHideTimeoutId = null; this.fullscreenHeaderHideTimeoutId = null;
} }
} }
interface WebKitFullscreenVideoElement extends HTMLVideoElement { interface WebKitFullscreenVideoElement extends HTMLVideoElement {

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<div class="absolute inset-0"> <div class="absolute inset-0">
@if (showExpanded()) { @if (showExpanded()) {
<section <section

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering, complexity */
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
Component, Component,
@@ -88,55 +87,42 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
} }
}) })
export class VoiceWorkspaceComponent { export class VoiceWorkspaceComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly store = inject(Store);
private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
private readonly appI18n = inject(AppI18nService);
private readonly remoteStreamRevision = signal(0);
private readonly miniWindowWidth = 320;
private readonly miniWindowHeight = 228;
private miniWindowDragging = false;
private miniDragOffsetX = 0;
private miniDragOffsetY = 0;
private wasExpanded = false;
private wasAutoHideChrome = false;
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly observedRemoteStreams = new Map<string, {
stream: MediaStream;
cleanup: () => void;
}>();
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview'); readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
readonly currentRoom = this.store.selectSignal(selectCurrentRoom); readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers); readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
readonly voiceSessionInfo = this.voiceSession.voiceSession; readonly voiceSessionInfo = this.voiceSession.voiceSession;
readonly showExpanded = this.voiceWorkspace.isExpanded; readonly showExpanded = this.voiceWorkspace.isExpanded;
readonly showMiniWindow = this.voiceWorkspace.isMinimized; readonly showMiniWindow = this.voiceWorkspace.isMinimized;
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares; readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
readonly miniPosition = this.voiceWorkspace.miniWindowPosition; readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
readonly showWorkspaceHeader = signal(true); readonly showWorkspaceHeader = signal(true);
readonly isConnected = computed(() => this.webrtc.isVoiceConnected()); readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
readonly isMuted = computed(() => this.webrtc.isMuted()); readonly isMuted = computed(() => this.webrtc.isMuted());
readonly isDeafened = computed(() => this.webrtc.isDeafened()); readonly isDeafened = computed(() => this.webrtc.isDeafened());
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing()); readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
readonly includeSystemAudio = signal(false); readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced'); readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
readonly askScreenShareQuality = signal(true); readonly askScreenShareQuality = signal(true);
readonly showScreenShareQualityDialog = signal(false); readonly showScreenShareQualityDialog = signal(false);
readonly connectedVoiceUsers = computed(() => { readonly connectedVoiceUsers = computed(() => {
@@ -290,18 +276,23 @@ export class VoiceWorkspaceComponent {
}); });
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null); readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
readonly shouldAutoHideChrome = computed( readonly shouldAutoHideChrome = computed(
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0 () => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
); );
readonly hasMultipleShares = computed(() => this.activeShares().length > 1); readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
readonly widescreenShare = computed( readonly widescreenShare = computed(
() => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null () => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null
); );
readonly focusedAudioShare = computed(() => { readonly focusedAudioShare = computed(() => {
const share = this.widescreenShare(); const share = this.widescreenShare();
return share && !share.isLocal && share.hasAudio ? share : null; return share && !share.isLocal && share.hasAudio ? share : null;
}); });
readonly focusedShareTitle = computed(() => { readonly focusedShareTitle = computed(() => {
const share = this.widescreenShare(); const share = this.widescreenShare();
@@ -317,6 +308,7 @@ export class VoiceWorkspaceComponent {
? this.appI18n.instant('voice.workspace.yourCamera') ? this.appI18n.instant('voice.workspace.yourCamera')
: this.appI18n.instant('voice.workspace.yourScreen'); : this.appI18n.instant('voice.workspace.yourScreen');
}); });
readonly thumbnailShares = computed(() => { readonly thumbnailShares = computed(() => {
const widescreenShareId = this.widescreenShareId(); const widescreenShareId = this.widescreenShareId();
@@ -326,9 +318,11 @@ export class VoiceWorkspaceComponent {
return this.activeShares().filter((share) => share.id !== widescreenShareId); return this.activeShares().filter((share) => share.id !== widescreenShareId);
}); });
readonly miniPreviewShare = computed( readonly miniPreviewShare = computed(
() => this.widescreenShare() ?? this.activeShares()[0] ?? null () => this.widescreenShare() ?? this.activeShares()[0] ?? null
); );
readonly miniPreviewTitle = computed(() => { readonly miniPreviewTitle = computed(() => {
const previewShare = this.miniPreviewShare(); const previewShare = this.miniPreviewShare();
@@ -344,7 +338,9 @@ export class VoiceWorkspaceComponent {
? this.appI18n.instant('voice.workspace.yourCamera') ? this.appI18n.instant('voice.workspace.yourCamera')
: this.appI18n.instant('voice.workspace.yourScreen'); : this.appI18n.instant('voice.workspace.yourScreen');
}); });
readonly liveShareCount = computed(() => this.activeShares().length); readonly liveShareCount = computed(() => this.activeShares().length);
readonly connectedVoiceChannelName = computed(() => { readonly connectedVoiceChannelName = computed(() => {
const me = this.currentUser(); const me = this.currentUser();
const room = this.currentRoom(); const room = this.currentRoom();
@@ -361,21 +357,55 @@ export class VoiceWorkspaceComponent {
return sessionRoomName || this.appI18n.instant('voice.workspace.voiceLounge'); return sessionRoomName || this.appI18n.instant('voice.workspace.voiceLounge');
}); });
readonly serverName = computed( readonly serverName = computed(
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || this.appI18n.instant('voice.workspace.voiceServer') () => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || this.appI18n.instant('voice.workspace.voiceServer')
); );
liveStreamCountLabel(count: number): string { private readonly destroyRef = inject(DestroyRef);
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
return this.appI18n.instant(key, { count }); private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
}
miniWindowStreamHint(count: number): string { private readonly store = inject(Store);
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
return this.appI18n.instant(key, { count }); private readonly webrtc = inject(VoiceConnectionFacade);
}
private readonly screenShare = inject(ScreenShareFacade);
private readonly viewport = inject(ViewportService);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
private readonly appI18n = inject(AppI18nService);
private readonly remoteStreamRevision = signal(0);
private readonly miniWindowWidth = 320;
private readonly miniWindowHeight = 228;
private miniWindowDragging = false;
private miniDragOffsetX = 0;
private miniDragOffsetY = 0;
private wasExpanded = false;
private wasAutoHideChrome = false;
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly observedRemoteStreams = new Map<string, {
stream: MediaStream;
cleanup: () => void;
}>();
constructor() { constructor() {
this.destroyRef.onDestroy(() => { this.destroyRef.onDestroy(() => {
@@ -509,14 +539,6 @@ export class VoiceWorkspaceComponent {
}); });
} }
onWorkspacePointerMove(): void {
if (!this.shouldAutoHideChrome()) {
return;
}
this.revealWorkspaceChrome();
}
@HostListener('window:mousemove', ['$event']) @HostListener('window:mousemove', ['$event'])
onWindowMouseMove(event: MouseEvent): void { onWindowMouseMove(event: MouseEvent): void {
if (!this.miniWindowDragging) { if (!this.miniWindowDragging) {
@@ -548,6 +570,26 @@ export class VoiceWorkspaceComponent {
this.ensureMiniWindowPosition(); this.ensureMiniWindowPosition();
} }
liveStreamCountLabel(count: number): string {
const key = count === 1 ? 'voice.workspace.liveStream' : 'voice.workspace.liveStreams';
return this.appI18n.instant(key, { count });
}
miniWindowStreamHint(count: number): string {
const key = count === 1 ? 'voice.workspace.miniWindowHintSingle' : 'voice.workspace.miniWindowHint';
return this.appI18n.instant(key, { count });
}
onWorkspacePointerMove(): void {
if (!this.shouldAutoHideChrome()) {
return;
}
this.revealWorkspaceChrome();
}
trackUser(index: number, user: User): string { trackUser(index: number, user: User): string {
return this.getUserPeerKey(user) || `${index}`; return this.getUserPeerKey(user) || `${index}`;
} }
@@ -1057,4 +1099,5 @@ export class VoiceWorkspaceComponent {
private clamp(value: number, min: number, max: number): number { private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
DestroyRef, DestroyRef,
@@ -70,36 +69,32 @@ const ACTIVATION_DEBOUNCE_MS = 150;
templateUrl: './servers-rail.component.html' templateUrl: './servers-rail.component.html'
}) })
export class ServersRailComponent { export class ServersRailComponent {
private readonly appI18n = inject(AppI18nService);
private store = inject(Store);
private router = inject(Router);
private voiceSession = inject(VoiceSessionFacade);
private db = inject(DatabaseService);
private notifications = inject(NotificationsFacade);
readonly directCalls = inject(DirectCallService); readonly directCalls = inject(DirectCallService);
private serverDirectory = inject(ServerDirectoryFacade);
private destroyRef = inject(DestroyRef);
private banLookupRequestVersion = 0;
private bannedLookupUserKey: string | null = null;
private activationRequestVersion = 0;
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
private joinRequestVersion = 0;
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
private visibleSavedRoomCache: Room[] = [];
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
showMenu = signal(false); showMenu = signal(false);
menuX = signal(72); menuX = signal(72);
menuY = signal(100); menuY = signal(100);
contextRoom = signal<Room | null>(null); contextRoom = signal<Room | null>(null);
optimisticSelectedRoomId = signal<string | null>(null); optimisticSelectedRoomId = signal<string | null>(null);
showLeaveConfirm = signal(false); showLeaveConfirm = signal(false);
showCreateDialog = signal(false); showCreateDialog = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers); onlineUsers = this.store.selectSignal(selectOnlineUsers);
bannedRoomLookup = signal<Record<string, boolean>>({}); bannedRoomLookup = signal<Record<string, boolean>>({});
isOnServers = toSignal( isOnServers = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -107,6 +102,7 @@ export class ServersRailComponent {
), ),
{ initialValue: this.router.url.startsWith('/servers') } { initialValue: this.router.url.startsWith('/servers') }
); );
isOnDirectMessage = toSignal( isOnDirectMessage = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -114,6 +110,7 @@ export class ServersRailComponent {
), ),
{ initialValue: this.isDirectMessageUrl(this.router.url) } { initialValue: this.isDirectMessageUrl(this.router.url) }
); );
isOnDashboard = toSignal( isOnDashboard = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -121,6 +118,7 @@ export class ServersRailComponent {
), ),
{ initialValue: this.router.url.startsWith('/dashboard') } { initialValue: this.router.url.startsWith('/dashboard') }
); );
isOnCall = toSignal( isOnCall = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -128,6 +126,7 @@ export class ServersRailComponent {
), ),
{ initialValue: this.router.url.startsWith('/call/') } { initialValue: this.router.url.startsWith('/call/') }
); );
currentCallId = toSignal( currentCallId = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -135,6 +134,7 @@ export class ServersRailComponent {
), ),
{ initialValue: this.callIdFromUrl(this.router.url) } { initialValue: this.callIdFromUrl(this.router.url) }
); );
selectedCallIndex = computed(() => { selectedCallIndex = computed(() => {
const routeCallId = this.currentCallId(); const routeCallId = this.currentCallId();
const visibleCalls = this.directCalls.visibleActiveSessions(); const visibleCalls = this.directCalls.visibleActiveSessions();
@@ -155,13 +155,21 @@ export class ServersRailComponent {
return visibleCalls.findIndex((call) => call.callId === currentSession.callId); return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
}); });
bannedServerName = signal(''); bannedServerName = signal('');
showBannedDialog = signal(false); showBannedDialog = signal(false);
showPasswordDialog = signal(false); showPasswordDialog = signal(false);
passwordPromptRoom = signal<Room | null>(null); passwordPromptRoom = signal<Room | null>(null);
joinPassword = signal(''); joinPassword = signal('');
joinPasswordError = signal<string | null>(null); joinPasswordError = signal<string | null>(null);
visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)))); visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
voicePresenceByRoom = computed(() => { voicePresenceByRoom = computed(() => {
const presence: Record<string, number> = {}; const presence: Record<string, number> = {};
const seenByRoom = new Map<string, Set<string>>(); const seenByRoom = new Map<string, Set<string>>();
@@ -203,6 +211,38 @@ export class ServersRailComponent {
return presence; return presence;
}); });
private readonly appI18n = inject(AppI18nService);
private store = inject(Store);
private router = inject(Router);
private voiceSession = inject(VoiceSessionFacade);
private db = inject(DatabaseService);
private notifications = inject(NotificationsFacade);
private serverDirectory = inject(ServerDirectoryFacade);
private destroyRef = inject(DestroyRef);
private banLookupRequestVersion = 0;
private bannedLookupUserKey: string | null = null;
private activationRequestVersion = 0;
private activationTimer: ReturnType<typeof window.setTimeout> | null = null;
private joinRequestVersion = 0;
private joinRequestTimer: ReturnType<typeof window.setTimeout> | null = null;
private visibleSavedRoomCache: Room[] = [];
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
constructor() { constructor() {
effect(() => { effect(() => {
const rooms = this.savedRooms(); const rooms = this.savedRooms();
@@ -241,6 +281,8 @@ export class ServersRailComponent {
}); });
} }
trackRoomId = (index: number, room: Room) => room.id;
initial(name?: string): string { initial(name?: string): string {
if (!name) if (!name)
return '?'; return '?';
@@ -250,8 +292,6 @@ export class ServersRailComponent {
return ch || '?'; return ch || '?';
} }
trackRoomId = (index: number, room: Room) => room.id;
goToDashboard(): void { goToDashboard(): void {
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
@@ -342,13 +382,6 @@ export class ServersRailComponent {
return !!this.bannedRoomLookup()[room.id]; return !!this.bannedRoomLookup()[room.id];
} }
private callIdFromUrl(url: string): string | null {
const path = url.split(/[?#]/, 1)[0];
const match = path.match(/^\/call\/([^/]+)/);
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
openContextMenu(evt: MouseEvent, room: Room): void { openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault(); evt.preventDefault();
this.contextRoom.set(room); this.contextRoom.set(room);
@@ -454,6 +487,13 @@ export class ServersRailComponent {
return this.currentRoom()?.id === room.id; return this.currentRoom()?.id === room.id;
} }
private callIdFromUrl(url: string): string | null {
const path = url.split(/[?#]/, 1)[0];
const match = path.match(/^\/call\/([^/]+)/);
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] { private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room])); const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
const stabilizedRooms = nextRooms.map((room) => { const stabilizedRooms = nextRooms.map((room) => {
@@ -806,4 +846,5 @@ export class ServersRailComponent {
return nextRoom; return nextRoom;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
effect, effect,
@@ -35,17 +34,21 @@ import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
templateUrl: './bans-settings.component.html' templateUrl: './bans-settings.component.html'
}) })
export class BansSettingsComponent { export class BansSettingsComponent {
private store = inject(Store);
private actions$ = inject(Actions);
private db = inject(DatabaseService);
/** The currently selected server, passed from the parent. */ /** The currently selected server, passed from the parent. */
server = input<Room | null>(null); server = input<Room | null>(null);
/** Whether the current user is admin of this server. */ /** Whether the current user is admin of this server. */
isAdmin = input(false); isAdmin = input(false);
bannedUsers = signal<BanEntry[]>([]); bannedUsers = signal<BanEntry[]>([]);
private store = inject(Store);
private actions$ = inject(Actions);
private db = inject(DatabaseService);
constructor() { constructor() {
effect(() => { effect(() => {
const roomId = this.server()?.id; const roomId = this.server()?.id;
@@ -82,10 +85,6 @@ export class BansSettingsComponent {
oderId: ban.oderId })); oderId: ban.oderId }));
} }
private async loadBansForServer(roomId: string): Promise<void> {
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
}
formatExpiry(timestamp: number): string { formatExpiry(timestamp: number): string {
const date = new Date(timestamp); const date = new Date(timestamp);
@@ -96,4 +95,9 @@ export class BansSettingsComponent {
minute: '2-digit' }) minute: '2-digit' })
); );
} }
private async loadBansForServer(roomId: string): Promise<void> {
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
}
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -41,16 +40,23 @@ type DataAction = 'open' | 'export' | 'import' | 'erase' | 'restart';
templateUrl: './data-settings.component.html' templateUrl: './data-settings.component.html'
}) })
export class DataSettingsComponent { export class DataSettingsComponent {
private readonly electron = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.electron.isAvailable; readonly isElectron = this.electron.isAvailable;
readonly dataPath = signal<string | null>(null); readonly dataPath = signal<string | null>(null);
readonly busyAction = signal<DataAction | null>(null); readonly busyAction = signal<DataAction | null>(null);
readonly statusMessage = signal<string | null>(null); readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null); readonly errorMessage = signal<string | null>(null);
readonly restartRequired = signal(false); readonly restartRequired = signal(false);
private readonly electron = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
constructor() { constructor() {
void this.loadDataPath(); void this.loadDataPath();
} }
@@ -145,4 +151,5 @@ export class DataSettingsComponent {
this.busyAction.set(null); this.busyAction.set(null);
} }
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
DestroyRef, DestroyRef,
@@ -49,35 +48,46 @@ const APP_METRICS_POLL_INTERVAL_MS = 2_000;
templateUrl: './debugging-settings.component.html' templateUrl: './debugging-settings.component.html'
}) })
export class DebuggingSettingsComponent { export class DebuggingSettingsComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly debugging = inject(DebuggingService); readonly debugging = inject(DebuggingService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.platform.isElectron; readonly isElectron = this.platform.isElectron;
readonly ramLabel = signal<string | null>(null); readonly ramLabel = signal<string | null>(null);
readonly enabled = this.debugging.enabled; readonly enabled = this.debugging.enabled;
readonly isConsoleOpen = this.debugging.isConsoleOpen; readonly isConsoleOpen = this.debugging.isConsoleOpen;
readonly entryCount = computed(() => { readonly entryCount = computed(() => {
return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0); return this.debugging.entries().reduce((sum, entry) => sum + entry.count, 0);
}); });
readonly errorCount = computed(() => { readonly errorCount = computed(() => {
return this.debugging.entries().reduce((sum, entry) => { return this.debugging.entries().reduce((sum, entry) => {
return sum + (entry.level === 'error' ? entry.count : 0); return sum + (entry.level === 'error' ? entry.count : 0);
}, 0); }, 0);
}); });
readonly warningCount = computed(() => { readonly warningCount = computed(() => {
return this.debugging.entries().reduce((sum, entry) => { return this.debugging.entries().reduce((sum, entry) => {
return sum + (entry.level === 'warn' ? entry.count : 0); return sum + (entry.level === 'warn' ? entry.count : 0);
}, 0); }, 0);
}); });
readonly lastUpdatedLabel = computed(() => { readonly lastUpdatedLabel = computed(() => {
const lastEntry = this.debugging.entries().at(-1); const lastEntry = this.debugging.entries().at(-1);
return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet'); return lastEntry ? lastEntry.timeLabel : this.appI18n.instant('settings.debugging.noLogsYet');
}); });
private readonly destroyRef = inject(DestroyRef);
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
constructor() { constructor() {
if (this.isElectron) if (this.isElectron)
this.startRamPolling(); this.startRamPolling();
@@ -118,4 +128,5 @@ export class DebuggingSettingsComponent {
} }
}); });
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -34,20 +33,30 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
templateUrl: './general-settings.component.html' templateUrl: './general-settings.component.html'
}) })
export class GeneralSettingsComponent { export class GeneralSettingsComponent {
private platform = inject(PlatformService);
private electronBridge = inject(ElectronBridgeService);
readonly experimentalMedia = inject(ExperimentalMediaSettingsService); readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
readonly isElectron = this.platform.isElectron; readonly isElectron = this.platform.isElectron;
reopenLastViewedChat = signal(true); reopenLastViewedChat = signal(true);
autoStart = signal(false); autoStart = signal(false);
closeToTray = signal(true); closeToTray = signal(true);
savingAutoStart = signal(false); savingAutoStart = signal(false);
savingCloseToTray = signal(false); savingCloseToTray = signal(false);
ignoredGameProcesses = signal<string[]>([]); ignoredGameProcesses = signal<string[]>([]);
ignoredProcessDraft = signal(''); ignoredProcessDraft = signal('');
savingIgnoredGameProcesses = signal(false); savingIgnoredGameProcesses = signal(false);
private platform = inject(PlatformService);
private electronBridge = inject(ElectronBridgeService);
constructor() { constructor() {
this.loadGeneralSettings(); this.loadGeneralSettings();
@@ -119,31 +128,6 @@ export class GeneralSettingsComponent {
input.checked = this.experimentalMedia.vlcJsPlaybackEnabled(); input.checked = this.experimentalMedia.vlcJsPlaybackEnabled();
} }
private async loadDesktopSettings(): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
try {
const snapshot = await api.getDesktopSettings();
this.applyDesktopSettings(snapshot);
} catch {}
}
private loadGeneralSettings(): void {
const settings = loadGeneralSettingsFromStorage();
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
}
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
this.autoStart.set(snapshot.autoStart);
this.closeToTray.set(snapshot.closeToTray);
}
onIgnoredProcessDraftChange(event: Event): void { onIgnoredProcessDraftChange(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@@ -169,6 +153,31 @@ export class GeneralSettingsComponent {
await this.saveIgnoredGameProcesses(next); await this.saveIgnoredGameProcesses(next);
} }
private async loadDesktopSettings(): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
try {
const snapshot = await api.getDesktopSettings();
this.applyDesktopSettings(snapshot);
} catch {}
}
private loadGeneralSettings(): void {
const settings = loadGeneralSettingsFromStorage();
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
}
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
this.autoStart.set(snapshot.autoStart);
this.closeToTray.set(snapshot.closeToTray);
}
private async loadIgnoredGameProcesses(): Promise<void> { private async loadIgnoredGameProcesses(): Promise<void> {
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();
@@ -200,4 +209,5 @@ export class GeneralSettingsComponent {
this.savingIgnoredGameProcesses.set(false); this.savingIgnoredGameProcesses.set(false);
} }
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -52,17 +51,23 @@ import {
templateUrl: './ice-server-settings.component.html' templateUrl: './ice-server-settings.component.html'
}) })
export class IceServerSettingsComponent { export class IceServerSettingsComponent {
private iceSettings = inject(IceServerSettingsService);
private readonly appI18n = inject(AppI18nService);
entries = this.iceSettings.entries; entries = this.iceSettings.entries;
addError = signal<string | null>(null); addError = signal<string | null>(null);
newType: 'stun' | 'turn' = 'stun'; newType: 'stun' | 'turn' = 'stun';
newUrl = ''; newUrl = '';
newUsername = ''; newUsername = '';
newCredential = ''; newCredential = '';
private iceSettings = inject(IceServerSettingsService);
private readonly appI18n = inject(AppI18nService);
addEntry(): void { addEntry(): void {
this.addError.set(null); this.addError.set(null);
@@ -134,4 +139,5 @@ export class IceServerSettingsComponent {
trackEntry(_index: number, entry: IceServerEntry): string { trackEntry(_index: number, entry: IceServerEntry): string {
return entry.id; return entry.id;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
OnDestroy, OnDestroy,
@@ -29,8 +28,6 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
templateUrl: './local-api-settings.component.html' templateUrl: './local-api-settings.component.html'
}) })
export class LocalApiSettingsComponent implements OnInit, OnDestroy { export class LocalApiSettingsComponent implements OnInit, OnDestroy {
private readonly bridge = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
readonly isElectron = this.bridge.isAvailable; readonly isElectron = this.bridge.isAvailable;
@@ -55,10 +52,15 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
}); });
readonly allowedServersText = signal(''); readonly allowedServersText = signal('');
readonly hasPendingAllowedServersChanges = signal(false); readonly hasPendingAllowedServersChanges = signal(false);
readonly portText = signal('17878'); readonly portText = signal('17878');
readonly hasPendingPortChange = signal(false); readonly hasPendingPortChange = signal(false);
readonly busy = signal(false); readonly busy = signal(false);
readonly errorMessage = signal<string | null>(null); readonly errorMessage = signal<string | null>(null);
readonly statusLabel = computed(() => { readonly statusLabel = computed(() => {
@@ -81,6 +83,10 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
} }
}); });
private readonly bridge = inject(ElectronBridgeService);
private readonly appI18n = inject(AppI18nService);
private statusPollHandle: ReturnType<typeof setInterval> | null = null; private statusPollHandle: ReturnType<typeof setInterval> | null = null;
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@@ -278,4 +284,5 @@ export class LocalApiSettingsComponent implements OnInit, OnDestroy {
this.busy.set(false); this.busy.set(false);
} }
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -56,23 +55,27 @@ interface ServerMemberView extends RoomMember {
templateUrl: './members-settings.component.html' templateUrl: './members-settings.component.html'
}) })
export class MembersSettingsComponent { export class MembersSettingsComponent {
private store = inject(Store);
private readonly i18n = inject(AppI18nService);
/** The currently selected server, passed from the parent. */ /** The currently selected server, passed from the parent. */
server = input<Room | null>(null); server = input<Room | null>(null);
/** Whether the current user is admin of this server. */ /** Whether the current user is admin of this server. */
isAdmin = input(false); isAdmin = input(false);
accessRole = input<string | null>(null); accessRole = input<string | null>(null);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
usersEntities = this.store.selectSignal(selectUsersEntities); usersEntities = this.store.selectSignal(selectUsersEntities);
normalizedServer = computed(() => { normalizedServer = computed(() => {
const room = this.server(); const room = this.server();
return room ? normalizeRoomAccessControl(room) : null; return room ? normalizeRoomAccessControl(room) : null;
}); });
assignableRoles = computed<RoomRole[]>(() => findAssignableRoles(this.normalizedServer()?.roles ?? [])); assignableRoles = computed<RoomRole[]>(() => findAssignableRoles(this.normalizedServer()?.roles ?? []));
members = computed<ServerMemberView[]>(() => { members = computed<ServerMemberView[]>(() => {
@@ -104,6 +107,10 @@ export class MembersSettingsComponent {
}); });
}); });
private store = inject(Store);
private readonly i18n = inject(AppI18nService);
canChangeRoles(member: ServerMemberView): boolean { canChangeRoles(member: ServerMemberView): boolean {
const room = this.normalizedServer(); const room = this.normalizedServer();
const currentUser = this.currentUser(); const currentUser = this.currentUser();
@@ -163,4 +170,5 @@ export class MembersSettingsComponent {
roomId: room.id, roomId: room.id,
displayName: member.displayName })); displayName: member.displayName }));
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -53,25 +52,40 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
templateUrl: './network-settings.component.html' templateUrl: './network-settings.component.html'
}) })
export class NetworkSettingsComponent { export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryFacade);
private readonly appI18n = inject(AppI18nService);
readonly signalServerAuth = inject(SignalServerAuthService); readonly signalServerAuth = inject(SignalServerAuthService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
readonly provisionNotice = this.provisionNoticeService.notice; readonly provisionNotice = this.provisionNoticeService.notice;
servers = this.serverDirectory.servers; servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers; activeServers = this.serverDirectory.activeServers;
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers; hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
hasMultipleServers = computed(() => this.servers().length > 1); hasMultipleServers = computed(() => this.servers().length > 1);
hasMultipleActiveServers = computed(() => this.activeServers().length > 1); hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
isTesting = signal(false); isTesting = signal(false);
addError = signal<string | null>(null); addError = signal<string | null>(null);
newServerName = ''; newServerName = '';
newServerUrl = ''; newServerUrl = '';
autoReconnect = true; autoReconnect = true;
searchAllServers = true; searchAllServers = true;
private serverDirectory = inject(ServerDirectoryFacade);
private readonly appI18n = inject(AppI18nService);
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
private readonly provisionNoticeService = inject(SignalServerProvisionNoticeService);
constructor() { constructor() {
this.loadConnectionSettings(); this.loadConnectionSettings();
} }
@@ -166,4 +180,5 @@ export class NetworkSettingsComponent {
this.serverDirectory.setSearchAllServers(this.searchAllServers); this.serverDirectory.setSearchAllServers(this.searchAllServers);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -92,12 +91,13 @@ function upsertRoleChannelOverride(
templateUrl: './permissions-settings.component.html' templateUrl: './permissions-settings.component.html'
}) })
export class PermissionsSettingsComponent { export class PermissionsSettingsComponent {
private store = inject(Store);
private readonly appI18n = inject(AppI18nService);
server = input<Room | null>(null); server = input<Room | null>(null);
isAdmin = input(false); isAdmin = input(false);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
permissionDefinitions = computed(() => permissionDefinitions = computed(() =>
ROOM_PERMISSION_DEFINITIONS.map((definition) => ({ ROOM_PERMISSION_DEFINITIONS.map((definition) => ({
key: definition.key, key: definition.key,
@@ -105,24 +105,30 @@ export class PermissionsSettingsComponent {
description: this.appI18n.instant(`permissions.${definition.key}.description`) description: this.appI18n.instant(`permissions.${definition.key}.description`)
})) }))
); );
permissionStates: PermissionState[] = [ permissionStates: PermissionState[] = [
'inherit', 'inherit',
'allow', 'allow',
'deny' 'deny'
]; ];
normalizedServer = computed(() => { normalizedServer = computed(() => {
const room = this.server(); const room = this.server();
return room ? normalizeRoomAccessControl(room) : null; return room ? normalizeRoomAccessControl(room) : null;
}); });
roles = computed<RoomRole[]>(() => sortRolesForDisplay(this.normalizedServer()?.roles ?? [])); roles = computed<RoomRole[]>(() => sortRolesForDisplay(this.normalizedServer()?.roles ?? []));
channels = computed(() => this.normalizedServer()?.channels ?? []); channels = computed(() => this.normalizedServer()?.channels ?? []);
canManageRoles = computed(() => { canManageRoles = computed(() => {
const room = this.normalizedServer(); const room = this.normalizedServer();
const user = this.currentUser(); const user = this.currentUser();
return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageRoles')); return !!room && !!user && (room.hostId === user.id || room.hostId === user.oderId || resolveRoomPermission(room, user, 'manageRoles'));
}); });
canManageServer = computed(() => { canManageServer = computed(() => {
const room = this.normalizedServer(); const room = this.normalizedServer();
const user = this.currentUser(); const user = this.currentUser();
@@ -131,10 +137,17 @@ export class PermissionsSettingsComponent {
}); });
selectedRoleKey: string | null = null; selectedRoleKey: string | null = null;
selectedChannelKey = ''; selectedChannelKey = '';
roleName = ''; roleName = '';
roleColor = '#94a3b8'; roleColor = '#94a3b8';
private store = inject(Store);
private readonly appI18n = inject(AppI18nService);
constructor() { constructor() {
effect(() => { effect(() => {
const room = this.normalizedServer(); const room = this.normalizedServer();
@@ -406,4 +419,5 @@ export class PermissionsSettingsComponent {
trackRole(_: number, role: RoomRole): string { trackRole(_: number, role: RoomRole): string {
return role.id; return role.id;
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
effect, effect,
@@ -54,37 +53,54 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../../../shar
templateUrl: './server-settings.component.html' templateUrl: './server-settings.component.html'
}) })
export class ServerSettingsComponent { export class ServerSettingsComponent {
private store = inject(Store);
private modal = inject(SettingsModalService);
private serverIconImages = inject(ServerIconImageService);
private readonly appI18n = inject(AppI18nService);
/** The currently selected server, passed from the parent. */ /** The currently selected server, passed from the parent. */
server = input<Room | null>(null); server = input<Room | null>(null);
/** Whether the current user is admin of this server. */ /** Whether the current user is admin of this server. */
isAdmin = input(false); isAdmin = input(false);
/** Whether the current user can manage this server's icon. */ /** Whether the current user can manage this server's icon. */
canManageIcon = input(false); canManageIcon = input(false);
/** Whether the current user can delete this server. */ /** Whether the current user can delete this server. */
canDeleteServer = input(false); canDeleteServer = input(false);
roomName = ''; roomName = '';
roomDescription = ''; roomDescription = '';
isPrivate = signal(false); isPrivate = signal(false);
hasPassword = signal(false); hasPassword = signal(false);
passwordAction = signal<'keep' | 'update' | 'remove'>('keep'); passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
passwordError = signal<string | null>(null); passwordError = signal<string | null>(null);
roomPassword = ''; roomPassword = '';
maxUsers = 0; maxUsers = 0;
showDeleteConfirm = signal(false); showDeleteConfirm = signal(false);
iconError = signal<string | null>(null); iconError = signal<string | null>(null);
saveSuccess = signal<string | null>(null); saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
/** Reload form fields whenever the server input changes. */ /** Reload form fields whenever the server input changes. */
readonly serverData = this.server; readonly serverData = this.server;
private store = inject(Store);
private modal = inject(SettingsModalService);
private serverIconImages = inject(ServerIconImageService);
private readonly appI18n = inject(AppI18nService);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
constructor() { constructor() {
effect(() => { effect(() => {
const room = this.server(); const room = this.server();
@@ -243,4 +259,5 @@ export class ServerSettingsComponent {
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000); this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
} }
} }

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
@if (isOpen() && !isThemeStudioFullscreen()) { @if (isOpen() && !isThemeStudioFullscreen()) {
<!-- Backdrop (hidden on mobile where the modal is full-screen) --> <!-- Backdrop (hidden on mobile where the modal is full-screen) -->
<div <div

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -104,14 +103,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
}) })
export class SettingsModalComponent { export class SettingsModalComponent {
readonly modal = inject(SettingsModalService); readonly modal = inject(SettingsModalService);
private store = inject(Store);
private webrtc = inject(RealtimeSessionFacade);
private theme = inject(ThemeService);
private themeLibrary = inject(ThemeLibraryService);
private viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES; readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null;
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */ /** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
readonly isMobile = this.viewport.isMobile; readonly isMobile = this.viewport.isMobile;
@@ -124,21 +117,30 @@ export class SettingsModalComponent {
*/ */
readonly mobilePage = signal<'menu' | 'detail'>('menu'); readonly mobilePage = signal<'menu' | 'detail'>('menu');
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
isOpen = this.modal.isOpen; isOpen = this.modal.isOpen;
activePage = this.modal.activePage; activePage = this.modal.activePage;
themeStudioFullscreen = this.modal.themeStudioFullscreen; themeStudioFullscreen = this.modal.themeStudioFullscreen;
themeStudioMinimized = this.modal.themeStudioMinimized; themeStudioMinimized = this.modal.themeStudioMinimized;
isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen()); isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen());
activeThemeName = this.theme.activeThemeName; activeThemeName = this.theme.activeThemeName;
savedThemesAvailable = this.themeLibrary.isAvailable; savedThemesAvailable = this.themeLibrary.isAvailable;
savedThemes = this.themeLibrary.entries; savedThemes = this.themeLibrary.entries;
savedThemesBusy = this.themeLibrary.isBusy; savedThemesBusy = this.themeLibrary.isBusy;
selectedSavedTheme = this.themeLibrary.selectedEntry; selectedSavedTheme = this.themeLibrary.selectedEntry;
readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [ readonly globalPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
@@ -153,6 +155,7 @@ export class SettingsModalComponent {
{ id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' }, { id: 'data', label: this.appI18n.instant('settings.nav.data'), icon: 'lucideDownload' },
{ id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' } { id: 'debugging', label: this.appI18n.instant('settings.nav.debugging'), icon: 'lucideBug' }
]); ]);
readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [ readonly serverPages = computed<{ id: SettingsPage; label: string; icon: string }[]>(() => [
{ id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' }, { id: 'server', label: this.appI18n.instant('settings.nav.server'), icon: 'lucideSettings' },
{ id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' }, { id: 'serverPlugins', label: this.appI18n.instant('settings.nav.serverPlugins'), icon: 'lucidePackage' },
@@ -192,6 +195,7 @@ export class SettingsModalComponent {
}); });
selectedServerId = signal<string | null>(null); selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => { selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId(); const id = this.selectedServerId();
const currentRoom = this.currentRoom(); const currentRoom = this.currentRoom();
@@ -294,8 +298,25 @@ export class SettingsModalComponent {
}); });
animating = signal(false); animating = signal(false);
showThirdPartyLicenses = signal(false); showThirdPartyLicenses = signal(false);
private store = inject(Store);
private webrtc = inject(RealtimeSessionFacade);
private theme = inject(ThemeService);
private themeLibrary = inject(ThemeLibraryService);
private viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
private lastRequestedServerId: string | null = null;
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
constructor() { constructor() {
effect(() => { effect(() => {
if (!this.isOpen()) { if (!this.isOpen()) {
@@ -482,4 +503,5 @@ export class SettingsModalComponent {
this.themeLibrary.select(matchingTheme?.fileName ?? null); this.themeLibrary.select(matchingTheme?.fileName ?? null);
} }
} }

View File

@@ -31,23 +31,34 @@ type DesktopUpdateStatus =
templateUrl: './updates-settings.component.html' templateUrl: './updates-settings.component.html'
}) })
export class UpdatesSettingsComponent { export class UpdatesSettingsComponent {
private readonly appI18n = inject(AppI18nService);
readonly desktopUpdates = inject(DesktopAppUpdateService); readonly desktopUpdates = inject(DesktopAppUpdateService);
readonly mobileUpdates = inject(MobileAppUpdateService); readonly mobileUpdates = inject(MobileAppUpdateService);
readonly isElectron = this.desktopUpdates.isElectron; readonly isElectron = this.desktopUpdates.isElectron;
readonly isCapacitor = this.mobileUpdates.isCapacitor; readonly isCapacitor = this.mobileUpdates.isCapacitor;
readonly state = this.desktopUpdates.state; readonly state = this.desktopUpdates.state;
readonly mobileState = this.mobileUpdates.state; readonly mobileState = this.mobileUpdates.state;
readonly mobileStatusLabel = computed(() => readonly mobileStatusLabel = computed(() =>
this.getMobileStatusLabel(this.mobileState().status) this.getMobileStatusLabel(this.mobileState().status)
); );
readonly hasPendingManifestUrlChanges = signal(false); readonly hasPendingManifestUrlChanges = signal(false);
readonly manifestUrlsText = signal(''); readonly manifestUrlsText = signal('');
readonly statusLabel = computed(() => this.getStatusLabel(this.state().status)); readonly statusLabel = computed(() => this.getStatusLabel(this.state().status));
readonly isUsingConnectedServerDefaults = computed(() => { readonly isUsingConnectedServerDefaults = computed(() => {
return this.state().configuredManifestUrls.length === 0; return this.state().configuredManifestUrls.length === 0;
}); });
private readonly appI18n = inject(AppI18nService);
constructor() { constructor() {
effect(() => { effect(() => {
if (this.hasPendingManifestUrlChanges()) { if (this.hasPendingManifestUrlChanges()) {
@@ -178,4 +189,5 @@ export class UpdatesSettingsComponent {
return this.appI18n.instant(keyMap[status] ?? keyMap['idle']); return this.appI18n.instant(keyMap[status] ?? keyMap['idle']);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -53,13 +52,10 @@ interface AudioDevice {
templateUrl: './voice-settings.component.html' templateUrl: './voice-settings.component.html'
}) })
export class VoiceSettingsComponent { export class VoiceSettingsComponent {
private voiceConnection = inject(VoiceConnectionFacade);
private voicePlayback = inject(VoicePlaybackService);
private electronBridge = inject(ElectronBridgeService);
private platform = inject(PlatformService);
private readonly appI18n = inject(AppI18nService);
readonly audioService = inject(NotificationAudioService); readonly audioService = inject(NotificationAudioService);
readonly isElectron = this.platform.isElectron; readonly isElectron = this.platform.isElectron;
readonly screenShareQualityOptions = computed(() => readonly screenShareQualityOptions = computed(() =>
SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({ SCREEN_SHARE_QUALITY_OPTIONS.map((option) => ({
id: option.id, id: option.id,
@@ -69,26 +65,51 @@ export class VoiceSettingsComponent {
); );
inputDevices = signal<AudioDevice[]>([]); inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]); outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>(''); selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>(''); selectedOutputDevice = signal<string>('');
inputVolume = signal(100); inputVolume = signal(100);
outputVolume = signal(100); outputVolume = signal(100);
audioBitrate = signal(96); audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false); includeSystemAudio = signal(false);
noiseReduction = signal(true); noiseReduction = signal(true);
screenShareQuality = signal<ScreenShareQuality>('balanced'); screenShareQuality = signal<ScreenShareQuality>('balanced');
askScreenShareQuality = signal(true); askScreenShareQuality = signal(true);
hardwareAcceleration = signal(true); hardwareAcceleration = signal(true);
hardwareAccelerationRestartRequired = signal(false); hardwareAccelerationRestartRequired = signal(false);
readonly selectedScreenShareQualityDescription = computed( readonly selectedScreenShareQualityDescription = computed(
() => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? '' () => this.screenShareQualityOptions().find((option) => option.id === this.screenShareQuality())?.description ?? ''
); );
readonly notificationVolumePercent = computed(() => readonly notificationVolumePercent = computed(() =>
String(Math.round(this.audioService.notificationVolume() * 100)) String(Math.round(this.audioService.notificationVolume() * 100))
); );
private voiceConnection = inject(VoiceConnectionFacade);
private voicePlayback = inject(VoicePlaybackService);
private electronBridge = inject(ElectronBridgeService);
private platform = inject(PlatformService);
private readonly appI18n = inject(AppI18nService);
constructor() { constructor() {
this.loadVoiceSettings(); this.loadVoiceSettings();
this.loadAudioDevices(); this.loadAudioDevices();
@@ -291,4 +312,5 @@ export class VoiceSettingsComponent {
this.hardwareAcceleration.set(snapshot.hardwareAcceleration); this.hardwareAcceleration.set(snapshot.hardwareAcceleration);
this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired); this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired);
} }
} }

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -63,26 +62,40 @@ import { SelectOnFocusDirective, SubmitOnEnterDirective } from '../../shared/dir
* Settings page for managing signaling servers and connection preferences. * Settings page for managing signaling servers and connection preferences.
*/ */
export class SettingsComponent implements OnInit { export class SettingsComponent implements OnInit {
private serverDirectory = inject(ServerDirectoryFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private router = inject(Router);
private readonly appI18n = inject(AppI18nService);
audioService = inject(NotificationAudioService); audioService = inject(NotificationAudioService);
servers = this.serverDirectory.servers; servers = this.serverDirectory.servers;
activeServers = this.serverDirectory.activeServers; activeServers = this.serverDirectory.activeServers;
hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers; hasMissingDefaultServers = this.serverDirectory.hasMissingDefaultServers;
hasMultipleServers = computed(() => this.servers().length > 1); hasMultipleServers = computed(() => this.servers().length > 1);
hasMultipleActiveServers = computed(() => this.activeServers().length > 1); hasMultipleActiveServers = computed(() => this.activeServers().length > 1);
isTesting = signal(false); isTesting = signal(false);
addError = signal<string | null>(null); addError = signal<string | null>(null);
newServerName = ''; newServerName = '';
newServerUrl = ''; newServerUrl = '';
autoReconnect = true; autoReconnect = true;
searchAllServers = true; searchAllServers = true;
noiseReduction = true; noiseReduction = true;
private serverDirectory = inject(ServerDirectoryFacade);
private voiceConnection = inject(VoiceConnectionFacade);
private router = inject(Router);
private readonly appI18n = inject(AppI18nService);
/** Load persisted connection settings on component init. */ /** Load persisted connection settings on component init. */
ngOnInit(): void { ngOnInit(): void {
this.loadConnectionSettings(); this.loadConnectionSettings();
@@ -233,4 +246,5 @@ export class SettingsComponent implements OnInit {
await this.voiceConnection.toggleNoiseReduction(this.noiseReduction); await this.voiceConnection.toggleNoiseReduction(this.noiseReduction);
} }
} }

View File

@@ -59,13 +59,19 @@ const NON_TEXT_INPUT_TYPES = new Set([
}) })
export class NativeContextMenuComponent implements OnInit, OnDestroy { export class NativeContextMenuComponent implements OnInit, OnDestroy {
params = signal<ContextMenuParams | null>(null); params = signal<ContextMenuParams | null>(null);
customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null); customEmojiMenu = signal<(CustomEmojiContextMenuTarget & { posX: number; posY: number }) | null>(null);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly customEmoji = inject(CustomEmojiService); private readonly customEmoji = inject(CustomEmojiService);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly viewport = inject(ViewportService); private readonly viewport = inject(ViewportService);
private cleanup: (() => void) | null = null; private cleanup: (() => void) | null = null;
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null; private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
@HostListener('document:contextmenu', ['$event']) @HostListener('document:contextmenu', ['$event'])
@@ -709,4 +715,5 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
return typeof navigator !== 'undefined' return typeof navigator !== 'undefined'
&& (!!navigator.clipboard?.readText || this.electronBridge.isAvailable); && (!!navigator.clipboard?.readText || this.electronBridge.isAvailable);
} }
} }

View File

@@ -1,4 +1,3 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<div <div
appThemeNode="titleBar" appThemeNode="titleBar"
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none" class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -82,39 +81,37 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../core/i18n';
* Electron-style title bar with window controls, navigation, and server menu. * Electron-style title bar with window controls, navigation, and server menu.
*/ */
export class TitleBarComponent { export class TitleBarComponent {
private readonly appI18n = inject(AppI18nService);
private store = inject(Store);
private electronBridge = inject(ElectronBridgeService);
private serverDirectory = inject(ServerDirectoryFacade);
private router = inject(Router);
private webrtc = inject(RealtimeSessionFacade);
private platform = inject(PlatformService);
private voiceWorkspace = inject(VoiceWorkspaceService);
private settingsModal = inject(SettingsModalService);
private pluginRegistry = inject(PluginRegistryService);
private pluginRequirements = inject(PluginRequirementStateService);
private pluginStore = inject(PluginStoreService);
private getWindowControlsApi() {
return this.electronBridge.getApi();
}
isElectron = computed(() => this.platform.isElectron); isElectron = computed(() => this.platform.isElectron);
showMenuState = computed(() => false); showMenuState = computed(() => false);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest')); username = computed(() => this.currentUser()?.displayName || this.appI18n.instant('shell.titleBar.guest'));
serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer')); serverName = computed(() => this.serverDirectory.activeServer()?.name || this.appI18n.instant('shell.titleBar.noServer'));
isConnected = computed(() => this.webrtc.isConnected()); isConnected = computed(() => this.webrtc.isConnected());
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected()); isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUser()); isAuthed = computed(() => !!this.currentUser());
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
activeChannelId = this.store.selectSignal(selectActiveChannelId); activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels); textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels); voiceChannels = this.store.selectSignal(selectVoiceChannels);
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded; isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting); isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError); signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
isInDirectMessage = toSignal( isInDirectMessage = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -122,6 +119,7 @@ export class TitleBarComponent {
), ),
{ initialValue: this.router.url.startsWith('/dm/') } { initialValue: this.router.url.startsWith('/dm/') }
); );
isInRoomView = toSignal( isInRoomView = toSignal(
this.router.events.pipe( this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd), filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
@@ -129,8 +127,11 @@ export class TitleBarComponent {
), ),
{ initialValue: this.router.url.startsWith('/room/') } { initialValue: this.router.url.startsWith('/room/') }
); );
inRoom = computed(() => !!this.currentRoom() && this.isInRoomView()); inRoom = computed(() => !!this.currentRoom() && this.isInRoomView());
roomName = computed(() => this.currentRoom()?.name || ''); roomName = computed(() => this.currentRoom()?.name || '');
activeTextChannelName = computed(() => { activeTextChannelName = computed(() => {
const textChannels = this.textChannels(); const textChannels = this.textChannels();
@@ -143,12 +144,14 @@ export class TitleBarComponent {
return activeChannel ? activeChannel.name : id; return activeChannel ? activeChannel.name : id;
}); });
connectedVoiceChannelName = computed(() => { connectedVoiceChannelName = computed(() => {
const voiceChannelId = this.currentUser()?.voiceState?.roomId; const voiceChannelId = this.currentUser()?.voiceState?.roomId;
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId); const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge'); return voiceChannel?.name || this.appI18n.instant('shell.titleBar.voiceLounge');
}); });
roomContextMeta = computed(() => { roomContextMeta = computed(() => {
if (!this.currentRoom()) { if (!this.currentRoom()) {
return ''; return '';
@@ -162,9 +165,11 @@ export class TitleBarComponent {
return parts.join(' | '); return parts.join(' | ');
}); });
showRoomCompatibilityNotice = computed(() => showRoomCompatibilityNotice = computed(() =>
this.inRoom() && !!this.signalServerCompatibilityError() this.inRoom() && !!this.signalServerCompatibilityError()
); );
showRoomReconnectNotice = computed(() => showRoomReconnectNotice = computed(() =>
this.inRoom() this.inRoom()
&& !this.signalServerCompatibilityError() && !this.signalServerCompatibilityError()
@@ -174,21 +179,57 @@ export class TitleBarComponent {
|| this.isReconnecting() || this.isReconnecting()
) )
); );
serverPluginCount = computed(() => this.pluginRegistry.entries() serverPluginCount = computed(() => this.pluginRegistry.entries()
.filter((entry) => getPluginInstallScope(entry.manifest) === 'server') .filter((entry) => getPluginInstallScope(entry.manifest) === 'server')
.length); .length);
hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0); hasServerPlugins = computed(() => this.inRoom() && this.serverPluginCount() > 0);
requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements; requiredPluginRequirements = this.pluginRequirements.missingRequiredRequirements;
optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null); optionalPluginRequirement = computed(() => this.inRoom() ? this.pluginRequirements.visibleOptionalRequirements()[0] ?? null : null);
optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length); optionalPluginRequirementCount = computed(() => this.pluginRequirements.visibleOptionalRequirements().length);
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu()); showMenu = computed(() => this._showMenu());
showLeaveConfirm = signal(false); showLeaveConfirm = signal(false);
inviteStatus = signal<string | null>(null); inviteStatus = signal<string | null>(null);
creatingInvite = signal(false); creatingInvite = signal(false);
pluginRequirementBusy = signal(false); pluginRequirementBusy = signal(false);
pluginRequirementError = signal<string | null>(null); pluginRequirementError = signal<string | null>(null);
private readonly appI18n = inject(AppI18nService);
private store = inject(Store);
private electronBridge = inject(ElectronBridgeService);
private serverDirectory = inject(ServerDirectoryFacade);
private router = inject(Router);
private webrtc = inject(RealtimeSessionFacade);
private platform = inject(PlatformService);
private voiceWorkspace = inject(VoiceWorkspaceService);
private settingsModal = inject(SettingsModalService);
private pluginRegistry = inject(PluginRegistryService);
private pluginRequirements = inject(PluginRequirementStateService);
private pluginStore = inject(PluginStoreService);
private _showMenu = signal(false);
/** Minimize the Electron window. */ /** Minimize the Electron window. */
minimize() { minimize() {
const api = this.getWindowControlsApi(); const api = this.getWindowControlsApi();
@@ -256,15 +297,6 @@ export class TitleBarComponent {
} }
} }
/** Open the unified leave-server confirmation dialog. */
private openLeaveConfirm() {
this._showMenu.set(false);
if (this.currentRoom()) {
this.showLeaveConfirm.set(true);
}
}
/** Toggle the server dropdown menu. */ /** Toggle the server dropdown menu. */
toggleMenu() { toggleMenu() {
this.inviteStatus.set(null); this.inviteStatus.set(null);
@@ -354,6 +386,34 @@ export class TitleBarComponent {
this._showMenu.set(false); this._showMenu.set(false);
} }
/** Log out the current user, disconnect from signaling, and navigate to login. */
logout() {
this._showMenu.set(false);
// Disconnect from signaling server - this broadcasts "user_left" to all
// servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect();
clearStoredCurrentUserId();
this.store.dispatch(MessagesActions.clearMessages());
this.store.dispatch(RoomsActions.resetRoomsState());
this.store.dispatch(UsersActions.resetUsersState());
this.router.navigate(['/login']);
}
private getWindowControlsApi() {
return this.electronBridge.getApi();
}
/** Open the unified leave-server confirmation dialog. */
private openLeaveConfirm() {
this._showMenu.set(false);
if (this.currentRoom()) {
this.showLeaveConfirm.set(true);
}
}
private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> { private async installServerRequirements(requirements: PluginRequirementSummary[]): Promise<void> {
const room = this.currentRoom(); const room = this.currentRoom();
@@ -373,21 +433,6 @@ export class TitleBarComponent {
} }
} }
/** Log out the current user, disconnect from signaling, and navigate to login. */
logout() {
this._showMenu.set(false);
// Disconnect from signaling server - this broadcasts "user_left" to all
// servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect();
clearStoredCurrentUserId();
this.store.dispatch(MessagesActions.clearMessages());
this.store.dispatch(RoomsActions.resetRoomsState());
this.store.dispatch(UsersActions.resetUsersState());
this.router.navigate(['/login']);
}
private async copyInviteLink(inviteUrl: string): Promise<void> { private async copyInviteLink(inviteUrl: string): Promise<void> {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
try { try {
@@ -427,4 +472,5 @@ export class TitleBarComponent {
sourceUrl: room.sourceUrl sourceUrl: room.sourceUrl
}; };
} }
} }

View File

@@ -51,11 +51,11 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
types: [ types: [
{ {
id: 'INCOMING_CALL_ACTIONS', id: 'INCOMING_CALL_ACTIONS',
actions: [{ id: 'answer', title: mobileLabel('mobile.notifications.answer') }, { id: 'hangup', title: mobileLabel('mobile.notifications.decline') }] actions: this.incomingCallNotificationActions()
}, },
{ {
id: 'ACTIVE_CALL_ACTIONS', id: 'ACTIVE_CALL_ACTIONS',
actions: [{ id: 'mute', title: mobileLabel('mobile.notifications.mute') }, { id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') }] actions: this.activeCallNotificationActions()
} }
] ]
}); });
@@ -161,4 +161,18 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void { onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
this.actionHandler = handler; this.actionHandler = handler;
} }
private incomingCallNotificationActions() {
const answerAction = { id: 'answer', title: mobileLabel('mobile.notifications.answer') };
const hangupAction = { id: 'hangup', title: mobileLabel('mobile.notifications.decline') };
return [answerAction, hangupAction];
}
private activeCallNotificationActions() {
const muteAction = { id: 'mute', title: mobileLabel('mobile.notifications.mute') };
const hangupAction = { id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') };
return [muteAction, hangupAction];
}
} }

View File

@@ -8,14 +8,15 @@ import { MobileSqliteConnectionService } from '../../services/mobile-sqlite-conn
* Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells. * Domain persistence routes through {@link CapacitorDatabaseService} on Capacitor shells.
*/ */
export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter { export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapter {
private initialized = false;
constructor(private readonly connection: MobileSqliteConnectionService) {}
get isNativeSqlite(): boolean { get isNativeSqlite(): boolean {
return this.connection.isAvailable; return this.connection.isAvailable;
} }
private initialized = false;
constructor(private readonly connection: MobileSqliteConnectionService) {}
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.initialized) { if (this.initialized) {
return; return;
@@ -32,4 +33,5 @@ export class CapacitorMobilePersistenceAdapter implements MobilePersistenceAdapt
this.initialized = true; this.initialized = true;
console.info('[mobile] native SQLite persistence initialized'); console.info('[mobile] native SQLite persistence initialized');
} }
} }

View File

@@ -137,7 +137,8 @@ const SCHEMA_V2_MESSAGE_COLUMNS = [
'ALTER TABLE messages ADD COLUMN kind TEXT', 'ALTER TABLE messages ADD COLUMN kind TEXT',
'ALTER TABLE messages ADD COLUMN systemEvent TEXT' 'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
]; ];
const SCHEMA_V3_MESSAGE_COLUMNS = ['ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE messages ADD COLUMN headHash TEXT']; const SCHEMA_V3_REVISION_COLUMN = 'ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0';
const SCHEMA_V3_HEAD_HASH_COLUMN = 'ALTER TABLE messages ADD COLUMN headHash TEXT';
/** Returns DDL statements that still need to run for the stored schema version. */ /** Returns DDL statements that still need to run for the stored schema version. */
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] { export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {
@@ -157,7 +158,7 @@ export function resolveMobileSqliteMigrationStatements(storedVersion: number): s
} }
if (storedVersion < 3) { if (storedVersion < 3) {
statements.push(...SCHEMA_V3_MESSAGE_COLUMNS); statements.push(SCHEMA_V3_REVISION_COLUMN, SCHEMA_V3_HEAD_HASH_COLUMN);
statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '3')`); statements.push(`INSERT OR REPLACE INTO meta (key, value) VALUES ('${META_SCHEMA_VERSION_KEY}', '3')`);
} }

Some files were not shown because too many files have changed in this diff Show More