Compare commits

...

6 Commits

Author SHA1 Message Date
Myx
bb0ac930ad Improve attachment memory safety, downloads, and high-memory alert UX.
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 9m2s
Queue Release Build / build-windows (push) Successful in 28m8s
Queue Release Build / build-linux (push) Successful in 47m26s
Queue Release Build / build-android (push) Successful in 19m52s
Queue Release Build / finalize (push) Successful in 4m42s
Stream large receives to disk with chunk acks to cap renderer RAM, evict
off-screen display blobs, and route exports through a disk-aware download
service. Fix the high-memory dialog (backdrop dismiss, copy, log actions),
allow diagnostics paths in the path jail, and restore persisted image
hydration after reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 00:25:22 +02:00
Myx
f0d79aa627 fix: Bug - Files lose host on reload
Persist large uploads under app data on publish and restore, and re-announce hosted attachments after reload so peers can download again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 22:04:41 +02:00
Myx
95259e8943 fix: Bug - Sending files between users doesn't really work
Stream oversized generic attachments to disk instead of silently dropping chunks, avoid loading completed file downloads into renderer memory, and surface a clear error when the browser client cannot receive a file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:50:21 +02:00
Myx
924d4bbb1d fix: Bug - In direct voice call the status is displayed as offline
Resolve direct-call participant join state and DM peer status across user identity aliases so call UI no longer shows participants as disconnected when they are in the call.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:31:03 +02:00
Myx
baa350e90a fix: Bug - Users doesn't receive dm messages
Match direct messages against every local identity alias (home id and provisioned signal-server actor ids) so recipients accept traffic addressed to their per-server presence id instead of silently dropping it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 20:59:57 +02:00
Myx
b2a2d9d770 fix: Bug - Users appear as both online and offline
Align chat message sender ids with per-server presence identities so profile cards opened from message authors resolve the same live user state as the members panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 20:55:13 +02:00
91 changed files with 3461 additions and 478 deletions

View File

@@ -25,7 +25,9 @@ import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
import { import {
attachRendererDiagnosticsHooks, attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered, ensurePerfDiagIpcRegistered,
shutdownHighMemoryMonitoring,
shutdownPerfDiagnostics, shutdownPerfDiagnostics,
startHighMemoryMonitoring,
startPerfDiagnostics startPerfDiagnostics
} from '../diagnostics'; } from '../diagnostics';
@@ -39,6 +41,7 @@ function startLocalApiAfterWindowReady(): void {
export function registerAppLifecycle(): void { export function registerAppLifecycle(): void {
ensurePerfDiagIpcRegistered(); ensurePerfDiagIpcRegistered();
startHighMemoryMonitoring();
app.whenReady().then(async () => { app.whenReady().then(async () => {
const dockIconPath = getDockIconPath(); const dockIconPath = getDockIconPath();
@@ -83,6 +86,7 @@ export function registerAppLifecycle(): void {
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
prepareWindowForAppQuit(); prepareWindowForAppQuit();
shutdownHighMemoryMonitoring();
await shutdownPerfDiagnostics(); await shutdownPerfDiagnostics();
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {

View File

@@ -10,19 +10,21 @@ import { resolveReadablePath } from '../path-jail';
import { sumWorkingSetKb } from './process-metrics.rules'; import { sumWorkingSetKb } from './process-metrics.rules';
import { isPerfDiagEnabled } from './diagnostics.flags'; import { isPerfDiagEnabled } from './diagnostics.flags';
import { exceedsHighMemoryThreshold } from './high-memory-alert.rules'; import { exceedsHighMemoryThreshold } from './high-memory-alert.rules';
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules'; import { captureHighMemoryDiagnostics } from './high-memory-capture';
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
import { collectSessionContext } from './session-context.collector'; import { collectSessionContext } from './session-context.collector';
import { import {
clearHighMemoryAlert, clearHighMemoryAlert,
readHighMemoryAlert, readHighMemoryAlert,
writeHighMemoryAlert writeHighMemoryAlert,
type HighMemoryAlertRecord
} from './high-memory-alert.store'; } from './high-memory-alert.store';
import type { PerfDiagEntry } from './diagnostics.models'; import type { PerfDiagEntry } from './diagnostics.models';
import { PerfDiagWriter } from './diagnostics.writer'; import { PerfDiagWriter } from './diagnostics.writer';
const PROCESS_POLL_INTERVAL_MS = 5_000; const PROCESS_POLL_INTERVAL_MS = 5_000;
export const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
let activeWriter: PerfDiagWriter | null = null; let activeWriter: PerfDiagWriter | null = null;
let processPollTimer: NodeJS.Timeout | null = null; let processPollTimer: NodeJS.Timeout | null = null;
let diagnosticsEnabled = false; let diagnosticsEnabled = false;
@@ -67,6 +69,24 @@ export function ensurePerfDiagIpcRegistered(): void {
return true; return true;
}); });
ipcMain.handle('export-high-memory-diagnostics', async () => {
const metrics = collectAppMetricsSnapshot();
const totalKb = sumWorkingSetKb(metrics.processes) ?? 0;
const record = await captureHighMemoryDiagnostics({
userDataPath: app.getPath('userData'),
sessionStartedAt,
metrics,
totalWorkingSetKb: totalKb,
writer: activeWriter,
mainWindow: getMainWindow(),
reason: 'manual'
});
await persistAndNotifyHighMemoryAlert(record);
return record;
});
ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => { ipcMain.handle('show-log-file-in-folder', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) { if (typeof filePath !== 'string' || !filePath.trim()) {
return { return {
@@ -94,8 +114,48 @@ export function getActivePerfDiagWriter(): PerfDiagWriter | null {
return activeWriter; return activeWriter;
} }
export function startHighMemoryMonitoring(): void {
ensurePerfDiagIpcRegistered();
if (!sessionStartedAt) {
sessionStartedAt = Date.now();
highMemoryAlertTriggeredThisSession = false;
}
if (processPollTimer) {
return;
}
const sample = (): void => {
try {
const metrics = collectAppMetricsSnapshot();
const totalKb = sumWorkingSetKb(metrics.processes);
if (activeWriter && diagnosticsEnabled) {
activeWriter.append({
collectedAt: metrics.collectedAt,
source: 'main',
type: 'process',
payload: {
totalWorkingSetKb: totalKb,
processes: metrics.processes
}
});
}
void maybeTriggerHighMemoryAlert(metrics, totalKb);
} catch {
// Collector failures must never affect the app.
}
};
sample();
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
}
export function startPerfDiagnostics(): PerfDiagWriter | null { export function startPerfDiagnostics(): PerfDiagWriter | null {
ensurePerfDiagIpcRegistered(); ensurePerfDiagIpcRegistered();
startHighMemoryMonitoring();
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged); diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
if (!diagnosticsEnabled) { if (!diagnosticsEnabled) {
@@ -109,10 +169,7 @@ export function startPerfDiagnostics(): PerfDiagWriter | null {
}); });
activeWriter = writer; activeWriter = writer;
highMemoryAlertTriggeredThisSession = false;
sessionStartedAt = Date.now();
registerProcessCrashHandlers(writer); registerProcessCrashHandlers(writer);
startProcessMetricsPolling(writer);
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
@@ -188,14 +245,15 @@ export async function shutdownPerfDiagnostics(): Promise<void> {
} }
await activeWriter.flushSnapshot('shutdown'); await activeWriter.flushSnapshot('shutdown');
activeWriter = null;
diagnosticsEnabled = false;
}
export function shutdownHighMemoryMonitoring(): void {
if (processPollTimer) { if (processPollTimer) {
clearInterval(processPollTimer); clearInterval(processPollTimer);
processPollTimer = null; processPollTimer = null;
} }
activeWriter = null;
diagnosticsEnabled = false;
} }
function registerProcessCrashHandlers(writer: PerfDiagWriter): void { function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
@@ -241,34 +299,7 @@ function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
}); });
} }
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
const sample = (): void => {
try {
const metrics = collectAppMetricsSnapshot();
const totalKb = sumWorkingSetKb(metrics.processes);
writer.append({
collectedAt: metrics.collectedAt,
source: 'main',
type: 'process',
payload: {
totalWorkingSetKb: totalKb,
processes: metrics.processes
}
});
void maybeTriggerHighMemoryAlert(writer, metrics, totalKb);
} catch {
// Collector failures must never affect the app.
}
};
sample();
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
}
async function maybeTriggerHighMemoryAlert( async function maybeTriggerHighMemoryAlert(
writer: PerfDiagWriter,
metrics: AppMetricsSnapshot, metrics: AppMetricsSnapshot,
totalWorkingSetKb: number | null totalWorkingSetKb: number | null
): Promise<void> { ): Promise<void> {
@@ -278,51 +309,26 @@ async function maybeTriggerHighMemoryAlert(
highMemoryAlertTriggeredThisSession = true; highMemoryAlertTriggeredThisSession = true;
const detectedAt = Date.now(); const record = await captureHighMemoryDiagnostics({
const userDataPath = app.getPath('userData'); userDataPath: app.getPath('userData'),
const immediateRendererEntries = await collectImmediateRendererSamples(getMainWindow());
const environment = collectSessionContext({
sessionStartedAt, sessionStartedAt,
userDataPath
});
for (const entry of immediateRendererEntries) {
writer.append(entry);
}
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'environment',
payload: {
...environment
}
});
writer.append({
collectedAt: detectedAt,
source: 'main',
type: 'high-memory',
payload: buildHighMemoryDiagnosticPayload({
detectedAt,
totalWorkingSetKb: totalWorkingSetKb ?? 0,
metrics, metrics,
environment, totalWorkingSetKb: totalWorkingSetKb ?? 0,
mainProcessMemory: process.memoryUsage(), writer: activeWriter,
ringEntries: writer.bufferedEntries, mainWindow: getMainWindow(),
immediateRendererEntries, reason: 'threshold'
sessionId: writer.sessionId
})
}); });
await writer.flushSnapshot('high-memory-threshold'); await persistAndNotifyHighMemoryAlert(record);
}
await writeHighMemoryAlert(userDataPath, { async function persistAndNotifyHighMemoryAlert(record: HighMemoryAlertRecord): Promise<void> {
logFilePath: writer.snapshotFilePath, await writeHighMemoryAlert(app.getPath('userData'), record);
detectedAt, notifyHighMemoryAlert(record);
peakWorkingSetKb: totalWorkingSetKb ?? 0, }
sessionId: writer.sessionId
}); function notifyHighMemoryAlert(record: HighMemoryAlertRecord): void {
getMainWindow()?.webContents.send(HIGH_MEMORY_ALERT_PENDING_CHANNEL, record);
} }
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry { function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {

View File

@@ -33,7 +33,8 @@ describe('high-memory-alert.store', () => {
logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'), logFilePath: path.join(userDataPath, 'diagnostics', 'perf-session.jsonl'),
detectedAt: 1_700_000_000_000, detectedAt: 1_700_000_000_000,
peakWorkingSetKb: 2_200_000, peakWorkingSetKb: 2_200_000,
sessionId: 'session-1' sessionId: 'session-1',
reason: 'threshold' as const
}; };
await writeHighMemoryAlert(userDataPath, record); await writeHighMemoryAlert(userDataPath, record);

View File

@@ -1,11 +1,14 @@
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
export type HighMemoryAlertReason = 'manual' | 'threshold';
export interface HighMemoryAlertRecord { export interface HighMemoryAlertRecord {
logFilePath: string; logFilePath: string;
detectedAt: number; detectedAt: number;
peakWorkingSetKb: number; peakWorkingSetKb: number;
sessionId: string; sessionId: string;
reason?: HighMemoryAlertReason;
} }
export function resolveHighMemoryAlertPath(userDataPath: string): string { export function resolveHighMemoryAlertPath(userDataPath: string): string {
@@ -31,7 +34,10 @@ export async function readHighMemoryAlert(userDataPath: string): Promise<HighMem
logFilePath: parsed.logFilePath, logFilePath: parsed.logFilePath,
detectedAt: parsed.detectedAt, detectedAt: parsed.detectedAt,
peakWorkingSetKb: parsed.peakWorkingSetKb, peakWorkingSetKb: parsed.peakWorkingSetKb,
sessionId: parsed.sessionId sessionId: parsed.sessionId,
...(parsed.reason === 'manual' || parsed.reason === 'threshold'
? { reason: parsed.reason }
: {})
}; };
} catch { } catch {
return null; return null;

View File

@@ -0,0 +1,57 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import * as os from 'os';
import * as path from 'path';
import * as fsp from 'fs/promises';
import { captureHighMemoryDiagnostics } from './high-memory-capture';
vi.mock('./immediate-renderer-samples.collector', () => ({
collectImmediateRendererSamples: vi.fn(async () => [])
}));
vi.mock('./session-context.collector', () => ({
collectSessionContext: vi.fn(() => ({
platform: 'linux',
userDataPath: '/tmp/user-data'
}))
}));
describe('captureHighMemoryDiagnostics', () => {
let userDataPath = '';
beforeEach(async () => {
userDataPath = await fsp.mkdtemp(path.join(os.tmpdir(), 'metoyou-high-memory-capture-'));
});
it('writes a diagnostics snapshot and returns an alert record', async () => {
const record = await captureHighMemoryDiagnostics({
userDataPath,
sessionStartedAt: Date.now() - 60_000,
metrics: {
collectedAt: Date.now(),
processes: [
{
pid: 1,
type: 'Browser',
workingSetKb: 2_200_000
}
]
},
totalWorkingSetKb: 2_200_000,
writer: null,
mainWindow: null,
reason: 'manual'
});
expect(record.peakWorkingSetKb).toBe(2_200_000);
expect(record.reason).toBe('manual');
expect(record.logFilePath).toContain(userDataPath);
await expect(fsp.stat(record.logFilePath)).resolves.toBeDefined();
});
});

View File

@@ -0,0 +1,80 @@
import type { BrowserWindow } from 'electron';
import type { AppMetricsSnapshot } from '../app-metrics';
import { buildHighMemoryDiagnosticPayload } from './high-memory-snapshot.rules';
import { collectImmediateRendererSamples } from './immediate-renderer-samples.collector';
import { collectSessionContext } from './session-context.collector';
import type { HighMemoryAlertRecord } from './high-memory-alert.store';
import type { PerfDiagEntry } from './diagnostics.models';
import { PerfDiagWriter } from './diagnostics.writer';
export type HighMemoryCaptureReason = 'manual' | 'threshold';
export interface CaptureHighMemoryDiagnosticsInput {
userDataPath: string;
sessionStartedAt: number;
metrics: AppMetricsSnapshot;
totalWorkingSetKb: number;
writer: PerfDiagWriter | null;
mainWindow: BrowserWindow | null;
reason: HighMemoryCaptureReason;
}
export async function captureHighMemoryDiagnostics(
input: CaptureHighMemoryDiagnosticsInput
): Promise<HighMemoryAlertRecord> {
const detectedAt = Date.now();
const writer = input.writer ?? new PerfDiagWriter({
userDataPath: input.userDataPath,
sessionId: `${input.reason}-${detectedAt.toString(36)}-${process.pid}`
});
const immediateRendererEntries = await collectImmediateRendererSamples(input.mainWindow);
const environment = collectSessionContext({
sessionStartedAt: input.sessionStartedAt,
userDataPath: input.userDataPath
});
appendEntries(writer, immediateRendererEntries);
appendEntries(writer, [
{
collectedAt: detectedAt,
source: 'main',
type: 'environment',
payload: {
...environment
}
},
{
collectedAt: detectedAt,
source: 'main',
type: 'high-memory',
payload: buildHighMemoryDiagnosticPayload({
detectedAt,
totalWorkingSetKb: input.totalWorkingSetKb,
metrics: input.metrics,
environment,
mainProcessMemory: process.memoryUsage(),
ringEntries: writer.bufferedEntries,
immediateRendererEntries,
sessionId: writer.sessionId
})
}
]);
await writer.flushSnapshot(
input.reason === 'manual' ? 'manual-export' : 'high-memory-threshold'
);
return {
logFilePath: writer.snapshotFilePath,
detectedAt,
peakWorkingSetKb: input.totalWorkingSetKb,
sessionId: writer.sessionId,
reason: input.reason
};
}
function appendEntries(writer: PerfDiagWriter, entries: readonly PerfDiagEntry[]): void {
for (const entry of entries) {
writer.append(entry);
}
}

View File

@@ -15,8 +15,11 @@ export {
attachRendererDiagnosticsHooks, attachRendererDiagnosticsHooks,
ensurePerfDiagIpcRegistered, ensurePerfDiagIpcRegistered,
getActivePerfDiagWriter, getActivePerfDiagWriter,
HIGH_MEMORY_ALERT_PENDING_CHANNEL,
isPerfDiagActive, isPerfDiagActive,
shutdownHighMemoryMonitoring,
shutdownPerfDiagnostics, shutdownPerfDiagnostics,
startHighMemoryMonitoring,
startPerfDiagnostics startPerfDiagnostics
} from './diagnostics.lifecycle'; } from './diagnostics.lifecycle';
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models'; export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';

View File

@@ -0,0 +1,16 @@
import {
describe,
expect,
it
} from 'vitest';
import { isReadableRegularFile } from './file-read.rules';
describe('file-read.rules', () => {
it('accepts regular files', () => {
expect(isReadableRegularFile({ isFile: () => true })).toBe(true);
});
it('rejects directories and other non-file paths', () => {
expect(isReadableRegularFile({ isFile: () => false })).toBe(false);
});
});

View File

@@ -0,0 +1,6 @@
import type { Stats } from 'fs';
/** Only regular files can be read through the read-file IPC surface. */
export function isReadableRegularFile(stats: Pick<Stats, 'isFile'>): boolean {
return stats.isFile();
}

View File

@@ -68,6 +68,7 @@ import {
grantPluginReadRoot, grantPluginReadRoot,
resolveReadablePath resolveReadablePath
} from '../path-jail'; } from '../path-jail';
import { isReadableRegularFile } from './file-read.rules';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20; const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
@@ -654,9 +655,19 @@ export function setupSystemHandlers(): void {
return null; return null;
} }
try {
const stats = await fsp.stat(scopedPath);
if (!isReadableRegularFile(stats)) {
return null;
}
const data = await fsp.readFile(scopedPath); const data = await fsp.readFile(scopedPath);
return data.toString('base64'); return data.toString('base64');
} catch {
return null;
}
}); });
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => { ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
@@ -666,6 +677,13 @@ export function setupSystemHandlers(): void {
return null; return null;
} }
try {
const stats = await fsp.stat(scopedPath);
if (!isReadableRegularFile(stats)) {
return null;
}
const fileHandle = await fsp.open(scopedPath, 'r'); const fileHandle = await fsp.open(scopedPath, 'r');
try { try {
@@ -678,6 +696,9 @@ export function setupSystemHandlers(): void {
} finally { } finally {
await fileHandle.close(); await fileHandle.close();
} }
} catch {
return null;
}
}); });
ipcMain.handle('get-file-size', async (_event, filePath: string) => { ipcMain.handle('get-file-size', async (_event, filePath: string) => {
@@ -728,6 +749,17 @@ export function setupSystemHandlers(): void {
return true; return true;
}); });
ipcMain.handle('append-file-bytes', async (_event, filePath: string, bytes: Uint8Array) => {
const scopedPath = await resolveWritableUserDataFilePath(filePath);
if (!scopedPath) {
return false;
}
await fsp.appendFile(scopedPath, Buffer.from(bytes));
return true;
});
ipcMain.handle('delete-file', async (_event, filePath: string) => { ipcMain.handle('delete-file', async (_event, filePath: string) => {
const scopedPath = await resolveWritableUserDataFilePath(filePath); const scopedPath = await resolveWritableUserDataFilePath(filePath);

View File

@@ -35,6 +35,17 @@ describe('path-jail', () => {
await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath); await expect(assertPathUnderRoot(tempRoot, allowedPath, ['server'])).resolves.toBe(allowedPath);
}); });
it('accepts diagnostics log paths under diagnostics', async () => {
const diagnosticsDir = path.join(tempRoot, 'diagnostics');
fs.mkdirSync(diagnosticsDir, { recursive: true });
const logPath = path.join(diagnosticsDir, 'perf-session.jsonl');
fs.writeFileSync(logPath, '{}');
await expect(assertPathUnderRoot(tempRoot, logPath)).resolves.toBe(logPath);
});
it('accepts cached plugin bundle paths under plugin-bundles', async () => { it('accepts cached plugin bundle paths under plugin-bundles', async () => {
const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0'); const bundleDir = path.join(tempRoot, 'plugin-bundles', 'example.plugin', '1.0.0');

View File

@@ -9,7 +9,8 @@ export const DEFAULT_USER_DATA_SUBDIRS = [
'plugin-bundles', 'plugin-bundles',
'plugin-cache', 'plugin-cache',
'themes', 'themes',
'metoyou' 'metoyou',
'diagnostics'
] as const; ] as const;
export function isPathInside(parentPath: string, candidatePath: string): boolean { export function isPathInside(parentPath: string, candidatePath: string): boolean {

View File

@@ -11,6 +11,7 @@ const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received'; const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed'; const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
const HIGH_MEMORY_ALERT_PENDING_CHANNEL = 'high-memory-alert-pending';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -264,8 +265,23 @@ export interface ElectronAPI {
detectedAt: number; detectedAt: number;
peakWorkingSetKb: number; peakWorkingSetKb: number;
sessionId: string; sessionId: string;
reason?: 'manual' | 'threshold';
} | null>; } | null>;
acknowledgeHighMemoryAlert: () => Promise<boolean>; acknowledgeHighMemoryAlert: () => Promise<boolean>;
exportHighMemoryDiagnostics: () => Promise<{
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
reason?: 'manual' | 'threshold';
}>;
onHighMemoryAlertPending: (listener: (alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
reason?: 'manual' | 'threshold';
}) => void) => () => void;
showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>; showLogFileInFolder: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
@@ -335,6 +351,7 @@ export interface ElectronAPI {
grantPluginReadRoot: (rootPath: string) => Promise<boolean>; grantPluginReadRoot: (rootPath: string) => Promise<boolean>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>; appendFile: (filePath: string, data: string) => Promise<boolean>;
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>; openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
@@ -410,6 +427,23 @@ const electronAPI: ElectronAPI = {
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry), reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'), getPendingHighMemoryAlert: () => ipcRenderer.invoke('get-pending-high-memory-alert'),
acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'), acknowledgeHighMemoryAlert: () => ipcRenderer.invoke('acknowledge-high-memory-alert'),
exportHighMemoryDiagnostics: () => ipcRenderer.invoke('export-high-memory-diagnostics'),
onHighMemoryAlertPending: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}) => {
listener(alert);
};
ipcRenderer.on(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(HIGH_MEMORY_ALERT_PENDING_CHANNEL, wrappedListener);
};
},
showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath), showLogFileInFolder: (filePath) => ipcRenderer.invoke('show-log-file-in-folder', filePath),
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'), getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'), openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
@@ -478,6 +512,7 @@ const electronAPI: ElectronAPI = {
grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath), grantPluginReadRoot: (rootPath) => ipcRenderer.invoke('grant-plugin-read-root', rootPath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data), appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
appendFileBytes: (filePath, data) => ipcRenderer.invoke('append-file-bytes', filePath, data),
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName), saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath), openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),

