Files
Toju/toju-app/src/app/domains/attachment
Myx 31962aeb1a fix: restore build and stabilize E2E cross-signal behavior
Revert the automated member-ordering pass that broke Angular field init
(TS2729) and disable that rule until a safe reorder strategy exists.
Fix modal/confirm dialog i18n defaults via template fallbacks, search all
active endpoints (including offline), register foreign rooms with actor
owner IDs, sync profile display names from avatar summaries, and guard
dm-chat when a private call converts to a group conversation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 12:16:40 +02:00
..

Attachment Domain

Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser).

Module map

attachment/
├── application/
│   ├── facades/
│   │   └── attachment.facade.ts                     Thin entry point, delegates to manager
│   └── services/
│       ├── attachment-manager.service.ts        Orchestrates lifecycle, auto-download, peer listeners
│       ├── attachment-transfer.service.ts       P2P file transfer protocol (announce/request/chunk/cancel)
│       ├── attachment-transfer-transport.service.ts  Base64 encode/decode, chunked streaming
│       ├── attachment-persistence.service.ts    DB + filesystem persistence, migration from localStorage
│       └── attachment-runtime.store.ts          In-memory signal-based state (Maps for attachments, chunks, pending)
│
├── domain/
│   ├── logic/
│   │   └── attachment.logic.ts                  isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
│   ├── models/
│   │   ├── attachment.model.ts                  Attachment type extending AttachmentMeta with runtime state
│   │   └── attachment-transfer.model.ts         Protocol event types (file-announce, file-chunk, file-request, ...)
│   └── constants/
│       ├── attachment.constants.ts              MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
│       └── attachment-transfer.constants.ts     FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
│
├── infrastructure/
│   ├── services/
│   │   └── attachment-storage.service.ts        Electron filesystem access (save / read / delete)
│   └── util/
│       └── attachment-storage.util.ts           sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
│
└── index.ts                                     Barrel exports

Service composition

The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals).

graph TD
    Facade[AttachmentFacade]
    Manager[AttachmentManagerService]
    Transfer[AttachmentTransferService]
    Transport[AttachmentTransferTransportService]
    Persistence[AttachmentPersistenceService]
    Store[AttachmentRuntimeStore]
    Storage[AttachmentStorageService]
    Logic[attachment.logic]

    Facade --> Manager
    Manager --> Transfer
    Manager --> Persistence
    Manager --> Store
    Manager --> Logic
    Transfer --> Transport
    Transfer --> Store
    Persistence --> Storage
    Persistence --> Store
    Storage --> Helpers[attachment-storage.util]

    click Facade "application/facades/attachment.facade.ts" "Thin entry point" _blank
    click Manager "application/services/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
    click Transfer "application/services/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
    click Transport "application/services/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
    click Persistence "application/services/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
    click Store "application/services/attachment-runtime.store.ts" "In-memory signal-based state" _blank
    click Storage "infrastructure/services/attachment-storage.service.ts" "Electron filesystem access" _blank
    click Helpers "infrastructure/util/attachment-storage.util.ts" "Path helpers" _blank
    click Logic "domain/logic/attachment.logic.ts" "Pure decision functions" _blank

File transfer protocol

Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.

When Electron serves a file from disk, the sender reads one chunk at a time and uses the buffered data-channel send path so large saved media does not get loaded into renderer memory or flood the receiver.

sequenceDiagram
    participant S as Sender
    participant R as Receiver

    S->>R: file-announce (id, name, size, mimeType)
    Note over R: Store metadata in runtime store
    Note over R: shouldAutoRequestWhenWatched?

    R->>S: file-request (attachmentId)
    Note over S: Look up file in runtime store or on disk

    loop Every 64 KB chunk
        S->>R: file-chunk (attachmentId, index, data, progress, speed)
        Note over R: Append to chunk buffer, or append media directly to disk on Electron
        Note over R: Update progress + EWMA speed
    end

    Note over R: All chunks received
    Note over R: Reassemble blob, or open completed Electron media from disk
    Note over R: shouldPersistDownloadedAttachment? Save to disk

Transfer integrity invariants

