perf: performance improvements 1
This commit is contained in:
@@ -78,6 +78,12 @@ This area does **not** own:
|
|||||||
- Transfers are between connected peers only (no server CDN).
|
- Transfers are between connected peers only (no server CDN).
|
||||||
- Receive strategy is decided once at request time by `canReceiveAttachment` (`attachment.logic.ts`): ≤ 10 MB assembles in memory everywhere; > 10 MB streams to disk on Electron/Capacitor, assembles in memory on the browser up to its 50 MB persist cap, and is rejected with a visible `fileTooLarge` error beyond that. `handleFileChunk` must accept whatever the request gate admitted — a stricter chunk-time size cap silently drops chunks and stalls the transfer.
|
- Receive strategy is decided once at request time by `canReceiveAttachment` (`attachment.logic.ts`): ≤ 10 MB assembles in memory everywhere; > 10 MB streams to disk on Electron/Capacitor, assembles in memory on the browser up to its 50 MB persist cap, and is rejected with a visible `fileTooLarge` error beyond that. `handleFileChunk` must accept whatever the request gate admitted — a stricter chunk-time size cap silently drops chunks and stalls the transfer.
|
||||||
- Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate.
|
- Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate.
|
||||||
|
- Display-blob memory invariants (added 2026-07-14, RAM investigation):
|
||||||
|
- Inline hydration (`chat-message-item` effect) only runs for messages that are visible or within the `IntersectionObserver` root margin — gated by `attachment-hydration-visibility.rules.ts`. Off-screen rows never load blobs.
|
||||||
|
- Disk-hydrated blobs are **not** duplicated into `AttachmentRuntimeStore.originalFiles`; peer requests are served from the disk path (`streamRequestedFile` prefers `resolveExistingPath`). `originalFiles` only holds uploads/downloads that have no disk copy yet.
|
||||||
|
- `revokeAttachmentDisplayBlob` also drops the `originalFiles` entry when `savedPath` exists, so revocation actually frees the bytes.
|
||||||
|
- Message rows always revoke their display blobs on destroy (pins are respected), not only when they were visible.
|
||||||
|
- Room switch sweeps display blobs of all other rooms (`releaseDisplayBlobsForInactiveRooms`, driven by `collectMessageIdsForInactiveRoomBlobRelease`). Messages with unknown room mapping are left alone.
|
||||||
- "Shared from your device" badge only when bytes are local to the viewing user.
|
- "Shared from your device" badge only when bytes are local to the viewing user.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -114,5 +120,6 @@ This area does **not** own:
|
|||||||
|
|
||||||
| Date | Change |
|
| Date | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
|
| 2026-07-14 | Blob-memory invariants: visibility-gated hydration, no `originalFiles` duplication for disk-backed blobs, revoke-on-destroy, inactive-room blob sweep |
|
||||||
| 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` |
|
| 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` |
|
||||||
| 2026-07-05 | Expanded to full contract style |
|
| 2026-07-05 | Expanded to full contract style |
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ Rules (`message-sync.rules.ts`, `message-integrity.rules.ts`):
|
|||||||
|
|
||||||
- Merges are **additive** — sparser peers never wipe richer local history.
|
- Merges are **additive** — sparser peers never wipe richer local history.
|
||||||
- `findMissingIds` compares remote inventory to local `revision` / `headHash` (and legacy `ts` / `rc` / `ac`).
|
- `findMissingIds` compares remote inventory to local `revision` / `headHash` (and legacy `ts` / `rc` / `ac`).
|
||||||
- `INVENTORY_LIMIT` = 1_000_000 (safety ceiling for pathological rooms).
|
- `INVENTORY_LIMIT` = `FULL_SYNC_LIMIT` = **20_000** (2026-07-14, RAM investigation; previously 1_000_000). Building an inventory or full-sync batch loads full message rows into memory, so the ceiling must stay bounded. Only the most recent 20k messages per room are reconciled peer-to-peer; older messages stay local-only. `ACCOUNT_SYNC_MESSAGE_LIMIT` follows `FULL_SYNC_LIMIT`.
|
||||||
- Sync polling: 10 s when catching up, 15 min after a clean cycle (`SYNC_POLL_FAST_MS` / `SYNC_POLL_SLOW_MS`).
|
- Sync polling: 10 s when catching up, 15 min after a clean cycle (`SYNC_POLL_FAST_MS` / `SYNC_POLL_SLOW_MS`).
|
||||||
|
- NgRx store retention: on room switch, inactive rooms are pruned to the most recent `CACHED_INACTIVE_ROOM_MESSAGE_LIMIT` = **100** messages each (`messages.reducer.ts`), keeping return-visit rendering instant while bounding store growth across many rooms. The active room is never pruned; the local DB keeps full history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -204,6 +205,7 @@ Deletes keep tombstone semantics (`isDeleted`, empty `content`) so inventory syn
|
|||||||
|
|
||||||
| Date | Change |
|
| Date | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
|
| 2026-07-14 | RAM bounds: `INVENTORY_LIMIT`/`FULL_SYNC_LIMIT` lowered to 20k (most recent messages reconcile); NgRx prunes inactive rooms to 100 cached messages on room switch |
|
||||||
| 2026-07-13 | Incoming DMs raise system notifications through the notifications domain (previously unread-badge only) |
|
| 2026-07-13 | Incoming DMs raise system notifications through the notifications domain (previously unread-badge only) |
|
||||||
| 2026-07-13 | Recipient matching for DM and `direct-call` events must span all local identity aliases (provisioned actor ids included) |
|
| 2026-07-13 | Recipient matching for DM and `direct-call` events must span all local identity aliases (provisioned actor ids included) |
|
||||||
| 2026-07-05 | Initial comprehensive messaging contract (replaces thin direct-messaging summary) |
|
| 2026-07-05 | Initial comprehensive messaging contract (replaces thin direct-messaging summary) |
|
||||||
|
|||||||
@@ -40,7 +40,14 @@ Relay rules: RTC messages require shared server membership (except DM-specific t
|
|||||||
|
|
||||||
## P2P data channel
|
## P2P data channel
|
||||||
|
|
||||||
Carries voice/screen control messages, chat, attachments, and state sync — not server-relayed. Data-channel failure triggers peer renegotiation or full rebuild (see realtime README).
|
Carries voice/screen control messages, chat, attachments, and state sync — not server-relayed. Data-channel failure triggers peer renegotiation or full rebuild (see realtime README). When a failed control channel is replaced (`replaceDataChannel`), the old channel is closed first so its SCTP resources are released.
|
||||||
|
|
||||||
|
## Media memory invariants (2026-07-14, RAM investigation)
|
||||||
|
|
||||||
|
- `removePeer` / `closeAllPeers` clear **all four** remote stream maps, including `remotePeerCameraStreams` (previously leaked per departed peer).
|
||||||
|
- Video tiles (`voice-workspace-stream-tile`) pause and null `srcObject` in `ngOnDestroy` (`voice-workspace-stream-video.rules.ts`) so Chromium releases decoder/frame buffers immediately.
|
||||||
|
- `debug-network-metrics` drops a peer's entry when the peer is fully removed and caps the store at `MAX_TRACKED_DEBUG_NETWORK_PEERS` = 200 (oldest evicted).
|
||||||
|
- Electron registers `setDisplayMediaRequestHandler` once per app run (guarded in `create-window.ts`), not on every window recreation.
|
||||||
|
|
||||||
## Mobile / Capacitor
|
## Mobile / Capacitor
|
||||||
|
|
||||||
@@ -57,4 +64,5 @@ Background voice uses Android foreground service + iOS audio/CallKit bridges —
|
|||||||
|
|
||||||
| Date | Change |
|
| Date | Change |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
|
| 2026-07-14 | Media memory invariants: camera-stream map cleanup, tile `srcObject` release, debug-metrics cap, single display-media handler registration, replaced data channels closed |
|
||||||
| 2026-07-05 | Initial cross-context voice/WebRTC contract |
|
| 2026-07-05 | Initial cross-context voice/WebRTC contract |
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import * as path from 'path';
|
|||||||
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
||||||
import { readDesktopSettings } from '../desktop-settings';
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
|
import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
|
||||||
|
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
let closeToTrayEnabled = true;
|
let closeToTrayEnabled = true;
|
||||||
let appQuitting = false;
|
let appQuitting = false;
|
||||||
let youtubeRequestHeadersConfigured = false;
|
let youtubeRequestHeadersConfigured = false;
|
||||||
|
let displayMediaHandlerConfigured = false;
|
||||||
|
|
||||||
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
|
||||||
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
|
const YOUTUBE_EMBED_REFERRER = 'https://toju.app/';
|
||||||
@@ -189,31 +191,12 @@ function emitWindowState(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWindow(): Promise<void> {
|
function ensureDisplayMediaRequestHandler(): void {
|
||||||
const windowIconPath = getWindowIconPath();
|
if (!shouldRegisterDisplayMediaHandler(process.platform, displayMediaHandlerConfigured)) {
|
||||||
|
return;
|
||||||
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
|
||||||
ensureTray();
|
|
||||||
ensureYoutubeEmbedRequestHeaders();
|
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
|
||||||
width: 1400,
|
|
||||||
height: 900,
|
|
||||||
minWidth: 800,
|
|
||||||
minHeight: 600,
|
|
||||||
frame: false,
|
|
||||||
title: DESKTOP_APP_DISPLAY_NAME,
|
|
||||||
titleBarStyle: 'hidden',
|
|
||||||
backgroundColor: '#0a0a0f',
|
|
||||||
...(windowIconPath ? { icon: windowIconPath } : {}),
|
|
||||||
webPreferences: {
|
|
||||||
backgroundThrottling: false,
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
preload: path.join(__dirname, '..', 'preload.js'),
|
|
||||||
webSecurity: true
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
displayMediaHandlerConfigured = true;
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
session.defaultSession.setDisplayMediaRequestHandler(
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
@@ -241,9 +224,10 @@ export async function createWindow(): Promise<void> {
|
|||||||
},
|
},
|
||||||
{ useSystemPicker: true }
|
{ useSystemPicker: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
session.defaultSession.setDisplayMediaRequestHandler(
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
async (request, respond) => {
|
async (request, respond) => {
|
||||||
// On Windows the system picker (useSystemPicker: true) is preferred.
|
// On Windows the system picker (useSystemPicker: true) is preferred.
|
||||||
@@ -277,6 +261,34 @@ export async function createWindow(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createWindow(): Promise<void> {
|
||||||
|
const windowIconPath = getWindowIconPath();
|
||||||
|
|
||||||
|
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||||
|
ensureTray();
|
||||||
|
ensureYoutubeEmbedRequestHeaders();
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
frame: false,
|
||||||
|
title: DESKTOP_APP_DISPLAY_NAME,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
backgroundColor: '#0a0a0f',
|
||||||
|
...(windowIconPath ? { icon: windowIconPath } : {}),
|
||||||
|
webPreferences: {
|
||||||
|
backgroundThrottling: false,
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, '..', 'preload.js'),
|
||||||
|
webSecurity: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureDisplayMediaRequestHandler();
|
||||||
|
|
||||||
if (process.env['NODE_ENV'] === 'development') {
|
if (process.env['NODE_ENV'] === 'development') {
|
||||||
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
|
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
|
||||||
|
|
||||||
|
describe('shouldRegisterDisplayMediaHandler', () => {
|
||||||
|
it('registers once for platforms that need a fallback picker', () => {
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('linux', false)).toBe(true);
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('win32', false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-register on window recreation', () => {
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('linux', true)).toBe(false);
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('win32', true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never registers on platforms with a native picker', () => {
|
||||||
|
expect(shouldRegisterDisplayMediaHandler('darwin', false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* The display-media request handler is a session-level singleton. Registering
|
||||||
|
* it inside `createWindow()` without a guard re-installed a fresh handler
|
||||||
|
* (holding fresh closures) every time the window was recreated from the tray
|
||||||
|
* or a deep link. Registration happens at most once per app run.
|
||||||
|
*/
|
||||||
|
export function shouldRegisterDisplayMediaHandler(
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
alreadyConfigured: boolean
|
||||||
|
): boolean {
|
||||||
|
if (alreadyConfigured) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return platform === 'linux' || platform === 'win32';
|
||||||
|
}
|
||||||
@@ -210,9 +210,11 @@ Components read attachment state reactively through the store's signals. The sto
|
|||||||
Image inline previews on Electron/desktop use renderer `blob:` URLs rebuilt from disk. To cap RAM in media-heavy channels:
|
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.
|
- **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`).
|
- **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`). Hydration itself is visibility-gated (`attachment-hydration-visibility.rules.ts`) — off-screen rows never load blobs, and destroyed rows always release theirs.
|
||||||
|
- **No byte duplication:** disk-hydrated blobs are never copied into `AttachmentRuntimeStore.originalFiles` (`applyAttachmentBlob`); revocation of a disk-backed blob also drops any stale `originalFiles` entry. `originalFiles` only carries uploads/downloads that have no disk copy yet.
|
||||||
|
- **Room switch sweep:** `releaseDisplayBlobsForInactiveRooms` revokes display blobs for messages of all other rooms when navigation lands on a different room.
|
||||||
- **Pinned overlays** (lightbox / image gallery) call `pinDisplayBlobs` so an open full-screen view is not revoked while its message scrolls off-screen.
|
- **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.
|
- **Serving** is unaffected: peers still download from `savedPath` / `filePath` (`streamRequestedFile` prefers the disk path); blob URLs are display-only.
|
||||||
|
|
||||||
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).
|
While a revoked image waits to rehydrate, chat renders the existing image-grid spinner skeleton (`isAttachmentPendingInlineHydration`).
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -10,7 +10,11 @@ import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|||||||
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
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 {
|
||||||
|
buildAttachmentDisplayPinKey,
|
||||||
|
collectMessageIdsForInactiveRoomBlobRelease,
|
||||||
|
shouldRevokeDisplayBlobForAttachment
|
||||||
|
} from '../../domain/logic/attachment-blob-eviction.rules';
|
||||||
import {
|
import {
|
||||||
getWatchedAttachmentRoomIdFromUrl,
|
getWatchedAttachmentRoomIdFromUrl,
|
||||||
isDirectMessageAttachmentRoomId,
|
isDirectMessageAttachmentRoomId,
|
||||||
@@ -68,8 +72,14 @@ export class AttachmentManagerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousRoomId = this.watchedRoomId;
|
||||||
|
|
||||||
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
||||||
|
|
||||||
|
if (this.watchedRoomId !== previousRoomId) {
|
||||||
|
this.releaseDisplayBlobsForInactiveRooms(this.watchedRoomId);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.watchedRoomId) {
|
if (this.watchedRoomId) {
|
||||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||||
@@ -211,6 +221,18 @@ export class AttachmentManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
releaseDisplayBlobsForInactiveRooms(activeRoomId: string | null): void {
|
||||||
|
const messageIds = collectMessageIdsForInactiveRoomBlobRelease(
|
||||||
|
Array.from(this.runtimeStore.getAttachmentEntries(), ([messageId]) => messageId),
|
||||||
|
(messageId) => this.runtimeStore.getMessageRoomId(messageId) ?? null,
|
||||||
|
activeRoomId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const messageId of messageIds) {
|
||||||
|
this.revokeOffscreenDisplayBlobsForMessage(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -134,6 +134,35 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
expect(attachmentStorage.readFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not duplicate disk-hydrated bytes into the original-file cache', async () => {
|
||||||
|
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);
|
||||||
|
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.ensureInlineDisplayObjectUrl(attachment)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(attachment.objectUrl).toMatch(/^blob:/);
|
||||||
|
expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => {
|
it('restores a blob from a whole-file read when the store cannot read chunks (browser store)', async () => {
|
||||||
attachmentStorage.canReadFileChunks.mockReturnValue(false);
|
attachmentStorage.canReadFileChunks.mockReturnValue(false);
|
||||||
|
|
||||||
@@ -263,4 +292,66 @@ describe('AttachmentPersistenceService', () => {
|
|||||||
|
|
||||||
revokeSpy.mockRestore();
|
revokeSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('releases the cached original file when revoking a disk-backed display blob', () => {
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
|
||||||
|
runtimeStore.setOriginalFile('msg-1:att-1', new File(['abc'], 'photo.png', { type: 'image/png' }));
|
||||||
|
|
||||||
|
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(true);
|
||||||
|
expect(runtimeStore.getOriginalFile('msg-1:att-1')).toBeUndefined();
|
||||||
|
|
||||||
|
revokeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the cached original file when the attachment is not persisted to disk yet', () => {
|
||||||
|
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);
|
||||||
|
const attachment = {
|
||||||
|
id: 'att-2',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
filename: 'clip.mp4',
|
||||||
|
size: 3,
|
||||||
|
mime: 'video/mp4',
|
||||||
|
isImage: false,
|
||||||
|
available: true,
|
||||||
|
objectUrl: 'blob:http://localhost/def'
|
||||||
|
};
|
||||||
|
|
||||||
|
runtimeStore.setOriginalFile('msg-1:att-2', new File(['abc'], 'clip.mp4', { type: 'video/mp4' }));
|
||||||
|
|
||||||
|
expect(service.revokeAttachmentDisplayBlob(attachment)).toBe(false);
|
||||||
|
expect(runtimeStore.getOriginalFile('msg-1:att-2')).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-6
@@ -141,6 +141,13 @@ export class AttachmentPersistenceService {
|
|||||||
this.revokeAttachmentObjectUrl(attachment);
|
this.revokeAttachmentObjectUrl(attachment);
|
||||||
attachment.objectUrl = undefined;
|
attachment.objectUrl = undefined;
|
||||||
|
|
||||||
|
// Once the bytes live on disk, the cached File duplicate is redundant:
|
||||||
|
// peer requests are served from the disk path and re-display rehydrates
|
||||||
|
// from disk, so keeping it would double the attachment's memory cost.
|
||||||
|
if (attachment.savedPath?.trim()) {
|
||||||
|
this.runtimeStore.deleteOriginalFile(`${attachment.messageId}:${attachment.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,15 +395,12 @@ export class AttachmentPersistenceService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The blob always comes from a disk path here, so peers are served from
|
||||||
|
// disk and no original-file copy is cached; caching one would keep a second
|
||||||
|
// full copy of the bytes alive for the whole session.
|
||||||
private applyAttachmentBlob(attachment: Attachment, blob: Blob): void {
|
private applyAttachmentBlob(attachment: Attachment, blob: Blob): void {
|
||||||
attachment.objectUrl = URL.createObjectURL(blob);
|
attachment.objectUrl = URL.createObjectURL(blob);
|
||||||
attachment.available = true;
|
attachment.available = true;
|
||||||
|
|
||||||
this.runtimeStore.setOriginalFile(
|
|
||||||
`${attachment.messageId}:${attachment.id}`,
|
|
||||||
new File([blob], attachment.filename, { type: attachment.mime })
|
|
||||||
);
|
|
||||||
|
|
||||||
this.runtimeStore.touch();
|
this.runtimeStore.touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
@@ -7,6 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildAttachmentDisplayPinKey,
|
buildAttachmentDisplayPinKey,
|
||||||
canRevokeAttachmentDisplayBlob,
|
canRevokeAttachmentDisplayBlob,
|
||||||
|
collectMessageIdsForInactiveRoomBlobRelease,
|
||||||
shouldRevokeDisplayBlobForAttachment
|
shouldRevokeDisplayBlobForAttachment
|
||||||
} from './attachment-blob-eviction.rules';
|
} from './attachment-blob-eviction.rules';
|
||||||
|
|
||||||
@@ -58,4 +59,40 @@ describe('attachment-blob-eviction rules', () => {
|
|||||||
|
|
||||||
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
|
expect(shouldRevokeDisplayBlobForAttachment('msg-1', attachment, new Set())).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('collectMessageIdsForInactiveRoomBlobRelease', () => {
|
||||||
|
const messageRoomIds = new Map([
|
||||||
|
['msg-a', 'room-1'],
|
||||||
|
['msg-b', 'room-2'],
|
||||||
|
['msg-c', 'room-2']
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('selects messages that belong to rooms other than the active one', () => {
|
||||||
|
expect(collectMessageIdsForInactiveRoomBlobRelease(
|
||||||
|
[
|
||||||
|
'msg-a',
|
||||||
|
'msg-b',
|
||||||
|
'msg-c'
|
||||||
|
],
|
||||||
|
(messageId) => messageRoomIds.get(messageId) ?? null,
|
||||||
|
'room-1'
|
||||||
|
)).toEqual(['msg-b', 'msg-c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips messages with an unknown room so they are not evicted by mistake', () => {
|
||||||
|
expect(collectMessageIdsForInactiveRoomBlobRelease(
|
||||||
|
['msg-a', 'msg-unknown'],
|
||||||
|
(messageId) => messageRoomIds.get(messageId) ?? null,
|
||||||
|
'room-2'
|
||||||
|
)).toEqual(['msg-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects every known-room message when no room is active', () => {
|
||||||
|
expect(collectMessageIdsForInactiveRoomBlobRelease(
|
||||||
|
['msg-a', 'msg-b'],
|
||||||
|
(messageId) => messageRoomIds.get(messageId) ?? null,
|
||||||
|
null
|
||||||
|
)).toEqual(['msg-a', 'msg-b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,31 @@ export function shouldRevokeDisplayBlobForAttachment(
|
|||||||
return canRevokeAttachmentDisplayBlob(attachment);
|
return canRevokeAttachmentDisplayBlob(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On room switch, display blobs from every other room are released so their
|
||||||
|
* memory does not accumulate across the servers a user visits in a session.
|
||||||
|
* Messages whose room is unknown are left alone rather than evicted blindly.
|
||||||
|
*/
|
||||||
|
export function collectMessageIdsForInactiveRoomBlobRelease(
|
||||||
|
messageIds: Iterable<string>,
|
||||||
|
resolveMessageRoomId: (messageId: string) => string | null,
|
||||||
|
activeRoomId: string | null
|
||||||
|
): string[] {
|
||||||
|
const selected: string[] = [];
|
||||||
|
|
||||||
|
for (const messageId of messageIds) {
|
||||||
|
const roomId = resolveMessageRoomId(messageId);
|
||||||
|
|
||||||
|
if (!roomId || roomId === activeRoomId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.push(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { shouldHydrateInlineImageForVisibility, shouldHydratePlayableMediaForVisibility } from './attachment-hydration-visibility.rules';
|
||||||
|
|
||||||
|
const diskBackedImage = {
|
||||||
|
available: false,
|
||||||
|
filename: 'photo.png',
|
||||||
|
id: 'att-1',
|
||||||
|
isImage: true,
|
||||||
|
mime: 'image/png',
|
||||||
|
savedPath: '/appdata/photo.png'
|
||||||
|
};
|
||||||
|
const diskBackedVideo = {
|
||||||
|
available: false,
|
||||||
|
mime: 'video/mp4',
|
||||||
|
savedPath: '/appdata/clip.mp4'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('attachment hydration visibility rules', () => {
|
||||||
|
it('hydrates a disk-backed image only when the message is visible', () => {
|
||||||
|
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, true)).toBe(true);
|
||||||
|
expect(shouldHydrateInlineImageForVisibility(diskBackedImage, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never hydrates an image that is already displayable', () => {
|
||||||
|
const displayable = {
|
||||||
|
...diskBackedImage,
|
||||||
|
available: true,
|
||||||
|
objectUrl: 'blob:http://localhost/abc'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(shouldHydrateInlineImageForVisibility(displayable, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates disk-backed playable media only when the message is visible', () => {
|
||||||
|
expect(shouldHydratePlayableMediaForVisibility(diskBackedVideo, true)).toBe(true);
|
||||||
|
expect(shouldHydratePlayableMediaForVisibility(diskBackedVideo, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never hydrates media that already has an object URL', () => {
|
||||||
|
const hydrated = {
|
||||||
|
...diskBackedVideo,
|
||||||
|
objectUrl: 'blob:http://localhost/def'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(shouldHydratePlayableMediaForVisibility(hydrated, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Attachment } from '../models/attachment.model';
|
||||||
|
import { isAttachmentPendingInlineHydration, isInlineDisplayableImage } from './attachment-image.rules';
|
||||||
|
import { isAttachmentPendingMediaHydration } from './attachment.logic';
|
||||||
|
|
||||||
|
type InlineImageCandidate = Pick<
|
||||||
|
Attachment,
|
||||||
|
'available' | 'filePath' | 'filename' | 'isImage' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type PlayableMediaCandidate = Pick<
|
||||||
|
Attachment,
|
||||||
|
'available' | 'filePath' | 'mime' | 'objectUrl' | 'receivedBytes' | 'savedPath'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display blobs are only hydrated for messages inside (or near) the viewport.
|
||||||
|
* Hydrating off-screen rows loads full decoded media into the blob store for
|
||||||
|
* rows the user may never scroll to, which is the main renderer/browser-process
|
||||||
|
* memory driver in attachment-heavy rooms.
|
||||||
|
*/
|
||||||
|
export function shouldHydrateInlineImageForVisibility(
|
||||||
|
image: InlineImageCandidate,
|
||||||
|
isMessageVisible: boolean
|
||||||
|
): boolean {
|
||||||
|
if (!isMessageVisible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInlineDisplayableImage(image)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAttachmentPendingInlineHydration(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHydratePlayableMediaForVisibility(
|
||||||
|
media: PlayableMediaCandidate,
|
||||||
|
isMessageVisible: boolean
|
||||||
|
): boolean {
|
||||||
|
if (!isMessageVisible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.objectUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAttachmentPendingMediaHydration(media);
|
||||||
|
}
|
||||||
@@ -3,9 +3,23 @@ import {
|
|||||||
it,
|
it,
|
||||||
expect
|
expect
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { findMissingIds } from './message-sync.rules';
|
import {
|
||||||
|
findMissingIds,
|
||||||
|
FULL_SYNC_LIMIT,
|
||||||
|
INVENTORY_LIMIT
|
||||||
|
} from './message-sync.rules';
|
||||||
|
|
||||||
describe('message-sync.rules', () => {
|
describe('message-sync.rules', () => {
|
||||||
|
it('keeps sync limits bounded so full-room loads cannot spike memory unbounded', () => {
|
||||||
|
// Sync inventories and full-sync batches load complete message rows into
|
||||||
|
// memory. An effectively-unlimited ceiling (previously 1,000,000) turns a
|
||||||
|
// pathological room into a multi-hundred-MB allocation in one sync cycle.
|
||||||
|
expect(INVENTORY_LIMIT).toBeLessThanOrEqual(20_000);
|
||||||
|
expect(FULL_SYNC_LIMIT).toBeLessThanOrEqual(20_000);
|
||||||
|
expect(INVENTORY_LIMIT).toBeGreaterThanOrEqual(5_000);
|
||||||
|
expect(FULL_SYNC_LIMIT).toBeGreaterThanOrEqual(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
it('requests ids with newer revision or mismatched head hash', () => {
|
it('requests ids with newer revision or mismatched head hash', () => {
|
||||||
const localMap = new Map<string, { ts: number; rc: number; ac: number; revision: number; headHash: string }>();
|
const localMap = new Map<string, { ts: number; rc: number; ac: number; revision: number; headHash: string }>();
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
|
|
||||||
/** Maximum number of messages to include in sync inventories.
|
/** Maximum number of messages to include in sync inventories.
|
||||||
*
|
*
|
||||||
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
|
* The inventory protocol ships messages in the room (id, ts, rc, ac) chunked
|
||||||
* chunked at `CHUNK_SIZE`, so peers converge on the full history regardless
|
* at `CHUNK_SIZE`. Building an inventory loads the full message rows into
|
||||||
* of how lopsided their message counts are. The constant remains as a safety
|
* memory, so this ceiling must stay bounded: the previous effectively-
|
||||||
* ceiling for pathological rooms.
|
* unlimited value (1,000,000) let a single sync cycle allocate hundreds of MB
|
||||||
|
* in a pathological room. The most recent `INVENTORY_LIMIT` messages are
|
||||||
|
* reconciled; anything older stays local-only.
|
||||||
*/
|
*/
|
||||||
export const INVENTORY_LIMIT = 1_000_000;
|
export const INVENTORY_LIMIT = 20_000;
|
||||||
|
|
||||||
/** Number of messages per chunk for inventory / batch transfers. */
|
/** Number of messages per chunk for inventory / batch transfers. */
|
||||||
export const CHUNK_SIZE = 200;
|
export const CHUNK_SIZE = 200;
|
||||||
@@ -25,8 +27,8 @@ export const SYNC_POLL_SLOW_MS = 900_000;
|
|||||||
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
|
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
|
||||||
export const SYNC_TIMEOUT_MS = 5_000;
|
export const SYNC_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
/** Large limit used for legacy full-sync operations. */
|
/** Ceiling for legacy full-sync and account-sync batches (most recent first). */
|
||||||
export const FULL_SYNC_LIMIT = 1_000_000;
|
export const FULL_SYNC_LIMIT = 20_000;
|
||||||
|
|
||||||
/** Inventory item representing a message's sync state. */
|
/** Inventory item representing a message's sync state. */
|
||||||
export interface InventoryItem {
|
export interface InventoryItem {
|
||||||
|
|||||||
+8
-9
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, */
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
isInlineDisplayableImage
|
isInlineDisplayableImage
|
||||||
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
} from '../../../../../attachment/domain/logic/attachment-image.rules';
|
||||||
import { isAttachmentPendingMediaHydration } from '../../../../../attachment/domain/logic/attachment.logic';
|
import { isAttachmentPendingMediaHydration } from '../../../../../attachment/domain/logic/attachment.logic';
|
||||||
|
import { shouldHydrateInlineImageForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules';
|
||||||
|
import { shouldHydratePlayableMediaForVisibility } from '../../../../../attachment/domain/logic/attachment-hydration-visibility.rules';
|
||||||
import { ATTACHMENT_BLOB_VISIBILITY_ROOT_MARGIN } from '../../../../../attachment/domain/logic/attachment-blob-eviction.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';
|
||||||
@@ -275,11 +277,7 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
const isVisible = this.isMessageVisible();
|
const isVisible = this.isMessageVisible();
|
||||||
|
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
if (isInlineDisplayableImage(image)) {
|
if (!shouldHydrateInlineImageForVisibility(image, isVisible)) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAttachmentPendingInlineHydration(image)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +291,7 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const media of mediaAttachments) {
|
for (const media of mediaAttachments) {
|
||||||
if (media.objectUrl || !isAttachmentPendingMediaHydration(media)) {
|
if (!shouldHydratePlayableMediaForVisibility(media, isVisible)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,9 +593,10 @@ export class ChatMessageItemComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.visibilityObserver?.disconnect();
|
this.visibilityObserver?.disconnect();
|
||||||
this.visibilityObserver = null;
|
this.visibilityObserver = null;
|
||||||
|
|
||||||
if (this.isMessageVisible()) {
|
// Destroyed rows always release their display blobs (pins are respected
|
||||||
|
// inside the facade); rows destroyed while off-screen previously kept
|
||||||
|
// their blobs alive for the rest of the session.
|
||||||
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id);
|
this.attachmentsSvc.revokeOffscreenDisplayBlobsForMessage(this.message().id);
|
||||||
}
|
|
||||||
|
|
||||||
this.clearLongPressTimer();
|
this.clearLongPressTimer();
|
||||||
this.detachMobileSheet();
|
this.detachMobileSheet();
|
||||||
|
|||||||
+2
@@ -28,6 +28,7 @@ import { ViewportService } from '../../../../core/platform';
|
|||||||
import { MobileAppLifecycleService, MobilePictureInPictureService } from '../../../../infrastructure/mobile';
|
import { MobileAppLifecycleService, MobilePictureInPictureService } from '../../../../infrastructure/mobile';
|
||||||
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
import { VoiceWorkspacePlaybackService } from '../voice-workspace-playback.service';
|
||||||
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
import { VoiceWorkspaceStreamItem } from '../voice-workspace.models';
|
||||||
|
import { releaseVideoElementStream } from './voice-workspace-stream-video.rules';
|
||||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -169,6 +170,7 @@ export class VoiceWorkspaceStreamTileComponent implements OnDestroy {
|
|||||||
void document.exitFullscreen().catch(() => {});
|
void document.exitFullscreen().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
releaseVideoElementStream(this.videoRef()?.nativeElement);
|
||||||
this.unlockOrientation();
|
this.unlockOrientation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { releaseVideoElementStream } from './voice-workspace-stream-video.rules';
|
||||||
|
|
||||||
|
describe('releaseVideoElementStream', () => {
|
||||||
|
it('pauses the element and detaches the media stream so the decoder can be released', () => {
|
||||||
|
const pause = vi.fn();
|
||||||
|
const video = {
|
||||||
|
srcObject: { getTracks: () => [] } as unknown as MediaStream,
|
||||||
|
pause
|
||||||
|
};
|
||||||
|
|
||||||
|
releaseVideoElementStream(video);
|
||||||
|
|
||||||
|
expect(pause).toHaveBeenCalled();
|
||||||
|
expect(video.srcObject).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op for a missing element', () => {
|
||||||
|
expect(() => releaseVideoElementStream(undefined)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still detaches when pause throws', () => {
|
||||||
|
const video = {
|
||||||
|
srcObject: {} as MediaStream,
|
||||||
|
pause: vi.fn(() => {
|
||||||
|
throw new Error('detached element');
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
releaseVideoElementStream(video);
|
||||||
|
|
||||||
|
expect(video.srcObject).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
export interface DetachableVideoElement {
|
||||||
|
srcObject: MediaStream | MediaSource | Blob | null | undefined;
|
||||||
|
pause(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach a media stream from a video element so Chromium can release the
|
||||||
|
* decoder and frame buffers immediately instead of waiting for GC. Destroyed
|
||||||
|
* tiles that keep `srcObject` bound retain decode state for streams that are
|
||||||
|
* no longer rendered, which accumulates across camera/screen-share churn.
|
||||||
|
*/
|
||||||
|
export function releaseVideoElementStream(video: DetachableVideoElement | null | undefined): void {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
video.pause();
|
||||||
|
} catch { /* already detached from the document */ }
|
||||||
|
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
clearDebugNetworkPeerMetrics,
|
||||||
|
getDebugNetworkMetricSnapshot,
|
||||||
|
MAX_TRACKED_DEBUG_NETWORK_PEERS,
|
||||||
|
recordDebugNetworkPing
|
||||||
|
} from './debug-network-metrics';
|
||||||
|
|
||||||
|
describe('debug network metrics retention', () => {
|
||||||
|
it('releases a peer metric entry when the peer is cleared', () => {
|
||||||
|
recordDebugNetworkPing('peer-cleared', 42);
|
||||||
|
expect(getDebugNetworkMetricSnapshot('peer-cleared')).not.toBeNull();
|
||||||
|
|
||||||
|
clearDebugNetworkPeerMetrics('peer-cleared');
|
||||||
|
|
||||||
|
expect(getDebugNetworkMetricSnapshot('peer-cleared')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts the oldest peer entries once the tracked-peer cap is exceeded', () => {
|
||||||
|
recordDebugNetworkPing('peer-oldest', 10);
|
||||||
|
|
||||||
|
for (let index = 0; index < MAX_TRACKED_DEBUG_NETWORK_PEERS; index++) {
|
||||||
|
recordDebugNetworkPing(`peer-fill-${index}`, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getDebugNetworkMetricSnapshot('peer-oldest')).toBeNull();
|
||||||
|
expect(
|
||||||
|
getDebugNetworkMetricSnapshot(`peer-fill-${MAX_TRACKED_DEBUG_NETWORK_PEERS - 1}`)
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,13 @@ type DebugNetworkHandshakeType = 'answer' | 'ice_candidate' | 'offer';
|
|||||||
|
|
||||||
const FILE_RATE_WINDOW_MS = 6_000;
|
const FILE_RATE_WINDOW_MS = 6_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer metric entries are tiny, but the store is keyed by every peer id ever
|
||||||
|
* seen in the session. Long sessions with peer churn would otherwise grow the
|
||||||
|
* map for the app lifetime, so the oldest entries are evicted past this cap.
|
||||||
|
*/
|
||||||
|
export const MAX_TRACKED_DEBUG_NETWORK_PEERS = 200;
|
||||||
|
|
||||||
export interface DebugNetworkMetricHandshakeCounts {
|
export interface DebugNetworkMetricHandshakeCounts {
|
||||||
answersReceived: number;
|
answersReceived: number;
|
||||||
answersSent: number;
|
answersSent: number;
|
||||||
@@ -236,12 +243,25 @@ class DebugNetworkMetricsStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPeer(peerId: string): void {
|
||||||
|
this.metrics.delete(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
private ensure(peerId: string): InternalDebugNetworkMetricState {
|
private ensure(peerId: string): InternalDebugNetworkMetricState {
|
||||||
const existing = this.metrics.get(peerId);
|
const existing = this.metrics.get(peerId);
|
||||||
|
|
||||||
if (existing)
|
if (existing)
|
||||||
return existing;
|
return existing;
|
||||||
|
|
||||||
|
while (this.metrics.size >= MAX_TRACKED_DEBUG_NETWORK_PEERS) {
|
||||||
|
const oldestPeerId = this.metrics.keys().next().value;
|
||||||
|
|
||||||
|
if (oldestPeerId === undefined)
|
||||||
|
break;
|
||||||
|
|
||||||
|
this.metrics.delete(oldestPeerId);
|
||||||
|
}
|
||||||
|
|
||||||
const created: InternalDebugNetworkMetricState = {
|
const created: InternalDebugNetworkMetricState = {
|
||||||
connectionDrops: 0,
|
connectionDrops: 0,
|
||||||
downloads: createDownloadRates(),
|
downloads: createDownloadRates(),
|
||||||
@@ -384,3 +404,8 @@ export function recordDebugNetworkFileChunk(
|
|||||||
export function getDebugNetworkMetricSnapshot(peerId: string): DebugNetworkMetricSnapshot | null {
|
export function getDebugNetworkMetricSnapshot(peerId: string): DebugNetworkMetricSnapshot | null {
|
||||||
return debugNetworkMetricsStore.getSnapshot(peerId);
|
return debugNetworkMetricsStore.getSnapshot(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop the metric entry for a peer that fully left (no reconnect pending). */
|
||||||
|
export function clearDebugNetworkPeerMetrics(peerId: string): void {
|
||||||
|
debugNetworkMetricsStore.clearPeer(peerId);
|
||||||
|
}
|
||||||
|
|||||||
+6
@@ -340,6 +340,12 @@ export class PeerConnectionManager {
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Release the failed channel before adopting the replacement; otherwise
|
||||||
|
// its SCTP resources and handlers stay alive for the connection lifetime.
|
||||||
|
try {
|
||||||
|
expectedChannel.close();
|
||||||
|
} catch { /* channel may already be closed */ }
|
||||||
|
|
||||||
const replacement = peerData.connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
|
const replacement = peerData.connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
|
||||||
|
|
||||||
peerData.dataChannel = replacement;
|
peerData.dataChannel = replacement;
|
||||||
|
|||||||
+44
-1
@@ -5,7 +5,11 @@ import {
|
|||||||
PeerConnectionManagerContext,
|
PeerConnectionManagerContext,
|
||||||
RecoveryHandlers
|
RecoveryHandlers
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import { scheduleDataChannelRecovery } from './peer-recovery';
|
import {
|
||||||
|
closeAllPeers,
|
||||||
|
removePeer,
|
||||||
|
scheduleDataChannelRecovery
|
||||||
|
} from './peer-recovery';
|
||||||
|
|
||||||
describe('peer recovery', () => {
|
describe('peer recovery', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -13,6 +17,40 @@ describe('peer recovery', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes every remote stream map entry for a removed peer, including camera streams', () => {
|
||||||
|
const channel = createDataChannel(DATA_CHANNEL_STATE_OPEN);
|
||||||
|
const context = createContext('alice');
|
||||||
|
|
||||||
|
context.state.activePeerConnections.set('bob', createPeerData(channel, 'connected'));
|
||||||
|
context.state.remotePeerStreams.set('bob', createMediaStream());
|
||||||
|
context.state.remotePeerVoiceStreams.set('bob', createMediaStream());
|
||||||
|
context.state.remotePeerScreenShareStreams.set('bob', createMediaStream());
|
||||||
|
context.state.remotePeerCameraStreams.set('bob', createMediaStream());
|
||||||
|
|
||||||
|
removePeer(context, 'bob');
|
||||||
|
|
||||||
|
expect(context.state.remotePeerStreams.has('bob')).toBe(false);
|
||||||
|
expect(context.state.remotePeerVoiceStreams.has('bob')).toBe(false);
|
||||||
|
expect(context.state.remotePeerScreenShareStreams.has('bob')).toBe(false);
|
||||||
|
expect(context.state.remotePeerCameraStreams.has('bob')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears every remote stream map when all peers close, including camera streams', () => {
|
||||||
|
const context = createContext('alice');
|
||||||
|
|
||||||
|
context.state.remotePeerStreams.set('bob', createMediaStream());
|
||||||
|
context.state.remotePeerVoiceStreams.set('bob', createMediaStream());
|
||||||
|
context.state.remotePeerScreenShareStreams.set('bob', createMediaStream());
|
||||||
|
context.state.remotePeerCameraStreams.set('bob', createMediaStream());
|
||||||
|
|
||||||
|
closeAllPeers(context.state);
|
||||||
|
|
||||||
|
expect(context.state.remotePeerStreams.size).toBe(0);
|
||||||
|
expect(context.state.remotePeerVoiceStreams.size).toBe(0);
|
||||||
|
expect(context.state.remotePeerScreenShareStreams.size).toBe(0);
|
||||||
|
expect(context.state.remotePeerCameraStreams.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('recreates a peer immediately when the data channel is already closed', () => {
|
it('recreates a peer immediately when the data channel is already closed', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
@@ -205,9 +243,14 @@ function createPeerData(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMediaStream(): MediaStream {
|
||||||
|
return { getTracks: () => [] } as unknown as MediaStream;
|
||||||
|
}
|
||||||
|
|
||||||
function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel {
|
function createDataChannel(readyState: RTCDataChannelState): RTCDataChannel {
|
||||||
return {
|
return {
|
||||||
bufferedAmount: 0,
|
bufferedAmount: 0,
|
||||||
|
close: vi.fn(),
|
||||||
label: 'chat',
|
label: 'chat',
|
||||||
readyState
|
readyState
|
||||||
} as unknown as RTCDataChannel;
|
} as unknown as RTCDataChannel;
|
||||||
|
|||||||
+4
@@ -14,6 +14,7 @@ import {
|
|||||||
RemovePeerOptions
|
RemovePeerOptions
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
import { clearAllPingTimers, stopPingInterval } from '../messaging/ping';
|
import { clearAllPingTimers, stopPingInterval } from '../messaging/ping';
|
||||||
|
import { clearDebugNetworkPeerMetrics } from '../../logging/debug-network-metrics';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close and remove a peer connection, data channel, and emit a disconnect event.
|
* Close and remove a peer connection, data channel, and emit a disconnect event.
|
||||||
@@ -33,11 +34,13 @@ export function removePeer(
|
|||||||
if (!preserveReconnectState) {
|
if (!preserveReconnectState) {
|
||||||
clearPeerReconnectTimer(state, peerId);
|
clearPeerReconnectTimer(state, peerId);
|
||||||
state.disconnectedPeerTracker.delete(peerId);
|
state.disconnectedPeerTracker.delete(peerId);
|
||||||
|
clearDebugNetworkPeerMetrics(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.remotePeerStreams.delete(peerId);
|
state.remotePeerStreams.delete(peerId);
|
||||||
state.remotePeerVoiceStreams.delete(peerId);
|
state.remotePeerVoiceStreams.delete(peerId);
|
||||||
state.remotePeerScreenShareStreams.delete(peerId);
|
state.remotePeerScreenShareStreams.delete(peerId);
|
||||||
|
state.remotePeerCameraStreams.delete(peerId);
|
||||||
|
|
||||||
if (peerData) {
|
if (peerData) {
|
||||||
if (peerData.dataChannel)
|
if (peerData.dataChannel)
|
||||||
@@ -72,6 +75,7 @@ export function closeAllPeers(state: PeerConnectionManagerState): void {
|
|||||||
state.remotePeerStreams.clear();
|
state.remotePeerStreams.clear();
|
||||||
state.remotePeerVoiceStreams.clear();
|
state.remotePeerVoiceStreams.clear();
|
||||||
state.remotePeerScreenShareStreams.clear();
|
state.remotePeerScreenShareStreams.clear();
|
||||||
|
state.remotePeerCameraStreams.clear();
|
||||||
state.peerNegotiationQueue.clear();
|
state.peerNegotiationQueue.clear();
|
||||||
state.peerLatencies.clear();
|
state.peerLatencies.clear();
|
||||||
state.pendingPings.clear();
|
state.pendingPings.clear();
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { Message } from '../../shared-kernel';
|
||||||
|
import { MessagesActions } from './messages.actions';
|
||||||
|
import {
|
||||||
|
CACHED_INACTIVE_ROOM_MESSAGE_LIMIT,
|
||||||
|
initialState,
|
||||||
|
messagesReducer
|
||||||
|
} from './messages.reducer';
|
||||||
|
|
||||||
|
function buildMessage(id: string, roomId: string, timestamp: number): Message {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
roomId,
|
||||||
|
senderId: 'user-1',
|
||||||
|
senderName: 'User One',
|
||||||
|
content: `message ${id}`,
|
||||||
|
timestamp,
|
||||||
|
reactions: [],
|
||||||
|
isDeleted: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessages(roomId: string, count: number, startTimestamp = 0): Message[] {
|
||||||
|
return Array.from({ length: count }, (_, index) =>
|
||||||
|
buildMessage(`${roomId}-msg-${index}`, roomId, startTimestamp + index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('messagesReducer inactive-room pruning', () => {
|
||||||
|
it('prunes an inactive room down to the cached limit when switching to another room', () => {
|
||||||
|
const overflow = 40;
|
||||||
|
const seeded = messagesReducer(
|
||||||
|
initialState,
|
||||||
|
MessagesActions.loadMessagesSuccess({
|
||||||
|
messages: buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + overflow)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const loadedRoomA = { ...seeded, currentRoomId: 'room-a' };
|
||||||
|
const afterSwitch = messagesReducer(
|
||||||
|
loadedRoomA,
|
||||||
|
MessagesActions.loadMessages({ roomId: 'room-b' })
|
||||||
|
);
|
||||||
|
const roomAMessages = Object.values(afterSwitch.entities).filter(
|
||||||
|
(message) => message?.roomId === 'room-a'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(roomAMessages).toHaveLength(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT);
|
||||||
|
// The most recent messages survive; the oldest are dropped.
|
||||||
|
expect(afterSwitch.entities[`room-a-msg-${overflow - 1}`]).toBeUndefined();
|
||||||
|
expect(afterSwitch.entities[`room-a-msg-${overflow}`]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every message of the room being loaded', () => {
|
||||||
|
const seeded = messagesReducer(
|
||||||
|
initialState,
|
||||||
|
MessagesActions.loadMessagesSuccess({
|
||||||
|
messages: buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const loadedRoomA = { ...seeded, currentRoomId: 'room-b' };
|
||||||
|
const afterLoad = messagesReducer(
|
||||||
|
loadedRoomA,
|
||||||
|
MessagesActions.loadMessages({ roomId: 'room-a' })
|
||||||
|
);
|
||||||
|
const roomAMessages = Object.values(afterLoad.entities).filter(
|
||||||
|
(message) => message?.roomId === 'room-a'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(roomAMessages).toHaveLength(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prune anything when reloading the same room', () => {
|
||||||
|
const seeded = messagesReducer(
|
||||||
|
initialState,
|
||||||
|
MessagesActions.loadMessagesSuccess({
|
||||||
|
messages: buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const loadedRoomA = { ...seeded, currentRoomId: 'room-a' };
|
||||||
|
const afterReload = messagesReducer(
|
||||||
|
loadedRoomA,
|
||||||
|
MessagesActions.loadMessages({ roomId: 'room-a' })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(afterReload.ids).toHaveLength(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes each inactive room independently', () => {
|
||||||
|
let state = messagesReducer(
|
||||||
|
initialState,
|
||||||
|
MessagesActions.loadMessagesSuccess({
|
||||||
|
messages: [...buildMessages('room-a', CACHED_INACTIVE_ROOM_MESSAGE_LIMIT + 10, 0), ...buildMessages('room-b', 5, 100_000)]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
state = { ...state, currentRoomId: 'room-a' };
|
||||||
|
state = messagesReducer(state, MessagesActions.loadMessages({ roomId: 'room-c' }));
|
||||||
|
|
||||||
|
const roomACount = Object.values(state.entities).filter((message) => message?.roomId === 'room-a').length;
|
||||||
|
const roomBCount = Object.values(state.entities).filter((message) => message?.roomId === 'room-b').length;
|
||||||
|
|
||||||
|
expect(roomACount).toBe(CACHED_INACTIVE_ROOM_MESSAGE_LIMIT);
|
||||||
|
expect(roomBCount).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,41 @@ export const initialState: MessagesState = messagesAdapter.getInitialState({
|
|||||||
exhaustedConversations: {}
|
exhaustedConversations: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum messages retained in the store per *inactive* room. Cached rooms
|
||||||
|
* keep a recent slice so a return visit renders immediately, but sync and
|
||||||
|
* scroll-up can grow a room's slice to thousands of entries; without pruning,
|
||||||
|
* the store grows unbounded across every room visited in a session. The
|
||||||
|
* active room is never pruned - its window is managed by the chat view.
|
||||||
|
*/
|
||||||
|
export const CACHED_INACTIVE_ROOM_MESSAGE_LIMIT = 100;
|
||||||
|
|
||||||
|
function pruneInactiveRoomMessages(state: MessagesState, activeRoomId: string): MessagesState {
|
||||||
|
const retainedPerRoom = new Map<string, number>();
|
||||||
|
const idsToRemove: string[] = [];
|
||||||
|
const ids = state.ids as string[];
|
||||||
|
|
||||||
|
// ids are sorted oldest-to-newest; walk backwards so the newest messages of
|
||||||
|
// each inactive room are the ones retained.
|
||||||
|
for (let index = ids.length - 1; index >= 0; index--) {
|
||||||
|
const message = state.entities[ids[index]];
|
||||||
|
|
||||||
|
if (!message || message.roomId === activeRoomId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retained = (retainedPerRoom.get(message.roomId) ?? 0) + 1;
|
||||||
|
|
||||||
|
retainedPerRoom.set(message.roomId, retained);
|
||||||
|
|
||||||
|
if (retained > CACHED_INACTIVE_ROOM_MESSAGE_LIMIT) {
|
||||||
|
idsToRemove.push(ids[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idsToRemove.length > 0 ? messagesAdapter.removeMany(idsToRemove, state) : state;
|
||||||
|
}
|
||||||
|
|
||||||
export const messagesReducer = createReducer(
|
export const messagesReducer = createReducer(
|
||||||
initialState,
|
initialState,
|
||||||
|
|
||||||
@@ -48,17 +83,23 @@ export const messagesReducer = createReducer(
|
|||||||
// return visit (or a prefetched room) renders immediately from memory. The
|
// return visit (or a prefetched room) renders immediately from memory. The
|
||||||
// selectors (`selectChannelMessages`, `channelMessages` computed) already
|
// selectors (`selectChannelMessages`, `channelMessages` computed) already
|
||||||
// filter by `currentRoom.id`, so leaving stale rooms in the entity adapter
|
// filter by `currentRoom.id`, so leaving stale rooms in the entity adapter
|
||||||
// is safe. Memory cost is ~30 messages per saved room; tracked at
|
// is safe. On room switch, inactive rooms are pruned to
|
||||||
// /memories/repo/electron-server-switch-performance.md.
|
// `CACHED_INACTIVE_ROOM_MESSAGE_LIMIT` so the cache stays bounded.
|
||||||
on(MessagesActions.loadMessages, (state, { roomId }) => ({
|
on(MessagesActions.loadMessages, (state, { roomId }) => {
|
||||||
...state,
|
const pruned = state.currentRoomId === roomId
|
||||||
|
? state
|
||||||
|
: pruneInactiveRoomMessages(state, roomId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pruned,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
currentRoomId: roomId,
|
currentRoomId: roomId,
|
||||||
exhaustedConversations: state.currentRoomId === roomId
|
exhaustedConversations: state.currentRoomId === roomId
|
||||||
? state.exhaustedConversations
|
? state.exhaustedConversations
|
||||||
: {}
|
: {}
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
|
on(MessagesActions.loadMessagesSuccess, (state, { messages }) =>
|
||||||
messagesAdapter.upsertMany(messages, {
|
messagesAdapter.upsertMany(messages, {
|
||||||
|
|||||||
Reference in New Issue
Block a user