View File

@@ -18,8 +18,10 @@
}, },
"highMemoryAlert": { "highMemoryAlert": {
"badge": "High memory usage", "badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session", "thresholdTitle": "The app is using {{usageGb}} GB of RAM",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.", "thresholdMessage": "MetoYou crossed the 2 GB memory threshold. A diagnostics log was saved so you can inspect what was using memory or share it with support.",
"manualTitle": "RAM diagnostics exported ({{usageGb}} GB in use)",
"manualMessage": "A snapshot of current memory usage was saved. Open the log, reveal it in your file manager, or copy the path to share with support.",
"openLog": "Open log file", "openLog": "Open log file",
"showInFolder": "Show in folder", "showInFolder": "Show in folder",
"copyPath": "Copy path", "copyPath": "Copy path",

View File

@@ -8,7 +8,8 @@
"chunksOutOfOrder": "Received media chunks out of order. Retry the download.", "chunksOutOfOrder": "Received media chunks out of order. Retry the download.",
"writeDownloadFailed": "Could not write media download to disk.", "writeDownloadFailed": "Could not write media download to disk.",
"openDownloadFailed": "Could not open completed media download from disk.", "openDownloadFailed": "Could not open completed media download from disk.",
"downloadFailed": "Media download failed. Retry the download." "downloadFailed": "Media download failed. Retry the download.",
"fileTooLarge": "This file is too large to download in this client. Use the desktop app or ask the sender to share a smaller file."
} }
} }
} }

View File

@@ -441,7 +441,9 @@
"title": "App-wide debugging", "title": "App-wide debugging",
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.", "description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
"processRam": "Process RAM", "processRam": "Process RAM",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.", "exportRamDiagnostics": "Export RAM diagnostics",
"exportRamDiagnosticsWorking": "Exporting...",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
"capturedEvents": "Captured events", "capturedEvents": "Captured events",
"lastUpdate": "Last update: {{label}}", "lastUpdate": "Last update: {{label}}",
"noLogsYet": "No logs yet", "noLogsYet": "No logs yet",

View File

@@ -18,8 +18,10 @@
}, },
"highMemoryAlert": { "highMemoryAlert": {
"badge": "High memory usage", "badge": "High memory usage",
"title": "The app used {{usageGb}} GB of RAM last session", "thresholdTitle": "The app is using {{usageGb}} GB of RAM",
"message": "A diagnostics log was saved in your app data folder. You can open it now or copy the path if you want to share it with support.", "thresholdMessage": "MetoYou crossed the 2 GB memory threshold. A diagnostics log was saved so you can inspect what was using memory or share it with support.",
"manualTitle": "RAM diagnostics exported ({{usageGb}} GB in use)",
"manualMessage": "A snapshot of current memory usage was saved. Open the log, reveal it in your file manager, or copy the path to share with support.",
"openLog": "Open log file", "openLog": "Open log file",
"showInFolder": "Show in folder", "showInFolder": "Show in folder",
"copyPath": "Copy path", "copyPath": "Copy path",
@@ -36,7 +38,8 @@
"chunksOutOfOrder": "Received media chunks out of order. Retry the download.", "chunksOutOfOrder": "Received media chunks out of order. Retry the download.",
"writeDownloadFailed": "Could not write media download to disk.", "writeDownloadFailed": "Could not write media download to disk.",
"openDownloadFailed": "Could not open completed media download from disk.", "openDownloadFailed": "Could not open completed media download from disk.",
"downloadFailed": "Media download failed. Retry the download." "downloadFailed": "Media download failed. Retry the download.",
"fileTooLarge": "This file is too large to download in this client. Use the desktop app or ask the sender to share a smaller file."
} }
}, },
"auth": { "auth": {
@@ -1506,7 +1509,9 @@
"title": "App-wide debugging", "title": "App-wide debugging",
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.", "description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
"processRam": "Process RAM", "processRam": "Process RAM",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.", "exportRamDiagnostics": "Export RAM diagnostics",
"exportRamDiagnosticsWorking": "Exporting...",
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds. Export saves a diagnostics log and opens the high-memory alert dialog.",
"capturedEvents": "Captured events", "capturedEvents": "Captured events",
"lastUpdate": "Last update: {{label}}", "lastUpdate": "Last update: {{label}}",
"noLogsYet": "No logs yet", "noLogsYet": "No logs yet",

View File

@@ -256,6 +256,7 @@ export interface ElectronHighMemoryAlertRecord {
detectedAt: number; detectedAt: number;
peakWorkingSetKb: number; peakWorkingSetKb: number;
sessionId: string; sessionId: string;
reason?: 'manual' | 'threshold';
} }
export interface ElectronApi { export interface ElectronApi {
@@ -281,6 +282,8 @@ export interface ElectronApi {
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>; reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>; getPendingHighMemoryAlert?: () => Promise<ElectronHighMemoryAlertRecord | null>;
acknowledgeHighMemoryAlert?: () => Promise<boolean>; acknowledgeHighMemoryAlert?: () => Promise<boolean>;
exportHighMemoryDiagnostics?: () => Promise<ElectronHighMemoryAlertRecord>;
onHighMemoryAlertPending?: (listener: (alert: ElectronHighMemoryAlertRecord) => void) => () => void;
showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>; showLogFileInFolder?: (filePath: string) => Promise<{ shown: boolean; reason?: string }>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
openCurrentDataFolder: () => Promise<boolean>; openCurrentDataFolder: () => Promise<boolean>;
@@ -319,6 +322,7 @@ export interface ElectronApi {
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>; grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>; appendFile: (filePath: string, data: string) => Promise<boolean>;
appendFileBytes: (filePath: string, data: Uint8Array) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>; openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;

View File

@@ -4,20 +4,22 @@ import { ElectronBridgeService } from './electron/electron-bridge.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlatformService { export class PlatformService {
readonly isElectron: boolean;
readonly isCapacitor: boolean; readonly isCapacitor: boolean;
readonly isBrowser: boolean; readonly isBrowser: boolean;
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
constructor() { constructor() {
this.isElectron = this.electronBridge.isAvailable; const isElectron = this.electronBridge.isAvailable;
const runtime = detectRuntimePlatform({ const runtime = detectRuntimePlatform({
hasElectronApi: this.isElectron, hasElectronApi: isElectron,
capacitorIsNative: isCapacitorNativeRuntime() capacitorIsNative: isCapacitorNativeRuntime()
}); });
this.isCapacitor = runtime === 'capacitor'; this.isCapacitor = runtime === 'capacitor';
this.isBrowser = runtime === 'browser'; this.isBrowser = runtime === 'browser';
} }
get isElectron(): boolean {
return this.electronBridge.isAvailable;
}
} }

View File

@@ -0,0 +1,164 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DOCUMENT } from '@angular/common';
import { Injector, runInInjectionContext } from '@angular/core';
import { DesktopHighMemoryAlertService } from './desktop-high-memory-alert.service';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
describe('DesktopHighMemoryAlertService', () => {
let electronBridge: {
isAvailable: boolean;
getApi: ReturnType<typeof vi.fn>;
};
let documentStub: Document;
beforeEach(() => {
documentStub = {
body: null,
createElement: vi.fn(),
execCommand: vi.fn(() => true)
} as unknown as Document;
electronBridge = {
isAvailable: true,
getApi: vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => ({
logFilePath: '/tmp/diagnostics/session.ndjson',
detectedAt: 1,
peakWorkingSetKb: 2_200_000,
sessionId: 'session-1'
})),
onHighMemoryAlertPending: vi.fn(() => () => undefined),
exportHighMemoryDiagnostics: vi.fn(async () => ({
logFilePath: '/tmp/diagnostics/manual.ndjson',
detectedAt: 2,
peakWorkingSetKb: 1_800_000,
sessionId: 'session-2',
reason: 'manual' as const
})),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}))
};
});
function createService(): DesktopHighMemoryAlertService {
const injector = Injector.create({
providers: [
DesktopHighMemoryAlertService,
{ provide: ElectronBridgeService, useValue: electronBridge },
{ provide: DOCUMENT, useValue: documentStub }
]
});
return runInInjectionContext(injector, () => injector.get(DesktopHighMemoryAlertService));
}
it('loads a pending alert from disk on initialize', async () => {
const service = createService();
await service.initialize();
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/session.ndjson');
expect(service.peakUsageGb()).toBe('2.10');
});
it('shows the modal when a live high-memory alert event arrives', async () => {
let listener: ((alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
}) => void) | undefined;
electronBridge.getApi = vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => null),
onHighMemoryAlertPending: vi.fn((callback) => {
listener = callback;
return () => undefined;
}),
exportHighMemoryDiagnostics: vi.fn(async () => null),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}));
const service = createService();
await service.initialize();
listener?.({
logFilePath: '/tmp/diagnostics/live.ndjson',
detectedAt: 3,
peakWorkingSetKb: 2_400_000,
sessionId: 'session-3'
});
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/live.ndjson');
});
it('exports diagnostics manually and opens the modal with manual copy', async () => {
const service = createService();
await expect(service.exportDiagnostics()).resolves.toBe(true);
expect(service.pendingAlert()?.logFilePath).toBe('/tmp/diagnostics/manual.ndjson');
expect(service.pendingAlert()?.reason).toBe('manual');
expect(service.titleKey()).toBe('app.highMemoryAlert.manualTitle');
expect(service.messageKey()).toBe('app.highMemoryAlert.manualMessage');
});
it('uses threshold copy for live high-memory alerts', async () => {
let listener: ((alert: {
logFilePath: string;
detectedAt: number;
peakWorkingSetKb: number;
sessionId: string;
reason?: 'manual' | 'threshold';
}) => void) | undefined;
electronBridge.getApi = vi.fn(() => ({
getPendingHighMemoryAlert: vi.fn(async () => null),
onHighMemoryAlertPending: vi.fn((callback) => {
listener = callback;
return () => undefined;
}),
exportHighMemoryDiagnostics: vi.fn(async () => null),
acknowledgeHighMemoryAlert: vi.fn(async () => true)
}));
const service = createService();
await service.initialize();
listener?.({
logFilePath: '/tmp/diagnostics/live.ndjson',
detectedAt: 3,
peakWorkingSetKb: 2_400_000,
sessionId: 'session-3',
reason: 'threshold'
});
expect(service.titleKey()).toBe('app.highMemoryAlert.thresholdTitle');
expect(service.messageKey()).toBe('app.highMemoryAlert.thresholdMessage');
});
it('copies the diagnostics log path to the clipboard', async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
});
const service = createService();
await service.initialize();
await expect(service.copyLogPath()).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('/tmp/diagnostics/session.ndjson');
});
});

View File

@@ -4,16 +4,21 @@ import {
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { PlatformService } from '../platform';
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules'; import { formatKilobytesAsGigabytes } from '../platform/electron/electron-app-metrics.rules';
import {
resolveHighMemoryAlertCopyKind,
resolveHighMemoryAlertMessageKey,
resolveHighMemoryAlertTitleKey
} from './high-memory-alert-copy.rules';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DesktopHighMemoryAlertService { export class DesktopHighMemoryAlertService {
private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly document = inject(DOCUMENT);
readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null); readonly pendingAlert = signal<ElectronHighMemoryAlertRecord | null>(null);
@@ -23,24 +28,55 @@ export class DesktopHighMemoryAlertService {
return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null; return alert ? formatKilobytesAsGigabytes(alert.peakWorkingSetKb) : null;
}); });
readonly titleKey = computed(() => resolveHighMemoryAlertTitleKey(
resolveHighMemoryAlertCopyKind(this.pendingAlert())
));
readonly messageKey = computed(() => resolveHighMemoryAlertMessageKey(
resolveHighMemoryAlertCopyKind(this.pendingAlert())
));
private initialized = false;
private removePendingListener: (() => void) | null = null;
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (!this.platform.isElectron) { if (!this.electronBridge.isAvailable || this.initialized) {
return; return;
} }
this.initialized = true;
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();
if (!api?.getPendingHighMemoryAlert) { if (!api) {
return; return;
} }
const alert = await api.getPendingHighMemoryAlert(); this.removePendingListener?.();
this.removePendingListener = api.onHighMemoryAlertPending?.((alert) => {
this.pendingAlert.set(alert);
}) ?? null;
const alert = await api.getPendingHighMemoryAlert?.();
if (alert) { if (alert) {
this.pendingAlert.set(alert); this.pendingAlert.set(alert);
} }
} }
async exportDiagnostics(): Promise<boolean> {
const api = this.electronBridge.getApi();
const alert = await api?.exportHighMemoryDiagnostics?.();
if (!alert) {
return false;
}
this.pendingAlert.set(alert);
return true;
}
async dismiss(): Promise<void> { async dismiss(): Promise<void> {
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();
@@ -70,13 +106,49 @@ export class DesktopHighMemoryAlertService {
await api.showLogFileInFolder(alert.logFilePath); await api.showLogFileInFolder(alert.logFilePath);
} }
async copyLogPath(): Promise<void> { async copyLogPath(): Promise<boolean> {
const alert = this.pendingAlert(); const alert = this.pendingAlert();
if (!alert?.logFilePath) { if (!alert?.logFilePath) {
return; return false;
} }
await navigator.clipboard.writeText(alert.logFilePath); return await this.writeTextToClipboard(alert.logFilePath);
}
private async writeTextToClipboard(value: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {}
}
const body = this.document.body;
if (!body) {
return false;
}
const textarea = this.document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
body.appendChild(textarea);
textarea.focus();
textarea.select();
let copied = false;
try {
copied = this.document.execCommand('copy');
} catch {}
body.removeChild(textarea);
return copied;
} }
} }

View File

@@ -0,0 +1,47 @@
import {
describe,
expect,
it
} from 'vitest';
import {
resolveHighMemoryAlertCopyKind,
resolveHighMemoryAlertMessageKey,
resolveHighMemoryAlertTitleKey
} from './high-memory-alert-copy.rules';
describe('high-memory-alert-copy.rules', () => {
it('uses threshold copy for live alerts and legacy records without a reason', () => {
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 2_100_000,
sessionId: 'session-1'
})).toBe('threshold');
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 2_100_000,
sessionId: 'session-1',
reason: 'threshold'
})).toBe('threshold');
});
it('uses manual copy for exported diagnostics', () => {
expect(resolveHighMemoryAlertCopyKind({
logFilePath: '/tmp/log.jsonl',
detectedAt: 1,
peakWorkingSetKb: 1_800_000,
sessionId: 'session-2',
reason: 'manual'
})).toBe('manual');
});
it('maps copy kinds to translation keys', () => {
expect(resolveHighMemoryAlertTitleKey('threshold')).toBe('app.highMemoryAlert.thresholdTitle');
expect(resolveHighMemoryAlertTitleKey('manual')).toBe('app.highMemoryAlert.manualTitle');
expect(resolveHighMemoryAlertMessageKey('threshold')).toBe('app.highMemoryAlert.thresholdMessage');
expect(resolveHighMemoryAlertMessageKey('manual')).toBe('app.highMemoryAlert.manualMessage');
});
});

View File

@@ -0,0 +1,21 @@
import type { ElectronHighMemoryAlertRecord } from '../platform/electron/electron-api.models';
export type HighMemoryAlertCopyKind = 'threshold' | 'manual';
export function resolveHighMemoryAlertCopyKind(
alert: ElectronHighMemoryAlertRecord | null | undefined
): HighMemoryAlertCopyKind {
return alert?.reason === 'manual' ? 'manual' : 'threshold';
}
export function resolveHighMemoryAlertTitleKey(kind: HighMemoryAlertCopyKind): string {
return kind === 'manual'
? 'app.highMemoryAlert.manualTitle'
: 'app.highMemoryAlert.thresholdTitle';
}
export function resolveHighMemoryAlertMessageKey(kind: HighMemoryAlertCopyKind): string {
return kind === 'manual'
? 'app.highMemoryAlert.manualMessage'
: 'app.highMemoryAlert.thresholdMessage';
}

View File

@@ -107,12 +107,15 @@ Concurrent triggers (file-announce, message sync, peer connect) can race to requ
- **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window. - **Requester:** `requestFromAnyPeer` marks the request pending *synchronously* before any async work, so the manager's `hasPendingRequest` gate closes the double-request race window.
- **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer. - **Sender:** `handleFileRequest` / `fulfillRequestWithFile` track active outbound streams per `(messageId, fileId, peerId)` and ignore duplicate requests while a stream is in flight. A fresh `file-request` clears any earlier `file-cancel` marker from that peer.
- **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. - **Receiver:** chunk buffers are dense (`Array.from({ length: total })`, never sparse `new Array(total)`); a chunk index that is already buffered is ignored entirely and never counts toward `receivedBytes`; a transfer finalizes only when *every* chunk index is present — byte counters are never a substitute for chunk completeness. Assembly state is released only after the attachment is marked `available`, and chunks arriving for an already-available attachment are dropped. When the active store supports streaming (`canStreamToDisk`), **all** persistable downloads append directly to disk — metadata `filePath` does not force an in-memory assembly fallback. Disk-streamed receives decode each chunk once, append bytes through Electron IPC (`append-file-bytes`), and acknowledge the sender with `file-chunk-ack` so only one chunk is in flight at a time (preventing unbounded base64 retention in the renderer). Completed media stays on `savedPath` until inline display hydration runs on demand.
- **Sender:** after each `file-chunk` the transport awaits the matching `file-chunk-ack` before sending the next chunk, in addition to data-channel bufferedAmount back-pressure.
### Failure handling ### Failure handling
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress. If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
Peers that finish downloading a file re-announce it and register themselves as mirror hosts. New download requests prefer mirror hosts over the original uploader so the sharer's device is not the only upload source. Repeat `file-announce` events for already-known attachments update the host list but do not re-trigger auto-download.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant R as Receiver participant R as Receiver
@@ -155,6 +158,7 @@ An optional experimental VLC.js adapter can be enabled from General settings. Wh
- `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device). - `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device).
- `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths. - `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths.
- `canHostAttachment(attachment)` — alias of `deviceHasLocalCopy`; any peer with local bytes can serve downloads.
- `isSharingFromThisDevice(attachment, currentUserId)``isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state. - `isSharingFromThisDevice(attachment, currentUserId)``isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state.
The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device). The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device).
@@ -195,3 +199,14 @@ Room and conversation names are sanitised to remove filesystem-unsafe characters
- **cancellations**: IDs of transfers the user cancelled - **cancellations**: IDs of transfers the user cancelled
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service. Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
### Display blob lifecycle (memory)
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
- **Room restore** (`restoreLocalAttachmentsForRoom`) resolves `savedPath` for hosting only — it does not hydrate every image blob up front.
- **Visibility** (`ChatMessageItemComponent` + `IntersectionObserver` on the chat scrollport) hydrates blobs when a message enters view (with `ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN`) and revokes them when it leaves, as long as a disk path can rehydrate later (`canRevokeAttachmentDisplayBlob`).
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
- **Serving** is unaffected: peers still download from `savedPath` / `filePath`; blob URLs are display-only.
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).

View File