Concurrent triggers (file-announce, message sync, peer connect) can race to request the same file, and a sender can receive duplicate file-requests for the same attachment. The transfer service enforces these invariants so duplicate streams can never corrupt a download (regression: receivers used to finalize after only the first chunks):

  • 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.
  • 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.

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.

sequenceDiagram
    participant R as Receiver
    participant P1 as Peer A
    participant P2 as Peer B

    R->>P1: file-request
    P1->>R: file-not-found
    Note over R: Try next peer
    R->>P2: file-request
    P2->>R: file-chunk (1/N)
    P2->>R: file-chunk (2/N)
    P2->>R: file-chunk (N/N)
    Note over R: Transfer complete

Auto-download rules

When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic:

Condition Auto-download?
Image or video, size <= 10 MB Yes
Image or video, size > 10 MB No
Non-media file No

The decision lives in shouldAutoRequestWhenWatched() which calls isAttachmentMedia() and checks against MAX_AUTO_SAVE_SIZE_BYTES.

Direct-message routes (/dm/:conversationId and /pm/:conversationId) are treated as watched attachment containers named direct-message:<conversationId>, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media.

Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.

An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads /vlcjs/metoyou-vlc-player.js. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.

Ownership and the "Shared from your device" label

uploaderPeerId is the user id of whoever uploaded the file, not a per-device id. It is intentionally stable across a user's devices so an uploader can recognise their own attachments after sync. Because of that, "did this device upload it?" and "does this device hold the bytes?" are two different questions, and the UI must key the sharing affordance off the latter.

attachment-sharing.rules.ts makes this explicit:

  • 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.
  • 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).

Persistence

Attachment file persistence is platform-agnostic. AttachmentStorageService owns the server/<room>/<bucket> and direct-messages/... path layout and delegates the raw byte IO to a pluggable AttachmentFileStore chosen by PlatformService (mirroring how DatabaseService picks a DB backend):

  • Electron (ElectronAttachmentFileStore): real on-disk files via window.electronAPI; supports chunked reads and streamed (append) receive; maxPersistableBytes = Infinity.
  • Capacitor / Android (CapacitorAttachmentFileStore): native @capacitor/filesystem under Directory.Data (lazy-loaded per the LESSONS rule); inline media is displayed through a convertFileSrc webview URL instead of a renderer Blob, avoiding large-media memory pressure on mobile; maxPersistableBytes = Infinity.
  • Browser (BrowserAttachmentFileStore): a per-user IndexedDB virtual filesystem (metoyou-attachment-files::<userId>, store files keyed by path), so a user's own uploads and downloaded media survive reloads; maxPersistableBytes = MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES (50 MB) so very large media stays peer/in-memory only.

The transfer service consults attachmentStorage.canStreamToDisk() / canPersistSize(size) so the browser cap degrades gracefully (oversized media is kept in memory / peer-served instead of failing the disk path), and streamed receive only runs on stores with a real append primitive.

On Electron, local audio/video uploads are played through the original filesystem path when Electron exposes one, and received audio/video downloads are appended to an app-data file as chunks arrive. Completed audio/video downloads are then played through a file-backed media URL instead of being reloaded into a renderer Blob, which avoids full-file renderer memory pressure during download, startup restore, and playback. The storage path for downloaded server-room files is resolved per room and bucket:

{appDataPath}/server/{roomName}/{bucket}/{attachmentId}.{ext?}

Direct-message attachments use the conversation id instead of the server-room path:

{appDataPath}/direct-messages/{conversationId}/{bucket}/{attachmentId}.{ext?}

Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is video, audio, image, or files depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.

AttachmentPersistenceService handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On restore, ensureInlineDisplayObjectUrl resolves the stored path and, when the active store exposes a directly loadable URL (providesInlineObjectUrl, i.e. Capacitor), uses that URL as-is; otherwise it rebuilds a Blob from the stored bytes (Electron via chunked reads, browser via whole-file read with the correct MIME). Because the browser store persists bytes to IndexedDB, sent and received files are remembered across reload/restart on every platform.

Runtime store

AttachmentRuntimeStore is a signal-based in-memory store using Map instances for:

  • attachments: all known attachments keyed by ID
  • chunks: incoming chunk buffers during active transfers
  • pendingRequests: outbound requests waiting for a response
  • 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.