@@ -75,6 +75,24 @@ export class AttachmentFacade {
return this.manager.tryRestoreAttachmentFromLocal(...args); return this.manager.tryRestoreAttachmentFromLocal(...args);
} }
pinDisplayBlobs(
...args: Parameters<AttachmentManagerService['pinDisplayBlobs']>
): ReturnType<AttachmentManagerService['pinDisplayBlobs']> {
return this.manager.pinDisplayBlobs(...args);
}
unpinDisplayBlobs(
...args: Parameters<AttachmentManagerService['unpinDisplayBlobs']>
): ReturnType<AttachmentManagerService['unpinDisplayBlobs']> {
return this.manager.unpinDisplayBlobs(...args);
}
revokeOffscreenDisplayBlobsForMessage(
...args: Parameters<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']>
): ReturnType<AttachmentManagerService['revokeOffscreenDisplayBlobsForMessage']> {
return this.manager.revokeOffscreenDisplayBlobsForMessage(...args);
}
requestFile( requestFile(
...args: Parameters<AttachmentManagerService['requestFile']> ...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> { ): ReturnType<AttachmentManagerService['requestFile']> {
@@ -99,6 +117,12 @@ export class AttachmentFacade {
return this.manager.handleFileChunk(...args); return this.manager.handleFileChunk(...args);
} }
handleFileChunkAck(
...args: Parameters<AttachmentManagerService['handleFileChunkAck']>
): ReturnType<AttachmentManagerService['handleFileChunkAck']> {
return this.manager.handleFileChunkAck(...args);
}
handleFileRequest( handleFileRequest(
...args: Parameters<AttachmentManagerService['handleFileRequest']> ...args: Parameters<AttachmentManagerService['handleFileRequest']>
): ReturnType<AttachmentManagerService['handleFileRequest']> { ): ReturnType<AttachmentManagerService['handleFileRequest']> {

View File

@@ -0,0 +1,37 @@
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
describe('AttachmentChunkAckService', () => {
let service: AttachmentChunkAckService;
beforeEach(() => {
service = new AttachmentChunkAckService();
});
it('resolves a waiter when the matching chunk ack arrives', async () => {
const waitPromise = service.waitForAck('msg-1', 'file-1', 0, 1_000);
service.resolveAck('msg-1', 'file-1', 0);
await expect(waitPromise).resolves.toBeUndefined();
});
it('times out when no ack arrives', async () => {
vi.useFakeTimers();
const waitPromise = service.waitForAck('msg-1', 'file-1', 1, 50);
vi.advanceTimersByTime(51);
await expect(waitPromise).rejects.toThrow('attachment chunk ack timeout');
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { buildAttachmentChunkAckKey } from '../../domain/logic/attachment-chunk-ack.rules';
@Injectable({ providedIn: 'root' })
export class AttachmentChunkAckService {
private readonly waiters = new Map<string, () => void>();
waitForAck(
messageId: string,
fileId: string,
index: number,
timeoutMs = 60_000
): Promise<void> {
const key = buildAttachmentChunkAckKey(messageId, fileId, index);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.waiters.delete(key);
reject(new Error('attachment chunk ack timeout'));
}, timeoutMs);
this.waiters.set(key, () => {
clearTimeout(timer);
this.waiters.delete(key);
resolve();
});
});
}
resolveAck(messageId: string, fileId: string, index: number): void {
this.waiters.get(buildAttachmentChunkAckKey(messageId, fileId, index))?.();
}
cancelPendingForFile(messageId: string, fileId: string): void {
const prefix = `${messageId}:${fileId}:`;
for (const [key, resolve] of this.waiters) {
if (!key.startsWith(prefix)) {
continue;
}
resolve();
this.waiters.delete(key);
}
}
}

View File

@@ -0,0 +1,97 @@
import '@angular/compiler';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { DOCUMENT } from '@angular/common';
import { Injector, runInInjectionContext } from '@angular/core';
import { AttachmentDownloadService } from './attachment-download.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../../domain/models/attachment.model';
describe('AttachmentDownloadService', () => {
let electronBridge: {
isAvailable: boolean;
getApi: ReturnType<typeof vi.fn>;
};
let documentStub: Document;
let saveExistingFileAs: ReturnType<typeof vi.fn>;
let saveFileAs: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
electronBridge = {
isAvailable: true,
getApi: vi.fn(() => ({
saveExistingFileAs,
saveFileAs
}))
};
documentStub = {
body: {
appendChild: vi.fn(),
removeChild: vi.fn()
},
createElement: vi.fn(() => ({
click: vi.fn(),
remove: vi.fn(),
href: '',
download: ''
}))
} as unknown as Document;
});
function createService(): AttachmentDownloadService {
const injector = Injector.create({
providers: [
AttachmentDownloadService,
{ provide: ElectronBridgeService, useValue: electronBridge },
{ provide: DOCUMENT, useValue: documentStub }
]
});
return runInInjectionContext(injector, () => injector.get(AttachmentDownloadService));
}
it('exports a completed disk-only attachment through Electron save dialog', async () => {
const service = createService();
const attachment: Attachment = {
id: 'file-1',
messageId: 'message-1',
filename: 'large.bin',
mime: 'application/octet-stream',
size: 5_000_000_000,
available: true,
savedPath: '/appdata/server/room/files/large.bin'
};
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
expect(saveExistingFileAs).toHaveBeenCalledWith('/appdata/server/room/files/large.bin', 'large.bin');
expect(saveFileAs).not.toHaveBeenCalled();
});
it('does nothing when the attachment is not downloadable yet', async () => {
const service = createService();
const attachment: Attachment = {
id: 'file-2',
messageId: 'message-2',
filename: 'large.bin',
mime: 'application/octet-stream',
size: 5_000_000_000,
available: true
};
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(false);
expect(saveExistingFileAs).not.toHaveBeenCalled();
expect(saveFileAs).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,97 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
import type { Attachment } from '../../domain/models/attachment.model';
@Injectable({ providedIn: 'root' })
export class AttachmentDownloadService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly document = inject(DOCUMENT);
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
if (!canDownloadAttachment(attachment)) {
return false;
}
const electronApi = this.electronBridge.getApi();
const diskPath = resolveAttachmentDiskPath(attachment);
if (electronApi) {
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return true;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled) {
return true;
}
} catch {
/* fall back to browser download */
}
}
}
if (!attachment.objectUrl) {
return false;
}
const link = this.document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
this.document.body?.appendChild(link);
link.click();
link.remove();
return true;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl || attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
}

View File

@@ -4,9 +4,13 @@ import {
inject inject
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { take } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules'; import { yieldToAttachmentHydrationLoop } from '../../domain/logic/attachment-blob.rules';
import { buildAttachmentDisplayPinKey, shouldRevokeDisplayBlobForAttachment } from '../../domain/logic/attachment-blob-eviction.rules';
import { import {
getWatchedAttachmentRoomIdFromUrl, getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId, isDirectMessageAttachmentRoomId,
@@ -17,6 +21,7 @@ import type {
FileAnnouncePayload, FileAnnouncePayload,
FileCancelPayload, FileCancelPayload,
FileChunkPayload, FileChunkPayload,
FileChunkAckPayload,
FileNotFoundPayload, FileNotFoundPayload,
FileRequestPayload FileRequestPayload
} from '../../domain/models/attachment-transfer.model'; } from '../../domain/models/attachment-transfer.model';
@@ -32,6 +37,7 @@ export class AttachmentManagerService {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly database = inject(DatabaseService); private readonly database = inject(DatabaseService);
private readonly runtimeStore = inject(AttachmentRuntimeStore); private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly persistence = inject(AttachmentPersistenceService); private readonly persistence = inject(AttachmentPersistenceService);
@@ -40,14 +46,16 @@ export class AttachmentManagerService {
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false; private isDatabaseInitialised = false;
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>(); private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
private pinnedDisplayBlobKeys = new Set<string>();
constructor() { constructor() {
effect(() => { effect(() => {
if (this.database.isReady() && !this.isDatabaseInitialised) { if (this.database.isReady() && !this.isDatabaseInitialised) {
this.isDatabaseInitialised = true; this.isDatabaseInitialised = true;
void this.persistence.initFromDatabase().then(() => { void this.persistence.initFromDatabase().then(async () => {
if (this.watchedRoomId) { if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId); await this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
await this.announceHostedAttachments();
} }
}); });
} }
@@ -68,7 +76,10 @@ export class AttachmentManagerService {
this.webrtc.onPeerConnected.subscribe(() => { this.webrtc.onPeerConnected.subscribe(() => {
if (this.watchedRoomId) { if (this.watchedRoomId) {
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId); void this.restoreLocalAttachmentsForRoom(this.watchedRoomId).then(async () => {
await this.announceHostedAttachments();
});
void this.requestAutoDownloadsForRoom(this.watchedRoomId); void this.requestAutoDownloadsForRoom(this.watchedRoomId);
} }
}); });
@@ -152,6 +163,48 @@ export class AttachmentManagerService {
return restored; return restored;
} }
pinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
for (const attachment of attachments) {
if (!attachment.messageId || !attachment.id) {
continue;
}
this.pinnedDisplayBlobKeys.add(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
}
}
unpinDisplayBlobs(attachments: readonly Pick<Attachment, 'id' | 'messageId'>[]): void {
for (const attachment of attachments) {
if (!attachment.messageId || !attachment.id) {
continue;
}
this.pinnedDisplayBlobKeys.delete(buildAttachmentDisplayPinKey(attachment.messageId, attachment.id));
}
}
revokeOffscreenDisplayBlobsForMessage(messageId: string): void {
if (!messageId) {
return;
}
let hasChanges = false;
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (!shouldRevokeDisplayBlobForAttachment(messageId, attachment, this.pinnedDisplayBlobKeys)) {
continue;
}
if (this.persistence.revokeAttachmentDisplayBlob(attachment)) {
hasChanges = true;
}
}
if (hasChanges) {
this.runtimeStore.touch();
}
}
requestFile(messageId: string, attachment: Attachment): Promise<void> { requestFile(messageId: string, attachment: Attachment): Promise<void> {
return this.transfer.requestFile(messageId, attachment); return this.transfer.requestFile(messageId, attachment);
} }
@@ -165,9 +218,9 @@ export class AttachmentManagerService {
} }
handleFileAnnounce(payload: FileAnnouncePayload): void { handleFileAnnounce(payload: FileAnnouncePayload): void {
this.transfer.handleFileAnnounce(payload); const isNew = this.transfer.handleFileAnnounce(payload);
if (payload.messageId && payload.file?.id) { if (isNew && payload.messageId && payload.file?.id) {
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id); this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
} }
} }
@@ -176,6 +229,10 @@ export class AttachmentManagerService {
this.transfer.handleFileChunk(payload); this.transfer.handleFileChunk(payload);
} }
handleFileChunkAck(payload: FileChunkAckPayload): void {
this.transfer.handleFileChunkAck(payload);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> { async handleFileRequest(payload: FileRequestPayload): Promise<void> {
await this.transfer.handleFileRequest(payload); await this.transfer.handleFileRequest(payload);
} }
@@ -210,7 +267,7 @@ export class AttachmentManagerService {
for (const messageId of messageIds) { for (const messageId of messageIds) {
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) { for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) { if (await this.persistence.tryRestoreAttachmentHostOnly(attachment)) {
hasChanges = true; hasChanges = true;
await yieldToAttachmentHydrationLoop(); await yieldToAttachmentHydrationLoop();
} }
@@ -324,6 +381,15 @@ export class AttachmentManagerService {
return getWatchedAttachmentRoomIdFromUrl(url); return getWatchedAttachmentRoomIdFromUrl(url);
} }
private async announceHostedAttachments(): Promise<void> {
const currentUserId = await new Promise<string | null>((resolve) => {
this.store.select(selectCurrentUserId).pipe(take(1))
.subscribe((userId) => resolve(userId));
});
await this.transfer.reannounceHostedAttachments(currentUserId);
}
private isRoomWatched(roomId: string | null | undefined): boolean { private isRoomWatched(roomId: string | null | undefined): boolean {
return !!roomId && roomId === this.watchedRoomId; return !!roomId && roomId === this.watchedRoomId;
} }

View File

@@ -12,6 +12,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { DatabaseService } from '../../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
@@ -51,6 +52,7 @@ describe('AttachmentPersistenceService', () => {
savedPath: '/appdata/photo.png' savedPath: '/appdata/photo.png'
} }
])), ])),
getAttachmentsForMessage: vi.fn(() => Promise.resolve([])),
getMessageById: vi.fn(() => Promise.resolve(null)), getMessageById: vi.fn(() => Promise.resolve(null)),
saveAttachment: vi.fn(() => Promise.resolve()), saveAttachment: vi.fn(() => Promise.resolve()),
deleteAttachmentsForMessage: vi.fn(() => Promise.resolve()) deleteAttachmentsForMessage: vi.fn(() => Promise.resolve())
@@ -64,6 +66,9 @@ describe('AttachmentPersistenceService', () => {
getFileSize: vi.fn(() => Promise.resolve(3)), getFileSize: vi.fn(() => Promise.resolve(3)),
getFileUrl: vi.fn(() => Promise.resolve(null)), getFileUrl: vi.fn(() => Promise.resolve(null)),
canReadFileChunks: vi.fn(() => true), canReadFileChunks: vi.fn(() => true),
canCopyFiles: vi.fn(() => true),
createWritableFile: vi.fn(async () => '/appdata/server/room/files/setup.exe'),
copyFile: vi.fn(async () => true),
providesInlineObjectUrl: vi.fn(() => false) providesInlineObjectUrl: vi.fn(() => false)
}; };
}); });
@@ -75,7 +80,7 @@ describe('AttachmentPersistenceService', () => {
AttachmentRuntimeStore, AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database }, { provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => ({ pipe: () => ({ subscribe: () => {} }) }) } } { provide: Store, useValue: { select: () => of('room-1') } }
] ]
}); });
@@ -94,7 +99,17 @@ describe('AttachmentPersistenceService', () => {
}); });
it('hydrates blob URLs on demand for a single attachment', async () => { it('hydrates blob URLs on demand for a single attachment', async () => {
const service = createService(); const injector = Injector.create({
providers: [
AttachmentPersistenceService,
AttachmentRuntimeStore,
{ provide: DatabaseService, useValue: database },
{ provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: Store, useValue: { select: () => of('room-1') } }
]
});
const service = runInInjectionContext(injector, () => injector.get(AttachmentPersistenceService));
const runtimeStore = injector.get(AttachmentRuntimeStore);
await service.initFromDatabase(); await service.initFromDatabase();
@@ -108,10 +123,12 @@ describe('AttachmentPersistenceService', () => {
savedPath: '/appdata/photo.png', savedPath: '/appdata/photo.png',
available: false available: false
}; };
const versionBefore = runtimeStore.updated();
await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true); await expect(service.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
expect(attachment.available).toBe(true); expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toMatch(/^blob:/); expect(attachment.objectUrl).toMatch(/^blob:/);
expect(runtimeStore.updated()).toBeGreaterThan(versionBefore);
expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png'); expect(attachmentStorage.getFileSize).toHaveBeenCalledWith('/appdata/photo.png');
expect(attachmentStorage.readFileChunk).toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFile).not.toHaveBeenCalled();
@@ -169,4 +186,81 @@ describe('AttachmentPersistenceService', () => {
expect(attachmentStorage.readFile).not.toHaveBeenCalled(); expect(attachmentStorage.readFile).not.toHaveBeenCalled();
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled(); expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
}); });
it('copies an external upload path into app data and hydrates generic files without loading a blob', async () => {
attachmentStorage.resolveExistingPath
.mockResolvedValueOnce(null)
.mockResolvedValue('/appdata/server/room/files/setup.exe');
const service = createService();
const attachment = {
id: 'att-setup',
messageId: 'msg-1',
filename: 'setup.exe',
size: 628 * 1024 * 1024,
mime: 'application/octet-stream',
isImage: false,
filePath: '/home/ludde/Downloads/setup.exe',
available: false
};
await expect(service.ensurePersistedUploadHost(attachment)).resolves.toBe(true);
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
expect(attachment.available).toBe(true);
expect(attachment.objectUrl).toBeUndefined();
expect(attachmentStorage.copyFile).toHaveBeenCalledWith(
'/home/ludde/Downloads/setup.exe',
'/appdata/server/room/files/setup.exe'
);
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(database.saveAttachment).toHaveBeenCalled();
});
it('restores host metadata without hydrating media blobs when display hydration is disabled', async () => {
const service = createService();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: false
};
await expect(service.tryRestoreAttachmentHostOnly(attachment)).resolves.toBe(true);
expect(attachment.savedPath).toBe('/appdata/photo.png');
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.available).toBe(false);
expect(attachmentStorage.readFileChunk).not.toHaveBeenCalled();
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
});
it('revokes display blobs while keeping disk paths for later rehydration', () => {
const service = createService();
const attachment = {
id: 'att-1',
messageId: 'msg-1',
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
savedPath: '/appdata/photo.png',
available: true,
objectUrl: 'blob:http://localhost/abc'
};
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.savedPath).toBe('/appdata/photo.png');
expect(revokeSpy).toHaveBeenCalledWith('blob:http://localhost/abc');
revokeSpy.mockRestore();
});
}); });

View File

@@ -11,8 +11,10 @@ import {
decodeBase64ToUint8Array, decodeBase64ToUint8Array,
yieldToAttachmentHydrationLoop yieldToAttachmentHydrationLoop
} from '../../domain/logic/attachment-blob.rules'; } from '../../domain/logic/attachment-blob.rules';
import { canRevokeAttachmentDisplayBlob } from '../../domain/logic/attachment-blob-eviction.rules';
import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules'; import { isBlobObjectUrl, needsBlobObjectUrlForInlineDisplay } from '../../domain/logic/attachment-display-url.rules';
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules'; import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
import { isAttachmentMedia } from '../../domain/logic/attachment.logic';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -118,7 +120,7 @@ export class AttachmentPersistenceService {
} }
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> { async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
const restored = await this.ensureInlineDisplayObjectUrl(attachment); const restored = await this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: true });
if (restored) { if (restored) {
attachment.requestError = undefined; attachment.requestError = undefined;
@@ -127,6 +129,69 @@ export class AttachmentPersistenceService {
return restored; return restored;
} }
async tryRestoreAttachmentHostOnly(attachment: Attachment): Promise<boolean> {
return this.ensurePersistedUploadHost(attachment, { hydrateMediaForDisplay: false });
}
revokeAttachmentDisplayBlob(attachment: Attachment): boolean {
if (!canRevokeAttachmentDisplayBlob(attachment)) {
return false;
}
this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = undefined;
return true;
}
async ensurePersistedUploadHost(
attachment: Attachment,
options: { hydrateMediaForDisplay?: boolean } = {}
): Promise<boolean> {
const hydrateMediaForDisplay = options.hydrateMediaForDisplay !== false;
const existingPath = await this.attachmentStorage.resolveExistingPath(attachment);
if (existingPath) {
return this.hydrateAttachmentFromStoredPath(attachment, existingPath, hydrateMediaForDisplay);
}
if (!attachment.filePath?.trim() || !this.attachmentStorage.canCopyFiles()) {
return false;
}
const savedPath = await this.persistUploadCopyFromSourcePath(attachment, attachment.filePath);
if (!savedPath) {
attachment.filePath = undefined;
void this.persistAttachmentMeta(attachment);
return false;
}
return this.hydrateAttachmentFromStoredPath(attachment, savedPath, hydrateMediaForDisplay);
}
private async hydrateAttachmentFromStoredPath(
attachment: Attachment,
diskPath: string,
hydrateMediaForDisplay = true
): Promise<boolean> {
attachment.savedPath = diskPath;
if (isAttachmentMedia(attachment)) {
if (!hydrateMediaForDisplay) {
void this.persistAttachmentMeta(attachment);
return true;
}
return this.ensureInlineDisplayObjectUrl(attachment);
}
attachment.available = true;
void this.persistAttachmentMeta(attachment);
return true;
}
async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> { async ensureInlineDisplayObjectUrl(attachment: Attachment): Promise<boolean> {
if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) { if (!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl)) {
return true; return true;
@@ -156,6 +221,7 @@ export class AttachmentPersistenceService {
this.revokeAttachmentObjectUrl(attachment); this.revokeAttachmentObjectUrl(attachment);
attachment.objectUrl = nativeUrl; attachment.objectUrl = nativeUrl;
attachment.available = true; attachment.available = true;
this.runtimeStore.touch();
return true; return true;
} }
} }
@@ -330,6 +396,8 @@ export class AttachmentPersistenceService {
`${attachment.messageId}:${attachment.id}`, `${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime }) new File([blob], attachment.filename, { type: attachment.mime })
); );
this.runtimeStore.touch();
} }
private revokeAttachmentObjectUrl(attachment: Attachment): void { private revokeAttachmentObjectUrl(attachment: Attachment): void {

View File

@@ -12,6 +12,7 @@ export class AttachmentRuntimeStore {
private pendingRequests = new Map<string, Set<string>>(); private pendingRequests = new Map<string, Set<string>>();
private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>(); private chunkBuffers = new Map<string, (ArrayBuffer | undefined)[]>();
private chunkCounts = new Map<string, number>(); private chunkCounts = new Map<string, number>();
private announcedHostsByAttachment = new Map<string, Set<string>>();
touch(): void { touch(): void {
this.updated.set(this.updated() + 1); this.updated.set(this.updated() + 1);
@@ -66,6 +67,25 @@ export class AttachmentRuntimeStore {
return this.originalFiles.get(key); return this.originalFiles.get(key);
} }
deleteOriginalFile(key: string): void {
this.originalFiles.delete(key);
}
addAnnouncedHost(requestKey: string, peerId: string): void {
const hosts = this.announcedHostsByAttachment.get(requestKey) ?? new Set<string>();
hosts.add(peerId);
this.announcedHostsByAttachment.set(requestKey, hosts);
}
getAnnouncedHosts(requestKey: string): Set<string> {
return this.announcedHostsByAttachment.get(requestKey) ?? new Set();
}
deleteAnnouncedHosts(requestKey: string): void {
this.announcedHostsByAttachment.delete(requestKey);
}
findOriginalFileByFileId(fileId: string): File | null { findOriginalFileByFileId(fileId: string): File | null {
for (const [key, file] of this.originalFiles) { for (const [key, file] of this.originalFiles) {
if (key.endsWith(`:${fileId}`)) { if (key.endsWith(`:${fileId}`)) {
@@ -160,5 +180,11 @@ export class AttachmentRuntimeStore {
this.cancelledTransfers.delete(key); this.cancelledTransfers.delete(key);
} }
} }
for (const key of Array.from(this.announcedHostsByAttachment.keys())) {
if (key.startsWith(scopedPrefix)) {
this.announcedHostsByAttachment.delete(key);
}
}
} }
} }

View File

@@ -8,11 +8,13 @@ import {
decodeBase64, decodeBase64,
iterateBlobChunks iterateBlobChunks
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService { export class AttachmentTransferTransportService {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly chunkAcks = inject(AttachmentChunkAckService);
decodeBase64(base64: string): Uint8Array { decodeBase64(base64: string): Uint8Array {
return decodeBase64(base64); return decodeBase64(base64);
@@ -39,6 +41,7 @@ export class AttachmentTransferTransportService {
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunk.index);
} }
} }
@@ -84,6 +87,7 @@ export class AttachmentTransferTransportService {
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
} }
} }
@@ -122,6 +126,7 @@ export class AttachmentTransferTransportService {
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
await this.chunkAcks.waitForAck(messageId, fileId, chunkIndex);
} }
} }
} }

View File

@@ -21,6 +21,7 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service'; import { AttachmentTransferService } from './attachment-transfer.service';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
const MESSAGE_ID = 'msg-1'; const MESSAGE_ID = 'msg-1';
const FILE_ID = 'file-1'; const FILE_ID = 'file-1';
@@ -52,6 +53,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: ReturnType<typeof vi.fn>; resolveExistingPath: ReturnType<typeof vi.fn>;
resolveLegacyImagePath: ReturnType<typeof vi.fn>; resolveLegacyImagePath: ReturnType<typeof vi.fn>;
appendBase64: ReturnType<typeof vi.fn>; appendBase64: ReturnType<typeof vi.fn>;
appendBytes: ReturnType<typeof vi.fn>;
createWritableFile: ReturnType<typeof vi.fn>; createWritableFile: ReturnType<typeof vi.fn>;
deleteFile: ReturnType<typeof vi.fn>; deleteFile: ReturnType<typeof vi.fn>;
}; };
@@ -60,6 +62,11 @@ describe('AttachmentTransferService', () => {
streamFileToPeer: ReturnType<typeof vi.fn>; streamFileToPeer: ReturnType<typeof vi.fn>;
streamFileFromDiskToPeer: ReturnType<typeof vi.fn>; streamFileFromDiskToPeer: ReturnType<typeof vi.fn>;
}; };
let chunkAcks: {
resolveAck: ReturnType<typeof vi.fn>;
waitForAck: ReturnType<typeof vi.fn>;
cancelPendingForFile: ReturnType<typeof vi.fn>;
};
let webrtc: { let webrtc: {
getConnectedPeers: ReturnType<typeof vi.fn>; getConnectedPeers: ReturnType<typeof vi.fn>;
broadcastMessage: ReturnType<typeof vi.fn>; broadcastMessage: ReturnType<typeof vi.fn>;
@@ -88,6 +95,7 @@ describe('AttachmentTransferService', () => {
resolveExistingPath: vi.fn(async () => null), resolveExistingPath: vi.fn(async () => null),
resolveLegacyImagePath: vi.fn(async () => null), resolveLegacyImagePath: vi.fn(async () => null),
appendBase64: vi.fn(async () => true), appendBase64: vi.fn(async () => true),
appendBytes: vi.fn(async () => true),
createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'), createWritableFile: vi.fn(async () => '/appdata/server/room/files/file-1'),
deleteFile: vi.fn(async () => true) deleteFile: vi.fn(async () => true)
}; };
@@ -98,6 +106,12 @@ describe('AttachmentTransferService', () => {
streamFileFromDiskToPeer: vi.fn(async () => undefined) streamFileFromDiskToPeer: vi.fn(async () => undefined)
}; };
chunkAcks = {
resolveAck: vi.fn(),
waitForAck: vi.fn(async () => undefined),
cancelPendingForFile: vi.fn()
};
webrtc = { webrtc = {
getConnectedPeers: vi.fn(() => [PEER_ID]), getConnectedPeers: vi.fn(() => [PEER_ID]),
broadcastMessage: vi.fn(), broadcastMessage: vi.fn(),
@@ -115,7 +129,8 @@ describe('AttachmentTransferService', () => {
{ provide: AppI18nService, useValue: { instant: (key: string) => key } }, { provide: AppI18nService, useValue: { instant: (key: string) => key } },
{ provide: AttachmentStorageService, useValue: attachmentStorage }, { provide: AttachmentStorageService, useValue: attachmentStorage },
{ provide: AttachmentPersistenceService, useValue: persistence }, { provide: AttachmentPersistenceService, useValue: persistence },
{ provide: AttachmentTransferTransportService, useValue: transport } { provide: AttachmentTransferTransportService, useValue: transport },
{ provide: AttachmentChunkAckService, useValue: chunkAcks }
] ]
}); });
const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService)); const service = runInInjectionContext(injector, () => injector.get(AttachmentTransferService));
@@ -294,17 +309,13 @@ describe('AttachmentTransferService', () => {
}); });
it('streams a requested file only once while the same request is already in flight', async () => { it('streams a requested file only once while the same request is already in flight', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue(null);
const service = createService(); const service = createService();
registerIncomingAttachment(9); registerIncomingAttachment(9);
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' })); runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File([new Uint8Array(9)], 'photo.png', { type: 'image/png' }));
let releaseStream: () => void = () => undefined;
transport.streamFileToPeer.mockImplementation(() => new Promise<void>((resolve) => {
releaseStream = resolve;
}));
const firstRequest = service.handleFileRequest({ const firstRequest = service.handleFileRequest({
messageId: MESSAGE_ID, messageId: MESSAGE_ID,
fileId: FILE_ID, fileId: FILE_ID,
@@ -316,7 +327,6 @@ describe('AttachmentTransferService', () => {
fromPeerId: PEER_ID fromPeerId: PEER_ID
}); });
releaseStream();
await Promise.all([firstRequest, duplicateRequest]); await Promise.all([firstRequest, duplicateRequest]);
expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1); expect(transport.streamFileToPeer).toHaveBeenCalledTimes(1);
@@ -364,6 +374,23 @@ describe('AttachmentTransferService', () => {
return attachment; return attachment;
} }
function registerIncomingGenericFile(size: number): Attachment {
const attachment: Attachment = {
id: FILE_ID,
messageId: MESSAGE_ID,
filename: 'archive.zip',
size,
mime: 'application/zip',
isImage: false,
uploaderPeerId: PEER_ID,
available: false,
receivedBytes: 0
};
runtimeStore.setAttachmentsForMessage(MESSAGE_ID, [attachment]);
return attachment;
}
it('streams playable media to disk when the store supports streaming', async () => { it('streams playable media to disk when the store supports streaming', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true); attachmentStorage.canStreamToDisk.mockReturnValue(true);
@@ -379,7 +406,14 @@ describe('AttachmentTransferService', () => {
await vi.waitFor(() => expect(attachment.available).toBe(true)); await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.createWritableFile).toHaveBeenCalled(); expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).toHaveBeenCalled(); expect(attachmentStorage.appendBytes).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
type: 'file-chunk-ack',
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 0
});
expect(persistence.saveFileToDisk).not.toHaveBeenCalled(); expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
}); });
@@ -401,6 +435,18 @@ describe('AttachmentTransferService', () => {
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1); expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
}); });
it('resolves chunk ack waiters from inbound ack events', () => {
const service = createService();
service.handleFileChunkAck({
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 2
});
expect(chunkAcks.resolveAck).toHaveBeenCalledWith(MESSAGE_ID, FILE_ID, 2);
});
it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => { it('marks a request as pending synchronously so concurrent auto-download triggers cannot double-request', () => {
const service = createService(); const service = createService();
const attachment = registerIncomingAttachment(9); const attachment = registerIncomingAttachment(9);
@@ -409,4 +455,276 @@ describe('AttachmentTransferService', () => {
expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true); expect(service.hasPendingRequest(MESSAGE_ID, FILE_ID)).toBe(true);
}); });
it('streams oversized generic files to disk when the store supports streaming', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 256 * 1024 * 1024);
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.createWritableFile).toHaveBeenCalled();
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
type: 'file-chunk-ack',
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 0
});
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
expect(attachment.objectUrl).toBeUndefined();
});
it('streams large downloads to disk even when attachment metadata still carries a source filePath', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true);
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
attachment.filePath = '/home/ludde/archive.zip';
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.appendBytes).toHaveBeenCalled();
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(webrtc.sendToPeer).toHaveBeenCalledWith(PEER_ID, {
type: 'file-chunk-ack',
messageId: MESSAGE_ID,
fileId: FILE_ID,
index: 0
});
expect(persistence.saveFileToDisk).not.toHaveBeenCalled();
expect(runtimeStore.getChunkBuffer(`${MESSAGE_ID}:${FILE_ID}`)).toBeUndefined();
});
it('does not hydrate media blobs after a disk-streamed download completes', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(true);
const service = createService();
const attachment = registerIncomingVideo(3);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachment.savedPath).toBeTruthy();
expect(attachment.objectUrl).toBeUndefined();
expect(persistence.ensureInlineDisplayObjectUrl).not.toHaveBeenCalled();
});
it('rejects oversized browser downloads before requesting peers', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(false);
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
const service = createService();
const attachment = registerIncomingGenericFile(200 * 1024 * 1024);
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
expect(attachment.requestError).toBe('attachment.errors.fileTooLarge');
expect(webrtc.sendToPeer).not.toHaveBeenCalled();
});
it('assembles browser-sized generic files in memory when streaming is unavailable', async () => {
attachmentStorage.canStreamToDisk.mockReturnValue(false);
attachmentStorage.canPersistSize.mockImplementation((bytes: number) => bytes <= 50 * 1024 * 1024);
const service = createService();
const attachment = registerIncomingGenericFile(3);
service.handleFileChunk(chunkPayload(0, 1, [
1,
2,
3
]));
await vi.waitFor(() => expect(attachment.available).toBe(true));
expect(attachmentStorage.appendBase64).not.toHaveBeenCalled();
expect(persistence.saveFileToDisk).toHaveBeenCalledTimes(1);
});
it('copies oversized generic uploads with a source path into app data when publishing', async () => {
attachmentStorage.canCopyFiles.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true);
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
attachment.savedPath = '/appdata/server/room/files/setup.exe';
return attachment.savedPath;
});
const service = createService();
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
Object.defineProperty(file, 'path', { value: '/home/ludde/setup.exe' });
await service.publishAttachments(MESSAGE_ID, [file], PEER_ID);
expect(persistence.persistUploadCopyFromSourcePath).toHaveBeenCalled();
});
it('streams a restored oversized generic file from app data when the in-memory upload is gone', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
attachment.savedPath = '/appdata/server/room/files/setup.exe';
await service.handleFileRequest({
messageId: MESSAGE_ID,
fileId: FILE_ID,
fromPeerId: 'peer-2'
});
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalledWith(
'peer-2',
MESSAGE_ID,
FILE_ID,
'/appdata/server/room/files/setup.exe',
expect.any(Function)
);
});
it('re-announces hosted attachments that can still be served from disk', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
attachment.uploaderPeerId = PEER_ID;
attachment.savedPath = '/appdata/server/room/files/setup.exe';
attachment.available = true;
await service.reannounceHostedAttachments(PEER_ID);
expect(webrtc.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'file-announce',
messageId: MESSAGE_ID,
file: expect.objectContaining({ id: FILE_ID })
}));
});
it('requests a mirror host before the original uploader when both announced the file', async () => {
const uploaderPeer = 'uploader-peer';
const mirrorPeer = 'mirror-peer';
webrtc.getConnectedPeers.mockReturnValue([uploaderPeer, mirrorPeer]);
const service = createService();
const attachment = registerIncomingAttachment(3_000);
attachment.uploaderPeerId = uploaderPeer;
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, uploaderPeer);
runtimeStore.addAnnouncedHost(`${MESSAGE_ID}:${FILE_ID}`, mirrorPeer);
await service.requestFromAnyPeer(MESSAGE_ID, attachment);
expect(webrtc.sendToPeer).toHaveBeenCalledWith(mirrorPeer, expect.objectContaining({
type: 'file-request',
messageId: MESSAGE_ID,
fileId: FILE_ID
}));
});
it('records announced hosts from incoming file-announce payloads', () => {
const service = createService();
service.handleFileAnnounce({
messageId: MESSAGE_ID,
fromPeerId: 'mirror-peer',
file: {
id: FILE_ID,
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
uploaderPeerId: 'uploader-peer'
}
});
expect(runtimeStore.getAnnouncedHosts(`${MESSAGE_ID}:${FILE_ID}`).has('mirror-peer')).toBe(true);
});
it('does not register duplicate attachment metadata on repeat file-announce', () => {
const service = createService();
const announce = {
messageId: MESSAGE_ID,
fromPeerId: 'uploader-peer',
file: {
id: FILE_ID,
filename: 'photo.png',
size: 3,
mime: 'image/png',
isImage: true,
uploaderPeerId: 'uploader-peer'
}
};
expect(service.handleFileAnnounce(announce)).toBe(true);
expect(service.handleFileAnnounce(announce)).toBe(false);
expect(runtimeStore.getAttachmentsForMessage(MESSAGE_ID)).toHaveLength(1);
});
it('prefers streaming from disk over an in-memory original file when both exist', async () => {
attachmentStorage.resolveExistingPath.mockResolvedValue('/appdata/server/room/files/setup.exe');
const service = createService();
const attachment = registerIncomingGenericFile(12 * 1024 * 1024);
attachment.savedPath = '/appdata/server/room/files/setup.exe';
runtimeStore.setOriginalFile(`${MESSAGE_ID}:${FILE_ID}`, new File(['x'], 'setup.exe'));
await service.handleFileRequest({
messageId: MESSAGE_ID,
fileId: FILE_ID,
fromPeerId: 'peer-2'
});
expect(transport.streamFileFromDiskToPeer).toHaveBeenCalled();
expect(transport.streamFileToPeer).not.toHaveBeenCalled();
});
it('releases the in-memory upload copy after persisting a large generic file to disk', async () => {
attachmentStorage.canCopyFiles.mockReturnValue(true);
attachmentStorage.canPersistSize.mockReturnValue(true);
persistence.persistUploadCopyFromSourcePath.mockImplementation(async (attachment) => {
attachment.savedPath = '/appdata/server/room/files/setup.exe';
return attachment.savedPath;
});
const service = createService();
const file = new File([new Uint8Array(11 * 1024 * 1024)], 'setup.exe', { type: 'application/octet-stream' });
Object.defineProperty(file, 'path', { value: '/home/ludde/setup.exe' });
await service.publishAttachments(MESSAGE_ID, [file], PEER_ID);
const attachment = runtimeStore.getAttachmentsForMessage(MESSAGE_ID)[0];
expect(runtimeStore.getOriginalFile(`${MESSAGE_ID}:${attachment.id}`)).toBeUndefined();
expect(attachment.objectUrl).toBeUndefined();
expect(attachment.available).toBe(true);
expect(attachment.savedPath).toBe('/appdata/server/room/files/setup.exe');
});
}); });

View File

@@ -8,16 +8,23 @@ import { selectCurrentUserId } from '../../../../store/users/users.selectors';
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules'; import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules'; import { base64DecodedByteLength, decodeBase64ToUint8Array } from '../../domain/logic/attachment-blob.rules';
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic'; import { isSharingFromThisDevice, canHostAttachment } from '../../domain/logic/attachment-sharing.rules';
import { selectFileRequestPeer } from '../../domain/logic/attachment-request.rules';
import {
canReceiveAttachment,
shouldCopyLargeUploaderFileToAppData,
shouldPersistDownloadedAttachment,
shouldStreamAttachmentReceiveToDisk
} from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT, ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT, ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE, DEFAULT_ATTACHMENT_MIME_TYPE,
ATTACHMENT_DOWNLOAD_FAILED_KEY, ATTACHMENT_DOWNLOAD_FAILED_KEY,
ATTACHMENT_FILE_TOO_LARGE_KEY,
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY, ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY, ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY, ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
FILE_NOT_FOUND_REQUEST_ERROR_KEY, FILE_NOT_FOUND_REQUEST_ERROR_KEY,
@@ -30,6 +37,8 @@ import {
type FileCancelEvent, type FileCancelEvent,
type FileCancelPayload, type FileCancelPayload,
type FileChunkPayload, type FileChunkPayload,
type FileChunkAckPayload,
type FileChunkAckEvent,
type FileNotFoundEvent, type FileNotFoundEvent,
type FileNotFoundPayload, type FileNotFoundPayload,
type FileRequestEvent, type FileRequestEvent,
@@ -39,6 +48,7 @@ import {
import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
import { AttachmentChunkAckService } from './attachment-chunk-ack.service';
interface DiskReceiveAssembly { interface DiskReceiveAssembly {
path: string; path: string;
@@ -79,9 +89,10 @@ export class AttachmentTransferService {
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly persistence = inject(AttachmentPersistenceService); private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService); private readonly transport = inject(AttachmentTransferTransportService);
private readonly chunkAcks = inject(AttachmentChunkAckService);
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>(); private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
private readonly diskReceiveChains = new Map<string, Promise<void>>(); private readonly diskReceiveLocks = new Map<string, Promise<void>>();
private readonly activeOutboundTransfers = new Set<string>(); private readonly activeOutboundTransfers = new Set<string>();
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> { getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
@@ -188,6 +199,13 @@ export class AttachmentTransferService {
return; return;
} }
if (!canReceiveAttachment(attachment, this.receiveCapabilities())) {
this.runtimeStore.deletePendingRequest(requestKey);
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
this.runtimeStore.touch();
return;
}
if (clearedRequestError) if (clearedRequestError)
this.runtimeStore.touch(); this.runtimeStore.touch();
@@ -261,6 +279,7 @@ export class AttachmentTransferService {
} }
await this.persistPublishedAttachment(attachment, file); await this.persistPublishedAttachment(attachment, file);
this.releaseInMemoryUploadCopyIfPersisted(`${messageId}:${fileId}`, attachment);
const fileAnnounceEvent: FileAnnounceEvent = { const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce', type: 'file-announce',
@@ -288,17 +307,23 @@ export class AttachmentTransferService {
} }
} }
handleFileAnnounce(payload: FileAnnouncePayload): void { handleFileAnnounce(payload: FileAnnouncePayload): boolean {
const { messageId, file } = payload; const { messageId, file } = payload;
if (!messageId || !file) if (!messageId || !file) {
return; return false;
}
if (payload.fromPeerId) {
this.runtimeStore.addAnnouncedHost(this.buildRequestKey(messageId, file.id), payload.fromPeerId);
}
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)]; const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
const alreadyKnown = list.find((entry) => entry.id === file.id); const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown) if (alreadyKnown) {
return; return false;
}
const attachment: Attachment = { const attachment: Attachment = {
id: file.id, id: file.id,
@@ -320,6 +345,8 @@ export class AttachmentTransferService {
this.runtimeStore.setAttachmentsForMessage(messageId, list); this.runtimeStore.setAttachmentsForMessage(messageId, list);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
return true;
} }
handleFileChunk(payload: FileChunkPayload): void { handleFileChunk(payload: FileChunkPayload): void {
@@ -344,12 +371,14 @@ export class AttachmentTransferService {
return; return;
} }
if (!this.shouldReceiveToDisk(attachment) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) { if (!canReceiveAttachment(attachment, this.receiveCapabilities())) {
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
this.runtimeStore.touch();
return; return;
} }
if (this.shouldReceiveToDisk(attachment)) { if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, { void this.receiveDiskChunk(attachment, {
data, data,
fileId, fileId,
fromPeerId, fromPeerId,
@@ -361,6 +390,12 @@ export class AttachmentTransferService {
return; return;
} }
if (attachment.size > MAX_AUTO_SAVE_SIZE_BYTES) {
attachment.requestError = this.appI18n.instant(ATTACHMENT_FILE_TOO_LARGE_KEY);
this.runtimeStore.touch();
return;
}
const decodedBytes = this.transport.decodeBase64(data); const decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`; const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId); const requestKey = this.buildRequestKey(messageId, fileId);
@@ -378,10 +413,21 @@ export class AttachmentTransferService {
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer; chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1); this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
this.updateTransferProgress(attachment, decodedBytes, fromPeerId); this.updateTransferProgress(attachment, decodedBytes.byteLength, fromPeerId);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.finalizeTransferIfComplete(attachment, assemblyKey, total); void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
this.emitChunkAck({ fileId, fromPeerId, index, messageId });
}
handleFileChunkAck(payload: FileChunkAckPayload): void {
const { messageId, fileId, index } = payload;
if (!messageId || !fileId || typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
return;
}
this.chunkAcks.resolveAck(messageId, fileId, index);
} }
async handleFileRequest(payload: FileRequestPayload): Promise<void> { async handleFileRequest(payload: FileRequestPayload): Promise<void> {
@@ -495,21 +541,6 @@ export class AttachmentTransferService {
fromPeerId: string fromPeerId: string
): Promise<void> { ): Promise<void> {
const exactKey = `${messageId}:${fileId}`; const exactKey = `${messageId}:${fileId}`;
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
?? this.runtimeStore.findOriginalFileByFileId(fileId);
if (originalFile) {
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
originalFile,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
const list = this.runtimeStore.getAttachmentsForMessage(messageId); const list = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = list.find((entry) => entry.id === fileId); const attachment = list.find((entry) => entry.id === fileId);
const diskPath = attachment const diskPath = attachment
@@ -528,6 +559,21 @@ export class AttachmentTransferService {
return; return;
} }
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
?? this.runtimeStore.findOriginalFileByFileId(fileId);
if (originalFile) {
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
originalFile,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
if (attachment?.isImage) { if (attachment?.isImage) {
const roomName = await this.persistence.resolveCurrentRoomName(); const roomName = await this.persistence.resolveCurrentRoomName();
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath( const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
@@ -614,14 +660,13 @@ export class AttachmentTransferService {
const connectedPeers = this.webrtc.getConnectedPeers(); const connectedPeers = this.webrtc.getConnectedPeers();
const requestKey = this.buildRequestKey(messageId, fileId); const requestKey = this.buildRequestKey(messageId, fileId);
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>(); const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
const announcedHosts = this.runtimeStore.getAnnouncedHosts(requestKey);
let targetPeerId: string | undefined; const targetPeerId = selectFileRequestPeer({
connectedPeers,
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) { triedPeers,
targetPeerId = preferredPeerId; announcedHosts,
} else { uploaderPeerId: preferredPeerId
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId)); });
}
if (!targetPeerId) { if (!targetPeerId) {
this.runtimeStore.deletePendingRequest(requestKey); this.runtimeStore.deletePendingRequest(requestKey);
@@ -661,16 +706,16 @@ export class AttachmentTransferService {
private updateTransferProgress( private updateTransferProgress(
attachment: Attachment, attachment: Attachment,
decodedBytes: Uint8Array, chunkByteLength: number,
fromPeerId?: string fromPeerId?: string
): void { ): void {
const now = Date.now(); const now = Date.now();
const previousReceived = attachment.receivedBytes ?? 0; const previousReceived = attachment.receivedBytes ?? 0;
attachment.receivedBytes = previousReceived + decodedBytes.byteLength; attachment.receivedBytes = previousReceived + chunkByteLength;
if (fromPeerId) { if (fromPeerId) {
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now); recordDebugNetworkFileChunk(fromPeerId, chunkByteLength, now);
} }
if (!attachment.startedAtMs) if (!attachment.startedAtMs)
@@ -680,7 +725,7 @@ export class AttachmentTransferService {
attachment.lastUpdateMs = now; attachment.lastUpdateMs = now;
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs); const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000; const instantaneousBps = (chunkByteLength / elapsedMs) * 1000;
const previousSpeed = attachment.speedBps ?? instantaneousBps; const previousSpeed = attachment.speedBps ?? instantaneousBps;
attachment.speedBps = attachment.speedBps =
@@ -729,6 +774,7 @@ export class AttachmentTransferService {
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
} }
/** /**
@@ -748,7 +794,7 @@ export class AttachmentTransferService {
return; return;
} }
if (shouldCopyUploaderMediaToAppData( if (shouldCopyLargeUploaderFileToAppData(
attachment, attachment,
attachment.filePath, attachment.filePath,
this.attachmentStorage.canCopyFiles() this.attachmentStorage.canCopyFiles()
@@ -766,6 +812,81 @@ export class AttachmentTransferService {
} }
} }
async reannounceHostedAttachments(currentUserId: string | null | undefined): Promise<void> {
if (!currentUserId) {
return;
}
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) {
if (!canHostAttachment(attachment)) {
continue;
}
const canServe = await this.attachmentStorage.resolveExistingPath(attachment);
if (!canServe) {
continue;
}
await this.announceLocalHost(attachment, currentUserId);
}
}
}
private releaseInMemoryUploadCopyIfPersisted(exactKey: string, attachment: Attachment): void {
if (!attachment.savedPath?.trim() || attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
return;
}
this.runtimeStore.deleteOriginalFile(exactKey);
if (!attachment.objectUrl?.startsWith('blob:')) {
return;
}
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
if (!this.isPlayableMedia(attachment)) {
attachment.objectUrl = undefined;
attachment.available = true;
}
}
private async announceLocalHost(attachment: Attachment, hostPeerId?: string | null): Promise<void> {
if (!canHostAttachment(attachment)) {
return;
}
const announcingPeerId = hostPeerId ?? await this.resolveCurrentUserId();
if (!announcingPeerId) {
return;
}
this.runtimeStore.addAnnouncedHost(
this.buildRequestKey(attachment.messageId, attachment.id),
announcingPeerId
);
const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce',
messageId: attachment.messageId,
file: {
id: attachment.id,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId
}
};
this.webrtc.broadcastMessage(fileAnnounceEvent);
}
private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> { private async applySavedPathObjectUrl(attachment: Attachment, savedPath: string | null): Promise<void> {
if (!savedPath) { if (!savedPath) {
return; return;
@@ -784,37 +905,57 @@ export class AttachmentTransferService {
} }
private shouldReceiveToDisk(attachment: Attachment): boolean { private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) && return shouldStreamAttachmentReceiveToDisk(attachment, this.receiveCapabilities());
!attachment.filePath &&
this.attachmentStorage.canStreamToDisk() &&
this.attachmentStorage.canPersistSize(attachment.size);
} }
private enqueueDiskFileChunk( private receiveCapabilities() {
attachment: Attachment, return {
payload: ValidFileChunkPayload canStreamToDisk: this.attachmentStorage.canStreamToDisk(),
): void { canPersistSize: (bytes: number) => this.attachmentStorage.canPersistSize(bytes)
};
}
private receiveDiskChunk(attachment: Attachment, payload: ValidFileChunkPayload): void {
const assemblyKey = `${payload.messageId}:${payload.fileId}`; const assemblyKey = `${payload.messageId}:${payload.fileId}`;
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve(); const previous = this.diskReceiveLocks.get(assemblyKey) ?? Promise.resolve();
const next = previous const next = previous
.catch(() => undefined) .catch(() => undefined)
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload)) .then(async () => {
await this.handleDiskFileChunk(attachment, assemblyKey, payload);
this.emitChunkAck(payload);
})
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error)); .catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
this.diskReceiveChains.set(assemblyKey, next); this.diskReceiveLocks.set(assemblyKey, next);
void next.finally(() => { void next.finally(() => {
if (this.diskReceiveChains.get(assemblyKey) === next) { if (this.diskReceiveLocks.get(assemblyKey) === next) {
this.diskReceiveChains.delete(assemblyKey); this.diskReceiveLocks.delete(assemblyKey);
} }
}); });
} }
private emitChunkAck(payload: Pick<ValidFileChunkPayload, 'fileId' | 'fromPeerId' | 'index' | 'messageId'>): void {
if (!payload.fromPeerId) {
return;
}
const ack: FileChunkAckEvent = {
type: 'file-chunk-ack',
messageId: payload.messageId,
fileId: payload.fileId,
index: payload.index
};
this.webrtc.sendToPeer(payload.fromPeerId, ack);
}
private async handleDiskFileChunk( private async handleDiskFileChunk(
attachment: Attachment, attachment: Attachment,
assemblyKey: string, assemblyKey: string,
payload: ValidFileChunkPayload payload: ValidFileChunkPayload
): Promise<void> { ): Promise<void> {
const decodedBytes = this.transport.decodeBase64(payload.data); const chunkByteLength = base64DecodedByteLength(payload.data);
const chunkBytes = decodeBase64ToUint8Array(payload.data);
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId); const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
this.runtimeStore.deletePendingRequest(requestKey); this.runtimeStore.deletePendingRequest(requestKey);
@@ -834,7 +975,7 @@ export class AttachmentTransferService {
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY)); throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY));
} }
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data); const didAppend = await this.attachmentStorage.appendBytes(assembly.path, chunkBytes);
if (!didAppend) { if (!didAppend) {
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY)); throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
@@ -842,7 +983,7 @@ export class AttachmentTransferService {
assembly.receivedIndexes.add(payload.index); assembly.receivedIndexes.add(payload.index);
assembly.receivedCount += 1; assembly.receivedCount += 1;
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId); this.updateTransferProgress(attachment, chunkByteLength, payload.fromPeerId);
this.runtimeStore.touch(); this.runtimeStore.touch();
if (assembly.receivedCount < assembly.total) { if (assembly.receivedCount < assembly.total) {
@@ -850,17 +991,12 @@ export class AttachmentTransferService {
} }
attachment.savedPath = assembly.path; attachment.savedPath = assembly.path;
const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
if (!restoredForDisplay) {
throw new Error(this.appI18n.instant(ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY));
}
attachment.available = true; attachment.available = true;
attachment.objectUrl = undefined;
this.diskReceiveAssemblies.delete(assemblyKey); this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
void this.announceLocalHost(attachment);
} }
private async getOrCreateDiskReceiveAssembly( private async getOrCreateDiskReceiveAssembly(

View File

@@ -22,3 +22,4 @@ export const ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY = 'attachment.errors.chunksOutOf
export const ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed'; export const ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed';
export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed'; export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed';
export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed'; export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed';
export const ATTACHMENT_FILE_TOO_LARGE_KEY = 'attachment.errors.fileTooLarge';

View File

@@ -0,0 +1,61 @@
import {
describe,
expect,
it
} from 'vitest';
import {
buildAttachmentDisplayPinKey,
canRevokeAttachmentDisplayBlob,
shouldRevokeDisplayBlobForAttachment
} from './attachment-blob-eviction.rules';
describe('attachment-blob-eviction rules', () => {
it('builds a stable pin key from message and attachment ids', () => {
expect(buildAttachmentDisplayPinKey('msg-1', 'att-1')).toBe('msg-1:att-1');
});
it('allows revoking blob urls when a disk path can rehydrate the attachment', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 0,
available: true
})).toBe(true);
});
it('refuses to revoke blobs that are the only local copy', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
receivedBytes: 0,
available: true
})).toBe(false);
});
it('refuses to revoke blobs while a download is still in progress', () => {
expect(canRevokeAttachmentDisplayBlob({
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 1024,
available: false
})).toBe(false);
});
it('skips revocation for pinned attachments', () => {
const attachment = {
id: 'att-1',
objectUrl: 'blob:http://localhost/abc',
savedPath: '/appdata/photo.png',
receivedBytes: 0,
available: true
};
expect(shouldRevokeDisplayBlobForAttachment(
'msg-1',
attachment,
new Set([buildAttachmentDisplayPinKey('msg-1', 'att-1')])
)).toBe(false);
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
});
});

View File

@@ -0,0 +1,50 @@
import { isBlobObjectUrl } from './attachment-display-url.rules';
/** Margin around the chat scrollport used to hydrate blobs before they enter view. */
export const ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN = '200px';
export interface AttachmentDisplayBlobCandidate {
available?: boolean;
filePath?: string;
objectUrl?: string;
receivedBytes?: number;
savedPath?: string;
}
export function buildAttachmentDisplayPinKey(messageId: string, attachmentId: string): string {
return `${messageId}:${attachmentId}`;
}
export function canRevokeAttachmentDisplayBlob(
attachment: AttachmentDisplayBlobCandidate
): boolean {
if (!attachment.objectUrl || !isBlobObjectUrl(attachment.objectUrl)) {
return false;
}
if (!hasNonEmptyString(attachment.savedPath) && !hasNonEmptyString(attachment.filePath)) {
return false;
}
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
return false;
}
return true;
}
export function shouldRevokeDisplayBlobForAttachment(
messageId: string,
attachment: AttachmentDisplayBlobCandidate & { id: string },
pinnedKeys: ReadonlySet<string>
): boolean {
if (pinnedKeys.has(buildAttachmentDisplayPinKey(messageId, attachment.id))) {
return false;
}
return canRevokeAttachmentDisplayBlob(attachment);
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -4,7 +4,10 @@ import {
it it
} from 'vitest'; } from 'vitest';
import { decodeBase64ToUint8Array } from './attachment-blob.rules'; import {
base64DecodedByteLength,
decodeBase64ToUint8Array
} from './attachment-blob.rules';
describe('attachment blob rules', () => { describe('attachment blob rules', () => {
it('decodes base64 payloads into byte arrays', () => { it('decodes base64 payloads into byte arrays', () => {
@@ -16,4 +19,9 @@ describe('attachment blob rules', () => {
67 67
]); ]);
}); });
it('estimates decoded base64 byte length without allocating bytes', () => {
expect(base64DecodedByteLength('QUJD')).toBe(3);
expect(base64DecodedByteLength('YQ==')).toBe(1);
});
}); });

View File

@@ -29,6 +29,13 @@ export function encodeUint8ArrayToBase64(bytes: Uint8Array): string {
return btoa(binary); return btoa(binary);
} }
/** Returns the decoded byte length of a base64 payload without allocating the bytes. */
export function base64DecodedByteLength(base64: string): number {
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
}
/** Yield control back to the browser so long attachment hydration cannot freeze Electron. */ /** Yield control back to the browser so long attachment hydration cannot freeze Electron. */
export function yieldToAttachmentHydrationLoop(): Promise<void> { export function yieldToAttachmentHydrationLoop(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -0,0 +1,13 @@
import {
describe,
expect,
it
} from 'vitest';
import { buildAttachmentChunkAckKey } from './attachment-chunk-ack.rules';
describe('attachment-chunk-ack rules', () => {
it('builds a stable ack key from message, file, and chunk index', () => {
expect(buildAttachmentChunkAckKey('msg-1', 'file-1', 42)).toBe('msg-1:file-1:42');
});
});

View File

@@ -0,0 +1,3 @@
export function buildAttachmentChunkAckKey(messageId: string, fileId: string, index: number): string {
return `${messageId}:${fileId}:${index}`;
}

View File

@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import {
canDownloadAttachment,
resolveAttachmentDiskPath
} from './attachment-download.rules';
describe('attachment-download.rules', () => {
it('allows download when a completed disk-only attachment has no object URL', () => {
expect(canDownloadAttachment({
available: true,
savedPath: '/appdata/server/room/files/large.bin'
})).toBe(true);
});
it('allows download when a blob object URL is available', () => {
expect(canDownloadAttachment({
available: true,
objectUrl: 'blob:http://localhost/abc'
})).toBe(true);
});
it('rejects incomplete or empty local copies', () => {
expect(canDownloadAttachment({
available: false,
savedPath: '/appdata/server/room/files/large.bin'
})).toBe(false);
expect(canDownloadAttachment({
available: true
})).toBe(false);
});
it('prefers savedPath over filePath for disk export', () => {
expect(resolveAttachmentDiskPath({
savedPath: '/appdata/copy.bin',
filePath: '/home/me/original.bin'
})).toBe('/appdata/copy.bin');
});
});

View File

@@ -0,0 +1,25 @@
import type { Attachment } from '../models/attachment.model';
export function canDownloadAttachment(
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
): boolean {
if (attachment.available !== true) {
return false;
}
return hasNonEmptyString(attachment.objectUrl) ||
hasNonEmptyString(attachment.savedPath) ||
hasNonEmptyString(attachment.filePath);
}
export function resolveAttachmentDiskPath(
attachment: Pick<Attachment, 'savedPath' | 'filePath'>
): string | null {
const diskPath = attachment.savedPath?.trim() || attachment.filePath?.trim();
return diskPath || null;
}
function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}

View File

@@ -7,6 +7,7 @@ import {
import { import {
dedupeImageAttachmentsForDisplay, dedupeImageAttachmentsForDisplay,
hasImageFilename, hasImageFilename,
isAttachmentPendingInlineHydration,
isImageAttachment, isImageAttachment,
isInlineDisplayableImage, isInlineDisplayableImage,
resolvePublishAttachmentIsImage resolvePublishAttachmentIsImage
@@ -38,6 +39,27 @@ describe('attachment-image rules', () => {
})).toBe(true); })).toBe(true);
}); });
it('detects images waiting for on-demand blob hydration', () => {
expect(isAttachmentPendingInlineHydration({
id: '1',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: false,
savedPath: '/appdata/photo.png'
})).toBe(true);
expect(isAttachmentPendingInlineHydration({
id: '2',
filename: 'photo.png',
mime: 'image/png',
isImage: true,
available: true,
objectUrl: 'blob:http://localhost/photo',
savedPath: '/appdata/photo.png'
})).toBe(false);
});
it('dedupes image attachments by filename and prefers displayable copies', () => { it('dedupes image attachments by filename and prefers displayable copies', () => {
const deduped = dedupeImageAttachmentsForDisplay([ const deduped = dedupeImageAttachmentsForDisplay([
{ {

View File

@@ -22,6 +22,7 @@ export interface ImageAttachmentCandidate {
isImage: boolean; isImage: boolean;
mime: string; mime: string;
objectUrl?: string; objectUrl?: string;
receivedBytes?: number;
savedPath?: string; savedPath?: string;
} }
@@ -50,6 +51,27 @@ export function isInlineDisplayableImage(
!needsBlobObjectUrlForInlineDisplay(attachment.objectUrl); !needsBlobObjectUrlForInlineDisplay(attachment.objectUrl);
} }
export function isAttachmentPendingInlineHydration(
attachment: Pick<
ImageAttachmentCandidate,
'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
>
): boolean {
if (isInlineDisplayableImage(attachment)) {
return false;
}
if (!isImageAttachment(attachment)) {
return false;
}
if ((attachment.receivedBytes ?? 0) > 0 && attachment.available !== true) {
return false;
}
return !!(attachment.savedPath?.trim() || attachment.filePath?.trim());
}
export function imageAttachmentDisplayRank( export function imageAttachmentDisplayRank(
attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'> attachment: Pick<ImageAttachmentCandidate, 'available' | 'filePath' | 'isImage' | 'objectUrl' | 'savedPath'>
): number { ): number {

View File

@@ -0,0 +1,47 @@
import { selectFileRequestPeer } from './attachment-request.rules';
describe('selectFileRequestPeer', () => {
const uploader = 'uploader-peer';
const mirror = 'mirror-peer';
const other = 'other-peer';
it('prefers a mirror host over the original uploader when both are available', () => {
expect(selectFileRequestPeer({
connectedPeers: [
uploader,
mirror,
other
],
triedPeers: new Set(),
announcedHosts: new Set([uploader, mirror]),
uploaderPeerId: uploader
})).toBe(mirror);
});
it('falls back to the uploader when no mirror hosts are announced', () => {
expect(selectFileRequestPeer({
connectedPeers: [uploader, other],
triedPeers: new Set(),
announcedHosts: new Set([uploader]),
uploaderPeerId: uploader
})).toBe(uploader);
});
it('skips peers that were already tried', () => {
expect(selectFileRequestPeer({
connectedPeers: [mirror, uploader],
triedPeers: new Set([mirror]),
announcedHosts: new Set([mirror, uploader]),
uploaderPeerId: uploader
})).toBe(uploader);
});
it('returns undefined when every connected peer was already tried', () => {
expect(selectFileRequestPeer({
connectedPeers: [mirror, uploader],
triedPeers: new Set([mirror, uploader]),
announcedHosts: new Set([mirror, uploader]),
uploaderPeerId: uploader
})).toBeUndefined();
});
});

View File

@@ -0,0 +1,39 @@
export interface FileRequestPeerSelectionInput {
connectedPeers: string[];
triedPeers: ReadonlySet<string>;
announcedHosts: ReadonlySet<string>;
uploaderPeerId?: string;
}
/**
* Pick the next peer to request a file from. Mirror hosts (peers that announced
* they hold the bytes) are preferred over the original uploader so the sharer's
* device is not the only upload source.
*/
export function selectFileRequestPeer(input: FileRequestPeerSelectionInput): string | undefined {
const candidates = input.connectedPeers.filter((peerId) => !input.triedPeers.has(peerId));
if (candidates.length === 0) {
return undefined;
}
const mirrorHosts = candidates.filter(
(peerId) => input.announcedHosts.has(peerId) && peerId !== input.uploaderPeerId
);
if (mirrorHosts.length > 0) {
return mirrorHosts[0];
}
if (input.uploaderPeerId && candidates.includes(input.uploaderPeerId)) {
return input.uploaderPeerId;
}
const announcedCandidate = candidates.find((peerId) => input.announcedHosts.has(peerId));
if (announcedCandidate) {
return announcedCandidate;
}
return candidates[0];
}

View File

@@ -1,4 +1,5 @@
import { import {
canHostAttachment,
deviceHasLocalCopy, deviceHasLocalCopy,
isSharingFromThisDevice, isSharingFromThisDevice,
isUploaderUser isUploaderUser
@@ -66,4 +67,10 @@ describe('attachment sharing rules', () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe('canHostAttachment', () => {
it('is true for any device that holds the bytes locally', () => {
expect(canHostAttachment({ available: false, savedPath: '/appdata/file.bin' })).toBe(true);
});
});
}); });

View File

@@ -35,6 +35,13 @@ export function isSharingFromThisDevice(
return isUploaderUser(attachment, currentUserId) && deviceHasLocalCopy(attachment); return isUploaderUser(attachment, currentUserId) && deviceHasLocalCopy(attachment);
} }
/** True when this device can serve the attachment bytes to other peers. */
export function canHostAttachment(
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
): boolean {
return deviceHasLocalCopy(attachment);
}
function hasNonEmptyString(value: string | null | undefined): boolean { function hasNonEmptyString(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0; return typeof value === 'string' && value.trim().length > 0;
} }

View File

@@ -1,7 +1,10 @@
import { import {
getWatchedAttachmentRoomIdFromUrl, getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId, isDirectMessageAttachmentRoomId,
shouldCopyUploaderMediaToAppData shouldCopyUploaderMediaToAppData,
shouldCopyLargeUploaderFileToAppData,
shouldStreamAttachmentReceiveToDisk,
canReceiveAttachment
} from './attachment.logic'; } from './attachment.logic';
describe('attachment logic', () => { describe('attachment logic', () => {
@@ -33,6 +36,16 @@ describe('attachment logic', () => {
}, '/home/ludde/video.mp4', true)).toBe(true); }, '/home/ludde/video.mp4', true)).toBe(true);
}); });
it('copies any oversized upload with a source path into app data', () => {
expect(shouldCopyLargeUploaderFileToAppData({
size: 628 * 1024 * 1024
}, '/home/ludde/setup.exe', true)).toBe(true);
expect(shouldCopyLargeUploaderFileToAppData({
size: 1024
}, '/home/ludde/setup.exe', true)).toBe(false);
});
it('skips app-data copy for small uploads and missing source paths', () => { it('skips app-data copy for small uploads and missing source paths', () => {
expect(shouldCopyUploaderMediaToAppData({ expect(shouldCopyUploaderMediaToAppData({
size: 1024, size: 1024,
@@ -44,4 +57,48 @@ describe('attachment logic', () => {
mime: 'video/mp4' mime: 'video/mp4'
}, undefined, true)).toBe(false); }, undefined, true)).toBe(false);
}); });
it('streams any persistable download to disk when the store supports streaming', () => {
const capabilities = {
canStreamToDisk: true,
canPersistSize: (bytes: number) => bytes <= 256 * 1024 * 1024
};
expect(shouldStreamAttachmentReceiveToDisk({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, capabilities)).toBe(true);
expect(shouldStreamAttachmentReceiveToDisk({
size: 3,
mime: 'application/zip',
filePath: undefined
}, capabilities)).toBe(true);
expect(shouldStreamAttachmentReceiveToDisk({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: '/home/ludde/archive.zip'
}, capabilities)).toBe(true);
});
it('receives browser-sized files in memory when disk streaming is unavailable', () => {
const browserCapabilities = {
canStreamToDisk: false,
canPersistSize: (bytes: number) => bytes <= 50 * 1024 * 1024
};
expect(canReceiveAttachment({
size: 20 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, browserCapabilities)).toBe(true);
expect(canReceiveAttachment({
size: 200 * 1024 * 1024,
mime: 'application/zip',
filePath: undefined
}, browserCapabilities)).toBe(false);
});
}); });

View File

@@ -26,10 +26,18 @@ export function shouldCopyUploaderMediaToAppData(
attachment: Pick<Attachment, 'size' | 'mime'>, attachment: Pick<Attachment, 'size' | 'mime'>,
sourcePath?: string | null, sourcePath?: string | null,
canCopyFiles = false canCopyFiles = false
): boolean {
return shouldCopyLargeUploaderFileToAppData(attachment, sourcePath, canCopyFiles) &&
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/'));
}
export function shouldCopyLargeUploaderFileToAppData(
attachment: Pick<Attachment, 'size'>,
sourcePath?: string | null,
canCopyFiles = false
): boolean { ): boolean {
return canCopyFiles && return canCopyFiles &&
!!sourcePath && !!sourcePath?.trim() &&
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/')) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES; attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
} }
@@ -50,6 +58,41 @@ export function isDirectMessageAttachmentRoomId(roomId: string | null | undefine
return !!roomId && roomId.startsWith(DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX); return !!roomId && roomId.startsWith(DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX);
} }
export interface AttachmentReceiveCapabilities {
canStreamToDisk: boolean;
canPersistSize: (bytes: number) => boolean;
}
export function shouldStreamAttachmentReceiveToDisk(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
if (!capabilities.canStreamToDisk || !capabilities.canPersistSize(attachment.size)) {
return false;
}
return true;
}
export function canReceiveAttachmentInMemory(
attachment: Pick<Attachment, 'size'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
return true;
}
return !capabilities.canStreamToDisk && capabilities.canPersistSize(attachment.size);
}
export function canReceiveAttachment(
attachment: Pick<Attachment, 'size' | 'mime' | 'filePath'>,
capabilities: AttachmentReceiveCapabilities
): boolean {
return shouldStreamAttachmentReceiveToDisk(attachment, capabilities)
|| canReceiveAttachmentInMemory(attachment, capabilities);
}
function decodeUrlSegment(value: string): string { function decodeUrlSegment(value: string): string {
try { try {
return decodeURIComponent(value); return decodeURIComponent(value);

View File

@@ -17,6 +17,14 @@ export type FileChunkEvent = ChatEvent & {
fromPeerId?: string; fromPeerId?: string;
}; };
export type FileChunkAckEvent = ChatEvent & {
type: 'file-chunk-ack';
messageId: string;
fileId: string;
index: number;
fromPeerId?: string;
};
export type FileRequestEvent = ChatEvent & { export type FileRequestEvent = ChatEvent & {
type: 'file-request'; type: 'file-request';
messageId: string; messageId: string;
@@ -37,7 +45,7 @@ export type FileNotFoundEvent = ChatEvent & {
fileId: string; fileId: string;
}; };
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>; export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file' | 'fromPeerId'>;
export interface FileChunkPayload { export interface FileChunkPayload {
messageId?: string; messageId?: string;
@@ -48,6 +56,13 @@ export interface FileChunkPayload {
data?: ChatEvent['data']; data?: ChatEvent['data'];
} }
export interface FileChunkAckPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
}
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>; export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>; export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>; export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;

View File

@@ -1,5 +1,7 @@
export * from './application/facades/attachment.facade'; export * from './application/facades/attachment.facade';
export * from './application/services/attachment-download.service';
export * from './domain/constants/attachment.constants'; export * from './domain/constants/attachment.constants';
export * from './domain/logic/attachment-download.rules';
export * from './domain/logic/attachment-sharing.rules'; export * from './domain/logic/attachment-sharing.rules';
export * from './domain/logic/local-file-path.rules'; export * from './domain/logic/local-file-path.rules';
export * from './domain/models/attachment.model'; export * from './domain/models/attachment.model';

View File

@@ -187,6 +187,18 @@ export class AttachmentStorageService {
return this.store.appendFile(filePath, base64Data); return this.store.appendFile(filePath, base64Data);
} }
async appendBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
if (!filePath) {
return false;
}
if (this.platform.isElectron) {
return this.electronStore.appendFileBytes(filePath, bytes);
}
return this.appendBase64(filePath, encodeUint8ArrayToBase64(bytes));
}
async deleteFile(filePath: string): Promise<void> { async deleteFile(filePath: string): Promise<void> {
if (!filePath) { if (!filePath) {
return; return;

View File

@@ -74,6 +74,20 @@ export class ElectronAttachmentFileStore implements AttachmentFileStore {
} }
} }
async appendFileBytes(filePath: string, bytes: Uint8Array): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.appendFileBytes || !filePath) {
return false;
}
try {
return await electronApi.appendFileBytes(filePath, bytes);
} catch {
return false;
}
}
async readFile(filePath: string): Promise<string | null> { async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();

View File

@@ -0,0 +1,49 @@
import type { Message } from '../../../../shared-kernel';
import { resolveIncomingChatMessageSenderId, resolveRoomMessageSenderId } from './message-sender-identity.rules';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'home-user-1',
senderName: 'Alice',
content: 'hello',
timestamp: 1,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('message-sender-identity.rules', () => {
it('resolveRoomMessageSenderId uses the per-server actor id', () => {
const senderId = resolveRoomMessageSenderId(
{ id: 'home-user-1', oderId: 'home-oder-1' },
'https://signal.example.com',
(_serverUrl, fallback) => fallback === 'home-oder-1' ? 'server-user-1' : fallback
);
expect(senderId).toBe('server-user-1');
});
it('resolveIncomingChatMessageSenderId prefers relay sender identity over message senderId', () => {
const senderId = resolveIncomingChatMessageSenderId(
createMessage({ senderId: 'home-user-1' }),
{
senderId: 'server-user-1',
fromPeerId: 'peer-1'
}
);
expect(senderId).toBe('server-user-1');
});
it('resolveIncomingChatMessageSenderId falls back to fromPeerId for P2P chat', () => {
const senderId = resolveIncomingChatMessageSenderId(
createMessage({ senderId: 'home-user-1' }),
{ fromPeerId: 'server-user-1' }
);
expect(senderId).toBe('server-user-1');
});
});

View File

@@ -0,0 +1,37 @@
import type { Message } from '../../../../shared-kernel';
/** Resolve the sender id that should be stored for a room chat message. */
export function resolveRoomMessageSenderId(
currentUser: Pick<{ id: string; oderId: string }, 'id' | 'oderId'>,
roomSourceUrl: string | undefined,
resolveActorUserId: (serverUrl: string | undefined, fallbackUserId: string) => string
): string {
const homeUserKey = currentUser.oderId || currentUser.id;
return resolveActorUserId(roomSourceUrl, homeUserKey);
}
interface IncomingChatMessageEnvelope {
senderId?: string;
fromPeerId?: string;
fromUserId?: string;
}
/** Normalize incoming chat sender ids to the per-server identity used by presence. */
export function resolveIncomingChatMessageSenderId(
message: Pick<Message, 'senderId'>,
envelope: IncomingChatMessageEnvelope
): string {
const relayIdentity = [envelope.senderId, envelope.fromUserId]
.find((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (relayIdentity) {
return relayIdentity.trim();
}
if (envelope.fromPeerId?.trim()) {
return envelope.fromPeerId.trim();
}
return message.senderId;
}

View File

@@ -12,11 +12,14 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared'; import { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment'; import {
Attachment,
AttachmentDownloadService,
AttachmentFacade
} from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { import {
@@ -69,10 +72,10 @@ export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent; @ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService); private readonly viewport = inject(ViewportService);
@@ -300,6 +303,7 @@ export class ChatMessagesComponent {
return; return;
} }
this.attachmentsSvc.pinDisplayBlobs(attachments);
this.lightboxState.set({ this.lightboxState.set({
attachments, attachments,
index index
@@ -307,6 +311,12 @@ export class ChatMessagesComponent {
} }
closeLightbox(): void { closeLightbox(): void {
const state = this.lightboxState();
if (state) {
this.attachmentsSvc.unpinDisplayBlobs(state.attachments);
}
this.lightboxState.set(null); this.lightboxState.set(null);
} }
@@ -336,10 +346,17 @@ export class ChatMessagesComponent {
return; return;
} }
this.attachmentsSvc.pinDisplayBlobs(availableImages);
this.galleryAttachments.set(availableImages); this.galleryAttachments.set(availableImages);
} }
closeImageGallery(): void { closeImageGallery(): void {
const gallery = this.galleryAttachments();
if (gallery) {
this.attachmentsSvc.unpinDisplayBlobs(gallery);
}
this.galleryAttachments.set(null); this.galleryAttachments.set(null);
} }
@@ -352,46 +369,7 @@ export class ChatMessagesComponent {
} }
async downloadAttachment(attachment: Attachment): Promise<void> { async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl) await this.attachmentDownload.downloadToUserLocation(attachment);
return;
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} }
async copyImageToClipboard(attachment: Attachment): Promise<void> { async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -415,46 +393,6 @@ export class ChatMessagesComponent {
return message.senderId === this.currentUser()?.id; return message.senderId === this.currentUser()?.id;
} }
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl)
return null;
if (attachment.objectUrl.startsWith('file:'))
return null;
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
private convertToPng(blob: Blob): Promise<Blob> { private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (blob.type === 'image/png') { if (blob.type === 'image/png') {

View File

@@ -192,6 +192,10 @@
/> />
<div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/img:bg-black/20"></div> <div class="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
</div> </div>
} @else if (isImagePendingHydration(gridImage)) {
<div class="chat-image-grid-cell chat-image-grid-loading">
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if ((gridImage.receivedBytes || 0) > 0) { } @else if ((gridImage.receivedBytes || 0) > 0) {
<div class="chat-image-grid-cell chat-image-grid-loading"> <div class="chat-image-grid-cell chat-image-grid-loading">
<ng-icon <ng-icon
@@ -234,7 +238,7 @@
@for (att of attachmentsList; track att.id) { @for (att of attachmentsList; track att.id) {
@if (shouldShowAttachmentInList(att)) { @if (shouldShowAttachmentInList(att)) {
@if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) { @if (isImageLikeAttachment(att) && !imageGridLayout().useGrid) {
@if (att.available && att.objectUrl) { @if (isDisplayableImage(att)) {
<div <div
class="group/img relative inline-block" class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)" (contextmenu)="openImageContextMenu($event, att)"
@@ -269,6 +273,13 @@
</button> </button>
</div> </div>
</div> </div>
} @else if (isImagePendingHydration(att)) {
<div
appThemeNode="chatAttachmentCard"
class="flex max-h-80 min-h-32 min-w-48 items-center justify-center rounded-md border border-border bg-secondary/40 p-6"
>
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if ((att.receivedBytes || 0) > 0) { } @else if ((att.receivedBytes || 0) > 0) {
<div <div
appThemeNode="chatAttachmentCard" appThemeNode="chatAttachmentCard"

View File

@@ -5,11 +5,12 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, computed,
ElementRef,
effect, effect,
ElementRef,
inject, inject,
input, input,
OnDestroy, OnDestroy,
AfterViewInit,
output, output,
signal, signal,
TemplateRef, TemplateRef,
@@ -44,9 +45,11 @@ import {
} from '../../../../../attachment'; } from '../../../../../attachment';
import { import {
dedupeImageAttachmentsForDisplay, dedupeImageAttachmentsForDisplay,
isAttachmentPendingInlineHydration,
isImageAttachment, isImageAttachment,
isInlineDisplayableImage isInlineDisplayableImage
} from '../../../../../attachment/domain/logic/attachment-image.rules'; } from '../../../../../attachment/domain/logic/attachment-image.rules';
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.rules';
import { PlatformService, ViewportService } from '../../../../../../core/platform'; import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { ExperimentalMediaSettingsService } from '../../../../../experimental-media'; import { ExperimentalMediaSettingsService } from '../../../../../experimental-media';
@@ -56,6 +59,7 @@ import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules
import { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules'; import { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { Message, User } from '../../../../../../shared-kernel'; import { Message, User } from '../../../../../../shared-kernel';
import { resolveUserByIdentity } from '../../../../../../store/users/user-identity-lookup.rules';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component'; import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins'; import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
@@ -167,10 +171,11 @@ interface MissingPluginEmbedFallback {
style: 'display: contents;' style: 'display: contents;'
} }
}) })
export class ChatMessageItemComponent implements OnDestroy { export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>; @ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService); private readonly pluginRequirements = inject(PluginRequirementStateService);
@@ -187,6 +192,8 @@ export class ChatMessageItemComponent implements OnDestroy {
private readonly appI18n = inject(AppI18nService); private readonly appI18n = inject(AppI18nService);
private mobileSheetOverlayRef: OverlayRef | null = null; private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null; private longPressTimer: number | null = null;
private visibilityObserver: IntersectionObserver | null = null;
private readonly isMessageVisible = signal(false);
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 attachmentVersion = signal(this.attachmentsSvc.updated());
@@ -221,7 +228,7 @@ export class ChatMessageItemComponent implements OnDestroy {
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 = resolveUserByIdentity(this.userLookup(), msg.senderId);
return ( return (
found ?? { found ?? {
@@ -263,12 +270,17 @@ export class ChatMessageItemComponent implements OnDestroy {
const images = this.imageAttachments(); const images = this.imageAttachments();
void this.attachmentVersion(); void this.attachmentVersion();
const isVisible = this.isMessageVisible();
for (const image of images) { for (const image of images) {
if (isInlineDisplayableImage(image)) { if (isInlineDisplayableImage(image)) {
continue; continue;
} }
if (!isAttachmentPendingInlineHydration(image)) {
continue;
}
const liveAttachment = this.getLiveAttachment(image.id); const liveAttachment = this.getLiveAttachment(image.id);
if (!liveAttachment) { if (!liveAttachment) {
@@ -278,7 +290,11 @@ export class ChatMessageItemComponent implements OnDestroy {
void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment); void this.attachmentsSvc.tryRestoreAttachmentFromLocal(liveAttachment);
} }
if (images.some((image) => !isInlineDisplayableImage(image))) { if (!isVisible) {
return;
}
if (images.some((image) => !isInlineDisplayableImage(image) && !isAttachmentPendingInlineHydration(image))) {
void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId); void this.attachmentsSvc.queueAutoDownloadsForMessage(messageId);
} }
}); });
@@ -500,7 +516,67 @@ export class ChatMessageItemComponent implements OnDestroy {
} }
} }
ngAfterViewInit(): void {
if (typeof IntersectionObserver === 'undefined') {
return;
}
const host = this.elementRef.nativeElement;
const scrollRoot = host.closest('[appThemeNode="chatMessageList"]');
this.visibilityObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) {
return;
}
this.handleMessageVisibilityChange(entry.isIntersecting);
},
{
root: scrollRoot,
rootMargin: ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN,
threshold: 0
}
);
this.visibilityObserver.observe(host);
this.syncInitialMessageVisibility(host, scrollRoot as HTMLElement | null);
}
private syncInitialMessageVisibility(host: HTMLElement, scrollRoot: HTMLElement | null): void {
if (this.isElementIntersectingScrollRoot(host, scrollRoot)) {
this.isMessageVisible.set(true);
}
}
private isElementIntersectingScrollRoot(host: HTMLElement, scrollRoot: HTMLElement | null): boolean {
const hostRect = host.getBoundingClientRect();
if (!scrollRoot) {
return hostRect.bottom > 0 &&
hostRect.top < window.innerHeight &&
hostRect.right > 0 &&
hostRect.left < window.innerWidth;
}
const rootRect = scrollRoot.getBoundingClientRect();
return hostRect.bottom > rootRect.top &&
hostRect.top < rootRect.bottom &&
hostRect.right > rootRect.left &&
hostRect.left < rootRect.right;
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.visibilityObserver?.disconnect();
this.visibilityObserver = null;
if (this.isMessageVisible()) {
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id);
}
this.clearLongPressTimer(); this.clearLongPressTimer();
this.detachMobileSheet(); this.detachMobileSheet();
} }
@@ -771,6 +847,10 @@ export class ChatMessageItemComponent implements OnDestroy {
return isInlineDisplayableImage(attachment); return isInlineDisplayableImage(attachment);
} }
isImagePendingHydration(attachment: ChatMessageAttachmentViewModel): boolean {
return isAttachmentPendingInlineHydration(attachment);
}
isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean { isImageLikeAttachment(attachment: ChatMessageAttachmentViewModel): boolean {
return isImageAttachment(attachment); return isImageAttachment(attachment);
} }
@@ -881,6 +961,21 @@ export class ChatMessageItemComponent implements OnDestroy {
}; };
} }
private handleMessageVisibilityChange(isVisible: boolean): void {
if (isVisible === this.isMessageVisible()) {
return;
}
this.isMessageVisible.set(isVisible);
const messageId = this.message().id;
if (isVisible) {
return;
}
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(messageId);
}
private getLiveAttachment(attachmentId: string): Attachment | undefined { private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId); return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
} }

View File

@@ -34,6 +34,7 @@ import {
ChatMessageReplyEvent ChatMessageReplyEvent
} from '../../models/chat-messages.model'; } from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors'; import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { buildUserIdentityLookup } from '../../../../../../store/users/user-identity-lookup.rules';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component'; import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
@@ -146,21 +147,11 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}); });
readonly userLookup = computed<ReadonlyMap<string, User>>(() => { readonly userLookup = computed<ReadonlyMap<string, User>>(() => {
const lookup = new Map<string, User>(); const lookup = new Map(buildUserIdentityLookup(this.allUsers()));
for (const user of this.allUsers()) {
lookup.set(user.id, user);
if (user.oderId && user.oderId !== user.id) {
lookup.set(user.oderId, user);
}
}
for (const user of this.userLookupOverrides()) { for (const user of this.userLookupOverrides()) {
lookup.set(user.id, user); for (const [key, value] of buildUserIdentityLookup([user])) {
lookup.set(key, value);
if (user.oderId && user.oderId !== user.id) {
lookup.set(user.oderId, user);
} }
} }

View File

@@ -16,6 +16,7 @@ import {
} from '../../../../infrastructure/mobile'; } from '../../../../infrastructure/mobile';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { import {
VoiceActivityService, VoiceActivityService,
VoiceConnectionFacade, VoiceConnectionFacade,
@@ -109,6 +110,47 @@ describe('DirectCallService', () => {
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled(); expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
}); });
it('marks a remote join against the session participant alias stored locally', async () => {
const aliceForeign = createUser('alice-foreign', 'Alice');
const bobForeign = createUser('bob-foreign', 'Bob');
const bobHome = { ...bobForeign, id: 'bob-home', oderId: 'bob-home' };
const context = createServiceContext({ currentUser: aliceForeign, allUsers: [aliceForeign, bobForeign] });
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'ring',
callId: 'dm-alice-foreign--bob-foreign',
conversationId: 'dm-alice-foreign--bob-foreign',
createdAt: 10,
sender: toParticipant(bobForeign),
participantIds: ['alice-foreign', 'bob-foreign'],
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
}
});
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-foreign--bob-foreign')).not.toBeNull());
context.directCallEvents.next({
type: 'direct-call',
directCall: {
action: 'join',
callId: 'dm-alice-foreign--bob-foreign',
conversationId: 'dm-alice-foreign--bob-foreign',
createdAt: 10,
sender: toParticipant(bobHome),
participantIds: ['alice-foreign', 'bob-foreign'],
participants: [toParticipant(aliceForeign), toParticipant(bobForeign)]
}
});
await vi.waitFor(() => {
const session = context.service.sessionById('dm-alice-foreign--bob-foreign');
expect(session?.participants['bob-foreign']?.joined).toBe(true);
});
});
it('answers an incoming call from the modal action', async () => { it('answers an incoming call from the modal action', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] }); const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
@@ -573,9 +615,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
{ {
provide: MobileMediaService, provide: MobileMediaService,
useValue: { useValue: {
ensureVoiceCapturePermissions: vi.fn(async () => true),
setSpeakerphoneEnabled: vi.fn(async () => undefined) setSpeakerphoneEnabled: vi.fn(async () => undefined)
} }
}, },
{
provide: RealtimeSessionFacade,
useValue: {
getClientInstanceId: vi.fn(() => 'test-client'),
requestVoiceClientTakeover: vi.fn()
}
},
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Injectable, Injectable,
computed, computed,
@@ -33,6 +33,11 @@ import {
User User
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model'; import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
import {
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
} from '../../domain/logic/direct-call-participant-identity.rules';
import { toDirectMessageParticipant } from '../../../direct-message'; import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -772,13 +777,20 @@ export class DirectCallService {
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession { private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
return { return {
...nextSession, ...nextSession,
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [ participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => {
const previousEntry = findDirectCallParticipantEntryForUser(previousSession, {
id: participant.userId,
oderId: participant.profile.userId
});
return [
participant.userId, participant.userId,
{ {
...participant, ...participant,
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined joined: previousEntry?.participant.joined ?? participant.joined
} }
])) ];
}))
}; };
} }
@@ -865,7 +877,14 @@ export class DirectCallService {
joined: boolean, joined: boolean,
status: DirectCallSession['status'] status: DirectCallSession['status']
): DirectCallSession { ): DirectCallSession {
const participant = session.participants[participantId]; const knownUser = this.users().find((user) =>
user.id === participantId || user.oderId === participantId || user.peerId === participantId
);
const entry = knownUser
? findDirectCallParticipantEntryForUser(session, knownUser, [participantId])
: findDirectCallParticipantEntry(session, participantId);
const key = entry?.key ?? participantId;
const participant = entry?.participant ?? session.participants[participantId];
return { return {
...session, ...session,
@@ -874,7 +893,7 @@ export class DirectCallService {
...session.participants, ...session.participants,
...(participant ...(participant
? { ? {
[participantId]: { [key]: {
...participant, ...participant,
joined joined
} }
@@ -916,9 +935,9 @@ export class DirectCallService {
} }
private isCurrentUserJoined(session: DirectCallSession): boolean { private isCurrentUserJoined(session: DirectCallSession): boolean {
const meId = this.currentUserId(); const user = this.currentUser();
return !!meId && !!session.participants[meId]?.joined; return !!user && isDirectCallParticipantJoined(session, user);
} }
private stopLocalMedia(session: DirectCallSession): void { private stopLocalMedia(session: DirectCallSession): void {
@@ -955,8 +974,13 @@ export class DirectCallService {
} }
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void { private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void {
const knownUser = this.users().find((user) =>
user.id === userId || user.oderId === userId || user.peerId === userId
);
const resolvedUserId = knownUser?.id ?? userId;
this.store.dispatch(UsersActions.updateVoiceState({ this.store.dispatch(UsersActions.updateVoiceState({
userId, userId: resolvedUserId,
voiceState: { voiceState: {
isConnected: connected, isConnected: connected,
isMuted: false, isMuted: false,

View File

@@ -0,0 +1,80 @@
import type { DirectCallSession } from '../models/direct-call.model';
import {
findDirectCallParticipantEntry,
findDirectCallParticipantEntryForUser,
isDirectCallParticipantJoined
} from './direct-call-participant-identity.rules';
function createSession(participants: DirectCallSession['participants']): DirectCallSession {
return {
callId: 'dm-alice-home--bob-foreign',
conversationId: 'dm-alice-home--bob-foreign',
createdAt: 1,
initiatorId: 'alice-home',
participantIds: Object.keys(participants),
participants,
status: 'connected'
};
}
describe('direct-call-participant-identity.rules', () => {
it('findDirectCallParticipantEntryForUser resolves join state across provisioned participant aliases', () => {
const session = createSession({
'bob-foreign': {
userId: 'bob-foreign',
profile: {
userId: 'bob-foreign',
username: 'bob',
displayName: 'Bob'
},
joined: true
}
});
expect(isDirectCallParticipantJoined(session, {
id: 'bob-entity',
oderId: 'bob-home'
}, ['bob-foreign'])).toBe(true);
expect(findDirectCallParticipantEntry(session, 'bob-foreign')?.participant.joined).toBe(true);
});
it('findDirectCallParticipantEntryForUser matches store users keyed by a different entity id', () => {
const session = createSession({
'bob-foreign': {
userId: 'bob-foreign',
profile: {
userId: 'bob-foreign',
username: 'bob',
displayName: 'Bob'
},
joined: true
}
});
expect(findDirectCallParticipantEntryForUser(session, {
id: 'bob-entity',
oderId: 'bob-home',
peerId: 'bob-peer'
}, ['bob-foreign'])?.participant.joined).toBe(true);
});
it('isDirectCallParticipantJoined returns false when no alias matches a joined participant', () => {
const session = createSession({
'bob-home': {
userId: 'bob-home',
profile: {
userId: 'bob-home',
username: 'bob',
displayName: 'Bob'
},
joined: false
}
});
expect(isDirectCallParticipantJoined(session, {
id: 'bob-entity',
oderId: 'bob-foreign'
}, ['bob-foreign'])).toBe(false);
});
});

View File

@@ -0,0 +1,88 @@
import type { User } from '../../../../shared-kernel';
import type { DirectCallParticipant, DirectCallSession } from '../models/direct-call.model';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
/** Collect every id that can represent a user in direct-call participant state. */
export function collectDirectCallUserIdentityKeys(
user: UserIdentityFields,
additionalIds: readonly string[] = []
): string[] {
const keys: string[] = [];
for (const candidate of [
user.id,
user.oderId,
user.peerId,
...additionalIds
]) {
const normalized = candidate?.trim();
if (normalized && !keys.includes(normalized)) {
keys.push(normalized);
}
}
return keys;
}
export function findDirectCallParticipantEntry(
session: Pick<DirectCallSession, 'participants'>,
identity: string | undefined
): { key: string; participant: DirectCallParticipant } | undefined {
if (!identity?.trim()) {
return undefined;
}
const trimmed = identity.trim();
const direct = session.participants[trimmed];
if (direct) {
return { key: trimmed, participant: direct };
}
for (const [key, participant] of Object.entries(session.participants)) {
if (participant.userId === trimmed || participant.profile.userId === trimmed) {
return { key, participant };
}
}
return undefined;
}
export function findDirectCallParticipantEntryForUser(
session: Pick<DirectCallSession, 'participants'>,
user: UserIdentityFields,
additionalIds: readonly string[] = []
): { key: string; participant: DirectCallParticipant } | undefined {
for (const identity of collectDirectCallUserIdentityKeys(user, additionalIds)) {
const entry = findDirectCallParticipantEntry(session, identity);
if (entry) {
return entry;
}
}
const userKeys = new Set(collectDirectCallUserIdentityKeys(user, additionalIds));
for (const [key, participant] of Object.entries(session.participants)) {
const participantKeys = collectDirectCallUserIdentityKeys({
id: participant.userId,
oderId: participant.profile.userId
});
if (participantKeys.some((participantKey) => userKeys.has(participantKey))) {
return { key, participant };
}
}
return undefined;
}
export function isDirectCallParticipantJoined(
session: Pick<DirectCallSession, 'participants'>,
user: UserIdentityFields,
additionalIds: readonly string[] = []
): boolean {
return !!findDirectCallParticipantEntryForUser(session, user, additionalIds)?.participant.joined;
}

View File

@@ -4,6 +4,7 @@ import {
createGroupConversation, createGroupConversation,
directMessageEventIncludesUser, directMessageEventIncludesUser,
directMessageSyncIncludesUser, directMessageSyncIncludesUser,
directMessageConversationIncludesUser,
createDirectCallStartedMessage, createDirectCallStartedMessage,
getDirectConversationId, getDirectConversationId,
isGroupDirectConversation, isGroupDirectConversation,
@@ -137,6 +138,32 @@ describe('DirectMessageService domain flow', () => {
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false); expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false);
}); });
it('recognises direct-message recipients across identity aliases', () => {
const payload = {
message: createMessage('message-1', 'SENT', getDirectConversationId('alice', 'bob-foreign'), ['bob-foreign']),
participants: [alice, { ...bob, userId: 'bob-foreign' }],
sender: alice
};
const bobIds = new Set(['bob', 'bob-foreign']);
expect(directMessageEventIncludesUser(payload, bobIds)).toBe(true);
expect(directMessageEventIncludesUser(payload, 'bob')).toBe(false);
});
it('recognises conversation participants across identity aliases', () => {
const conversation = {
...createDirectConversation(alice, bob, 10),
participants: ['alice', 'bob-foreign'],
participantProfiles: {
alice,
'bob-foreign': { ...bob, userId: 'bob-foreign' }
}
};
expect(directMessageConversationIncludesUser(conversation, new Set(['bob', 'bob-foreign']))).toBe(true);
expect(directMessageConversationIncludesUser(conversation, 'bob')).toBe(false);
});
it('recognises only declared sync participants', () => { it('recognises only declared sync participants', () => {
const payload = { const payload = {
conversationId: 'dm-group-test', conversationId: 'dm-group-test',

View File

@@ -14,6 +14,7 @@ import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service'; import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment'; import { AttachmentFacade } from '../../../attachment';
import { CustomEmojiService } from '../../../custom-emoji'; import { CustomEmojiService } from '../../../custom-emoji';
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
import { import {
advanceDirectMessageStatus, advanceDirectMessageStatus,
createDirectConversation, createDirectConversation,
@@ -27,6 +28,7 @@ import {
updateMessageStatusInConversation, updateMessageStatusInConversation,
upsertDirectMessage upsertDirectMessage
} from '../../domain/logic/direct-message.logic'; } from '../../domain/logic/direct-message.logic';
import { collectDirectMessageSelfUserIds, isSelfDirectMessageSender } from '../../domain/logic/direct-message-identity.rules';
import { import {
DirectMessage, DirectMessage,
DirectMessageConversation, DirectMessageConversation,
@@ -67,6 +69,7 @@ export class DirectMessageService {
private readonly delivery = inject(PeerDeliveryService); private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly customEmoji = inject(CustomEmojiService); private readonly customEmoji = inject(CustomEmojiService);
private readonly credentialStore = inject(SignalServerCredentialStoreService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly currentUser = this.store.selectSignal(selectCurrentUser);
@@ -501,8 +504,9 @@ export class DirectMessageService {
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> { private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const selfUserIds = this.getSelfUserIds();
if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) { if (!directMessageEventIncludesUser(payload, selfUserIds) || isSelfDirectMessageSender(payload, selfUserIds)) {
return; return;
} }
@@ -571,8 +575,9 @@ export class DirectMessageService {
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> { private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.findConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) { if (!conversation || !directMessageConversationIncludesUser(conversation, selfUserIds)) {
return; return;
} }
@@ -580,16 +585,16 @@ export class DirectMessageService {
} }
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void { private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
const currentUserId = this.getCurrentUserId(); const selfUserIds = this.getSelfUserIds();
if (!currentUserId || payload.sender.userId === currentUserId) { if (selfUserIds.size === 0 || selfUserIds.has(payload.sender.userId)) {
return; return;
} }
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId); const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
if (!conversation if (!conversation
|| !directMessageConversationIncludesUser(conversation, currentUserId) || !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) { || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return; return;
} }
@@ -621,10 +626,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const conversation = await this.findConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
const selfUserIds = this.getSelfUserIds();
if (!conversation if (!conversation
|| payload.sender.userId === ownerId || selfUserIds.has(payload.sender.userId)
|| !directMessageConversationIncludesUser(conversation, ownerId) || !directMessageConversationIncludesUser(conversation, selfUserIds)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) { || !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return; return;
} }
@@ -647,12 +653,13 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser); const currentParticipant = toDirectMessageParticipant(currentUser);
const selfUserIds = this.getSelfUserIds();
if (payload.sender.userId === ownerId) { if (selfUserIds.has(payload.sender.userId)) {
return; return;
} }
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) { if (!directMessageSyncIncludesUser(payload, selfUserIds) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
return; return;
} }
@@ -929,7 +936,9 @@ export class DirectMessageService {
return []; return [];
} }
return conversation.participants.filter((participantId) => participantId !== currentUserId); const selfUserIds = this.getSelfUserIds();
return conversation.participants.filter((participantId) => !selfUserIds.has(participantId));
} }
private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' { private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
@@ -991,4 +1000,16 @@ export class DirectMessageService {
return ownerId; return ownerId;
} }
private getSelfUserIds(): ReadonlySet<string> {
const currentUser = this.currentUser();
if (!currentUser) {
return new Set();
}
const actorUserIds = this.credentialStore.listValidCredentials().map((credential) => credential.userId);
return collectDirectMessageSelfUserIds(currentUser, actorUserIds);
}
} }

View File

@@ -9,7 +9,8 @@ import {
} from 'rxjs'; } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectAllUsers } from '../../../../store/users/users.selectors'; import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { ChatEvent, User } from '../../../../shared-kernel'; import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
import type { ChatEvent } from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PeerDeliveryService { export class PeerDeliveryService {
@@ -87,13 +88,13 @@ export class PeerDeliveryService {
return recipientId; return recipientId;
} }
const user = this.users().find((candidate: User) => const lookup = buildUserIdentityLookup(this.users());
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId const user = resolveUserByIdentity(lookup, recipientId);
);
const candidates = [ const candidates = [
user?.oderId, user?.oderId,
user?.peerId, user?.peerId,
user?.id user?.id,
recipientId
].filter((candidate): candidate is string => !!candidate); ].filter((candidate): candidate is string => !!candidate);
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null; return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
@@ -135,9 +136,8 @@ export class PeerDeliveryService {
} }
private resolveCandidateIds(recipientId: string): string[] { private resolveCandidateIds(recipientId: string): string[] {
const user = this.users().find((candidate: User) => const lookup = buildUserIdentityLookup(this.users());
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId const user = resolveUserByIdentity(lookup, recipientId);
);
return [ return [
recipientId, recipientId,

View File

@@ -0,0 +1,114 @@
import {
collectDirectMessageSelfUserIds,
directMessageConversationIncludesAnyUser,
directMessageEventIncludesAnyUser,
isSelfDirectMessageSender
} from './direct-message-identity.rules';
import type { DirectMessageConversation, DirectMessageParticipant } from '../models/direct-message.model';
const aliceHome: DirectMessageParticipant = {
userId: 'alice-home',
username: 'alice',
displayName: 'Alice'
};
const bobHome: DirectMessageParticipant = {
userId: 'bob-home',
username: 'bob',
displayName: 'Bob'
};
describe('direct-message-identity.rules', () => {
it('collects home and provisioned actor ids for the local user', () => {
const ids = collectDirectMessageSelfUserIds(
{ id: 'alice-home', oderId: 'alice-home' },
['alice-foreign']
);
expect(Array.from(ids).sort()).toEqual(['alice-foreign', 'alice-home']);
});
it('accepts incoming direct messages addressed to a provisioned actor id', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--bob-foreign',
senderId: 'alice-home',
recipientId: 'bob-foreign',
recipientIds: ['bob-foreign'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: aliceHome,
participants: [aliceHome, { ...bobHome, userId: 'bob-foreign' }]
};
expect(directMessageEventIncludesAnyUser(payload, collectDirectMessageSelfUserIds(
{ id: 'bob-home', oderId: 'bob-home' },
['bob-foreign']
))).toBe(true);
});
it('rejects incoming direct messages that do not target any local identity', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--charlie',
senderId: 'alice-home',
recipientId: 'charlie',
recipientIds: ['charlie'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: aliceHome,
participants: [aliceHome]
};
expect(directMessageEventIncludesAnyUser(payload, collectDirectMessageSelfUserIds(
{ id: 'bob-home', oderId: 'bob-home' },
['bob-foreign']
))).toBe(false);
});
it('treats any local identity as the sender for echo suppression', () => {
const payload = {
message: {
id: 'message-1',
conversationId: 'dm-alice-home--bob-foreign',
senderId: 'alice-foreign',
recipientId: 'bob-foreign',
recipientIds: ['bob-foreign'],
content: 'hello',
timestamp: 1,
status: 'SENT' as const
},
sender: { ...aliceHome, userId: 'alice-foreign' }
};
const selfIds = collectDirectMessageSelfUserIds(
{ id: 'alice-home', oderId: 'alice-home' },
['alice-foreign']
);
expect(isSelfDirectMessageSender(payload, selfIds)).toBe(true);
});
it('matches conversations stored under a different participant alias', () => {
const conversation: DirectMessageConversation = {
id: 'dm-alice-home--bob-foreign',
participants: ['alice-home', 'bob-foreign'],
participantProfiles: {
'alice-home': aliceHome,
'bob-foreign': { ...bobHome, userId: 'bob-foreign' }
},
messages: [],
lastMessageAt: 1,
unreadCount: 0
};
expect(directMessageConversationIncludesAnyUser(
conversation,
collectDirectMessageSelfUserIds({ id: 'bob-home', oderId: 'bob-home' }, ['bob-foreign'])
)).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import type { User } from '../../../../shared-kernel';
import { directMessageConversationIncludesUser, directMessageEventIncludesUser } from './direct-message.logic';
import type { DirectMessageConversation, DirectMessageEventPayload } from '../models/direct-message.model';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
/** Collect every id that can represent the local user in direct-message traffic. */
export function collectDirectMessageSelfUserIds(
user: UserIdentityFields,
additionalUserIds: readonly string[] = []
): ReadonlySet<string> {
const ids = new Set<string>();
for (const candidate of [
user.id,
user.oderId,
user.peerId,
...additionalUserIds
]) {
const normalized = candidate?.trim();
if (normalized) {
ids.add(normalized);
}
}
return ids;
}
export function directMessageEventIncludesAnyUser(
payload: DirectMessageEventPayload,
userIds: ReadonlySet<string>
): boolean {
return directMessageEventIncludesUser(payload, userIds);
}
export function isSelfDirectMessageSender(
payload: DirectMessageEventPayload,
userIds: ReadonlySet<string>
): boolean {
return userIds.has(payload.sender.userId) || userIds.has(payload.message.senderId);
}
export function directMessageConversationIncludesAnyUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userIds: ReadonlySet<string>
): boolean {
return directMessageConversationIncludesUser(conversation, userIds);
}

View File

@@ -100,23 +100,46 @@ export function isGroupDirectConversation(conversation: DirectMessageConversatio
export function directMessageConversationIncludesUser( export function directMessageConversationIncludesUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>, conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userId: string userId: string | ReadonlySet<string>
): boolean { ): boolean {
return conversation.participants.includes(userId) || !!conversation.participantProfiles[userId]; const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
if (conversation.participants.some((participantId) => userIds.has(participantId))) {
return true;
}
if (Object.keys(conversation.participantProfiles).some((participantId) => userIds.has(participantId))) {
return true;
}
return Object.values(conversation.participantProfiles).some((participant) =>
userIds.has(participant.userId)
);
} }
export function directMessageEventIncludesUser( export function directMessageEventIncludesUser(
payload: DirectMessageEventPayload, payload: DirectMessageEventPayload,
userId: string userId: string | ReadonlySet<string>
): boolean { ): boolean {
return collectDirectMessageEventParticipantIds(payload).has(userId); const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
const participantIds = collectDirectMessageEventParticipantIds(payload);
for (const candidate of userIds) {
if (participantIds.has(candidate)) {
return true;
}
}
return false;
} }
export function directMessageSyncIncludesUser( export function directMessageSyncIncludesUser(
payload: DirectMessageSyncEventPayload, payload: DirectMessageSyncEventPayload,
userId: string userId: string | ReadonlySet<string>
): boolean { ): boolean {
return payload.participants.some((participant) => participant.userId === userId); const userIds = typeof userId === 'string' ? new Set([userId]) : userId;
return payload.participants.some((participant) => userIds.has(participant.userId));
} }
export function upsertDirectMessage( export function upsertDirectMessage(

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
computed, computed,
@@ -15,7 +15,6 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform'; import { ViewportService } from '../../../../core/platform';
import { import {
BottomSheetComponent, BottomSheetComponent,
@@ -23,11 +22,16 @@ import {
UserAvatarComponent UserAvatarComponent
} from '../../../../shared'; } from '../../../../shared';
import { DirectCallService } from '../../../direct-call'; import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment'; import {
Attachment,
AttachmentDownloadService,
AttachmentFacade
} from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme'; import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { isConversationBound } from './dm-chat.rules'; import { isConversationBound } from './dm-chat.rules';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { buildUserIdentityLookup, resolveUserByIdentity } from '../../../../store/users/user-identity-lookup.rules';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide'; import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import { import {
@@ -87,7 +91,7 @@ export class DmChatComponent {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly electronBridge = inject(ElectronBridgeService); private readonly attachmentDownload = inject(AttachmentDownloadService);
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService); private readonly linkMetadata = inject(LinkMetadataService);
@@ -137,13 +141,14 @@ export class DmChatComponent {
readonly participantUsers = computed<User[]>(() => { readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const knownUsers = this.allUsers(); const knownUsers = this.allUsers();
const userLookup = buildUserIdentityLookup(knownUsers);
if (!conversation) { if (!conversation) {
return []; return [];
} }
return conversation.participants.map((participantId) => { return conversation.participants.map((participantId) => {
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId); const knownUser = resolveUserByIdentity(userLookup, participantId);
const participant = conversation.participantProfiles[participantId]; const participant = conversation.participantProfiles[participantId];
return ( return (
@@ -483,49 +488,7 @@ export class DmChatComponent {
} }
async downloadAttachment(attachment: Attachment): Promise<void> { async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl) { await this.attachmentDownload.downloadToUserLocation(attachment);
return;
}
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} }
async copyImageToClipboard(attachment: Attachment): Promise<void> { async copyImageToClipboard(attachment: Attachment): Promise<void> {
@@ -597,48 +560,6 @@ export class DmChatComponent {
return `${messageId}:${url}`; return `${messageId}:${url}`;
} }
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) {
return null;
}
if (attachment.objectUrl.startsWith('file:')) {
return null;
}
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null { private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) { if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null; return null;
@@ -651,7 +572,9 @@ export class DmChatComponent {
return null; return null;
} }
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null; return this.participantUsers().find((user) =>
user.id === peerId || user.oderId === peerId || user.peerId === peerId
) ?? resolveUserByIdentity(buildUserIdentityLookup(this.allUsers()), peerId) ?? null;
} }
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string { private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {

View File

@@ -136,7 +136,7 @@ describe('CreateServerDialogComponent', () => {
component.create(); component.create();
expect(router.navigate).toHaveBeenCalledWith(['/login']); expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {} });
expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false); expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false);
}); });
}); });

View File

@@ -23,6 +23,7 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade'; import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { PluginRequirementService, PluginStoreService } from '../../../plugins'; import { PluginRequirementService, PluginStoreService } from '../../../plugins';
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing'; import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import type { ServerInfo } from '../../domain/models/server-directory.model'; import type { ServerInfo } from '../../domain/models/server-directory.model';
import type { User } from '../../../../shared-kernel'; import type { User } from '../../../../shared-kernel';
@@ -100,6 +101,9 @@ function createHarness(options: HarnessOptions = {}) {
sendRawMessageToSignalUrl: vi.fn() sendRawMessageToSignalUrl: vi.fn()
} as unknown as RealtimeSessionFacade; } as unknown as RealtimeSessionFacade;
const externalLinks = { open: vi.fn() } as unknown as ExternalLinkService; const externalLinks = { open: vi.fn() } as unknown as ExternalLinkService;
const signalServerAuthorize = {
ensureCredentialForServerUrl: vi.fn(() => Promise.resolve(true))
} as unknown as SignalServerAuthorizeService;
const injector = Injector.create({ const injector = Injector.create({
providers: [ providers: [
ServerBrowserComponent, ServerBrowserComponent,
@@ -111,6 +115,7 @@ function createHarness(options: HarnessOptions = {}) {
{ provide: RealtimeSessionFacade, useValue: webrtc }, { provide: RealtimeSessionFacade, useValue: webrtc },
{ provide: PluginRequirementService, useValue: pluginRequirements }, { provide: PluginRequirementService, useValue: pluginRequirements },
{ provide: PluginStoreService, useValue: pluginStore }, { provide: PluginStoreService, useValue: pluginStore },
{ provide: SignalServerAuthorizeService, useValue: signalServerAuthorize },
...provideAppI18nForTests() ...provideAppI18nForTests()
] ]
}); });

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
Component, Component,
@@ -30,6 +30,7 @@ import {
participantToUser, participantToUser,
type DirectCallSession type DirectCallSession
} from '../../domains/direct-call'; } from '../../domains/direct-call';
import { isDirectCallParticipantJoined } from '../../domains/direct-call/domain/logic/direct-call-participant-identity.rules';
import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component'; import { DmChatComponent } from '../../domains/direct-message/feature/dm-chat/dm-chat.component';
import { import {
VoiceActivityService, VoiceActivityService,
@@ -123,9 +124,9 @@ export class PrivateCallComponent {
}); });
readonly isConnected = computed(() => { readonly isConnected = computed(() => {
const session = this.session(); const session = this.session();
const currentUserId = this.currentUserKey(); const currentUser = this.currentUser();
return !!session && !!currentUserId && !!session.participants[currentUserId]?.joined; return !!session && !!currentUser && isDirectCallParticipantJoined(session, currentUser);
}); });
readonly isMuted = this.voice.isMuted; readonly isMuted = this.voice.isMuted;
readonly isDeafened = this.voice.isDeafened; readonly isDeafened = this.voice.isDeafened;
@@ -439,7 +440,6 @@ export class PrivateCallComponent {
isParticipantConnected(user: User): boolean { isParticipantConnected(user: User): boolean {
const session = this.session(); const session = this.session();
const userId = this.userKey(user);
const current = this.currentUser(); const current = this.currentUser();
if (!session) { if (!session) {
@@ -453,11 +453,11 @@ export class PrivateCallComponent {
); );
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId); const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
if (isSelf && inCallVoice) { if (isSelf && (isDirectCallParticipantJoined(session, user) || inCallVoice)) {
return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId()); return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId());
} }
return !!session.participants[userId]?.joined || inCallVoice; return isDirectCallParticipantJoined(session, user) || inCallVoice;
} }
isPassiveCallParticipant(user: User): boolean { isPassiveCallParticipant(user: User): boolean {

View File

@@ -43,6 +43,18 @@
</div> </div>
<span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span> <span class="font-mono text-sm text-foreground">{{ ramLabel() ?? '-' }}</span>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
(click)="exportRamDiagnostics()"
[disabled]="isExportingRamDiagnostics()"
class="rounded-lg border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
{{ isExportingRamDiagnostics()
? ('settings.debugging.exportRamDiagnosticsWorking' | translate)
: ('settings.debugging.exportRamDiagnostics' | translate) }}
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.ramHint' | translate }}</p> <p class="mt-1 text-xs text-muted-foreground">{{ 'settings.debugging.ramHint' | translate }}</p>
</section> </section>
} }

View File

@@ -21,6 +21,7 @@ import { interval } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators'; import { startWith, switchMap } from 'rxjs/operators';
import { DebuggingService } from '../../../../core/services/debugging.service'; import { DebuggingService } from '../../../../core/services/debugging.service';
import { DesktopHighMemoryAlertService } from '../../../../core/services/desktop-high-memory-alert.service';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules'; import { formatAppRamLabel } from '../../../../core/platform/electron/electron-app-metrics.rules';
import { PlatformService } from '../../../../core/platform'; import { PlatformService } from '../../../../core/platform';
@@ -53,10 +54,12 @@ export class DebuggingSettingsComponent {
private readonly platform = inject(PlatformService); private readonly platform = inject(PlatformService);
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
readonly debugging = inject(DebuggingService); readonly debugging = inject(DebuggingService);
private readonly highMemoryAlert = inject(DesktopHighMemoryAlertService);
private readonly appI18n = inject(AppI18nService); 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 isExportingRamDiagnostics = signal(false);
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(() => {
@@ -97,6 +100,20 @@ export class DebuggingSettingsComponent {
this.debugging.clear(); this.debugging.clear();
} }
async exportRamDiagnostics(): Promise<void> {
if (!this.isElectron || this.isExportingRamDiagnostics()) {
return;
}
this.isExportingRamDiagnostics.set(true);
try {
await this.highMemoryAlert.exportDiagnostics();
} finally {
this.isExportingRamDiagnostics.set(false);
}
}
private startRamPolling(): void { private startRamPolling(): void {
const api = this.electronBridge.getApi(); const api = this.electronBridge.getApi();

View File

@@ -5,14 +5,20 @@
(dismissed)="dismiss()" (dismissed)="dismiss()"
/> />
<div
class="fixed inset-0 z-[121] flex items-center justify-center px-4 pointer-events-none"
>
<div <div
appThemeNode="highMemoryAlertDialog" appThemeNode="highMemoryAlertDialog"
class="fixed inset-0 z-[121] flex items-center justify-center px-4" class="pointer-events-auto relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg"
role="alertdialog" role="alertdialog"
aria-modal="true"
[attr.aria-labelledby]="'high-memory-alert-title'" [attr.aria-labelledby]="'high-memory-alert-title'"
[attr.aria-describedby]="'high-memory-alert-description'" [attr.aria-describedby]="'high-memory-alert-description'"
(click)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
> >
<div class="relative w-full max-w-lg rounded-xl border border-border bg-card p-4 shadow-lg">
<button <button
type="button" type="button"
(click)="dismiss()" (click)="dismiss()"
@@ -33,14 +39,14 @@
id="high-memory-alert-title" id="high-memory-alert-title"
class="mt-1 pr-10 text-base font-semibold text-foreground" class="mt-1 pr-10 text-base font-semibold text-foreground"
> >
{{ 'app.highMemoryAlert.title' | translate:{ usageGb: alertService.peakUsageGb() } }} {{ alertService.titleKey() | translate:{ usageGb: alertService.peakUsageGb() } }}
</h2> </h2>
<p <p
id="high-memory-alert-description" id="high-memory-alert-description"
class="mt-2 pr-2 text-sm leading-6 text-muted-foreground" class="mt-2 pr-2 text-sm leading-6 text-muted-foreground"
> >
{{ 'app.highMemoryAlert.message' | translate }} {{ alertService.messageKey() | translate }}
</p> </p>
<p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground"> <p class="mt-3 break-all rounded-lg border border-border/70 bg-secondary/40 px-3 py-2 font-mono text-[11px] leading-5 text-muted-foreground">

View File

@@ -0,0 +1,108 @@
import '@angular/compiler';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import { EnvironmentInjector, Injector } from '@angular/core';
import { PerfDiagnosticsCollector } from './diagnostics.collector';
import {
bootstrapPerfDiagnostics,
registerImmediatePerfDiagCollector,
resetDiagnosticsBootstrapStateForTests
} from './diagnostics.bootstrap';
import type { PerfDiagEntry } from './diagnostics.models';
describe('diagnostics.bootstrap', () => {
let injector: EnvironmentInjector;
let collector: {
collectSample: ReturnType<typeof vi.fn>;
buildEntries: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
resetDiagnosticsBootstrapStateForTests();
collector = {
collectSample: vi.fn(() => ({
storeDomains: { attachment: 1024 },
storeBytes: { attachment: 1024 },
components: { ChatMessageItemComponent: 12 },
componentDomains: { chat: 12 },
suspectedLeaks: [],
heap: { usedJsHeapMb: 256, totalJsHeapMb: 300 },
route: '/room/test',
durationMs: 2
})),
buildEntries: vi.fn(() => ([
{
collectedAt: 1,
source: 'renderer',
type: 'store',
payload: { domains: { attachment: 1024 } }
},
{
collectedAt: 1,
source: 'renderer',
type: 'components',
payload: { domains: { chat: 12 } }
},
{
collectedAt: 1,
source: 'renderer',
type: 'heap',
payload: { usedJsHeapMb: 256 }
}
] satisfies PerfDiagEntry[]))
};
injector = Injector.create({
providers: [{ provide: PerfDiagnosticsCollector, useValue: collector }]
}) as EnvironmentInjector;
});
afterEach(() => {
resetDiagnosticsBootstrapStateForTests();
});
it('registers immediate renderer samples without perf diagnostics being enabled', () => {
registerImmediatePerfDiagCollector(injector);
const entries = globalThis.__collectPerfDiagSample?.() ?? [];
expect(entries).toHaveLength(3);
expect(entries.find((entry) => entry.type === 'store')?.payload).toEqual({
domains: { attachment: 1024 }
});
expect(entries.find((entry) => entry.type === 'components')?.payload).toEqual({
domains: { chat: 12 }
});
});
it('keeps the immediate collector registered after periodic sampling stops', async () => {
vi.useFakeTimers();
vi.stubGlobal('window', { addEventListener: vi.fn() });
registerImmediatePerfDiagCollector(injector);
const reportSample = vi.fn(async () => {
throw new Error('writer unavailable');
});
await bootstrapPerfDiagnostics({
isPerfDiagEnabled: async () => true,
reportPerfDiagSample: reportSample
}, injector);
await vi.runOnlyPendingTimersAsync();
expect(globalThis.__collectPerfDiagSample?.()).toHaveLength(3);
vi.useRealTimers();
vi.unstubAllGlobals();
});
});

View File

@@ -16,11 +16,38 @@ const SAMPLE_INTERVAL_MS = 10_000;
let started = false; let started = false;
let sampleTimer: ReturnType<typeof setInterval> | null = null; let sampleTimer: ReturnType<typeof setInterval> | null = null;
let immediateCollectorRegistered = false;
export function registerImmediatePerfDiagCollector(injector: EnvironmentInjector): void {
if (immediateCollectorRegistered) {
return;
}
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
immediateCollectorRegistered = true;
}
export async function bootstrapPerfDiagnostics( export async function bootstrapPerfDiagnostics(
api: ElectronApi, api: ElectronApi,
injector: EnvironmentInjector injector: EnvironmentInjector
): Promise<void> { ): Promise<void> {
registerImmediatePerfDiagCollector(injector);
const reportSample = api.reportPerfDiagSample; const reportSample = api.reportPerfDiagSample;
if (started || !api.isPerfDiagEnabled || !reportSample) { if (started || !api.isPerfDiagEnabled || !reportSample) {
@@ -41,22 +68,6 @@ export async function bootstrapPerfDiagnostics(
started = true; started = true;
let immediateSampleCollector: PerfDiagnosticsCollector | null = null;
runInInjectionContext(injector, () => {
immediateSampleCollector = inject(PerfDiagnosticsCollector);
});
globalThis.__collectPerfDiagSample = () => {
if (!immediateSampleCollector) {
return [];
}
const sample = immediateSampleCollector.collectSample();
return sample ? immediateSampleCollector.buildEntries(sample) : [];
};
const reporter: PerfDiagReporter = { const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry) report: (entry: PerfDiagEntry) => reportSample(entry)
}; };
@@ -113,6 +124,12 @@ function stopPerfDiagnosticsSampling(): void {
sampleTimer = null; sampleTimer = null;
} }
delete globalThis.__collectPerfDiagSample;
started = false; started = false;
} }
/** @internal Resets module state between unit tests. */
export function resetDiagnosticsBootstrapStateForTests(): void {
stopPerfDiagnosticsSampling();
immediateCollectorRegistered = false;
delete globalThis.__collectPerfDiagSample;
}

View File

@@ -161,6 +161,13 @@ export interface FileChunkChatEvent extends ChatEventBase {
data: string; data: string;
} }
export interface FileChunkAckChatEvent extends ChatEventBase {
type: 'file-chunk-ack';
messageId: string;
fileId: string;
index: number;
}
export interface FileRequestChatEvent extends ChatEventBase { export interface FileRequestChatEvent extends ChatEventBase {
type: 'file-request'; type: 'file-request';
messageId: string; messageId: string;
@@ -498,6 +505,7 @@ export type ChatEvent =
| ReactionRemovedEvent | ReactionRemovedEvent
| FileAnnounceChatEvent | FileAnnounceChatEvent
| FileChunkChatEvent | FileChunkChatEvent
| FileChunkAckChatEvent
| FileRequestChatEvent | FileRequestChatEvent
| FileCancelChatEvent | FileCancelChatEvent
| FileNotFoundChatEvent | FileNotFoundChatEvent

View File

@@ -27,6 +27,7 @@ import { ProfileSignalServerTagComponent } from './profile-signal-server-tag.com
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { User, UserStatus } from '../../../shared-kernel'; import { User, UserStatus } from '../../../shared-kernel';
import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors';
import { findUserEntityByIdentity } from '../../../store/users/user-identity-lookup.rules';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service'; import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service';
import { FriendService } from '../../../domains/direct-message/application/services/friend.service'; import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
@@ -108,7 +109,9 @@ export class ProfileCardMobileComponent implements OnDestroy {
readonly displayedUser = computed(() => { readonly displayedUser = computed(() => {
const snapshot = this.user(); const snapshot = this.user();
const entities = this.users(); const entities = this.users();
const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId]; const liveUser = findUserEntityByIdentity(entities, snapshot.id)
?? findUserEntityByIdentity(entities, snapshot.oderId)
?? findUserEntityByIdentity(entities, snapshot.peerId);
return liveUser ? { ...snapshot, ...liveUser } : snapshot; return liveUser ? { ...snapshot, ...liveUser } : snapshot;
}); });

View File

@@ -32,6 +32,7 @@ import {
} from '../../../domains/profile-avatar'; } from '../../../domains/profile-avatar';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { selectUsersEntities } from '../../../store/users/users.selectors'; import { selectUsersEntities } from '../../../store/users/users.selectors';
import { findUserEntityByIdentity } from '../../../store/users/user-identity-lookup.rules';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { formatGameActivityElapsed } from '../../../domains/game-activity'; import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service'; import { ExternalLinkService } from '../../../core/platform/external-link.service';
@@ -67,7 +68,9 @@ export class ProfileCardComponent {
readonly displayedUser = computed(() => { readonly displayedUser = computed(() => {
const snapshot = this.user(); const snapshot = this.user();
const entities = this.users(); const entities = this.users();
const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId]; const liveUser = findUserEntityByIdentity(entities, snapshot.id)
?? findUserEntityByIdentity(entities, snapshot.oderId)
?? findUserEntityByIdentity(entities, snapshot.peerId);
return liveUser ? { ...snapshot, ...liveUser } : snapshot; return liveUser ? { ...snapshot, ...liveUser } : snapshot;
}); });

View File

@@ -68,6 +68,35 @@ describe('dispatchIncomingMessage multi-device sync', () => {
expect(action).not.toBeNull(); expect(action).not.toBeNull();
expect(saveMessage).toHaveBeenCalled(); expect(saveMessage).toHaveBeenCalled();
}); });
it('normalizes relayed chat sender ids to the per-server identity', async () => {
const saveMessage = vi.fn(async () => undefined);
const rememberMessageRoom = vi.fn();
const context = createContext({
db: { saveMessage },
attachments: { rememberMessageRoom },
currentUser: { id: 'viewer-home', oderId: 'viewer-home' },
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-a' }]
});
const action = await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-message',
senderId: 'server-user-1',
fromPeerId: 'server-user-1',
message: createMessage({
senderId: 'home-user-1',
roomId: 'room-a'
})
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(action).not.toBeNull();
expect(saveMessage).toHaveBeenCalledWith(expect.objectContaining({ senderId: 'server-user-1' }));
});
}); });
describe('dispatchIncomingMessage room-scoped sync', () => { describe('dispatchIncomingMessage room-scoped sync', () => {

View File

@@ -45,6 +45,7 @@ import {
mergeIncomingRevision mergeIncomingRevision
} from './messages.helpers'; } from './messages.helpers';
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service'; import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
import { resolveIncomingChatMessageSenderId } from '../../domains/chat/domain/rules/message-sender-identity.rules';
type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>; type AnnouncedAttachment = Pick<AttachmentMeta, 'id' | 'filename' | 'size' | 'mime' | 'isImage' | 'uploaderPeerId'>;
type AttachmentMetaMap = Record<string, AttachmentMeta[]>; type AttachmentMetaMap = Record<string, AttachmentMeta[]>;
@@ -58,6 +59,7 @@ type IncomingMessageType =
| 'chat-sync-full' | 'chat-sync-full'
| 'file-announce' | 'file-announce'
| 'file-chunk' | 'file-chunk'
| 'file-chunk-ack'
| 'file-request' | 'file-request'
| 'file-cancel' | 'file-cancel'
| 'file-not-found'; | 'file-not-found';
@@ -362,30 +364,39 @@ function handleChatMessage(
if (!isKnownRoomId(msg.roomId, ctx)) if (!isKnownRoomId(msg.roomId, ctx))
return EMPTY; return EMPTY;
const senderId = resolveIncomingChatMessageSenderId(msg, {
senderId: (event as { senderId?: string }).senderId,
fromPeerId: event.fromPeerId,
fromUserId: (event as { fromUserId?: string }).fromUserId
});
const normalizedMessage = senderId === msg.senderId ? msg : { ...msg, senderId };
// Skip only messages that originated on this client instance. // Skip only messages that originated on this client instance.
const isOwnMessageOnThisClient = const isOwnMessageOnThisClient =
(msg.senderId === currentUser?.id || msg.senderId === currentUser?.oderId) (normalizedMessage.senderId === currentUser?.id
|| normalizedMessage.senderId === currentUser?.oderId
|| msg.senderId === currentUser?.id
|| msg.senderId === currentUser?.oderId)
&& (!msg.clientInstanceId || msg.clientInstanceId === ctx.getClientInstanceId()); && (!msg.clientInstanceId || msg.clientInstanceId === ctx.getClientInstanceId());
if (isOwnMessageOnThisClient) if (isOwnMessageOnThisClient)
return EMPTY; return EMPTY;
attachments.rememberMessageRoom(msg.id, msg.roomId); attachments.rememberMessageRoom(normalizedMessage.id, normalizedMessage.roomId);
trackBackgroundOperation( trackBackgroundOperation(
db.saveMessage(msg), db.saveMessage(normalizedMessage),
debugging, debugging,
'Failed to persist incoming chat message', 'Failed to persist incoming chat message',
{ {
channelId: msg.channelId || 'general', channelId: normalizedMessage.channelId || 'general',
fromPeerId: event.fromPeerId ?? null, fromPeerId: event.fromPeerId ?? null,
messageId: msg.id, messageId: normalizedMessage.id,
roomId: msg.roomId, roomId: normalizedMessage.roomId,
senderId: msg.senderId senderId: normalizedMessage.senderId
} }
); );
return of(MessagesActions.receiveMessage({ message: msg })); return of(MessagesActions.receiveMessage({ message: normalizedMessage }));
} }
function handleMessageRevision( function handleMessageRevision(
@@ -573,10 +584,6 @@ function handleFileAnnounce(
): Observable<Action> { ): Observable<Action> {
attachments.handleFileAnnounce(event); attachments.handleFileAnnounce(event);
if (event.messageId) {
attachments.queueAutoDownloadsForMessage(event.messageId, event.file?.id);
}
return EMPTY; return EMPTY;
} }
@@ -588,6 +595,14 @@ function handleFileChunk(
return EMPTY; return EMPTY;
} }
function handleFileChunkAck(
event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext
): Observable<Action> {
attachments.handleFileChunkAck(event);
return EMPTY;
}
function handleFileRequest( function handleFileRequest(
event: IncomingMessageEvent, event: IncomingMessageEvent,
{ attachments }: IncomingMessageContext { attachments }: IncomingMessageContext
@@ -726,6 +741,7 @@ const HANDLER_MAP: Readonly<Record<string, MessageHandler>> = {
// Attachments // Attachments
'file-announce': handleFileAnnounce, 'file-announce': handleFileAnnounce,
'file-chunk': handleFileChunk, 'file-chunk': handleFileChunk,
'file-chunk-ack': handleFileChunkAck,
'file-request': handleFileRequest, 'file-request': handleFileRequest,
'file-cancel': handleFileCancel, 'file-cancel': handleFileCancel,
'file-not-found': handleFileNotFound, 'file-not-found': handleFileNotFound,

View File

@@ -8,7 +8,7 @@
* The giant `incomingMessages$` switch-case has been replaced by a * The giant `incomingMessages$` switch-case has been replaced by a
* handler registry in `messages-incoming.handlers.ts`. * handler registry in `messages-incoming.handlers.ts`.
*/ */
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import {
Actions, Actions,
@@ -52,7 +52,9 @@ import {
} from '../../shared-kernel'; } from '../../shared-kernel';
import { hydrateMessages } from './messages.helpers'; import { hydrateMessages } from './messages.helpers';
import { canEditMessage } from '../../domains/chat/domain/rules/message.rules'; import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
import { resolveRoomMessageSenderId } from '../../domains/chat/domain/rules/message-sender-identity.rules';
import { resolveRoomPermission } from '../../domains/access-control'; import { resolveRoomPermission } from '../../domains/access-control';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers'; import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service'; import { MessageRevisionService } from '../../domains/chat/application/services/message-revision.service';
import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules'; import { materializeMessageFromRevision } from '../../domains/chat/domain/rules/message-revision.builder.rules';
@@ -76,6 +78,7 @@ export class MessagesEffects {
private readonly platform = inject(PlatformService); private readonly platform = inject(PlatformService);
private readonly i18n = inject(AppI18nService); private readonly i18n = inject(AppI18nService);
private readonly messageRevisions = inject(MessageRevisionService); private readonly messageRevisions = inject(MessageRevisionService);
private readonly signalServerAuth = inject(SignalServerAuthService);
/** Loads messages for a room from the local database, hydrating reactions. */ /** Loads messages for a room from the local database, hydrating reactions. */
loadMessages$ = createEffect(() => loadMessages$ = createEffect(() =>
@@ -238,11 +241,16 @@ export class MessagesEffects {
return of(MessagesActions.sendMessageFailure({ error: this.i18n.instant('chat.effects.notConnectedToRoom') })); return of(MessagesActions.sendMessageFailure({ error: this.i18n.instant('chat.effects.notConnectedToRoom') }));
} }
const senderId = resolveRoomMessageSenderId(
currentUser,
currentRoom.sourceUrl,
(serverUrl, fallbackUserId) => this.signalServerAuth.resolveActorUserIdForServer(serverUrl, fallbackUserId)
);
const draftMessage: Message = { const draftMessage: Message = {
id: id ?? uuidv4(), id: id ?? uuidv4(),
roomId: currentRoom.id, roomId: currentRoom.id,
channelId: channelId || 'general', channelId: channelId || 'general',
senderId: currentUser.id, senderId,
senderName: currentUser.displayName || currentUser.username, senderName: currentUser.displayName || currentUser.username,
content, content,
timestamp: this.timeSync.now(), timestamp: this.timeSync.now(),

View File

@@ -0,0 +1,49 @@
import type { User } from '../../shared-kernel';
import {
buildUserIdentityLookup,
findUserEntityByIdentity,
resolveUserByIdentity
} from './user-identity-lookup.rules';
function createUser(overrides: Partial<User> = {}): User {
return {
id: 'server-user-1',
oderId: 'server-user-1',
username: 'alice',
displayName: 'Alice',
status: 'online',
role: 'member',
joinedAt: 1,
presenceServerIds: ['room-1'],
isOnline: true,
...overrides
};
}
describe('user-identity-lookup.rules', () => {
it('indexes id, oderId, and peerId aliases in buildUserIdentityLookup', () => {
const user = createUser({
id: 'server-user-1',
oderId: 'oder-1',
peerId: 'peer-1'
});
const lookup = buildUserIdentityLookup([user]);
expect(resolveUserByIdentity(lookup, 'server-user-1')).toBe(user);
expect(resolveUserByIdentity(lookup, 'oder-1')).toBe(user);
expect(resolveUserByIdentity(lookup, 'peer-1')).toBe(user);
});
it('findUserEntityByIdentity resolves users keyed by a different entity id', () => {
const user = createUser({
id: 'server-user-1',
oderId: 'oder-1'
});
const entities = {
'server-user-1': user
};
expect(findUserEntityByIdentity(entities, 'oder-1')).toBe(user);
expect(findUserEntityByIdentity(entities, 'home-user-1')).toBeUndefined();
});
});

View File

@@ -0,0 +1,68 @@
import type { User } from '../../shared-kernel';
type UserIdentityFields = Pick<User, 'id' | 'oderId' | 'peerId'>;
function collectUserIdentityKeys(user: UserIdentityFields): string[] {
const keys: string[] = [];
if (user.id?.trim()) {
keys.push(user.id.trim());
}
if (user.oderId?.trim() && user.oderId !== user.id) {
keys.push(user.oderId.trim());
}
if (user.peerId?.trim() && user.peerId !== user.id && user.peerId !== user.oderId) {
keys.push(user.peerId.trim());
}
return keys;
}
/** Build a lookup map keyed by every known identity alias for each user. */
export function buildUserIdentityLookup(users: readonly User[]): ReadonlyMap<string, User> {
const lookup = new Map<string, User>();
for (const user of users) {
for (const key of collectUserIdentityKeys(user)) {
lookup.set(key, user);
}
}
return lookup;
}
/** Resolve a user from a pre-built identity lookup map. */
export function resolveUserByIdentity(
lookup: ReadonlyMap<string, User>,
identity: string | undefined
): User | undefined {
if (!identity?.trim()) {
return undefined;
}
return lookup.get(identity.trim());
}
/** Resolve a user entity when NgRx entity keys may not match the queried identity. */
export function findUserEntityByIdentity(
entities: Record<string, User | undefined>,
identity: string | undefined
): User | undefined {
if (!identity?.trim()) {
return undefined;
}
const trimmed = identity.trim();
const direct = entities[trimmed];
if (direct) {
return direct;
}
return Object.values(entities).find((user): user is User =>
!!user
&& (user.id === trimmed || user.oderId === trimmed || user.peerId === trimmed)
);
}

View File

@@ -30,12 +30,19 @@ bootstrapApplication(App, appConfig)
.then(async (appRef) => { .then(async (appRef) => {
const api = getElectronApi(); const api = getElectronApi();
if (!api?.isPerfDiagEnabled) { if (!api) {
return; return;
} }
const { bootstrapPerfDiagnostics } = await import('./app/infrastructure/diagnostics/diagnostics.bootstrap'); const {
registerImmediatePerfDiagCollector,
bootstrapPerfDiagnostics
} = await import('./app/infrastructure/diagnostics/diagnostics.bootstrap');
registerImmediatePerfDiagCollector(appRef.injector);
if (api.isPerfDiagEnabled) {
await bootstrapPerfDiagnostics(api, appRef.injector); await bootstrapPerfDiagnostics(api, appRef.injector);
}
}) })
.catch((err) => console.error(err)); .catch((err) => console.error(err));