chore: Fix app
This commit is contained in:
+15
-2
@@ -9,14 +9,27 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
|||||||
## Feature list (alphabetical)
|
## Feature list (alphabetical)
|
||||||
|
|
||||||
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
||||||
|
- [Attachments](features/attachments.md) — P2P chunked file transfer over WebRTC data channels with Electron/Capacitor disk persistence.
|
||||||
- [Authentication](features/authentication.md) — signaling-server session tokens, protected REST/WebSocket identity, and client bearer storage.
|
- [Authentication](features/authentication.md) — signaling-server session tokens, protected REST/WebSocket identity, and client bearer storage.
|
||||||
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
||||||
|
- [Desktop Local API](features/desktop-local-api.md) — Electron localhost HTTP read API, auth proxy, and offline Docusaurus docs.
|
||||||
|
- [Direct Messaging](features/direct-messaging.md) — index entry; full contract in [Messaging](features/messaging.md).
|
||||||
|
- [Game Activity](features/game-activity.md) — RAWG game matching, Electron process detection, and P2P now-playing sync.
|
||||||
|
- [Invites & Join Requests](features/invites-join-requests.md) — invite links, HTML landing pages, and moderated join approval.
|
||||||
|
- [Klipy GIFs](features/klipy-gifs.md) — server-proxied GIF search for chat and DM composers.
|
||||||
|
- [Link Preview & Media Proxy](features/link-preview-media-proxy.md) — SSRF-guarded link unfurling and image proxy on the signaling server.
|
||||||
- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server.
|
- [Message Integrity](features/message-integrity.md) — signed P2P message revision chains, inventory `headHash` convergence, and Ed25519 signing-key registration on the signaling server.
|
||||||
|
- [Messaging](features/messaging.md) — server-channel chat, direct messages, inventory sync, and DM delivery state machine.
|
||||||
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
||||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
- [Plugins](features/plugins.md) — client plugin runtime, server metadata API, Electron plugin data, and P2P message bus.
|
||||||
|
- [Push Notifications](features/push-notifications.md) — FCM/APNs device tokens on the server and Capacitor registration.
|
||||||
|
- [Server Directory](features/server-directory.md) — multi-endpoint catalog, REST CRUD/join/moderation, and room signal affinity.
|
||||||
|
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints consumed by `/dashboard` and `/servers`.
|
||||||
|
- [Signaling](features/signaling.md) — canonical WebSocket envelope catalog, ordering invariants, and relay rules.
|
||||||
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
- [Signal Server Tag](features/signal-server-tag.md) — configurable signal-server display tag shown on profile cards for a user's registration server.
|
||||||
|
- [Voice & WebRTC](features/voice-webrtc.md) — voice/camera/screen-share WebRTC with signaling relay and multi-device ownership.
|
||||||
|
|
||||||
The product client already documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
|
The product client also documents its bounded contexts at `toju-app/src/app/domains/<name>/README.md` (Access Control, Attachment, Authentication, Chat, Custom Emoji, Direct Call, Direct Message, Experimental Media, Game Activity, Notifications, Plugins, Profile Avatar, Screen Share, Server Directory, Theme, Voice Connection, Voice Session). Those domain READMEs cover internal product-client behavior.
|
||||||
|
|
||||||
`agents-docs/features/<slug>.md` is for **cross-context** contracts and feature areas that span more than one subdomain — WebSocket envelopes, IPC channels, plugin manifests, end-to-end flows that touch client + server + Electron together. Add an entry here the first time you write one.
|
`agents-docs/features/<slug>.md` is for **cross-context** contracts and feature areas that span more than one subdomain — WebSocket envelopes, IPC channels, plugin manifests, end-to-end flows that touch client + server + Electron together. Add an entry here the first time you write one.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file [i18n] [testing]
|
||||||
|
|
||||||
|
- **Trigger:** Added new `call.errors.*` keys to `toju-app/public/i18n/catalog/call.json` and used them in code; the full test run failed in `app-i18n-catalog.rules.spec.ts` with "Missing i18n keys" even though the keys existed in the catalog file.
|
||||||
|
- **Rule:** The runtime and the catalog spec read the merged `toju-app/public/i18n/en.json`, not the per-area `catalog/*.json` files — after any catalog edit, run `npm run i18n:sync` (root script, `tools/sync-app-i18n-catalog.mjs`) and commit the regenerated `en.json` alongside the catalog change.
|
||||||
|
- **Why:** without the sync the new strings silently fall back to raw keys at runtime and the catalog spec fails, but only in the full suite — targeted spec runs of the feature under change pass, so the failure surfaces late.
|
||||||
|
- **Example:** `npm run i18n:sync && npm run test` after adding `call.errors.microphonePermissionDenied` to `catalog/call.json`.
|
||||||
|
|
||||||
### Match direct-call recipients against every local identity alias, exactly like DMs already do [direct-call] [identity]
|
### Match direct-call recipients against every local identity alias, exactly like DMs already do [direct-call] [identity]
|
||||||
|
|
||||||
- **Trigger:** "User receiving direct call doesn't get notified" — a caller who met the callee through a room on the caller's signal server addressed the ring by the callee's *provisioned actor id*; `handleIncomingCallEvent` admitted only `payload.participantIds.includes(oderId || id)`, so the ring was silently dropped, the caller sat "In Voice", and the callee saw nothing. DMs had the identical bug fixed earlier (`baa350e`), but the fix stopped at `DirectMessageService` and never reached `DirectCallService`.
|
- **Trigger:** "User receiving direct call doesn't get notified" — a caller who met the callee through a room on the caller's signal server addressed the ring by the callee's *provisioned actor id*; `handleIncomingCallEvent` admitted only `payload.participantIds.includes(oderId || id)`, so the ring was silently dropped, the caller sat "In Voice", and the callee saw nothing. DMs had the identical bug fixed earlier (`baa350e`), but the fix stopped at `DirectMessageService` and never reached `DirectCallService`.
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
# App i18n
|
# App i18n
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
Client-side UI string localization for the product client (`toju-app`), using the same `@ngx-translate/core` stack as the marketing website.
|
Client-side UI string localization for the product client (`toju-app`), using the same `@ngx-translate/core` stack as the marketing website.
|
||||||
|
|
||||||
|
## Migration status
|
||||||
|
|
||||||
|
Only **English** ships today (`SUPPORTED_APP_LOCALES = ['en']`). The catalog workflow and `translate` pipe are in place, but many components still use hardcoded strings — new user-visible copy should use i18n keys; migrate adjacent strings when touching a component. There is no locale preference UI yet.
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
- Bundle locale JSON under `toju-app/public/i18n/`.
|
- Bundle locale JSON under `toju-app/public/i18n/`.
|
||||||
@@ -60,3 +67,14 @@ The sync script also extracts `theme.registry.*` labels/descriptions from `theme
|
|||||||
- `toju-app/src/app/core/i18n/app-i18n.rules.spec.ts`
|
- `toju-app/src/app/core/i18n/app-i18n.rules.spec.ts`
|
||||||
- `toju-app/src/app/core/i18n/app-i18n.service.spec.ts`
|
- `toju-app/src/app/core/i18n/app-i18n.service.spec.ts`
|
||||||
- `toju-app/src/app/core/i18n/app-i18n.testing.ts` — `provideAppI18nForTests()` / `initializeAppI18nForTests()` for Vitest injectors
|
- `toju-app/src/app/core/i18n/app-i18n.testing.ts` — `provideAppI18nForTests()` / `initializeAppI18nForTests()` for Vitest injectors
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `toju-app/AGENTS.md` — i18n usage rules for agents
|
||||||
|
- Marketing site i18n is separate: `website/public/i18n/`
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Documented partial migration status and locale UI gap |
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Attachments
|
||||||
|
|
||||||
|
> **Area:** attachments
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Attachments move file bytes peer-to-peer over the WebRTC ordered data channel using a announce → request → chunk protocol. Chat and DMs attach metadata to messages; the signaling server does not store or relay file payloads. Sibling devices learn attachment **metadata** via `account_sync` `chat-sync-batch` but must still download bytes from a peer that has them.
|
||||||
|
|
||||||
|
Domain internals: [`toju-app/src/app/domains/attachment/README.md`](../../toju-app/src/app/domains/attachment/README.md).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Chunked P2P transfer with flow control and cancel semantics.
|
||||||
|
- Auto-download when policy allows; disk streaming on Electron/Capacitor.
|
||||||
|
- Ownership vs "shared from your device" UI rules.
|
||||||
|
- Persist attachment rows + filesystem paths on desktop/mobile.
|
||||||
|
|
||||||
|
This area does **not** own:
|
||||||
|
|
||||||
|
- Message envelopes or delivery states → [messaging.md](messaging.md).
|
||||||
|
- WebRTC negotiation → [voice-webrtc.md](voice-webrtc.md).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Announce** — sender advertises `fileId`, name, size, mime without sending bytes.
|
||||||
|
- **Mirror host** — peer that holds a complete copy and can serve chunks.
|
||||||
|
- **Buffered send** — waits for data-channel back-pressure (4 MB high / 1 MB low water marks on chat channel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2P protocol
|
||||||
|
|
||||||
|
| type | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `file-announce` | Metadata only |
|
||||||
|
| `file-request` | Receiver starts download |
|
||||||
|
| `file-chunk` | Base64 chunk (`index`, `total`, `data`) |
|
||||||
|
| `file-chunk-ack` | Per-chunk flow control |
|
||||||
|
| `file-cancel` | Abort in flight |
|
||||||
|
| `file-not-found` | Host lacks bytes |
|
||||||
|
|
||||||
|
**Chunk size:** `FILE_CHUNK_SIZE_BYTES` = **64 KB** (`attachment-transfer.constants.ts`).
|
||||||
|
|
||||||
|
**Electron send path:** reads one chunk at a time from disk via IPC (`append-file-bytes` / read chunk) to avoid loading whole files into renderer memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
| Runtime | Metadata | Bytes |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| Browser | In-memory / optional save | Below **10 MB** auto-save cap (`MAX_AUTO_SAVE_SIZE_BYTES`) |
|
||||||
|
| Electron | SQLite `attachments` + CQRS | `user/<username>/…` via `AttachmentStorageService` / IPC |
|
||||||
|
| Capacitor | SQLite | App-private attachment directory — [mobile-capacitor.md](mobile-capacitor.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download / export to user location
|
||||||
|
|
||||||
|
`AttachmentDownloadService.downloadToUserLocation` picks the runtime-appropriate export path:
|
||||||
|
|
||||||
|
| Runtime | Behavior |
|
||||||
|
|---------|----------|
|
||||||
|
| Electron | `saveExistingFileAs` (disk-backed) or `saveFileAs` (blob) native save dialog |
|
||||||
|
| Browser | Anchor `download` click on the object URL |
|
||||||
|
| Capacitor | `CapacitorAttachmentExportService.exportToDevice`: copies the disk file from `Directory.Data` into `Directory.Documents` (or fetches the object URL and writes base64) using `buildAttachmentExportFileName` (timestamp suffix so exports never collide — Android 11+ rejects overwrites of files the app did not create). Anchor downloads do nothing in the Android WebView. |
|
||||||
|
|
||||||
|
## Multi-device
|
||||||
|
|
||||||
|
`chat-sync-batch` in `account_sync` carries an `attachments` map (local paths stripped). Sibling devices discover files exist; P2P `file-request` still required for bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business rules and invariants
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- Visibility-based blob lifecycle on desktop: revoke `blob:` URLs when messages scroll off-screen if disk can rehydrate.
|
||||||
|
- "Shared from your device" badge only when bytes are local to the viewing user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical implementation
|
||||||
|
|
||||||
|
- Facade: `AttachmentFacade` → `AttachmentManagerService`
|
||||||
|
- Protocol: `AttachmentTransferService` + `AttachmentTransferTransportService`
|
||||||
|
- Electron IPC: `read-file-chunk`, `append-file-bytes`, `write-file`, `delete-file`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Domain logic specs under `attachment/`
|
||||||
|
- E2E: `e2e/tests/chat/chat-message-features.spec.ts`, `local-attachment-persistence.spec.ts`, `multi-device-attachment-sharing.spec.ts`, `large-generic-file-transfer.spec.ts` (browser receiver, generic file above the 10 MB auto-save cap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- No server-side virus scanning; peers trust senders they are connected to.
|
||||||
|
- Files stay in user data directories (Electron path jail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related features
|
||||||
|
|
||||||
|
- [messaging.md](messaging.md) — message + attachment metadata coupling
|
||||||
|
- [authentication.md](authentication.md) — `account_sync` batches
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — mobile storage
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-13 | Capacitor download/export to public `Documents` via `CapacitorAttachmentExportService` |
|
||||||
|
| 2026-07-05 | Expanded to full contract style |
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
# Authentication
|
# Authentication
|
||||||
|
|
||||||
Session-token authentication for the signaling server and product client.
|
> **Area:** authentication
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Session-token authentication binds REST mutations and WebSocket `identify` to a user identity on each signaling server. The product client may hold **multiple** server credentials (home + foreign auto-provisioned accounts) while keeping one local user profile. Multi-device tabs share one identity via separate `clientInstanceId` values and `account_sync` relay.
|
||||||
|
|
||||||
|
WebSocket details: [signaling.md](signaling.md). Local API tokens: [desktop-local-api.md](desktop-local-api.md).
|
||||||
|
|
||||||
## Trust boundaries
|
## Trust boundaries
|
||||||
|
|
||||||
@@ -8,8 +16,8 @@ Session-token authentication for the signaling server and product client.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token |
|
| Signaling server REST (mutations) | `Authorization: Bearer <token>` | Actor user IDs in request bodies are ignored; server derives `authUserId` from the token |
|
||||||
| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public |
|
| Signaling server REST (discovery) | None | `GET /api/servers`, featured/trending/search remain public |
|
||||||
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type |
|
| Signaling server WebSocket | `identify.token` | Connections must identify before any other message type — see [signaling.md](signaling.md) |
|
||||||
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only |
|
| Electron Local API | Separate in-memory bearer tokens | Proxies login to allowed signaling servers only — see [desktop-local-api.md](desktop-local-api.md) |
|
||||||
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
| Product client local DB | OS user account | SQLite and attachments are plaintext at rest |
|
||||||
|
|
||||||
## Client logout
|
## Client logout
|
||||||
@@ -36,13 +44,81 @@ Session-token authentication for the signaling server and product client.
|
|||||||
|
|
||||||
## Protected REST routes
|
## Protected REST routes
|
||||||
|
|
||||||
Require `Authorization: Bearer`:
|
Require `Authorization: Bearer` (`requireAuth` middleware). Public routes are listed for contrast.
|
||||||
|
|
||||||
- `PUT/POST/DELETE` under `/api/servers/*` (except public `GET`)
|
### Users (`/api/users`)
|
||||||
- `PUT /api/requests/:id`
|
|
||||||
- Plugin-support mutations under `/api/servers/:serverId/plugins/*`
|
| Method | Path | Auth |
|
||||||
- `/api/users/device-tokens/*`
|
|--------|------|------|
|
||||||
- `POST /api/users/logout`
|
| POST | `/register` | Public |
|
||||||
|
| POST | `/login` | Public |
|
||||||
|
| GET | `/:id/signing-public-key` | Public |
|
||||||
|
| PUT | `/me/signing-key` | Bearer |
|
||||||
|
| POST | `/logout` | Bearer |
|
||||||
|
|
||||||
|
### Device tokens (`/api/users/device-tokens`)
|
||||||
|
|
||||||
|
All routes require bearer; `userId` in body or path must equal `authUserId` (`403` otherwise).
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|--------|------|
|
||||||
|
| POST | `/` |
|
||||||
|
| GET | `/:userId` |
|
||||||
|
| POST | `/:userId/dispatch` |
|
||||||
|
|
||||||
|
### Servers (`/api/servers`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/`, `/featured`, `/trending`, `/:id` | Public |
|
||||||
|
| POST | `/` | Bearer |
|
||||||
|
| PUT | `/:id` | Bearer |
|
||||||
|
| DELETE | `/:id` | Bearer |
|
||||||
|
| POST | `/:id/join` | Bearer |
|
||||||
|
| POST | `/:id/leave` | Bearer |
|
||||||
|
| POST | `/:id/heartbeat` | Bearer |
|
||||||
|
| POST | `/:id/invites` | Bearer |
|
||||||
|
| GET | `/:id/requests` | Bearer |
|
||||||
|
| POST | `/:id/moderation/kick` | Bearer |
|
||||||
|
| POST | `/:id/moderation/ban` | Bearer |
|
||||||
|
| POST | `/:id/moderation/unban` | Bearer |
|
||||||
|
|
||||||
|
### Join requests (`/api/requests`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| PUT | `/:id` | Bearer (approve/deny) |
|
||||||
|
|
||||||
|
### Plugin support (`/api/servers/:serverId/plugins`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/` | Public (metadata read) |
|
||||||
|
| PUT | `/:pluginId/requirement` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/requirement` | Bearer |
|
||||||
|
| PUT | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| GET/PUT/DELETE | `/:pluginId/data/*` | **410 Gone** (server plugin data disabled) |
|
||||||
|
|
||||||
|
### Public (no bearer)
|
||||||
|
|
||||||
|
- `GET /api/health`, `/api/time`
|
||||||
|
- `GET /api/link-metadata`, `/api/image-proxy`
|
||||||
|
- `GET /api/klipy/config`, `/api/klipy/gifs`
|
||||||
|
- `POST /api/games/match`
|
||||||
|
- `GET /api/invites/:id`
|
||||||
|
- `GET /invite/:id` (HTML invite page)
|
||||||
|
- OpenAPI docs routes (`/api/openapi.json`, `/api/docs`, …) — gated by server config, not session auth
|
||||||
|
|
||||||
|
Full server-directory semantics: [server-directory.md](server-directory.md).
|
||||||
|
|
||||||
|
## Message signing key registration
|
||||||
|
|
||||||
|
Ed25519 signing keys for [message-integrity.md](message-integrity.md) register via `PUT /api/users/me/signing-key` with `{ publicKeyJwk }`.
|
||||||
|
|
||||||
|
- **When registered:** `AuthenticationService` calls `MessageSigningService.registerSigningPublicKeyIfNeeded()` after successful **home** `POST /login` and `POST /register` only (`authentication.service.ts`).
|
||||||
|
- **Scope:** registration uses the **active** signaling server's API base (`ServerDirectoryFacade.activeServer()`). Foreign-server auto-provision (`authorizeSignalServer` / `SignalServerProvisionerService`) does **not** currently call signing-key registration — message integrity on foreign servers depends on a later login path or manual registration when that server becomes active.
|
||||||
|
- **Storage:** private key in `localStorage` (`metoyou.messageSigningKeyPair`); public key directory on server SQLite only.
|
||||||
|
|
||||||
## WebSocket identify contract
|
## WebSocket identify contract
|
||||||
|
|
||||||
@@ -135,3 +211,16 @@ Startup routing for signed-out visitors is decided by `resolveUnauthenticatedSta
|
|||||||
- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development.
|
- CORS allowlist: optional `corsAllowlist` in `server/data/variables.json` or `CORS_ALLOWLIST` env (comma-separated). Empty allowlist keeps permissive CORS for local development.
|
||||||
- Push-token routes require bearer auth and user-id match.
|
- Push-token routes require bearer auth and user-id match.
|
||||||
- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC.
|
- RTC relay: direct-message/direct-call types always relay; server-icon types require shared server membership; WebRTC offer/answer/ice remain open for cross-server DM WebRTC.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — WebSocket `identify`, `account_sync`, ordering invariants
|
||||||
|
- [desktop-local-api.md](desktop-local-api.md) — Electron Local API bearer tokens
|
||||||
|
- [message-integrity.md](message-integrity.md) — signing keys and revision chains
|
||||||
|
- [server-directory.md](server-directory.md) — protected server REST mutations
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Expanded protected-route inventory; clarified signing-key registration scope; cross-links |
|
||||||
|
|||||||
@@ -2,64 +2,125 @@
|
|||||||
|
|
||||||
> **Area:** custom-emoji
|
> **Area:** custom-emoji
|
||||||
> **Status:** Active
|
> **Status:** Active
|
||||||
> **Last updated:** 2026-06-05
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Custom emoji lets users upload small image emoji, use them in chat messages and reactions, and sync emoji assets needed for rendering to connected peers over the existing data-channel mesh.
|
Custom emoji lets users upload small image emoji, use them in chat messages and reactions, and sync the image bytes to connected peers over the WebRTC data channel (and to sibling devices via `account_sync`). The signaling server never stores emoji assets.
|
||||||
|
|
||||||
|
Internal UI and NgRx wiring: [`toju-app/src/app/domains/custom-emoji/README.md`](../../toju-app/src/app/domains/custom-emoji/README.md). Chat composer integration: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md).
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
- Own custom emoji asset validation, local persistence, user-saved library membership, shortcut ranking, and peer-to-peer asset sync.
|
- Validate uploads (size, MIME), persist image assets locally, and track per-user **saved library** membership.
|
||||||
- Expose a shared picker consumed by chat message reactions and the chat composer.
|
- Rank shortcuts by local usage (not synced across devices).
|
||||||
- Keep usage ranking local to the current user; usage counts are not synced.
|
- Sync assets P2P (`custom-emoji-*` envelopes) and proactively push referenced emoji when sending messages.
|
||||||
- Does not store custom emoji on the signaling server.
|
- Relay the same envelopes on `account_sync` for multi-device library convergence.
|
||||||
|
- Expose `CustomEmojiPickerComponent` for composer and reactions.
|
||||||
|
|
||||||
## Key Concepts
|
This area does **not** own:
|
||||||
|
|
||||||
- **Custom emoji asset**: A user-created image stored as a data URL with id, name, mime, size, hash, creator, timestamps, and optional saved-library membership.
|
- Message send/edit transport → [messaging.md](messaging.md).
|
||||||
- **Known custom emoji**: A synced asset available for message rendering and forwarding, but not shown in the current user's picker unless saved.
|
- Profile avatar bytes → `toju-app/src/app/domains/profile-avatar/README.md`.
|
||||||
- **Saved custom emoji**: A known asset the current user added to their library; saved emoji appear in the picker and shortcut ranking. Library membership is **user-bound, not client-bound** — it is tracked per signed-in user (keyed by user id), so a second account on the same device never inherits the first account's library.
|
- Server-side storage (none).
|
||||||
- **Emoji shortcut row**: The seven most-used emoji entries for the current user plus an eighth control that opens the full selector.
|
|
||||||
- **Custom emoji token**: The stable message/reaction representation `:emoji[id](name)`, resolved locally to the synced image asset when rendering.
|
|
||||||
- **Composer emoji alias**: The readable inline draft representation `:name:`. The composer rewrites known aliases to stable custom emoji tokens only when sending.
|
|
||||||
|
|
||||||
## Peer Envelope Contract
|
## Key concepts
|
||||||
|
|
||||||
Custom emoji uses `ChatEvent` data-channel envelopes:
|
- **Custom emoji asset** — image with `id`, `name`, `mime`, `size`, `hash`, `creatorUserId`, `dataUrl` (or reconstructed from chunks).
|
||||||
|
- **Known emoji** — synced for rendering; not necessarily in the picker.
|
||||||
|
- **Saved emoji** — in the active user's library (`metoyou_custom_emoji_saved:<userId>`); shown in picker and shortcut row.
|
||||||
|
- **Token** — stable wire form `:emoji[id](name)` in message/reaction bodies.
|
||||||
|
- **Composer alias** — draft form `:name:` rewritten to a token on send when the name is known.
|
||||||
|
- **Shortcut row** — seven most-used saved entries plus opener for full picker.
|
||||||
|
|
||||||
- `custom-emoji-summary`: `{ customEmojiSummaries: [{ id, hash, updatedAt }] }`
|
---
|
||||||
- `custom-emoji-request`: `{ ids: string[] }`
|
|
||||||
- `custom-emoji-full`: `{ customEmojiTransfer: Omit<CustomEmoji, 'dataUrl'>, total: number }`
|
|
||||||
- `custom-emoji-chunk`: `{ customEmojiId, index, total, data }`
|
|
||||||
|
|
||||||
When a peer connects, each side sends a summary of known assets. The receiver requests missing or stale emoji by id, and the owner replies with a small manifest followed by bounded base64 chunks using buffered peer sends. Creating a new emoji also streams that manifest and chunk sequence to every currently connected peer. Outgoing room chat messages, edits, reactions, and direct messages proactively push every referenced custom emoji asset to connected peers in parallel with the message event, so receivers do not wait for a request round-trip. Small assets that fit under `CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES` travel inline in one `custom-emoji-full` event; larger assets use manifest plus chunks. Incoming chat messages and chat-sync batches still scan for `:emoji[id](name)` tokens and request any missing assets from the sender as a repair path. Full inline `customEmoji` payloads remain accepted for backward compatibility.
|
## Peer envelope contract (P2P)
|
||||||
|
|
||||||
## Business Rules
|
| type | Payload |
|
||||||
|
|------|---------|
|
||||||
|
| `custom-emoji-summary` | `{ customEmojiSummaries: [{ id, hash, updatedAt }] }` |
|
||||||
|
| `custom-emoji-request` | `{ ids: string[] }` |
|
||||||
|
| `custom-emoji-full` | manifest (`customEmojiTransfer`) ± inline bytes |
|
||||||
|
| `custom-emoji-chunk` | `{ customEmojiId, index, total, data }` base64 |
|
||||||
|
|
||||||
- Uploads are capped at 1 MB.
|
**Handshake:** on peer connect both sides send summaries; receiver requests stale/missing ids; owner sends manifest then chunked payloads via buffered sends.
|
||||||
- Accepted image types match profile avatars: WebP, GIF, JPG, and JPEG.
|
|
||||||
- Local shortcut ranking is keyed by the active user and includes Unicode emoji plus saved custom emoji only.
|
|
||||||
- Saved-library membership is bound to the user, not the client: `CustomEmojiService` tracks the set of saved emoji ids per user id in `localStorage` (`metoyou_custom_emoji_saved:<userId>`, mirroring the per-user usage ranking). The picker shows only emoji in the active user's saved set, so signing in as a different account on the same client never exposes the previous account's library. On first load after this change the set is seeded from legacy `savedByUser` rows the user actually created (`creatorUserId === userId`), so creators keep their library while other local accounts stay empty.
|
|
||||||
- Message rendering reserves inline emoji space with a transparent placeholder image while a referenced custom emoji asset is not yet available; deferred markdown placeholders rewrite tokens to readable `:name:` aliases so raw `:emoji[id](name)` text never flashes in chat.
|
|
||||||
- Seen custom emoji are not added to the picker automatically; right-click a rendered custom emoji in chat or on a custom emoji reaction and choose **Add to emoji library** from the app context menu (`NativeContextMenuComponent`).
|
|
||||||
- Saved custom emoji can be removed from the picker library by right-clicking them inside the emoji picker and choosing **Remove from emoji library**; the asset stays available for rendering messages that already reference it.
|
|
||||||
- Emoji hosts are marked with `data-custom-emoji` / `data-custom-emoji-library` plus `data-custom-emoji-id` so the global context menu can distinguish them from regular images and suppress the default **Copy Image** action.
|
|
||||||
- The full emoji picker includes a search field that filters built-in Unicode emoji by common terms and saved custom emoji by name.
|
|
||||||
- Custom emoji data-channel chunks are capped below typical SCTP message limits; back-pressure alone is not enough because a single oversized send can fire `RTCDataChannel.onerror`.
|
|
||||||
- Completed transfers are persisted only when the reconstructed data URL matches the manifest size and hash; corrupt local rows are dropped before summaries are advertised.
|
|
||||||
|
|
||||||
## Data Access
|
**Proactive push:** outgoing chat/DM messages scan for tokens and push assets to connected peers in parallel with the message event.
|
||||||
|
|
||||||
- Browser runtime stores custom emoji image assets in IndexedDB store `customEmojis` (per-user database scope).
|
**Inline threshold:** assets ≤ `CUSTOM_EMOJI_INLINE_MAX_JSON_BYTES` (48 KiB) ship in one `custom-emoji-full`; larger assets use manifest + chunks.
|
||||||
- Electron runtime stores custom emoji image assets in SQLite table `custom_emojis`, created by migration `1000000000011-AddCustomEmojis` (a single shared desktop database).
|
|
||||||
- Renderer access goes through `DatabaseService` methods `saveCustomEmoji`, `getCustomEmojis`, and `deleteCustomEmoji`. These persist the image **assets** only; they are not scoped per user (the Electron table is shared across local accounts). Per-user **library membership** lives separately in `localStorage` (`metoyou_custom_emoji_saved:<userId>`), which is what keeps the picker user-bound even on a shared client database.
|
**Repair path:** incoming messages and `chat-sync-batch` scan for tokens and request missing assets from the sender.
|
||||||
|
|
||||||
|
### Multi-device (`account_sync`)
|
||||||
|
|
||||||
|
Relayable types (`account-sync.rules.ts`): `custom-emoji-summary`, `custom-emoji-request`, `custom-emoji-full`, `custom-emoji-chunk`. See [signaling.md](signaling.md) and [authentication.md](authentication.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business rules and invariants
|
||||||
|
|
||||||
|
- Max upload **1 MB**; MIME: WebP, GIF, JPEG/JPG (same set as profile avatars).
|
||||||
|
- Library membership is **per user id**, not per device — second account on same machine does not inherit another user's saved set.
|
||||||
|
- Seeing an emoji in chat does **not** add it to the library; user must **Add to emoji library** from context menu.
|
||||||
|
- Remove from library hides picker entry but keeps asset for messages that already reference it.
|
||||||
|
- Chunks stay below SCTP-safe sizes; oversized single sends can trigger `RTCDataChannel.onerror` even when back-pressure is idle.
|
||||||
|
- Persist only when reconstructed `dataUrl` matches manifest **size and hash**; corrupt rows are dropped before advertising summaries.
|
||||||
|
- Placeholder rendering avoids flashing raw tokens while assets are in flight.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
| Runtime | Asset bytes | Library membership |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| Browser | IndexedDB `customEmojis` (per-user DB scope) | `localStorage` `metoyou_custom_emoji_saved:<userId>` |
|
||||||
|
| Electron | SQLite `custom_emojis` (shared desktop DB) | same localStorage key |
|
||||||
|
| Capacitor | SQLite `custom_emojis` in `metoyou__<userId>` | same localStorage key |
|
||||||
|
|
||||||
|
API: `DatabaseService.saveCustomEmoji` / `getCustomEmojis` / `deleteCustomEmoji`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical implementation
|
||||||
|
|
||||||
|
- Rules: `domains/custom-emoji/domain/custom-emoji.rules.ts`
|
||||||
|
- Service: `CustomEmojiService`; effects: `CustomEmojiSyncEffects`
|
||||||
|
- Picker: `feature/custom-emoji-picker/`
|
||||||
|
- Context menu: `data-custom-emoji` / `data-custom-emoji-library` attributes on rendered hosts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Unit tests cover upload size validation, shortcut selection, picker search filtering, custom emoji token generation, data-channel chunk splitting, readable composer alias rewriting, transfer integrity, saved-library membership, and add/remove library context-menu actions.
|
- `custom-emoji.rules.spec.ts`, `custom-emoji.service.spec.ts`, `custom-emoji-picker.component.spec.ts`
|
||||||
|
- `account-sync.rules.spec.ts` (relayable types)
|
||||||
|
- E2E: `e2e/tests/chat/custom-emoji-user-binding.spec.ts`
|
||||||
|
|
||||||
## Security Considerations
|
---
|
||||||
|
|
||||||
- Emoji payloads are image-only and size-limited before persistence or broadcast.
|
## Security considerations
|
||||||
- Assets sync only to already connected peers; the signaling server does not persist or proxy emoji images.
|
|
||||||
|
- Image-only, size-capped payloads before persist or broadcast.
|
||||||
|
- Assets reach only connected peers (or same-account devices via `account_sync`); server never proxies bytes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Usage counts and shortcut ranking are **local only**.
|
||||||
|
- Electron asset table is **shared across OS users** on one desktop install; library keys remain per MetoYou user id.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related features
|
||||||
|
|
||||||
|
- [messaging.md](messaging.md) — tokens in message bodies, proactive push on send
|
||||||
|
- [signaling.md](signaling.md) — `account_sync`
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — Capacitor SQLite path
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Restructured to match messaging doc style; fixed duplicate sections; Capacitor + account_sync |
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Desktop Local API
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Electron hosts an optional **localhost HTTP API** that exposes read-only access to the local SQLite database, proxies login to allowed signaling servers, and serves bundled Docusaurus documentation offline.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| Electron `api/router.ts` | HTTP routes, bearer token store, CQRS query dispatch |
|
||||||
|
| Desktop settings | Enable/disable Local API, port, allowed signal servers |
|
||||||
|
| `docs-site` build | Static bundle mounted at `/docusaurus/*` |
|
||||||
|
|
||||||
|
## Trust boundary
|
||||||
|
|
||||||
|
Separate **in-memory bearer tokens** from signaling-server session tokens. Login via Local API issues a local token; read routes require `Authorization: Bearer`. See [authentication.md](authentication.md).
|
||||||
|
|
||||||
|
## Routes (summary)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| GET | `/api/health` | No | Local API liveness |
|
||||||
|
| GET | `/api/openapi.json`, `/docs`, `/scalar/api-reference.js` | No | API docs (Scalar) |
|
||||||
|
| GET | `/docusaurus/*` | No | In-app documentation site |
|
||||||
|
| POST | `/api/auth/login` | No | Proxy to configured signaling server; returns local bearer |
|
||||||
|
| POST | `/api/auth/logout` | Bearer | Revoke local token |
|
||||||
|
| GET | `/api/profile` | Bearer | Current user profile |
|
||||||
|
| GET | `/api/rooms`, `/api/rooms/{roomId}`, `.../users`, `.../messages`, `.../bans` | Bearer | Read-only room data |
|
||||||
|
| GET | `/api/messages/{messageId}`, `.../reactions`, `.../attachments` | Bearer | Message graph |
|
||||||
|
| GET | `/api/users/{userId}`, `/api/attachments`, `/api/plugin-data` | Bearer | User + plugin data reads |
|
||||||
|
| GET | `/api/meta/{key}` | Bearer | Meta key lookup |
|
||||||
|
|
||||||
|
Database routes return **503** when SQLite is not initialised.
|
||||||
|
|
||||||
|
## IPC
|
||||||
|
|
||||||
|
- `get-local-api-status`, `open-local-api-docs`, `open-docusaurus-docs`
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — trust table
|
||||||
|
- `electron/CONTEXT.md` — Local API vocabulary
|
||||||
|
- `docs-site/CONTEXT.md` — documentation bundle
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial Local API route catalog |
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Direct Messaging
|
||||||
|
|
||||||
|
> **Area:** messaging
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Direct messaging (1:1 and group PMs) is documented in full in **[messaging.md](messaging.md)** — transports, delivery state machine, sync protocol, storage, and security. This file remains as an index entry in [FEATURES.md](../FEATURES.md).
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
- **Domain:** `toju-app/src/app/domains/direct-message/`
|
||||||
|
- **Entry points:** `DirectMessageService`, `PeerDeliveryService`, `FriendService`
|
||||||
|
- **Persistence:** `metoyou_direct_message_*` (per-user local storage)
|
||||||
|
- **P2P types:** `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-typing`, `direct-message-sync-request`, `direct-message-sync`
|
||||||
|
- **Calls:** `direct-call` shares `PeerDeliveryService` → [voice-webrtc.md](voice-webrtc.md)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [messaging.md](messaging.md) — full cross-context contract
|
||||||
|
- [signaling.md](signaling.md) — WebSocket relay
|
||||||
|
- Domain README: [`toju-app/src/app/domains/direct-message/README.md`](../../toju-app/src/app/domains/direct-message/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Slimmed to index; comprehensive content moved to messaging.md |
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Game Activity
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
"Now playing" game detection: Electron foreground-window/process heuristics, RAWG metadata match via signaling server, and P2P `game-activity` broadcast to peers. Shown on profile cards and room sidebars.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| `game-activity` domain | Scan loop, confidence scoring, P2P broadcast, user store updates |
|
||||||
|
| Electron IPC | `get-running-process-names`, `get-active-game-candidate` |
|
||||||
|
| Signaling server | `POST /api/games/match` (RAWG proxy + miss cache) |
|
||||||
|
| P2P | `game-activity` data-channel event |
|
||||||
|
|
||||||
|
## Server API
|
||||||
|
|
||||||
|
### `POST /api/games/match`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Body:** `{ processNames: string[], candidates?: { processName, score }[] }` (bounded list sizes)
|
||||||
|
- **Response:** `{ game: MatchedGame | null }` — RAWG-backed title, cover art, store links
|
||||||
|
|
||||||
|
Misses cached in server SQLite (`GameMatchMiss`) to limit API calls.
|
||||||
|
|
||||||
|
## Client detection
|
||||||
|
|
||||||
|
- Periodic scan (default 10 s, configurable 5–60 s in localStorage `metoyou_game_scan_interval_ms`).
|
||||||
|
- Ignores launcher/helper processes via `IGNORED_PROCESS_NAMES` and regex patterns.
|
||||||
|
- **Electron:** suppresses scan when MetoYou window is focused; prefers foreground-window candidate from `get-active-game-candidate`.
|
||||||
|
- **Browser/Capacitor:** no process scan — activity only from P2P peers.
|
||||||
|
|
||||||
|
## P2P event
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "game-activity", "activity": { "game", "startedAt", "processName", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Peers merge into `User.gameActivity` in NgRx store.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — not WS-relayed
|
||||||
|
- [server-directory.md](server-directory.md) — API base URL for match endpoint
|
||||||
|
- Domain README: [`toju-app/src/app/domains/game-activity/README.md`](../../toju-app/src/app/domains/game-activity/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context game-activity contract |
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Invites & Join Requests
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Invite links and join-request approval let users join private or moderated chat-servers without public listing. Spans signaling **server** REST + HTML invite pages and the product **client** `server-directory` invite feature.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: create time-limited invites, resolve invite metadata, record join requests, notify requesters on moderation decisions.
|
||||||
|
- Client: create/copy invite links, render invite landing UX, call join API with invite codes/passwords.
|
||||||
|
- It does NOT own: WebSocket room membership (`join_server` after REST join succeeds).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Invite:** opaque id mapping to a server; may expire.
|
||||||
|
- **Join request:** pending membership when server requires approval.
|
||||||
|
- **request_update:** server-pushed WebSocket notification when a moderator approves/denies.
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
### Invites
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| POST | `/api/servers/:id/invites` | Bearer | Create invite (moderator) |
|
||||||
|
| GET | `/api/invites/:id` | Public | Resolve invite metadata + server card |
|
||||||
|
| GET | `/invite/:id` | Public | HTML invite landing page (browser) |
|
||||||
|
|
||||||
|
### Join
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| POST | `/api/servers/:id/join` | Bearer | Join with password, invite id, or public access; may create join request |
|
||||||
|
|
||||||
|
### Join requests (moderation)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| GET | `/api/servers/:id/requests` | Bearer (`manageServer`) | List pending requests |
|
||||||
|
| PUT | `/api/requests/:id` | Bearer (`manageServer`) | Approve or deny; body `{ status }` |
|
||||||
|
|
||||||
|
`PUT /api/requests/:id` validates optional `ownerId` matches authenticated user, checks `manageServer` permission, updates status, and sends `notifyUser(request.userId, { type: 'request_update', request })`.
|
||||||
|
|
||||||
|
## Client flow
|
||||||
|
|
||||||
|
1. Moderator creates invite via `ServerDirectoryFacade.createInvite()`.
|
||||||
|
2. Recipient opens `/invite/:id` or deep link; client resolves `GET /api/invites/:id`.
|
||||||
|
3. Authenticated user calls `POST /api/servers/:id/join` with invite payload.
|
||||||
|
4. On approval-required servers, user waits for `request_update` or polls requests list (moderator UI).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — join/leave REST
|
||||||
|
- [authentication.md](authentication.md) — bearer on mutations
|
||||||
|
- [signaling.md](signaling.md) — `join_server` after join
|
||||||
|
- Domain README: [`toju-app/src/app/domains/server-directory/README.md`](../../toju-app/src/app/domains/server-directory/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context invite/join-request contract |
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Klipy GIFs
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
GIF search in chat and DM composers via a **Klipy API proxy** on the signaling server. API keys stay server-side; clients call same-origin routes on the active signal server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: proxy/search Klipy API (`variables.json` `klipyApiKey`).
|
||||||
|
- Client `chat` domain: `KlipyService`, composer picker; DMs reuse the same integration.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /api/klipy/config`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Response:** `{ enabled: boolean }` — `enabled` when server has a configured API key
|
||||||
|
|
||||||
|
### `GET /api/klipy/gifs`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Query:** `q` (search), `page`, `per_page` (default 24, max 50)
|
||||||
|
- **Response:** Normalised `{ gifs: [{ id, slug, title, url, previewUrl, width, height }], hasNext }`
|
||||||
|
- **Upstream:** `https://api.klipy.com/api/v1` with 8 s timeout
|
||||||
|
|
||||||
|
## Client behavior
|
||||||
|
|
||||||
|
- GIF picker visibility is resolved against the **current chat-server's** signal server (not a global offline endpoint) so KLIPY availability matches the room's backend.
|
||||||
|
- Selected GIFs send as markdown image messages; rendering uses [link-preview-media-proxy.md](link-preview-media-proxy.md) image proxy when needed.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — per-room signal server for config
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — DM composer reuse
|
||||||
|
- Domain README: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial Klipy proxy contract |
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Link Preview & Media Proxy
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The signaling server fetches untrusted URLs on behalf of clients for **link embed previews** and **image proxying**, with SSRF guards. Chat and DM composers render embeds using these endpoints.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: outbound fetch with host validation, caching, size limits.
|
||||||
|
- Client `chat` domain: request metadata when messages contain URLs; render cards in message list.
|
||||||
|
- It does NOT store embeds long-term on the server beyond in-memory cache.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `GET /api/link-metadata`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Query:** `url` (http/https)
|
||||||
|
- **Response:** `{ title?, description?, imageUrl?, siteName? }`
|
||||||
|
- **Guards:** `resolveAndValidateHost` + `safeFetch`; 8 s timeout; HTML capped at 512 KB; in-memory cache sized by `variables.json` link-preview config
|
||||||
|
|
||||||
|
### `GET /api/image-proxy`
|
||||||
|
|
||||||
|
- **Auth:** Public
|
||||||
|
- **Query:** `url` (http/https)
|
||||||
|
- **Response:** Raw image bytes (`Content-Type` from origin)
|
||||||
|
- **Limits:** image/* only; max 8 MB; 8 s timeout; SSRF validation
|
||||||
|
- **Cache:** `Cache-Control: public, max-age=3600`
|
||||||
|
|
||||||
|
## Client usage
|
||||||
|
|
||||||
|
Message markdown / link-embed pipeline calls link-metadata for unfurling; proxied images load through `/api/image-proxy` when direct fetch would fail (CORS, mixed content).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — requests use active server's API base
|
||||||
|
- Domain README: [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial link preview / image proxy contract |
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
# Message Integrity
|
# Message Integrity
|
||||||
|
|
||||||
Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots and revision events.
|
> **Area:** messaging
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Signed, append-only **message revisions** give P2P chat a verifiable history without central message storage. The materialized `Message` row in local SQLite/IDB is a cache; peers converge via inventory snapshots (`revision`, `headHash`) and `message-revision` events.
|
||||||
|
|
||||||
|
Parent transport and sync context: [messaging.md](messaging.md).
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
|
|
||||||
@@ -15,7 +23,7 @@ Signed, append-only **message revisions** give P2P chat a verifiable history wit
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Product client (`toju-app`) | Revision construction, merge, verification, P2P broadcast, local persistence |
|
| Product client (`toju-app`) | Revision construction, merge, verification, P2P broadcast, local persistence |
|
||||||
| Signaling server (`server`) | `PUT /api/users/me/signing-key`, `GET /api/users/:id/signing-public-key` — key directory only, no message storage |
|
| Signaling server (`server`) | `PUT /api/users/me/signing-key`, `GET /api/users/:id/signing-public-key` — key directory only, no message storage |
|
||||||
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log (IDB store / SQLite meta) |
|
| Electron / mobile persistence | `revision` + `headHash` on message rows; revision audit log in IDB store (browser), SQLite `meta` table (Electron **and Capacitor** — keys `message-revision:<messageId>:<revision>`) |
|
||||||
|
|
||||||
Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user.
|
Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`) when the actor is a synthetic plugin user.
|
||||||
|
|
||||||
@@ -47,7 +55,23 @@ Plugin API messages may emit unsigned revisions (`plugin-edit` / `plugin-delete`
|
|||||||
| `PUT` | `/api/users/me/signing-key` | Bearer | `{ publicKeyJwk }` — stores Ed25519 public JWK on the user row |
|
| `PUT` | `/api/users/me/signing-key` | Bearer | `{ publicKeyJwk }` — stores Ed25519 public JWK on the user row |
|
||||||
| `GET` | `/api/users/:id/signing-public-key` | Public | `{ publicKeyJwk }` — used by peers to verify signatures |
|
| `GET` | `/api/users/:id/signing-public-key` | Public | `{ publicKeyJwk }` — used by peers to verify signatures |
|
||||||
|
|
||||||
Registration runs automatically after login/register via `AuthenticationService`.
|
Registration runs automatically after **home** login/register via `AuthenticationService` — see [authentication.md](authentication.md) for foreign-server scope.
|
||||||
|
|
||||||
|
## Multi-device relay (`account_sync`)
|
||||||
|
|
||||||
|
`message-revision` chat events are relayable to sibling connections via WebSocket `account_sync` (alongside legacy `chat-message` paths documented in [authentication.md](authentication.md)). Inventory convergence still prefers P2P data-channel sync when peers are connected.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — signing-key registration, `account_sync` chat batches
|
||||||
|
- [signaling.md](signaling.md) — `account_sync` envelope
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — Capacitor `meta` revision keys
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Capacitor meta persistence; account_sync cross-ref; signing registration scope |
|
||||||
|
|
||||||
## Degraded-mode behavior
|
## Degraded-mode behavior
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Messaging
|
||||||
|
|
||||||
|
> **Area:** messaging
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-13
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Messaging in MetoYou covers two transports that share inventory-sync concepts and (for DMs) a monotonic delivery state machine. **Server-channel chat** is broadcast by the signaling server over WebSocket (`chat_message`) as a narrow fallback when P2P data channels are down — the server does not persist message bodies. **Direct messages** (1:1 and group DMs) are primarily peer-to-peer over the WebRTC ordered data channel, with WebSocket signaling relay when no channel is open and an offline queue when neither path succeeds.
|
||||||
|
|
||||||
|
On both transports the client maintains local history (Electron SQLite / browser IndexedDB for server channels; user-scoped `localStorage` for DMs) and a **chunked inventory-sync protocol** so peers reconcile missing rows without flooding the link.
|
||||||
|
|
||||||
|
This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage boundaries. Internal NgRx orchestration lives in [`toju-app/src/app/domains/chat/README.md`](../../toju-app/src/app/domains/chat/README.md) and [`toju-app/src/app/domains/direct-message/README.md`](../../toju-app/src/app/domains/direct-message/README.md). WebSocket relay rules: [signaling.md](signaling.md). Signed revision chains: [message-integrity.md](message-integrity.md).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Send server-channel chat over WebSocket fallback (`chat_message`) and primarily over P2P (`chat-message`, `edit-message`, `delete-message`, `message-revision`).
|
||||||
|
- Send, edit, delete, and react in direct messages over the data channel with signaling fallback.
|
||||||
|
- Carry typing indicators: server channels (`typing` → `user_typing`) and DMs (`direct-message-typing`).
|
||||||
|
- Reconcile peer history via the inventory protocol (`chat-inventory` / `chat-sync-batch`; DM `direct-message-sync`).
|
||||||
|
- Drive a monotonic DM delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`.
|
||||||
|
- Relay multi-device chat via `account_sync` (`chat-message`, `message-revision`, `chat-sync-batch`).
|
||||||
|
|
||||||
|
This area does **not** own:
|
||||||
|
|
||||||
|
- Attachment payloads or chunked file transfer → [attachments.md](attachments.md).
|
||||||
|
- WebRTC session setup and data-channel lifecycle → [voice-webrtc.md](voice-webrtc.md).
|
||||||
|
- Write permission resolution (`writeMessages`, `manageMessages`, bans) → `toju-app/src/app/domains/access-control/README.md`.
|
||||||
|
- Full WebSocket envelope catalog (identity, voice, plugins) → [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Server-channel message** — room-scoped text in a saved chat-server. Primary path: P2P `chat-message` on the data channel. Fallback: server broadcasts `chat_message` to other connections in the room.
|
||||||
|
- **Direct message** — 1:1 or group PM. Persisted per user under `metoyou_direct_message_*` keys (domain-owned storage, not the global messages CQRS table).
|
||||||
|
- **Conversation** — DM thread (`direct` or `group`). Upgrading a 1:1 call to a group creates a **new** group conversation; the original 1:1 history is not copied.
|
||||||
|
- **Inventory event** — `chat-inventory` (P2P): sender announces message ids plus integrity fields (`ts`, `rc`, `ac`, `revision`, `headHash`); receiver requests missing or stale ids.
|
||||||
|
- **Sync batch** — `chat-sync-batch`: chunked response, **200 messages per envelope** (`CHUNK_SIZE` in `message-sync.rules.ts`).
|
||||||
|
- **Delivery state** — DM-only enum: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Advanced only via `advanceDirectMessageStatus` (never backwards).
|
||||||
|
- **Peer delivery** — `PeerDeliveryService` tries data channel, then signaling forward, then offline queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transports
|
||||||
|
|
||||||
|
### Server-channel chat
|
||||||
|
|
||||||
|
**P2P (primary):** `chat-message`, `edit-message`, `delete-message`, `message-revision`, reactions, and inventory events on the ordered data channel. See [message-integrity.md](message-integrity.md) for dual-emit revision behavior.
|
||||||
|
|
||||||
|
**WebSocket (fallback):** Client sends `chat_message`; `handleChatMessage` (`server/src/websocket/handler.ts`) broadcasts to other connections in the room. The server does **not** handle `edit_message` or `delete_message` on the wire — edits and deletes are P2P (and `account_sync` for sibling devices).
|
||||||
|
|
||||||
|
**Typing:** Client sends `typing`; server broadcasts `user_typing` (transient, no persistence).
|
||||||
|
|
||||||
|
**Multi-device:** Sibling tabs receive live chat via `account_sync` payloads (`chat-message`, `message-revision`, `chat-sync-batch`). See [authentication.md](authentication.md).
|
||||||
|
|
||||||
|
### Direct messages
|
||||||
|
|
||||||
|
**P2P (primary):** Events on the shared ordered data channel (same peer connections as voice/chat).
|
||||||
|
|
||||||
|
**WebSocket (fallback):** `PeerDeliveryService.sendViaSignaling` forwards these types to `targetUserId` without requiring shared server membership:
|
||||||
|
|
||||||
|
| type | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `direct-message` | New message |
|
||||||
|
| `direct-message-status` | Delivery / ack |
|
||||||
|
| `direct-message-mutation` | Edit, delete, reactions |
|
||||||
|
| `direct-message-typing` | Typing indicator |
|
||||||
|
| `direct-message-sync-request` | Request snapshot |
|
||||||
|
| `direct-message-sync` | Bounded history merge |
|
||||||
|
|
||||||
|
**Offline queue:** When both paths fail, `OfflineMessageQueueService` retains message ids; replay runs on `peerConnected$` / `networkRestored$` (no scheduled retry timer).
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
| Data | Where |
|
||||||
|
|------|--------|
|
||||||
|
| Server-channel messages | `DatabaseService` → Electron SQLite or browser IndexedDB (`messages` store) |
|
||||||
|
| Direct messages | `metoyou_direct_message_*` via direct-message repositories |
|
||||||
|
| Signaling server | **No message bytes** — broadcast/relay only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inventory / sync protocol
|
||||||
|
|
||||||
|
Shared shapes in `toju-app/src/app/shared-kernel/chat-events.ts`:
|
||||||
|
|
||||||
|
| Event | Role |
|
||||||
|
|-------|------|
|
||||||
|
| `chat-inventory-request` | Ask peer for inventory |
|
||||||
|
| `chat-inventory` | Announce ids + integrity snapshots |
|
||||||
|
| `chat-sync-request` | Request specific missing ids |
|
||||||
|
| `chat-sync-batch` | Up to **200** messages per envelope |
|
||||||
|
| `direct-message-sync-request` / `direct-message-sync` | DM-scoped snapshot merge |
|
||||||
|
|
||||||
|
Rules (`message-sync.rules.ts`, `message-integrity.rules.ts`):
|
||||||
|
|
||||||
|
- Merges are **additive** — sparser peers never wipe richer local history.
|
||||||
|
- `findMissingIds` compares remote inventory to local `revision` / `headHash` (and legacy `ts` / `rc` / `ac`).
|
||||||
|
- `INVENTORY_LIMIT` = 1_000_000 (safety ceiling for pathological rooms).
|
||||||
|
- Sync polling: 10 s when catching up, 15 min after a clean cycle (`SYNC_POLL_FAST_MS` / `SYNC_POLL_SLOW_MS`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Delivery state machine (DMs only)
|
||||||
|
|
||||||
|
| Value | Numeric | Meaning |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `QUEUED` | 0 | Composed locally; no successful send yet |
|
||||||
|
| `SENT` | 1 | Data channel or signaling forward accepted the payload |
|
||||||
|
| `DELIVERED` | 2 | At least one recipient acknowledged receipt |
|
||||||
|
| `ACKNOWLEDGED` | 3 | Full recipient set acknowledged (1:1: the peer; group: every participant) |
|
||||||
|
|
||||||
|
`advanceDirectMessageStatus` only moves forward (`direct-message.logic.ts`). Server-channel messages have no application-level delivery enum; the UI treats them as sent once the transport accepts the event.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edit and delete
|
||||||
|
|
||||||
|
**Server channels:** Outgoing edits check `canEditMessage(message, userId)` before broadcast. Incoming P2P `edit-message` / `delete-message` merge via NgRx handlers; signed paths prefer `message-revision` when integrity is enabled.
|
||||||
|
|
||||||
|
**DMs:** `direct-message-mutation` with types `edit`, `delete`, `reaction-add`, `reaction-remove`. `applyMutation` in `DirectMessageService` updates by `messageId` but **does not verify** the mutator is the original author — a non-cooperating peer could mutate another user's row. Server chat enforces authorship on **outgoing** edits only.
|
||||||
|
|
||||||
|
Deletes keep tombstone semantics (`isDeleted`, empty `content`) so inventory sync can converge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business rules and invariants
|
||||||
|
|
||||||
|
- The signaling server is **not authoritative** for message content — it relays `chat_message` and DM types opaquely.
|
||||||
|
- DM events are **ignored** unless the local user is in `recipients` / `participants` or already has the conversation locally.
|
||||||
|
- Recipient matching (DM **and** `direct-call`) must accept **every local identity alias** — home id, entity id, peer id, and each provisioned signal-server actor id from `SignalServerCredentialStoreService` — because senders who met the recipient on a foreign signal server address them by the provisioned actor id (`direct-message-identity.rules.ts`, `direct-call-participant-identity.rules.ts`).
|
||||||
|
- DM status transitions are **monotonic**.
|
||||||
|
- Inventory merges never downgrade a row with a newer `revision` / `headHash`.
|
||||||
|
- 1:1 → group upgrade **does not copy** private history into the new group thread.
|
||||||
|
- Unread counts are **idempotent by message id** — re-sync does not double-increment.
|
||||||
|
- Incoming DMs raise a system notification via `NotificationsFacade.handleIncomingDirectMessage` (title = sender name; `shouldDeliverDirectMessageNotification` suppresses only when the conversation is on screen in an active window, notifications are disabled, or the user is busy). System messages (e.g. call-started) and deletions never notify. On Capacitor this flows through the same `DesktopNotificationService` → LocalNotifications routing as server chat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical implementation
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
- `server/src/websocket/handler.ts` — `handleChatMessage`, `handleTyping`, DM forward via `forwardRtcMessage` / `DIRECT_SIGNALING_TYPES`.
|
||||||
|
- No message CQRS or entities on the server.
|
||||||
|
|
||||||
|
### Product client
|
||||||
|
|
||||||
|
| Area | Location |
|
||||||
|
|------|----------|
|
||||||
|
| Server chat effects / handlers | `store/messages/`, `domains/chat/` |
|
||||||
|
| DM service / queue | `domains/direct-message/application/services/` |
|
||||||
|
| Sync rules | `domains/chat/domain/rules/message-sync.rules.ts` |
|
||||||
|
| Wire types | `shared-kernel/chat-events.ts`, `direct-message-contracts.ts` |
|
||||||
|
| Account sync relay | `infrastructure/realtime/account-sync/` |
|
||||||
|
|
||||||
|
### Electron
|
||||||
|
|
||||||
|
- Server-channel rows: TypeORM `Message` entity + CQRS `save-message` / `delete-message`.
|
||||||
|
- DMs: renderer `localStorage` repositories (not the main message table).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: `message-sync.rules.spec.ts`, `message-integrity.rules.spec.ts`, `message.rules.spec.ts`, `direct-message.service.spec.ts`, `direct-message.logic` specs, `messages-incoming.handlers.spec.ts`, `account-sync-chat.helper.spec.ts`.
|
||||||
|
- E2E: `e2e/tests/chat/chat-message-features.spec.ts`, `multi-client-chat-sync.spec.ts`, `dm-flow.spec.ts`, `multi-device-attachment-sharing.spec.ts`, `e2e/tests/voice/dm-header-call-ring.spec.ts` (DM-header call ring, incl. cross-signal actor-id addressing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance considerations
|
||||||
|
|
||||||
|
- Sync batches: **200 messages per `chat-sync-batch` envelope**.
|
||||||
|
- `chat_message` broadcast is O(connections in room) per send.
|
||||||
|
- Group DMs: O(recipients) transport attempts per message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
- **No end-to-end encryption** for message bodies. WebRTC data channels use DTLS; signaling fallback is TLS WebSocket; local DBs store plaintext.
|
||||||
|
- **DM `applyMutation` does not verify authorship** on incoming mutations.
|
||||||
|
- **No server-side rate limit** on `chat_message` volume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known issues and limitations
|
||||||
|
|
||||||
|
- **No server-side chat log** — late joiners depend on peers with local history or `account_sync` from a sibling device.
|
||||||
|
- **DM mutation authorship** not verified on receive.
|
||||||
|
- **Offline queue** replays only on peer connect / network restore events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related features
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — WebSocket relay and ordering invariants
|
||||||
|
- [message-integrity.md](message-integrity.md) — signed revision chains
|
||||||
|
- [attachments.md](attachments.md) — file payloads alongside chat events
|
||||||
|
- [voice-webrtc.md](voice-webrtc.md) — data channel transport
|
||||||
|
- [authentication.md](authentication.md) — `account_sync` multi-device relay
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — short index (defers here)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 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-05 | Initial comprehensive messaging contract (replaces thin direct-messaging summary) |
|
||||||
@@ -31,10 +31,6 @@ npm run cap:sync
|
|||||||
npm run cap:open:android
|
npm run cap:open:android
|
||||||
npm run cap:open:ios
|
npm run cap:open:ios
|
||||||
|
|
||||||
### Linux: Android Studio path
|
|
||||||
|
|
||||||
Capacitor defaults to `/usr/local/android-studio/bin/studio.sh`. If Android Studio is installed elsewhere (common with **Flatpak** from Flathub), `npm run cap:open:android` uses `tools/resolve-android-studio-path.js` to locate `studio.sh` (Flatpak `active` symlink, Toolbox, snap, `/opt`, etc.). Override anytime with `CAPACITOR_ANDROID_STUDIO_PATH`.
|
|
||||||
|
|
||||||
# Convenience (build + sync + open)
|
# Convenience (build + sync + open)
|
||||||
npm run cap:build:android
|
npm run cap:build:android
|
||||||
npm run cap:build:ios
|
npm run cap:build:ios
|
||||||
@@ -44,6 +40,10 @@ npm run cap:apk:android
|
|||||||
# → toju-app/android/app/build/outputs/apk/debug/app-debug.apk
|
# → toju-app/android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Linux: Android Studio path
|
||||||
|
|
||||||
|
Capacitor defaults to `/usr/local/android-studio/bin/studio.sh`. If Android Studio is installed elsewhere (common with **Flatpak** from Flathub), `npm run cap:open:android` uses `tools/resolve-android-studio-path.js` to locate `studio.sh` (Flatpak `active` symlink, Toolbox, snap, `/opt`, etc.). Override anytime with `CAPACITOR_ANDROID_STUDIO_PATH`.
|
||||||
|
|
||||||
Config: `toju-app/capacitor.config.ts` (`webDir: ../dist/client/browser`).
|
Config: `toju-app/capacitor.config.ts` (`webDir: ../dist/client/browser`).
|
||||||
|
|
||||||
### CI (Gitea)
|
### CI (Gitea)
|
||||||
@@ -85,13 +85,15 @@ Re-run `npm run cap:assets:android` whenever `images/icon-new-rounded.png` chang
|
|||||||
| Feature | Status | Notes |
|
| Feature | Status | Notes |
|
||||||
|---------|--------|-------|
|
|---------|--------|-------|
|
||||||
| Push/local notifications | **Working (partial)** | Local notifications always available; remote push (FCM/APNs) registers only when Firebase/APNs is configured — app starts normally without `google-services.json` |
|
| Push/local notifications | **Working (partial)** | Local notifications always available; remote push (FCM/APNs) registers only when Firebase/APNs is configured — app starts normally without `google-services.json` |
|
||||||
|
| Chat message notifications | **Working** | `DesktopNotificationService` routes to `MobileNotificationsService.showMessage()` on Capacitor (LocalNotifications channel `toju-messages`); web `Notification` API is never used on native shells |
|
||||||
| Server push dispatch | **Working (configured)** | Tokens persist in server SQLite; outbound FCM/APNs via env credentials |
|
| Server push dispatch | **Working (configured)** | Tokens persist in server SQLite; outbound FCM/APNs via env credentials |
|
||||||
| In-call notifications | **Working (Capacitor)** | Persistent notification with answer/mute/hang-up actions |
|
| In-call notifications | **Working (Capacitor)** | Persistent notification with answer/mute/hang-up actions |
|
||||||
| Stream pop-out (PiP) | **Working (partial)** | Document PiP when WebView supports it; Android native PiP fallback via `MetoyouMobile` plugin |
|
| Stream pop-out (PiP) | **Working (partial)** | Document PiP when WebView supports it; Android native PiP fallback via `MetoyouMobile` plugin |
|
||||||
| Background voice | **Working (partial)** | Android foreground service; iOS `UIBackgroundModes` audio + CallKit active-call bridge |
|
| Background voice | **Working (partial)** | Android foreground service; iOS `UIBackgroundModes` audio + CallKit active-call bridge |
|
||||||
| iOS CallKit | **Working (partial)** | `MetoyouMobile.startCallKitSession` reports active calls; requires Xcode target wiring after `cap:sync` |
|
| iOS CallKit | **Working (partial)** | `MetoyouMobile.startCallKitSession` reports active calls; requires Xcode target wiring after `cap:sync` |
|
||||||
| Screensharing | **Limited** | Disabled on iOS WebView; Android `getDisplayMedia` may work |
|
| Screensharing | **Hidden on native mobile** | `getDisplayMedia` is unavailable in mobile WebViews; all screen-share buttons (private call, voice controls, floating controls, voice workspace) are gated behind `!viewport.isMobile() && !MobilePlatformService.isNativeMobile()` |
|
||||||
| Composer attachments | **Working** | Mobile attachment button + hidden file input |
|
| Composer attachments | **Working** | Mobile attachment button + hidden file input |
|
||||||
|
| Attachment download/export | **Working** | `AttachmentDownloadService` delegates to `CapacitorAttachmentExportService` on native shells: copies disk-backed files (or fetches the object URL) into the public `Documents` directory with a timestamped name; anchor `download` links do nothing in the Android WebView |
|
||||||
| Camera sharing | **Working** | Existing `getUserMedia` camera path in WebRTC stack |
|
| Camera sharing | **Working** | Existing `getUserMedia` camera path in WebRTC stack |
|
||||||
| Speakerphone | **Working (partial)** | Android `AudioManager` via `MetoyouMobile`; iOS `@capgo/capacitor-audio-session`; direct-call speaker toggle on native mobile |
|
| Speakerphone | **Working (partial)** | Android `AudioManager` via `MetoyouMobile`; iOS `@capgo/capacitor-audio-session`; direct-call speaker toggle on native mobile |
|
||||||
| Local DB (SQLite) | **Working** | `DatabaseService` routes Capacitor shells to `CapacitorDatabaseService` (native SQLite CRUD) |
|
| Local DB (SQLite) | **Working** | `DatabaseService` routes Capacitor shells to `CapacitorDatabaseService` (native SQLite CRUD) |
|
||||||
@@ -103,7 +105,7 @@ Re-run `npm run cap:assets:android` whenever `images/icon-new-rounded.png` chang
|
|||||||
- **iOS CallKit:** Plugin Swift source ships in `ios/App/App/MetoyouMobilePlugin.swift`; add it to the Xcode target if not auto-linked. Incoming-call UI is not fully bridged to WebRTC answer/hang-up yet.
|
- **iOS CallKit:** Plugin Swift source ships in `ios/App/App/MetoyouMobilePlugin.swift`; add it to the Xcode target if not auto-linked. Incoming-call UI is not fully bridged to WebRTC answer/hang-up yet.
|
||||||
- **iOS screenshare:** `getDisplayMedia` is not available in WKWebView.
|
- **iOS screenshare:** `getDisplayMedia` is not available in WKWebView.
|
||||||
- **Android PiP:** Native PiP enters activity-level PiP; WebView video may not always render inside PiP on all OEM WebViews.
|
- **Android PiP:** Native PiP enters activity-level PiP; WebView video may not always render inside PiP on all OEM WebViews.
|
||||||
- **Production discovery:** `signal.toju.app` may not expose `/api/servers/featured` or `/trending`; client skips those calls for known hosts.
|
- **Legacy discovery endpoints:** Older signal servers may not expose `/api/servers/featured` or `/trending` (they resolve as `/servers/:id` and return 404). The client still calls those routes on every online endpoint and **falls back per-endpoint to `GET /api/servers`** when 404 is returned — see [server-discovery.md](server-discovery.md).
|
||||||
- **Push delivery:** Requires FCM service account and APNs key configuration on the signaling server.
|
- **Push delivery:** Requires FCM service account and APNs key configuration on the signaling server.
|
||||||
|
|
||||||
## Push notification setup (FCM / APNs)
|
## Push notification setup (FCM / APNs)
|
||||||
@@ -133,8 +135,11 @@ Declared in `toju-app/android/app/src/main/AndroidManifest.xml`:
|
|||||||
| `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) |
|
| `BLUETOOTH_CONNECT` | Bluetooth headset routing during calls (Android 12+) |
|
||||||
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
| `POST_NOTIFICATIONS` | Incoming/active call notifications |
|
||||||
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MICROPHONE` | Background voice session |
|
||||||
|
| `READ_EXTERNAL_STORAGE` (maxSdk 32) / `WRITE_EXTERNAL_STORAGE` (maxSdk 29) | Attachment export to public `Documents` on Android 10 and below |
|
||||||
|
|
||||||
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins.
|
Before WebRTC capture, the client calls `MobileMediaService.ensureVoiceCapturePermissions()` / `ensureCameraCapturePermissions()`, which delegate to `MetoyouMobile.requestVoiceCapturePermissions()` / `requestCameraCapturePermissions()` on Capacitor shells. If the native plugin is unavailable or the bridge call fails, capture preflight defers to the WebView `getUserMedia` permission flow instead of aborting voice/camera joins. Preflight only blocks capture on an explicit native `denied` state (`mobile-media-permission.rules.ts`); a `prompt` state is deferred to the WebView so the user still gets the permission dialog.
|
||||||
|
|
||||||
|
Join and capture failures are surfaced in the UI instead of failing silently: `DirectCallService.joinCall` sets a `joinError` signal (`call.errors.*` i18n keys for signaling, capture-unsupported, mic permission, and mic unavailable cases), and the private-call and voice-controls components surface camera errors the same way.
|
||||||
|
|
||||||
On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call.
|
On Capacitor startup, `MobileRuntimePermissionsService` (via `MobileAppLifecycleService.initialize()`) proactively prompts for microphone, camera, local-notification, and push-notification runtime permissions so Android 13+ shells do not keep every permission in the "Not allowed" state until the user joins voice or receives a call.
|
||||||
|
|
||||||
@@ -165,16 +170,19 @@ Tokens persist in server SQLite (`device_tokens` table). Outbound push uses repo
|
|||||||
| `APNS_BUNDLE_ID` | Defaults to `com.metoyou.app` |
|
| `APNS_BUNDLE_ID` | Defaults to `com.metoyou.app` |
|
||||||
| `APNS_USE_SANDBOX` | `true` for development builds |
|
| `APNS_USE_SANDBOX` | `true` for development builds |
|
||||||
|
|
||||||
Manual dispatch (ops/testing):
|
Manual dispatch (ops/testing). Requires `Authorization: Bearer`; `:userId` in the path **must match** the authenticated user (`403` otherwise):
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/users/device-tokens/:userId/dispatch
|
POST /api/users/device-tokens/:userId/dispatch
|
||||||
|
Authorization: Bearer <token>
|
||||||
{ "title": "Incoming call", "body": "Alice is calling" }
|
{ "title": "Incoming call", "body": "Alice is calling" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`POST /api/users/device-tokens` and `GET /api/users/device-tokens/:userId` apply the same rule: body/param `userId` must equal the bearer identity.
|
||||||
|
|
||||||
## Android foreground service
|
## Android foreground service
|
||||||
|
|
||||||
`VoiceCallForegroundService` starts when `MobileCallSessionService` begins an active call. Required manifest permissions:
|
`VoiceCallForegroundService` starts when `MobileCallSessionService` begins an active call. The voice-channel path also starts/stops it directly: `MediaManager.enableVoice()` / `disableVoice()` call `startMobileVoiceForegroundSession()` / `stopMobileVoiceForegroundSession()` (`infrastructure/mobile/logic/mobile-voice-foreground-session.ts`) so channel voice keeps the mic alive when the app backgrounds. Required manifest permissions:
|
||||||
|
|
||||||
- `FOREGROUND_SERVICE`
|
- `FOREGROUND_SERVICE`
|
||||||
- `FOREGROUND_SERVICE_MICROPHONE`
|
- `FOREGROUND_SERVICE_MICROPHONE`
|
||||||
@@ -193,10 +201,13 @@ The service shows a low-importance ongoing notification while a call is active.
|
|||||||
- Routing: `infrastructure/persistence/database-backend.rules.ts` — Capacitor uses SQLite, not IndexedDB.
|
- Routing: `infrastructure/persistence/database-backend.rules.ts` — Capacitor uses SQLite, not IndexedDB.
|
||||||
- Per-user database files: `metoyou__<userId>` via `mobile-sqlite-database-name.rules.ts`.
|
- Per-user database files: `metoyou__<userId>` via `mobile-sqlite-database-name.rules.ts`.
|
||||||
- First launch runs DDL migrations stored in the `meta` table. Schema init failures are cached per database file so the client does not retry in a loop.
|
- First launch runs DDL migrations stored in the `meta` table. Schema init failures are cached per database file so the client does not retry in a loop.
|
||||||
|
- **Custom emoji assets** persist in the `custom_emojis` table (`CapacitorDatabaseService.saveCustomEmoji` / `getCustomEmojis` / `deleteCustomEmoji`).
|
||||||
|
- **Message revisions** persist in `meta` under keys `message-revision:<messageId>:<revision>` (JSON payload). See [message-integrity.md](message-integrity.md) and [custom-emoji.md](custom-emoji.md).
|
||||||
|
|
||||||
## Capacitor plugin loading
|
## Capacitor plugin loading
|
||||||
|
|
||||||
- `infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts` uses **static** `@capacitor/*` imports and `Capacitor.isPluginAvailable()` before returning a plugin. Do not `import()` plugin modules dynamically or `await` plugin objects (Capacitor proxies expose a throwing `.then()` stub).
|
- `infrastructure/mobile/adapters/capacitor/capacitor-plugin-loader.ts` loads `@capacitor/*` modules via **dynamic `import()`** only when `isCapacitorNativeRuntime()` is true, and checks `Capacitor.isPluginAvailable()` before returning a plugin. Electron and browser shells never evaluate these imports at startup.
|
||||||
|
- Do not `await` a Capacitor plugin proxy object directly — Capacitor proxies expose a throwing `.then()` stub; always call methods on the resolved plugin instance.
|
||||||
- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `AppUpdate`, `LocalNotifications`, push, and SQLite.
|
- After adding or upgrading Capacitor plugins, run `npm run build:prod && npm run cap:sync` so Android/iOS native projects register `App`, `AppUpdate`, `LocalNotifications`, push, and SQLite.
|
||||||
|
|
||||||
## Safe area (Android)
|
## Safe area (Android)
|
||||||
@@ -267,10 +278,16 @@ Phase 3 delivered:
|
|||||||
3. iOS CallKit bridge (partial) via `MetoyouMobile` plugin and `MobileCallKitService`.
|
3. iOS CallKit bridge (partial) via `MetoyouMobile` plugin and `MobileCallKitService`.
|
||||||
4. Android Firebase Gradle wiring with `google-services.json.example` (real file gitignored).
|
4. Android Firebase Gradle wiring with `google-services.json.example` (real file gitignored).
|
||||||
5. Capacitor plugin availability checks to avoid hard failures when plugins are missing pre-sync.
|
5. Capacitor plugin availability checks to avoid hard failures when plugins are missing pre-sync.
|
||||||
6. Discovery endpoint skip for production signal hosts without featured/trending routes.
|
6. Discovery 404 fallback to public server listing on legacy signal hosts (see [server-discovery.md](server-discovery.md)).
|
||||||
|
|
||||||
Remaining work:
|
Remaining work:
|
||||||
|
|
||||||
- Wire CallKit answer/end actions back into `DirectCallService`.
|
- Wire CallKit answer/end actions back into `DirectCallService`.
|
||||||
- Migrate legacy IndexedDB mobile data into SQLite where needed.
|
- Migrate legacy IndexedDB mobile data into SQLite where needed.
|
||||||
- Deploy featured/trending routes to production signal servers or add capability negotiation in health checks.
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-13 | Chat notifications routed to LocalNotifications (`toju-messages` channel + `ic_stat_metoyou` status icon); capture preflight blocks only on native `denied`; call join/camera errors surfaced via `call.errors.*`; voice channels start the foreground service; screen share hidden on native mobile; attachment export to `Documents`; full-screen overlays use `metoyou-fixed-safe-viewport` |
|
||||||
|
| 2026-07-05 | Corrected discovery fallback (not host skip), plugin-loader dynamic imports, markdown fence; added Capacitor custom-emoji/revision persistence and dispatch auth rules |
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Client-only plugin runtime with server-stored **metadata** (install requirements, event definitions) and Electron-local **plugin data** persistence. Plugins extend chat slash commands, toolbar actions, DOM mounts, and a P2P message bus — they never execute on the signaling server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| Product client (`plugins` domain) | Manifest validation, load order, `PluginHostService`, UI registry, store installs |
|
||||||
|
| Electron | Local manifest discovery (`plugins/`, `plugin-bundles/`), `plugin_data` CQRS table, path jail |
|
||||||
|
| Signaling server | Requirement/event metadata REST + `plugin_event` WebSocket broadcast; **no** plugin code execution |
|
||||||
|
| P2P data channel | `plugin-message-bus` events (ignored by chat reducers) |
|
||||||
|
|
||||||
|
Server plugin **data** HTTP routes return **410 Gone** (`PLUGIN_DATA_DISABLED`).
|
||||||
|
|
||||||
|
## Server REST (`/api/servers/:serverId/plugins`)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/` | Public (metadata snapshot) |
|
||||||
|
| PUT | `/:pluginId/requirement` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/requirement` | Bearer |
|
||||||
|
| PUT | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| DELETE | `/:pluginId/events/:eventName` | Bearer |
|
||||||
|
| GET/PUT/DELETE | `/:pluginId/data/*` | 410 (disabled) |
|
||||||
|
|
||||||
|
## WebSocket
|
||||||
|
|
||||||
|
| type | Direction | Purpose |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| `plugin_requirements` | Server → client | Snapshot after `join_server` / `view_server` |
|
||||||
|
| `plugin_event` | Client → server → room | Validated broadcast of plugin events |
|
||||||
|
| `plugin_error` | Server → client | Validation failure |
|
||||||
|
|
||||||
|
See [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Manifest scopes
|
||||||
|
|
||||||
|
- `scope: "client"` — global desktop/browser plugins (Settings → Client plugins).
|
||||||
|
- `scope: "server"` — per chat-server plugins; join may block until user consents to required plugins.
|
||||||
|
|
||||||
|
Store source manifests support HTTPS `bundle`/`bundleUrl` with optional SHA-256 `integrity` verification before `import()`.
|
||||||
|
|
||||||
|
## Electron IPC / storage
|
||||||
|
|
||||||
|
- `list-local-plugin-manifests`, `get-local-plugins-path`, `grant-plugin-read-root`
|
||||||
|
- Plugin preferences and `api.clientData` / `api.serverData` → `plugin_data` table (user-scoped)
|
||||||
|
- Cached bundles: `plugin-bundles/<plugin-id>/<version>/main.js`
|
||||||
|
|
||||||
|
## Client API surface (summary)
|
||||||
|
|
||||||
|
Plugins receive `TojuClientPluginApi`: `commands`, `ui.mountElement`, `ui.registerToolbarAction`, `messageBus`, `messages.setTyping`, `context.getCurrent()`, `clientData`/`serverData` async storage.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — `plugin_event`, `plugin_requirements`
|
||||||
|
- [server-directory.md](server-directory.md) — server-scoped install on join
|
||||||
|
- [authentication.md](authentication.md) — bearer on metadata mutations
|
||||||
|
- Domain README: [`toju-app/src/app/domains/plugins/README.md`](../../toju-app/src/app/domains/plugins/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context plugin contract |
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Push Notifications
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mobile remote push (FCM/APNs) and server-side device token storage. Desktop uses local/Electron notifications via the `notifications` domain.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| Signaling server | `device_tokens` SQLite table; FCM/APNs dispatch |
|
||||||
|
| Capacitor client | Token registration via `MobilePushRegistrationService` |
|
||||||
|
| Server env | FCM service account + APNs key configuration |
|
||||||
|
|
||||||
|
## REST API (`/api/users/device-tokens`)
|
||||||
|
|
||||||
|
All routes require bearer; `userId` must match authenticated identity.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/` | Upsert `{ userId, platform: "android"\|"ios", token }` |
|
||||||
|
| GET | `/:userId` | List tokens for user |
|
||||||
|
| POST | `/:userId/dispatch` | Manual push `{ title, body, data? }` (ops/testing) |
|
||||||
|
|
||||||
|
## Server configuration
|
||||||
|
|
||||||
|
Repository-root `.env`:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `FCM_SERVICE_ACCOUNT_PATH` or `FCM_SERVICE_ACCOUNT_JSON` | Android FCM HTTP v1 |
|
||||||
|
| `APNS_KEY_PATH`, `APNS_KEY_ID`, `APNS_TEAM_ID` | iOS APNs HTTP/2 |
|
||||||
|
| `APNS_BUNDLE_ID` | Default `com.metoyou.app` |
|
||||||
|
| `APNS_USE_SANDBOX` | Development builds |
|
||||||
|
|
||||||
|
## Mobile client
|
||||||
|
|
||||||
|
- Optional Firebase: app starts without `google-services.json`; registration skipped when remote push not configured.
|
||||||
|
- See [mobile-capacitor.md](mobile-capacitor.md) for FCM/APNs setup, permissions, and in-call local notifications.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — bearer + userId match rules
|
||||||
|
- [mobile-capacitor.md](mobile-capacitor.md) — client registration and foreground service
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial push notification contract |
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Server Directory
|
||||||
|
|
||||||
|
> **Area:** server-directory
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Server directory is the cross-context contract for **which signaling servers exist**, how clients health-check and route to them, and how public/private chat-servers are created, joined, updated, moderated, and discovered over REST. It spans the signaling **server** (`server/src/routes/servers.ts`, CQRS handlers) and the product **client** (`server-directory` domain + `ServerDirectoryFacade`).
|
||||||
|
|
||||||
|
Curated browse lists (featured/trending) are documented separately in [server-discovery.md](server-discovery.md). WebSocket membership (`join_server`, presence) is in [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: persist public server records, memberships, channels, roles, bans, invites, join requests; expose REST CRUD and access checks.
|
||||||
|
- Client: maintain configured endpoint list, health/compatibility probes, canonical endpoint dedup by `serverInstanceId`, room `sourceId`/`sourceUrl` affinity, and HTTP orchestration for all server operations.
|
||||||
|
- It does NOT own: P2P chat transport, voice WebRTC, or local Electron room/message persistence (except mirroring server metadata into local DB after join).
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **ServerEndpoint:** configured signaling base URL with health status, latency, and version compatibility.
|
||||||
|
- **ServerInfo:** public server card shape returned by search/discovery/GET — includes `sourceId`, `sourceName`, `sourceUrl` filled by the client API layer.
|
||||||
|
- **Room signal affinity:** each saved room records which endpoint registered it; reconnect prefers that URL before fallback endpoints.
|
||||||
|
- **serverInstanceId:** stable id from `GET /api/health` used to collapse alias URLs to one canonical endpoint.
|
||||||
|
|
||||||
|
## Public REST (no bearer)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/health` | Liveness, `serverVersion`, `serverInstanceId`, optional `serverTag` |
|
||||||
|
| GET | `/api/servers` | Free-text search / public listing (`q`, `limit`) |
|
||||||
|
| GET | `/api/servers/featured` | Curated popular list — [server-discovery.md](server-discovery.md) |
|
||||||
|
| GET | `/api/servers/trending` | Curated active list — [server-discovery.md](server-discovery.md) |
|
||||||
|
| GET | `/api/servers/:id` | Single server metadata |
|
||||||
|
|
||||||
|
## Protected REST (bearer required)
|
||||||
|
|
||||||
|
All mutations derive the actor from the session token; body user ids are not trusted.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/api/servers` | Register a new public server |
|
||||||
|
| PUT | `/api/servers/:id` | Update name, description, channels, icon metadata, access settings |
|
||||||
|
| DELETE | `/api/servers/:id` | Unregister server (owner) |
|
||||||
|
| POST | `/api/servers/:id/join` | Join or request access (password, invite, public) |
|
||||||
|
| POST | `/api/servers/:id/leave` | Leave membership |
|
||||||
|
| POST | `/api/servers/:id/heartbeat` | Refresh `lastSeen` for trending ranking |
|
||||||
|
| POST | `/api/servers/:id/invites` | Create invite link — [invites-join-requests.md](invites-join-requests.md) |
|
||||||
|
| GET | `/api/servers/:id/requests` | List pending join requests (moderators) |
|
||||||
|
| POST | `/api/servers/:id/moderation/kick` | Remove member |
|
||||||
|
| POST | `/api/servers/:id/moderation/ban` | Ban member (optional expiry) |
|
||||||
|
| POST | `/api/servers/:id/moderation/unban` | Lift ban |
|
||||||
|
|
||||||
|
Join-request approval: `PUT /api/requests/:id` — [invites-join-requests.md](invites-join-requests.md).
|
||||||
|
|
||||||
|
Plugin metadata under `/api/servers/:serverId/plugins` — [plugins.md](plugins.md).
|
||||||
|
|
||||||
|
## Client endpoint lifecycle
|
||||||
|
|
||||||
|
1. Load endpoints from `localStorage` (`metoyou_server_endpoints`); reconcile with environment defaults.
|
||||||
|
2. `testAllServers()` probes `GET /api/health` (5 s timeout); on failure falls back to `GET /api/servers`.
|
||||||
|
3. Mark incompatible when `serverVersion` fails semantic compatibility check.
|
||||||
|
4. `resolveCanonicalEndpoint()` collapses aliases sharing the same `serverInstanceId`.
|
||||||
|
5. Cold-start room reconnect waits for the initial health sweep before opening WebSockets.
|
||||||
|
|
||||||
|
## Multi-endpoint behavior
|
||||||
|
|
||||||
|
| Operation | Fan-out |
|
||||||
|
|-----------|---------|
|
||||||
|
| Search (`searchServers` with `searchAllServers`) | All online endpoints, dedupe by server id |
|
||||||
|
| Discovery (featured/trending) | All online endpoints + 404→public list fallback |
|
||||||
|
| Room CRUD/join | Authoritative room `sourceUrl` first; temporary fallback to other compatible endpoints on outage |
|
||||||
|
|
||||||
|
Only `status === 'incompatible'` stops fallback with an update-required message. Network errors and Cloudflare 521/522 must continue to the next endpoint.
|
||||||
|
|
||||||
|
## Server-owned channel metadata
|
||||||
|
|
||||||
|
`PUT /api/servers/:id` persists the server's `channels` array (text + voice). The client round-trips channel create/rename/delete through this API — local-only channel state is not authoritative. Server-side normalisation deduplicates names within each channel type.
|
||||||
|
|
||||||
|
## WebSocket complement
|
||||||
|
|
||||||
|
After REST join, the client sends `join_server` on the room's signaling URL. Presence (`server_users`, `user_joined`, `user_left`) is room-scoped on the WebSocket — see [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-discovery.md](server-discovery.md) — featured/trending ranking and browse UI
|
||||||
|
- [authentication.md](authentication.md) — bearer tokens for mutations
|
||||||
|
- [invites-join-requests.md](invites-join-requests.md) — invite links and approval workflow
|
||||||
|
- [signal-server-tag.md](signal-server-tag.md) — `serverTag` on health + profile cards
|
||||||
|
- Product-client domain README: [`toju-app/src/app/domains/server-directory/README.md`](../../toju-app/src/app/domains/server-directory/README.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context server-directory REST contract |
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Area:** server-directory
|
> **Area:** server-directory
|
||||||
> **Status:** Active
|
> **Status:** Active
|
||||||
> **Last updated:** 2025-02-14
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -67,13 +67,24 @@ Both endpoints live in `server/src/routes/servers.ts` and **must be registered b
|
|||||||
## Client internals
|
## Client internals
|
||||||
|
|
||||||
- `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()` call the routes through a shared private `getDiscoveryServers(path)` helper and normalise into `ServerInfo[]`.
|
- `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()` call the routes through a shared private `getDiscoveryServers(path)` helper and normalise into `ServerInfo[]`.
|
||||||
|
- **Multi-endpoint fan-out:** discovery queries **every online endpoint** (`getSearchableEndpoints()` + `forkJoin`), deduplicated by server ID — mirroring free-text search. Querying only the active endpoint made the default `/servers` view appear empty when populated servers lived on other endpoints.
|
||||||
|
- **Legacy 404 fallback:** when `GET /api/servers/featured` or `/trending` returns **404** (older signal servers resolve those paths as `/servers/:id`), `fetchDiscoveryFromEndpoint` falls back per-endpoint to the public `GET /api/servers` listing (`fetchPublicServerListForDiscovery`) instead of returning `[]`. Verified in `server-directory-api.service.spec.ts` (including production hosts like `signal.toju.app`).
|
||||||
- `ServerDirectoryService` → `ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary.
|
- `ServerDirectoryService` → `ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary.
|
||||||
- `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`.
|
- `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`.
|
||||||
- `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`.
|
- `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`.
|
||||||
- The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. A **Create a server** button (`lucidePlus`, `data-testid="server-rail-create"`) sits below the saved-server icons and opens `app-create-server-dialog` (a Toju modal on desktop / bottom sheet on mobile) which dispatches `RoomsActions.createRoom` directly; the dashboard / `/create-server` route remains as an alternative entry point. Rail icons (`h-12 w-12`, `md:h-11 w-11`) animate their corner radius on hover and `:active` for a Discord-style squircle effect.
|
- The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. A **Create a server** button (`lucidePlus`, `data-testid="server-rail-create"`) sits below the saved-server icons and opens `app-create-server-dialog` (a Toju modal on desktop / bottom sheet on mobile) which dispatches `RoomsActions.createRoom` directly; the dashboard / `/create-server` route remains as an alternative entry point. Rail icons (`h-12 w-12`, `md:h-11 w-11`) animate their corner radius on hover and `:active` for a Discord-style squircle effect.
|
||||||
- On mobile (`ViewportService.isMobile()`), `DashboardComponent`, `FindPeopleComponent` (`/people`), and `FindServersComponent` (`/servers`) each mount their page body inside a single `<swiper-container>` slide next to `app-servers-rail` (rail `shrink-0`, content `flex-1` with a left border), mirroring the chat-room / DM-workspace mobile layout so the primary navigation rail stays reachable. The page body is shared between the desktop and mobile branches via an `<ng-template #pageContent>` + `[ngTemplateOutlet]`, and each component declares `schemas: [CUSTOM_ELEMENTS_SCHEMA]` for the Swiper custom elements.
|
- On mobile (`ViewportService.isMobile()`), discovery routes (`/dashboard`, `/people`, `/servers`) render their page body full-width via `<ng-template #pageContent>` + `[ngTemplateOutlet]`. The **servers rail is global** in `app.html` (`shouldShowMobileAppServersRail` in `core/platform/mobile-shell-layout.rules.ts`) — discovery pages must **not** embed a second `<app-servers-rail>` or Swiper stack. Chat-room and DM-workspace routes keep their own embedded rail inside Swiper and hide the global shell rail (see `toju-app/AGENTS.md`).
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Product-client domain README: `toju-app/src/app/domains/server-directory/README.md`
|
- Product-client domain README: `toju-app/src/app/domains/server-directory/README.md`
|
||||||
|
- Full server-directory REST contract (CRUD, join, moderation): [server-directory.md](server-directory.md)
|
||||||
- People discovery (`/people`): `toju-app/src/app/domains/direct-message/README.md`
|
- People discovery (`/people`): `toju-app/src/app/domains/direct-message/README.md`
|
||||||
|
- Mobile shell: [mobile-capacitor.md](mobile-capacitor.md)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
| ---------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 2026-07-05 | Added multi-endpoint fan-out and 404 fallback; corrected mobile shell layout (global rail, no per-page Swiper) |
|
||||||
|
| 2025-02-14 | Initial documentation |
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
# Signal Server Tag
|
# Signal Server Tag
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
Users registered on a signal server can show that server's display tag on their profile card (opened by clicking their name or avatar).
|
Users registered on a signal server can show that server's display tag on their profile card (opened by clicking their name or avatar).
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Server: expose a human-readable tag per **endpoint** (not per user identity).
|
||||||
|
- Client: resolve tag for a user's **home** signaling server (`homeSignalServerUrl`) and render on profile cards.
|
||||||
|
|
||||||
## Server configuration
|
## Server configuration
|
||||||
|
|
||||||
`server/data/variables.json` accepts an optional `serverTag` string. When omitted, the server falls back to its public URL built from `serverProtocol`, `serverHost`, and `serverPort`.
|
`server/data/variables.json` accepts an optional `serverTag` string. When omitted, the server falls back to its public URL built from `serverProtocol`, `serverHost`, and `serverPort` (`server/src/config/variables.ts`).
|
||||||
|
|
||||||
## Health API
|
## Health API
|
||||||
|
|
||||||
@@ -12,11 +20,27 @@ Users registered on a signal server can show that server's display tag on their
|
|||||||
|
|
||||||
## WebSocket presence
|
## WebSocket presence
|
||||||
|
|
||||||
The client sends `homeSignalServerUrl` in `identify` messages. The signaling server echoes that value in `server_users` and `user_joined` payloads so other clients can resolve the correct tag.
|
The client sends `homeSignalServerUrl` in `identify` messages. The signaling server echoes that value in `server_users` and `user_joined` payloads so other clients can resolve the correct tag. See [signaling.md](signaling.md).
|
||||||
|
|
||||||
## Client behavior
|
## Client behavior
|
||||||
|
|
||||||
- Login and registration store `homeSignalServerUrl` on the current user.
|
- Login and registration store `homeSignalServerUrl` on the current user.
|
||||||
- Profile cards show the resolved tag beside the username in muted text.
|
- Profile cards show the resolved tag beside the username in muted text (`profile-signal-server-tag.component`).
|
||||||
- Configured labels render as `#tag`; URL fallbacks render as a globe icon with the URL in a tooltip.
|
- Configured labels render as `#tag`; URL fallbacks render as a globe icon with the URL in a tooltip.
|
||||||
- Tag resolution prefers the endpoint's cached `serverTag` from health checks, then falls back to the stored home URL.
|
- Tag resolution (`signal-server-tag.rules.ts`): match `homeSignalServerUrl` against configured endpoints and prefer cached health `serverTag`; otherwise show the raw URL fallback.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `toju-app/src/app/domains/server-directory/domain/logic/signal-server-tag.rules.spec.ts`
|
||||||
|
- `server/src/websocket/handler-status.spec.ts` (presence payload includes `homeSignalServerUrl`)
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [server-directory.md](server-directory.md) — endpoint health cache
|
||||||
|
- [authentication.md](authentication.md) — `homeSignalServerUrl` on identify
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Clarified per-endpoint tag vs per-user home URL; added test references |
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Signaling (WebSocket)
|
||||||
|
|
||||||
|
> **Area:** realtime
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The signaling server exposes a single WebSocket per origin that carries identity, room membership, presence, WebRTC SDP/ICE relay, selected server-relayed chat/DM/voice fallbacks, plugin events, and multi-device `account_sync`. The product client implements the consumer in `toju-app/src/app/infrastructure/realtime/signaling/`.
|
||||||
|
|
||||||
|
**Canonical contract:** this document and [`server/src/websocket/handler.ts`](../../server/src/websocket/handler.ts). Do **not** treat `toju-app/src/app/shared-kernel/signaling-contracts.ts` as authoritative — it lists legacy types (`join`, `leave`, `chat`, `ice-candidate`) that do not match the live server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
- Authenticate connections via `identify` (session token).
|
||||||
|
- Track per-connection room membership and broadcast room-scoped presence.
|
||||||
|
- Relay WebRTC offers/answers/ICE between peers that share server membership (or DM/direct-call rules).
|
||||||
|
- Relay narrow server fallbacks when P2P data channels are unavailable (chat, DM, voice presence).
|
||||||
|
- Forward `account_sync` payloads to sibling connections for the same user identity.
|
||||||
|
- It does NOT own: P2P data-channel payloads (attachments, message inventory, custom emoji chunks, plugin message bus), local persistence, or REST server-directory APIs.
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
|
||||||
|
- **Envelope:** JSON object with required `type` string; additional fields vary by type.
|
||||||
|
- **oderId:** user identity on the wire (legacy spelling, matches server code).
|
||||||
|
- **clientInstanceId:** per-tab/device id stored in `sessionStorage`; multiple open connections per `oderId` are allowed.
|
||||||
|
- **connectionScope:** optional string grouping connections (e.g. browser profile).
|
||||||
|
- **voiceActive:** server marks the connection that owns outbound RTC relay for a user; updated from `voice_state` payloads.
|
||||||
|
|
||||||
|
## Ordering invariants
|
||||||
|
|
||||||
|
1. **`identify` before anything else** — unauthenticated connections receive `auth_required` for all types except `identify` and `keepalive`.
|
||||||
|
2. **Per-connection serialization** — `handleWebSocketMessage` chains handlers per `connectionId` so `join_server` cannot run while `identify` is still awaiting the token DB lookup.
|
||||||
|
3. **Client replay on reconnect** — `SignalingManager.reIdentifyAndRejoin` sends `identify` then re-joins rooms; see [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md).
|
||||||
|
|
||||||
|
## Connection lifecycle (server → client)
|
||||||
|
|
||||||
|
On connect the server assigns a `connectionId` and may emit:
|
||||||
|
|
||||||
|
| type | When |
|
||||||
|
|------|------|
|
||||||
|
| `connected` | Immediately after WebSocket open (`server/src/websocket/index.ts`) |
|
||||||
|
|
||||||
|
On disconnect, if the connection was voice-active, the server broadcasts a cleared `voice_state` via `finalizeVoiceDisconnectForConnection`.
|
||||||
|
|
||||||
|
## Inbound types (client → server)
|
||||||
|
|
||||||
|
| type | Auth | Behavior |
|
||||||
|
|------|------|----------|
|
||||||
|
| `keepalive` | Optional | Responds `keepalive_ack` with `serverTime` |
|
||||||
|
| `identify` | N/A (establishes auth) | Validates session token; sets `oderId`, profile fields; evicts stale same `(oderId, connectionScope, clientInstanceId)` sockets; emits `account_sync_peer_online` to siblings |
|
||||||
|
| `join_server` | Required | Access check; adds `serverId` to connection; sends `server_users` + `plugin_requirements`; may broadcast `user_joined` (identity-aware) |
|
||||||
|
| `view_server` | Required | Switches `viewedServerId`; refreshes `server_users` + `plugin_requirements` |
|
||||||
|
| `leave_server` | Required | Removes membership; may broadcast `user_left` with remaining `serverIds` |
|
||||||
|
| `offer`, `answer`, `ice_candidate` | Required | Relay to `targetUserId` when peers share server membership |
|
||||||
|
| `direct-message`, `direct-message-status`, `direct-message-mutation`, `direct-message-typing`, `direct-message-sync-request`, `direct-message-sync`, `direct-call` | Required | Relay to `targetUserId` (DM rules — no shared-server requirement) |
|
||||||
|
| `server_icon_peer_request`, `server_icon_peer_data` | Required | Relay when both users share `serverId` membership |
|
||||||
|
| `chat_message` | Required | Broadcast to server members (excludes sender connection) |
|
||||||
|
| `voice_state` | Required | Updates `voiceActive` / snapshot; broadcast to server members |
|
||||||
|
| `voice_client_takeover` | Required | Notifies sibling connections via `notifyOtherConnectionsForOderId` |
|
||||||
|
| `account_sync` | Required | Forwards `payload` object to other connections for same `oderId` |
|
||||||
|
| `typing` | Required | Broadcast `user_typing` to server members |
|
||||||
|
| `status_update` | Required | Broadcast `status_update` (`online` \| `away` \| `busy` \| `offline`) to joined servers |
|
||||||
|
| `server_icon_available` | Required | Records local `iconUpdatedAt` per server on the connection |
|
||||||
|
| `server_icon_sync_request` | Required | Responds `server_icon_sync_peers` with peers having newer icons |
|
||||||
|
| `plugin_event` | Required | Validates against server plugin metadata; broadcast or `plugin_error` |
|
||||||
|
|
||||||
|
Unknown inbound types are logged and ignored.
|
||||||
|
|
||||||
|
### `identify` request fields
|
||||||
|
|
||||||
|
| Field | Required | Notes |
|
||||||
|
|-------|----------|-------|
|
||||||
|
| `token` | Yes | Session token from REST login/register |
|
||||||
|
| `oderId` | No | If present, must match token user id |
|
||||||
|
| `displayName` | No | Defaults to existing or `"User"` |
|
||||||
|
| `description`, `profileUpdatedAt`, `homeSignalServerUrl` | No | Profile card fields |
|
||||||
|
| `clientInstanceId` | No | Per-tab id for multi-device and voice ownership |
|
||||||
|
| `connectionScope` | No | Eviction scope for stale sockets |
|
||||||
|
|
||||||
|
Errors: `auth_error` with `MISSING_TOKEN`, `INVALID_TOKEN`, or `USER_ID_MISMATCH`.
|
||||||
|
|
||||||
|
## Server-emitted types (server → client)
|
||||||
|
|
||||||
|
| type | Trigger |
|
||||||
|
|------|---------|
|
||||||
|
| `keepalive_ack` | Response to `keepalive` |
|
||||||
|
| `auth_required` | Message before `identify` |
|
||||||
|
| `auth_error` | Failed `identify` |
|
||||||
|
| `access_denied` | `join_server` rejected (`serverId`, `reason`) |
|
||||||
|
| `server_users` | After join/view; lists unique users in room |
|
||||||
|
| `user_joined` | New identity in server (excludes same identity's connections) |
|
||||||
|
| `user_left` | Identity fully left server (`serverIds` = remaining rooms) |
|
||||||
|
| `plugin_requirements` | Plugin install snapshot after join/view |
|
||||||
|
| `plugin_error` | Invalid plugin event |
|
||||||
|
| `server_icon_sync_peers` | Response to `server_icon_sync_request` |
|
||||||
|
| `account_sync_peer_online` | Sibling connection came online |
|
||||||
|
| `account_sync` | Relayed multi-device payload |
|
||||||
|
| `chat_message` | Relayed room chat fallback |
|
||||||
|
| `user_typing` | Typing indicator |
|
||||||
|
| `status_update` | Presence status change |
|
||||||
|
| `voice_state` | Voice roster / disconnect cleanup |
|
||||||
|
| `voice_client_takeover` | Another tab took voice ownership |
|
||||||
|
| `plugin_event` | Broadcast plugin event |
|
||||||
|
| Forwarded RTC/DM | Copies of client messages with `fromUserId` set |
|
||||||
|
|
||||||
|
## Relay rules
|
||||||
|
|
||||||
|
- **RTC (`offer` / `answer` / `ice_candidate`):** forwarded when sender and target share any server membership.
|
||||||
|
- **Direct signaling types:** forwarded to `targetUserId` without shared-server check.
|
||||||
|
- **Server icon P2P:** both users must be members of `message.serverId`.
|
||||||
|
- **Broadcasts** (`chat_message`, `voice_state`, `typing`, etc.): exclude sender **connection id** (not whole identity) so multi-device sessions still receive updates.
|
||||||
|
- **`user_joined` / `user_left`:** exclude whole **identity** so other users do not see duplicate join/leave for multiple tabs.
|
||||||
|
|
||||||
|
## P2P vs signaling split
|
||||||
|
|
||||||
|
| Transport | Carries |
|
||||||
|
|-----------|---------|
|
||||||
|
| **WebRTC data channel** | Chat events, attachments, custom emoji, message revisions/inventory, profile avatar bytes, voice/screen control, plugin message bus, game activity |
|
||||||
|
| **WebSocket signaling** | Identity, membership, presence, RTC SDP/ICE, chat/DM/voice fallbacks, `account_sync`, plugin events |
|
||||||
|
|
||||||
|
Server-relayed chat (`chat_message`) and DM types exist so users see written chat and delivery state while data channels are down. Media, attachments, and inventory sync remain peer-plane responsibilities.
|
||||||
|
|
||||||
|
## Multi-device (`account_sync`)
|
||||||
|
|
||||||
|
The client wraps relayable local changes in `account_sync` envelopes. The server forwards the inner `payload` to other open connections for the same `oderId`. When a device identifies, siblings receive `account_sync_peer_online` and push snapshots (saved servers, friends, emoji library, chat history batches, etc.). See [authentication.md](authentication.md) and domain-specific feature docs.
|
||||||
|
|
||||||
|
## Client implementation map
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
|---------|----------|
|
||||||
|
| One socket per signal URL | `signaling/signaling.manager.ts` |
|
||||||
|
| Route picker | `signaling/signaling-transport-handler.ts` |
|
||||||
|
| Room affinity | `signaling/server-signaling-coordinator.ts` |
|
||||||
|
| Inbound dispatch | `signaling/signaling-message-handler.ts` |
|
||||||
|
| Constants (intervals, types) | `realtime.constants.ts` |
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [authentication.md](authentication.md) — session tokens and `identify` trust boundary
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — DM envelope relay types
|
||||||
|
- [voice-webrtc.md](voice-webrtc.md) — RTC relay and `voice_state`
|
||||||
|
- [plugins.md](plugins.md) — `plugin_event` / `plugin_requirements`
|
||||||
|
- [message-integrity.md](message-integrity.md) — signed revisions (P2P; `account_sync` relay)
|
||||||
|
- Product client deep dive: [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md)
|
||||||
|
- Server handler: [`server/src/websocket/handler.ts`](../../server/src/websocket/handler.ts)
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial canonical envelope catalog; deprecates `shared-kernel/signaling-contracts.ts` as wire source |
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Voice & WebRTC
|
||||||
|
|
||||||
|
> **Status:** Active
|
||||||
|
> **Last updated:** 2026-07-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Voice channels, camera, and screen-share use direct **WebRTC** peer connections between clients. The signaling server relays SDP offers/answers/ICE and **voice presence** (`voice_state`); media never flows through the server.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
| Layer | Owns |
|
||||||
|
|-------|------|
|
||||||
|
| `infrastructure/realtime/` | WebRTC sessions, negotiation, data channels, RNNoise worklet |
|
||||||
|
| `voice-connection` / `voice-session` domains | Facades, workspace UI, settings, multi-device ownership |
|
||||||
|
| `screen-share` domain | Source picker, quality presets (Electron) |
|
||||||
|
| Signaling server | RTC relay + `voice_state` / `voice_client_takeover` broadcast |
|
||||||
|
|
||||||
|
## WebSocket signaling types
|
||||||
|
|
||||||
|
| type | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `offer`, `answer`, `ice_candidate` | WebRTC negotiation relay to `targetUserId` |
|
||||||
|
| `voice_state` | Voice roster (mute/deafen/speaking, channel id); sets `voiceActive` on one connection per user |
|
||||||
|
| `voice_client_takeover` | Notify sibling tabs to yield voice ownership |
|
||||||
|
|
||||||
|
Relay rules: RTC messages require shared server membership (except DM-specific types). See [signaling.md](signaling.md).
|
||||||
|
|
||||||
|
## Multi-device voice
|
||||||
|
|
||||||
|
- Only one connection per `oderId` may be `voiceActive`; RTC offers route to that connection (fallback: any open connection).
|
||||||
|
- Other tabs show passive UI and may send `voice_client_takeover`.
|
||||||
|
- `clientInstanceId` in `identify` and voice payloads distinguishes tabs.
|
||||||
|
|
||||||
|
## Media pipeline (client)
|
||||||
|
|
||||||
|
- **Voice:** `getUserMedia` → optional RNNoise AudioWorklet → gain → same-room peer routing only.
|
||||||
|
- **Camera:** separate video track; same-room filter.
|
||||||
|
- **Screen share:** on-demand via data-channel `SCREEN_SHARE_REQUEST`; platform-specific capture (browser `getDisplayMedia`, Electron picker, Linux PulseAudio routing).
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## Mobile / Capacitor
|
||||||
|
|
||||||
|
Background voice uses Android foreground service + iOS audio/CallKit bridges — [mobile-capacitor.md](mobile-capacitor.md). Screen share is limited on mobile WebViews.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [signaling.md](signaling.md) — envelope catalog
|
||||||
|
- [direct-messaging.md](direct-messaging.md) — private calls share `PeerDeliveryService`
|
||||||
|
- [`toju-app/src/app/infrastructure/realtime/README.md`](../../toju-app/src/app/infrastructure/realtime/README.md) — negotiation, recovery, RNNoise
|
||||||
|
- Domain READMEs: `voice-connection`, `voice-session`, `screen-share`
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-07-05 | Initial cross-context voice/WebRTC contract |
|
||||||
@@ -59,4 +59,11 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<!-- Attachment export to public Documents needs legacy storage permissions on Android 10 and below. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 241 B |
Binary file not shown.
|
After Width: | Height: | Size: 180 B |
Binary file not shown.
|
After Width: | Height: | Size: 319 B |
Binary file not shown.
|
After Width: | Height: | Size: 476 B |
Binary file not shown.
|
After Width: | Height: | Size: 633 B |
@@ -13,8 +13,8 @@ const config: CapacitorConfig = {
|
|||||||
style: 'DARK'
|
style: 'DARK'
|
||||||
},
|
},
|
||||||
LocalNotifications: {
|
LocalNotifications: {
|
||||||
smallIcon: 'ic_stat_icon_config_sample',
|
smallIcon: 'ic_stat_metoyou',
|
||||||
iconColor: '#488AFF',
|
iconColor: '#4A217A',
|
||||||
sound: 'call.wav'
|
sound: 'call.wav'
|
||||||
},
|
},
|
||||||
PushNotifications: {
|
PushNotifications: {
|
||||||
|
|||||||
@@ -43,7 +43,13 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noRecipient": "Direct message conversation has no recipient to call.",
|
"noRecipient": "Direct message conversation has no recipient to call.",
|
||||||
"noCurrentUser": "Cannot use calls without a current user."
|
"noCurrentUser": "Cannot use calls without a current user.",
|
||||||
|
"signalingUnavailable": "Not connected to the call server. Check your connection and try again.",
|
||||||
|
"captureUnsupported": "Voice capture is not available on this device.",
|
||||||
|
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
|
||||||
|
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
|
||||||
|
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
|
||||||
|
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"incomingCallsChannel": "Incoming calls",
|
"incomingCallsChannel": "Incoming calls",
|
||||||
"activeCallsChannel": "Active calls",
|
"activeCallsChannel": "Active calls",
|
||||||
|
"messagesChannel": "Messages",
|
||||||
"answer": "Answer",
|
"answer": "Answer",
|
||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
|
|||||||
@@ -127,7 +127,13 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"noRecipient": "Direct message conversation has no recipient to call.",
|
"noRecipient": "Direct message conversation has no recipient to call.",
|
||||||
"noCurrentUser": "Cannot use calls without a current user."
|
"noCurrentUser": "Cannot use calls without a current user.",
|
||||||
|
"signalingUnavailable": "Not connected to the call server. Check your connection and try again.",
|
||||||
|
"captureUnsupported": "Voice capture is not available on this device.",
|
||||||
|
"microphonePermissionDenied": "Microphone access is blocked. Allow the microphone permission in system settings to join calls.",
|
||||||
|
"microphoneUnavailable": "Could not start the microphone. Close other apps that use it and try again.",
|
||||||
|
"cameraPermissionDenied": "Camera access is blocked. Allow the camera permission in system settings to share video.",
|
||||||
|
"cameraUnavailable": "Could not start the camera. Close other apps that use it and try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
@@ -517,6 +523,7 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"incomingCallsChannel": "Incoming calls",
|
"incomingCallsChannel": "Incoming calls",
|
||||||
"activeCallsChannel": "Active calls",
|
"activeCallsChannel": "Active calls",
|
||||||
|
"messagesChannel": "Messages",
|
||||||
"answer": "Answer",
|
"answer": "Answer",
|
||||||
"decline": "Decline",
|
"decline": "Decline",
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
|
|||||||
@@ -29,21 +29,26 @@ infrastructure adapters and UI.
|
|||||||
|
|
||||||
The larger domains also keep longer design notes in their own folders:
|
The larger domains also keep longer design notes in their own folders:
|
||||||
|
|
||||||
- [attachment/README.md](attachment/README.md)
|
|
||||||
- [access-control/README.md](access-control/README.md)
|
- [access-control/README.md](access-control/README.md)
|
||||||
|
- [attachment/README.md](attachment/README.md)
|
||||||
- [authentication/README.md](authentication/README.md)
|
- [authentication/README.md](authentication/README.md)
|
||||||
- [chat/README.md](chat/README.md)
|
- [chat/README.md](chat/README.md)
|
||||||
|
- [custom-emoji/README.md](custom-emoji/README.md)
|
||||||
- [direct-message/README.md](direct-message/README.md)
|
- [direct-message/README.md](direct-message/README.md)
|
||||||
- [direct-call/README.md](direct-call/README.md)
|
- [direct-call/README.md](direct-call/README.md)
|
||||||
- [experimental-media/README.md](experimental-media/README.md)
|
- [experimental-media/README.md](experimental-media/README.md)
|
||||||
|
- [game-activity/README.md](game-activity/README.md)
|
||||||
- [notifications/README.md](notifications/README.md)
|
- [notifications/README.md](notifications/README.md)
|
||||||
- [plugins/README.md](plugins/README.md)
|
- [plugins/README.md](plugins/README.md)
|
||||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||||
- [screen-share/README.md](screen-share/README.md)
|
- [screen-share/README.md](screen-share/README.md)
|
||||||
- [server-directory/README.md](server-directory/README.md)
|
- [server-directory/README.md](server-directory/README.md)
|
||||||
|
- [theme/README.md](theme/README.md)
|
||||||
- [voice-connection/README.md](voice-connection/README.md)
|
- [voice-connection/README.md](voice-connection/README.md)
|
||||||
- [voice-session/README.md](voice-session/README.md)
|
- [voice-session/README.md](voice-session/README.md)
|
||||||
|
|
||||||
|
Cross-context wire contracts live in [`agents-docs/features/`](../../agents-docs/features/) — see [`agents-docs/FEATURES.md`](../../agents-docs/FEATURES.md).
|
||||||
|
|
||||||
## Folder convention
|
## Folder convention
|
||||||
|
|
||||||
Every domain follows the same internal layout:
|
Every domain follows the same internal layout:
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ attachment/
|
|||||||
│
|
│
|
||||||
├── infrastructure/
|
├── infrastructure/
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ └── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
│ │ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||||
|
│ │ └── capacitor-attachment-export.service.ts Capacitor "download": copy/write bytes into public Documents
|
||||||
│ └── util/
|
│ └── util/
|
||||||
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
||||||
│
|
│
|
||||||
|
|||||||
+33
@@ -9,8 +9,15 @@ import {
|
|||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { Injector, runInInjectionContext } from '@angular/core';
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
|
||||||
|
const isCapacitorNativeRuntimeMock = vi.fn(() => false);
|
||||||
|
|
||||||
|
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
|
||||||
|
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
|
||||||
|
}));
|
||||||
|
|
||||||
import { AttachmentDownloadService } from './attachment-download.service';
|
import { AttachmentDownloadService } from './attachment-download.service';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
|
||||||
import type { Attachment } from '../../domain/models/attachment.model';
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
|
||||||
describe('AttachmentDownloadService', () => {
|
describe('AttachmentDownloadService', () => {
|
||||||
@@ -21,8 +28,11 @@ describe('AttachmentDownloadService', () => {
|
|||||||
let documentStub: Document;
|
let documentStub: Document;
|
||||||
let saveExistingFileAs: ReturnType<typeof vi.fn>;
|
let saveExistingFileAs: ReturnType<typeof vi.fn>;
|
||||||
let saveFileAs: ReturnType<typeof vi.fn>;
|
let saveFileAs: ReturnType<typeof vi.fn>;
|
||||||
|
let exportToDevice: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
isCapacitorNativeRuntimeMock.mockReturnValue(false);
|
||||||
|
exportToDevice = vi.fn(async () => true);
|
||||||
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
saveExistingFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||||
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
saveFileAs = vi.fn(async () => ({ saved: true, cancelled: false }));
|
||||||
|
|
||||||
@@ -53,6 +63,7 @@ describe('AttachmentDownloadService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
AttachmentDownloadService,
|
AttachmentDownloadService,
|
||||||
{ provide: ElectronBridgeService, useValue: electronBridge },
|
{ provide: ElectronBridgeService, useValue: electronBridge },
|
||||||
|
{ provide: CapacitorAttachmentExportService, useValue: { exportToDevice } },
|
||||||
{ provide: DOCUMENT, useValue: documentStub }
|
{ provide: DOCUMENT, useValue: documentStub }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -78,6 +89,28 @@ describe('AttachmentDownloadService', () => {
|
|||||||
expect(saveFileAs).not.toHaveBeenCalled();
|
expect(saveFileAs).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delegates to the Capacitor export service on a native mobile shell', async () => {
|
||||||
|
isCapacitorNativeRuntimeMock.mockReturnValue(true);
|
||||||
|
electronBridge.getApi = vi.fn(() => null);
|
||||||
|
|
||||||
|
const service = createService();
|
||||||
|
const attachment: Attachment = {
|
||||||
|
id: 'file-3',
|
||||||
|
messageId: 'message-3',
|
||||||
|
filename: 'photo.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
size: 2048,
|
||||||
|
available: true,
|
||||||
|
savedPath: 'metoyou/server/room/files/photo.png'
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.downloadToUserLocation(attachment)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(exportToDevice).toHaveBeenCalledWith(attachment);
|
||||||
|
expect(saveExistingFileAs).not.toHaveBeenCalled();
|
||||||
|
expect(saveFileAs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does nothing when the attachment is not downloadable yet', async () => {
|
it('does nothing when the attachment is not downloadable yet', async () => {
|
||||||
const service = createService();
|
const service = createService();
|
||||||
const attachment: Attachment = {
|
const attachment: Attachment = {
|
||||||
|
|||||||
+7
@@ -2,12 +2,15 @@ import { DOCUMENT } from '@angular/common';
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
|
||||||
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
import { canDownloadAttachment, resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||||
import type { Attachment } from '../../domain/models/attachment.model';
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
import { CapacitorAttachmentExportService } from '../../infrastructure/services/capacitor-attachment-export.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AttachmentDownloadService {
|
export class AttachmentDownloadService {
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
private readonly capacitorExport = inject(CapacitorAttachmentExportService);
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
|
async downloadToUserLocation(attachment: Attachment): Promise<boolean> {
|
||||||
@@ -15,6 +18,10 @@ export class AttachmentDownloadService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCapacitorNativeRuntime()) {
|
||||||
|
return this.capacitorExport.exportToDevice(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
const electronApi = this.electronBridge.getApi();
|
const electronApi = this.electronBridge.getApi();
|
||||||
const diskPath = resolveAttachmentDiskPath(attachment);
|
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { buildAttachmentExportFileName } from './attachment-export.rules';
|
||||||
|
|
||||||
|
describe('buildAttachmentExportFileName', () => {
|
||||||
|
it('appends the timestamp before the extension so exports never collide', () => {
|
||||||
|
expect(buildAttachmentExportFileName('report.pdf', 1_720_900_000_000)).toBe('report-1720900000000.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the timestamp at the end when there is no extension', () => {
|
||||||
|
expect(buildAttachmentExportFileName('README', 42)).toBe('README-42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps dotfiles intact instead of treating the leading dot as an extension', () => {
|
||||||
|
expect(buildAttachmentExportFileName('.env', 42)).toBe('.env-42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips directory components from the filename', () => {
|
||||||
|
expect(buildAttachmentExportFileName('../secret/../../etc/passwd.txt', 7)).toBe('passwd-7.txt');
|
||||||
|
expect(buildAttachmentExportFileName('folder\\file.bin', 7)).toBe('file-7.bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a generic name when the filename is empty after sanitising', () => {
|
||||||
|
expect(buildAttachmentExportFileName(' ', 7)).toBe('attachment-7');
|
||||||
|
expect(buildAttachmentExportFileName('a/b/', 7)).toBe('attachment-7');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const FALLBACK_EXPORT_BASE_NAME = 'attachment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the file name used when exporting an attachment to a user-visible
|
||||||
|
* directory (e.g. Android `Documents`). The timestamp is appended before the
|
||||||
|
* extension so repeated exports of the same file never collide - public
|
||||||
|
* directories on Android 11+ reject overwrites of files the app did not create.
|
||||||
|
*/
|
||||||
|
export function buildAttachmentExportFileName(filename: string, timestamp: number): string {
|
||||||
|
const baseName = stripDirectoryComponents(filename);
|
||||||
|
const dotIndex = baseName.lastIndexOf('.');
|
||||||
|
const hasExtension = dotIndex > 0;
|
||||||
|
const stem = hasExtension ? baseName.slice(0, dotIndex) : baseName;
|
||||||
|
const extension = hasExtension ? baseName.slice(dotIndex) : '';
|
||||||
|
|
||||||
|
return `${stem || FALLBACK_EXPORT_BASE_NAME}-${timestamp}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripDirectoryComponents(filename: string): string {
|
||||||
|
const segments = filename.split(/[/\\]/);
|
||||||
|
|
||||||
|
return segments[segments.length - 1]?.trim() ?? '';
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
const isCapacitorNativeRuntimeMock = vi.fn(() => true);
|
||||||
|
const loadFilesystemMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../../infrastructure/mobile/logic/platform-detection.rules', () => ({
|
||||||
|
isCapacitorNativeRuntime: () => isCapacitorNativeRuntimeMock()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./capacitor-attachment-filesystem.adapter', () => ({
|
||||||
|
loadCapacitorAttachmentFilesystem: () => loadFilesystemMock()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CapacitorAttachmentExportService } from './capacitor-attachment-export.service';
|
||||||
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
|
||||||
|
function createFakeAdapter() {
|
||||||
|
return {
|
||||||
|
filesystem: {
|
||||||
|
copy: vi.fn(async () => undefined),
|
||||||
|
writeFile: vi.fn(async () => ({ uri: 'file:///docs/out' }))
|
||||||
|
},
|
||||||
|
directory: 'DATA',
|
||||||
|
exportDirectory: 'DOCUMENTS',
|
||||||
|
convertFileSrc: (url: string) => url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAttachment(overrides: Partial<Attachment>): Attachment {
|
||||||
|
return {
|
||||||
|
id: 'file-1',
|
||||||
|
messageId: 'message-1',
|
||||||
|
filename: 'photo.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
size: 1024,
|
||||||
|
available: true,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CapacitorAttachmentExportService', () => {
|
||||||
|
let service: CapacitorAttachmentExportService;
|
||||||
|
let fakeAdapter: ReturnType<typeof createFakeAdapter>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(1_720_900_000_000);
|
||||||
|
isCapacitorNativeRuntimeMock.mockReturnValue(true);
|
||||||
|
fakeAdapter = createFakeAdapter();
|
||||||
|
loadFilesystemMock.mockResolvedValue(fakeAdapter);
|
||||||
|
service = new CapacitorAttachmentExportService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a disk-backed attachment from app data into the export directory', async () => {
|
||||||
|
const attachment = makeAttachment({ savedPath: 'metoyou/server/room/files/photo.png' });
|
||||||
|
|
||||||
|
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fakeAdapter.filesystem.copy).toHaveBeenCalledWith({
|
||||||
|
from: 'metoyou/server/room/files/photo.png',
|
||||||
|
directory: 'DATA',
|
||||||
|
to: 'photo-1720900000000.png',
|
||||||
|
toDirectory: 'DOCUMENTS'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeAdapter.filesystem.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes an in-memory attachment fetched from its object URL into the export directory', async () => {
|
||||||
|
const bytes = new TextEncoder().encode('hello');
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes)));
|
||||||
|
|
||||||
|
const attachment = makeAttachment({ objectUrl: 'blob:https://app/abc' });
|
||||||
|
|
||||||
|
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalledWith({
|
||||||
|
path: 'photo-1720900000000.png',
|
||||||
|
data: btoa('hello'),
|
||||||
|
directory: 'DOCUMENTS',
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the object URL when copying from disk fails', async () => {
|
||||||
|
fakeAdapter.filesystem.copy.mockRejectedValue(new Error('copy failed'));
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response(new TextEncoder().encode('x'))));
|
||||||
|
|
||||||
|
const attachment = makeAttachment({
|
||||||
|
savedPath: 'metoyou/server/room/files/photo.png',
|
||||||
|
objectUrl: 'capacitor://localhost/_capacitor_file_/photo.png'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.exportToDevice(attachment)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fakeAdapter.filesystem.writeFile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false off a native shell', async () => {
|
||||||
|
isCapacitorNativeRuntimeMock.mockReturnValue(false);
|
||||||
|
|
||||||
|
await expect(service.exportToDevice(makeAttachment({ savedPath: 'x' }))).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when there is neither a disk path nor an object URL', async () => {
|
||||||
|
await expect(service.exportToDevice(makeAttachment({}))).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { isCapacitorNativeRuntime } from '../../../../infrastructure/mobile/logic/platform-detection.rules';
|
||||||
|
import { encodeUint8ArrayToBase64 } from '../../domain/logic/attachment-blob.rules';
|
||||||
|
import { resolveAttachmentDiskPath } from '../../domain/logic/attachment-download.rules';
|
||||||
|
import { buildAttachmentExportFileName } from '../../domain/logic/attachment-export.rules';
|
||||||
|
import type { Attachment } from '../../domain/models/attachment.model';
|
||||||
|
import { loadCapacitorAttachmentFilesystem } from './capacitor-attachment-filesystem.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports attachments out of the app-private data directory into the device's
|
||||||
|
* user-visible `Documents` directory on Capacitor. Anchor-based `download`
|
||||||
|
* links do nothing in the Android WebView, so "download" on mobile means
|
||||||
|
* copying the bytes somewhere the user can reach through the Files app.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class CapacitorAttachmentExportService {
|
||||||
|
async exportToDevice(attachment: Attachment): Promise<boolean> {
|
||||||
|
if (!isCapacitorNativeRuntime()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesystem = await loadCapacitorAttachmentFilesystem();
|
||||||
|
|
||||||
|
if (!filesystem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportPath = buildAttachmentExportFileName(attachment.filename, Date.now());
|
||||||
|
const diskPath = resolveAttachmentDiskPath(attachment);
|
||||||
|
|
||||||
|
if (diskPath) {
|
||||||
|
try {
|
||||||
|
await filesystem.filesystem.copy({
|
||||||
|
from: diskPath,
|
||||||
|
directory: filesystem.directory,
|
||||||
|
to: exportPath,
|
||||||
|
toDirectory: filesystem.exportDirectory
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* fall back to the object URL below */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attachment.objectUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = await this.fetchAsBase64(attachment.objectUrl);
|
||||||
|
|
||||||
|
if (base64 === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await filesystem.filesystem.writeFile({
|
||||||
|
path: exportPath,
|
||||||
|
data: base64,
|
||||||
|
directory: filesystem.exportDirectory,
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAsBase64(objectUrl: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(objectUrl);
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
return encodeUint8ArrayToBase64(new Uint8Array(buffer));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -82,6 +82,7 @@ function createFakeFilesystem() {
|
|||||||
adapter: {
|
adapter: {
|
||||||
filesystem,
|
filesystem,
|
||||||
directory: 'DATA',
|
directory: 'DATA',
|
||||||
|
exportDirectory: 'DOCUMENTS',
|
||||||
convertFileSrc: (url: string) => url.replace('file://', 'capacitor://localhost/_capacitor_file_')
|
convertFileSrc: (url: string) => url.replace('file://', 'capacitor://localhost/_capacitor_file_')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+3
@@ -10,6 +10,8 @@ type CapacitorCoreModule = typeof import('@capacitor/core');
|
|||||||
export interface CapacitorAttachmentFilesystem {
|
export interface CapacitorAttachmentFilesystem {
|
||||||
filesystem: CapacitorFilesystemModule['Filesystem'];
|
filesystem: CapacitorFilesystemModule['Filesystem'];
|
||||||
directory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
|
directory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
|
||||||
|
/** User-visible directory (`Documents`) used when exporting attachments out of app storage. */
|
||||||
|
exportDirectory: CapacitorFilesystemModule['Directory'][keyof CapacitorFilesystemModule['Directory']];
|
||||||
convertFileSrc: (url: string) => string;
|
convertFileSrc: (url: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ async function resolveCapacitorAttachmentFilesystem(): Promise<CapacitorAttachme
|
|||||||
return {
|
return {
|
||||||
filesystem: filesystemModule.Filesystem,
|
filesystem: filesystemModule.Filesystem,
|
||||||
directory: filesystemModule.Directory.Data,
|
directory: filesystemModule.Directory.Data,
|
||||||
|
exportDirectory: filesystemModule.Directory.Documents,
|
||||||
convertFileSrc: (url: string) => coreModule.Capacitor.convertFileSrc(url)
|
convertFileSrc: (url: string) => coreModule.Capacitor.convertFileSrc(url)
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="h-full grid place-items-center bg-background">
|
<div class="h-full grid place-items-center overflow-y-auto bg-background p-4">
|
||||||
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
<div class="w-full max-w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideLogIn"
|
name="lucideLogIn"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="h-full grid place-items-center bg-background">
|
<div class="h-full grid place-items-center overflow-y-auto bg-background p-4">
|
||||||
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
<div class="w-full max-w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideUserPlus"
|
name="lucideUserPlus"
|
||||||
|
|||||||
@@ -178,3 +178,11 @@ Opening a conversation must land on the newest message even though images, link/
|
|||||||
## Typing indicator
|
## Typing indicator
|
||||||
|
|
||||||
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each positive event resets a 3-second TTL timer for that channel; an explicit `isTyping: false` event clears that user immediately. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
|
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each positive event resets a 3-second TTL timer for that channel; an explicit `isTyping: false` event clears that user immediately. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
|
||||||
|
|
||||||
|
## Cross-context feature docs
|
||||||
|
|
||||||
|
- [`agents-docs/features/messaging.md`](../../../../../agents-docs/features/messaging.md) — server chat + DM transports, sync, delivery states
|
||||||
|
- [`agents-docs/features/message-integrity.md`](../../../../../agents-docs/features/message-integrity.md) — signed revisions
|
||||||
|
- [`agents-docs/features/custom-emoji.md`](../../../../../agents-docs/features/custom-emoji.md)
|
||||||
|
- [`agents-docs/features/klipy-gifs.md`](../../../../../agents-docs/features/klipy-gifs.md)
|
||||||
|
- [`agents-docs/features/link-preview-media-proxy.md`](../../../../../agents-docs/features/link-preview-media-proxy.md)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
style="-webkit-app-region: no-drag"
|
style="-webkit-app-region: no-drag"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[90]">
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatGifPickerSurface"
|
appThemeNode="chatGifPickerSurface"
|
||||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
[ariaLabel]="'chat.overlays.closeGalleryAria' | translate"
|
[ariaLabel]="'chat.overlays.closeGalleryAria' | translate"
|
||||||
(dismissed)="closeGallery()"
|
(dismissed)="closeGallery()"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none fixed inset-0 z-[101] flex items-center justify-center p-4">
|
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[101] flex items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
[ariaLabel]="'chat.overlays.closePreviewAria' | translate"
|
[ariaLabel]="'chat.overlays.closePreviewAria' | translate"
|
||||||
(dismissed)="closeLightbox()"
|
(dismissed)="closeLightbox()"
|
||||||
/>
|
/>
|
||||||
<div class="pointer-events-none fixed inset-0 z-[110] flex items-center justify-center p-4">
|
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[110] flex items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
class="lightbox-stage pointer-events-auto relative max-h-[90vh] max-w-[90vw]"
|
class="lightbox-stage pointer-events-auto relative max-h-[90vh] max-w-[90vw]"
|
||||||
[class.lightbox-chrome-hidden]="!lightboxControlsVisible()"
|
[class.lightbox-chrome-hidden]="!lightboxControlsVisible()"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Custom Emoji Domain
|
||||||
|
|
||||||
|
User-created image emoji: validation, local asset storage, saved-library membership, P2P sync, and the shared picker consumed by chat reactions and the composer.
|
||||||
|
|
||||||
|
**Wire contract (P2P envelopes, `account_sync` relay):** [`agents-docs/features/custom-emoji.md`](../../../../../agents-docs/features/custom-emoji.md)
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
```
|
||||||
|
custom-emoji/
|
||||||
|
├── domain/
|
||||||
|
│ ├── custom-emoji.rules.ts Validation, tokens, chunk limits, shortcut selection
|
||||||
|
│ └── custom-emoji.rules.spec.ts
|
||||||
|
├── application/
|
||||||
|
│ ├── custom-emoji.service.ts Upload, library, known-asset cache, proactive push
|
||||||
|
│ ├── custom-emoji.service.spec.ts
|
||||||
|
│ └── custom-emoji-sync.effects.ts NgRx effects: data-channel + account_sync handling
|
||||||
|
├── feature/
|
||||||
|
│ └── custom-emoji-picker/ Picker UI, search, shortcut row
|
||||||
|
└── index.ts Barrel — `CustomEmojiService`, picker component, effects
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public entry points
|
||||||
|
|
||||||
|
| Export | Role |
|
||||||
|
|--------|------|
|
||||||
|
| `CustomEmojiService` | Upload, save/remove library, resolve tokens, peer summaries |
|
||||||
|
| `CustomEmojiPickerComponent` | Emoji selector for composer and reactions |
|
||||||
|
| `CustomEmojiSyncEffects` | Registers with NgRx for inbound sync events |
|
||||||
|
|
||||||
|
## NgRx / realtime touchpoints
|
||||||
|
|
||||||
|
- `CustomEmojiSyncEffects` listens for P2P `ChatEvent` types and `account_sync` payloads relayed from [`signaling`](../../../../../agents-docs/features/signaling.md).
|
||||||
|
- Chat domain calls `CustomEmojiService` when sending messages/reactions to push referenced assets to peers.
|
||||||
|
|
||||||
|
## UI integration
|
||||||
|
|
||||||
|
- **Chat composer** — `:name:` aliases rewrite to `:emoji[id](name)` on send; shortcut row shows top seven saved emoji.
|
||||||
|
- **Message reactions** — custom emoji reactions use the same token format.
|
||||||
|
- **Context menu** — add/remove from library on rendered custom emoji (`data-custom-emoji` attributes).
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Does not own chat message persistence or signaling server storage.
|
||||||
|
- Usage ranking is local per user id; not synced across devices.
|
||||||
|
- Import from `domains/custom-emoji` barrel only; chat imports the service, not internal paths.
|
||||||
+92
-4
@@ -477,6 +477,56 @@ describe('DirectCallService', () => {
|
|||||||
expect(context.voiceSession.endSession).toHaveBeenCalled();
|
expect(context.voiceSession.endSession).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces a join error when the microphone permission is denied instead of failing silently', async () => {
|
||||||
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||||
|
const session = createSession('connected', false);
|
||||||
|
|
||||||
|
session.participants.bob.joined = true;
|
||||||
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||||
|
|
||||||
|
context.mobileMedia.ensureVoiceCapturePermissions.mockResolvedValue(false);
|
||||||
|
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
|
||||||
|
await context.service.joinCall(session.callId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.service.joinError()).not.toBeNull();
|
||||||
|
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
|
||||||
|
expect(context.service.sessionById(session.callId)?.participants.alice.joined).not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a join error when getUserMedia rejects', async () => {
|
||||||
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||||
|
const session = createSession('connected', false);
|
||||||
|
|
||||||
|
session.participants.bob.joined = true;
|
||||||
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||||
|
|
||||||
|
await withStubbedGetUserMedia(vi.fn(async () => {
|
||||||
|
throw new Error('NotReadableError');
|
||||||
|
}), async () => {
|
||||||
|
await context.service.joinCall(session.callId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.service.joinError()).not.toBeNull();
|
||||||
|
expect(context.voice.setLocalStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the join error on a successful join', async () => {
|
||||||
|
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
|
||||||
|
const session = createSession('connected', false);
|
||||||
|
|
||||||
|
session.participants.bob.joined = true;
|
||||||
|
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
|
||||||
|
|
||||||
|
await withStubbedGetUserMedia(vi.fn(async () => new FakeMediaStream()), async () => {
|
||||||
|
await context.service.joinCall(session.callId);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.service.joinError()).toBeNull();
|
||||||
|
expect(context.voice.setLocalStream).toHaveBeenCalled();
|
||||||
|
expect(context.service.sessionById(session.callId)?.participants.alice.joined).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
|
||||||
const context = createServiceContext({ currentUser: alice, allUsers: [
|
const context = createServiceContext({ currentUser: alice, allUsers: [
|
||||||
alice,
|
alice,
|
||||||
@@ -643,6 +693,10 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
const voiceSession = {
|
const voiceSession = {
|
||||||
endSession: vi.fn()
|
endSession: vi.fn()
|
||||||
};
|
};
|
||||||
|
const mobileMedia = {
|
||||||
|
ensureVoiceCapturePermissions: vi.fn(async () => true),
|
||||||
|
setSpeakerphoneEnabled: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
const credentialStore = {
|
const credentialStore = {
|
||||||
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
|
listValidCredentials: vi.fn(() => (options.selfActorIds ?? []).map((userId) => ({
|
||||||
serverUrl: `https://signal.example/${userId}`,
|
serverUrl: `https://signal.example/${userId}`,
|
||||||
@@ -732,10 +786,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MobileMediaService,
|
provide: MobileMediaService,
|
||||||
useValue: {
|
useValue: mobileMedia
|
||||||
ensureVoiceCapturePermissions: vi.fn(async () => true),
|
|
||||||
setSpeakerphoneEnabled: vi.fn(async () => undefined)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: RealtimeSessionFacade,
|
provide: RealtimeSessionFacade,
|
||||||
@@ -761,6 +812,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
directCallEvents,
|
directCallEvents,
|
||||||
directMessages,
|
directMessages,
|
||||||
effectScheduler,
|
effectScheduler,
|
||||||
|
mobileMedia,
|
||||||
router,
|
router,
|
||||||
service: runInInjectionContext(injector, () => new DirectCallService()),
|
service: runInInjectionContext(injector, () => new DirectCallService()),
|
||||||
voice,
|
voice,
|
||||||
@@ -768,6 +820,42 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeMediaStream {
|
||||||
|
getTracks(): unknown[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Temporarily provide `navigator.mediaDevices.getUserMedia` for the join path under Node. */
|
||||||
|
async function withStubbedGetUserMedia(
|
||||||
|
getUserMedia: () => Promise<unknown>,
|
||||||
|
run: () => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
const globalWithNavigator = globalThis as { navigator?: { mediaDevices?: unknown } };
|
||||||
|
const originalNavigator = globalWithNavigator.navigator;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
...(originalNavigator ?? {}),
|
||||||
|
mediaDevices: { getUserMedia }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} finally {
|
||||||
|
if (originalNavigator === undefined) {
|
||||||
|
delete globalWithNavigator.navigator;
|
||||||
|
} else {
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalNavigator
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
|
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
|
||||||
return {
|
return {
|
||||||
type: 'direct-call',
|
type: 'direct-call',
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export class DirectCallService {
|
|||||||
&& this.hasConnectedParticipant(session)) ?? null;
|
&& this.hasConnectedParticipant(session)) ?? null;
|
||||||
});
|
});
|
||||||
readonly currentSession = signal<DirectCallSession | null>(null);
|
readonly currentSession = signal<DirectCallSession | null>(null);
|
||||||
|
/** User-facing reason the last joinCall attempt failed; null after a successful join. */
|
||||||
|
readonly joinError = signal<string | null>(null);
|
||||||
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
|
||||||
readonly mobileOverlaySession = computed(() => {
|
readonly mobileOverlaySession = computed(() => {
|
||||||
const callId = this.mobileOverlayCallId();
|
const callId = this.mobileOverlayCallId();
|
||||||
@@ -333,6 +335,7 @@ export class DirectCallService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.joinError.set(null);
|
||||||
this.leaveOtherJoinedCalls(callId);
|
this.leaveOtherJoinedCalls(callId);
|
||||||
this.leaveCurrentVoiceTargetForCall(callId);
|
this.leaveCurrentVoiceTargetForCall(callId);
|
||||||
this.audio.stop(AppSound.Call);
|
this.audio.stop(AppSound.Call);
|
||||||
@@ -344,22 +347,36 @@ export class DirectCallService {
|
|||||||
|
|
||||||
const ok = await this.voice.ensureSignalingConnected();
|
const ok = await this.voice.ensureSignalingConnected();
|
||||||
|
|
||||||
if (!ok || !navigator.mediaDevices?.getUserMedia) {
|
if (!ok) {
|
||||||
|
this.joinError.set(this.i18n.instant('call.errors.signalingUnavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
this.joinError.set(this.i18n.instant('call.errors.captureUnsupported'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
|
const voicePermissionsGranted = await this.mobileMedia.ensureVoiceCapturePermissions();
|
||||||
|
|
||||||
if (!voicePermissionsGranted) {
|
if (!voicePermissionsGranted) {
|
||||||
|
this.joinError.set(this.i18n.instant('call.errors.microphonePermissionDenied'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
let stream: MediaStream;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
echoCancellation: true,
|
echoCancellation: true,
|
||||||
noiseSuppression: false
|
noiseSuppression: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
this.joinError.set(this.i18n.instant('call.errors.microphoneUnavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.voice.setLocalStream(stream);
|
await this.voice.setLocalStream(stream);
|
||||||
this.voiceActivity.trackLocalMic(meId, stream);
|
this.voiceActivity.trackLocalMic(meId, stream);
|
||||||
|
|||||||
+2
-2
@@ -4,9 +4,9 @@
|
|||||||
[dismissable]="false"
|
[dismissable]="false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
|
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[121] flex items-center justify-center p-4">
|
||||||
<section
|
<section
|
||||||
class="pointer-events-auto w-full max-w-sm rounded-lg border border-border bg-card shadow-2xl"
|
class="pointer-events-auto max-h-full w-full max-w-sm overflow-y-auto rounded-lg border border-border bg-card shadow-2xl"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="incoming-call-title"
|
aria-labelledby="incoming-call-title"
|
||||||
|
|||||||
@@ -61,3 +61,9 @@ Conversation participants keep avatar/profile metadata captured from user cards
|
|||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
Repositories are user-scoped and stored locally under `metoyou_direct_message_*` keys. The storage is intentionally domain-owned so browser and Electron runtimes share the same renderer API without changing the existing chat-message database tables.
|
Repositories are user-scoped and stored locally under `metoyou_direct_message_*` keys. The storage is intentionally domain-owned so browser and Electron runtimes share the same renderer API without changing the existing chat-message database tables.
|
||||||
|
|
||||||
|
## Cross-context feature docs
|
||||||
|
|
||||||
|
- [`agents-docs/features/messaging.md`](../../../../../agents-docs/features/messaging.md) — DM delivery states, sync, transports
|
||||||
|
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — DM WebSocket relay
|
||||||
|
- [`agents-docs/features/voice-webrtc.md`](../../../../../agents-docs/features/voice-webrtc.md) — private calls
|
||||||
|
|||||||
+11
@@ -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 { NotificationsFacade } from '../../../notifications';
|
||||||
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
import { SignalServerCredentialStoreService } from '../../../authentication/application/services/signal-server-credential-store.service';
|
||||||
import {
|
import {
|
||||||
advanceDirectMessageStatus,
|
advanceDirectMessageStatus,
|
||||||
@@ -72,6 +73,7 @@ export class DirectMessageService {
|
|||||||
private readonly credentialStore = inject(SignalServerCredentialStoreService);
|
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 notifications = inject(NotificationsFacade);
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||||
@@ -544,6 +546,15 @@ export class DirectMessageService {
|
|||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (incomingMessage.kind !== 'system' && !incomingMessage.isDeleted) {
|
||||||
|
void this.notifications.handleIncomingDirectMessage({
|
||||||
|
id: incomingMessage.id,
|
||||||
|
senderName: sender.displayName || sender.username || sender.userId,
|
||||||
|
content: incomingMessage.content,
|
||||||
|
conversationVisible: !shouldIncrementUnread
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldIncrementUnread) {
|
if (!shouldIncrementUnread) {
|
||||||
await this.markRead(conversationId);
|
await this.markRead(conversationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
(keydown.space)="closeGifPicker()"
|
(keydown.space)="closeGifPicker()"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[90]">
|
||||||
<div
|
<div
|
||||||
appThemeNode="chatGifPickerSurface"
|
appThemeNode="chatGifPickerSurface"
|
||||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Game Activity Domain
|
||||||
|
|
||||||
|
Foreground-window-first game detection, RAWG matching, and P2P now-playing sync.
|
||||||
|
|
||||||
|
**Cross-context contract:** [`agents-docs/features/game-activity.md`](../../../../../agents-docs/features/game-activity.md)
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
```
|
||||||
|
game-activity/
|
||||||
|
├── domain/
|
||||||
|
│ ├── game-activity.models.ts Shared-kernel types (re-exported)
|
||||||
|
│ └── game-activity-time.ts formatGameActivityElapsed()
|
||||||
|
├── application/
|
||||||
|
│ ├── game-activity.service.ts Scan loop, match cache, P2P broadcast
|
||||||
|
│ └── game-activity.service.spec.ts
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public entry points
|
||||||
|
|
||||||
|
| Export | Role |
|
||||||
|
|--------|------|
|
||||||
|
| `GameActivityService` | Started from `App` bootstrap; updates user store + broadcasts |
|
||||||
|
| `formatGameActivityElapsed()` | Profile card / sidebar elapsed time label |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `ElectronBridgeService` — process names and foreground candidate (desktop only)
|
||||||
|
- `ServerDirectoryFacade` — `POST /api/games/match` base URL
|
||||||
|
- `RealtimeSessionFacade` — P2P `game-activity` send/receive
|
||||||
|
|
||||||
|
## UI consumers
|
||||||
|
|
||||||
|
- Profile card components (`formatGameActivityElapsed`, `user.gameActivity`)
|
||||||
|
- Room side panel member list
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Does not own RAWG API keys (server configuration).
|
||||||
|
- Browser/mobile shells do not scan processes locally.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { NotificationsService } from '../services/notifications.service';
|
import { NotificationsService } from '../services/notifications.service';
|
||||||
|
|
||||||
@@ -39,6 +39,12 @@ export class NotificationsFacade {
|
|||||||
return this.service.handleIncomingMessage(...args);
|
return this.service.handleIncomingMessage(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleIncomingDirectMessage(
|
||||||
|
...args: Parameters<NotificationsService['handleIncomingDirectMessage']>
|
||||||
|
): ReturnType<NotificationsService['handleIncomingDirectMessage']> {
|
||||||
|
return this.service.handleIncomingDirectMessage(...args);
|
||||||
|
}
|
||||||
|
|
||||||
markCurrentChannelReadIfActive(
|
markCurrentChannelReadIfActive(
|
||||||
...args: Parameters<NotificationsService['markCurrentChannelReadIfActive']>
|
...args: Parameters<NotificationsService['markCurrentChannelReadIfActive']>
|
||||||
): ReturnType<NotificationsService['markCurrentChannelReadIfActive']> {
|
): ReturnType<NotificationsService['markCurrentChannelReadIfActive']> {
|
||||||
|
|||||||
+17
@@ -10,7 +10,9 @@ import type {
|
|||||||
User
|
User
|
||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
import { NotificationAudioService } from '../../../../core/services/notification-audio.service';
|
import { NotificationAudioService } from '../../../../core/services/notification-audio.service';
|
||||||
|
import { PlatformService } from '../../../../core/platform';
|
||||||
import { TimeSyncService } from '../../../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../../../core/services/time-sync.service';
|
||||||
|
import { MobileAppLifecycleService } from '../../../../infrastructure/mobile/services/mobile-app-lifecycle.service';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import {
|
import {
|
||||||
selectActiveChannelId,
|
selectActiveChannelId,
|
||||||
@@ -188,6 +190,21 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
|
|||||||
play: vi.fn()
|
play: vi.fn()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PlatformService,
|
||||||
|
useValue: {
|
||||||
|
isBrowser: true,
|
||||||
|
isCapacitor: false,
|
||||||
|
isElectron: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MobileAppLifecycleService,
|
||||||
|
useValue: {
|
||||||
|
initialize: vi.fn(async () => undefined),
|
||||||
|
onAppStateChange: vi.fn()
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: TimeSyncService,
|
provide: TimeSyncService,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
|||||||
+68
-1
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
@@ -9,7 +9,9 @@ import { Store } from '@ngrx/store';
|
|||||||
import type { Message, Room } from '../../../../shared-kernel';
|
import type { Message, Room } from '../../../../shared-kernel';
|
||||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||||
import { AppI18nService } from '../../../../core/i18n';
|
import { AppI18nService } from '../../../../core/i18n';
|
||||||
|
import { PlatformService } from '../../../../core/platform';
|
||||||
import { TimeSyncService } from '../../../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../../../core/services/time-sync.service';
|
||||||
|
import { MobileAppLifecycleService } from '../../../../infrastructure/mobile/services/mobile-app-lifecycle.service';
|
||||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import {
|
import {
|
||||||
selectActiveChannelId,
|
selectActiveChannelId,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
} from '../../../../store/rooms/rooms.selectors';
|
} from '../../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import {
|
import {
|
||||||
|
buildDirectMessageNotificationPayload,
|
||||||
buildNotificationDisplayPayload,
|
buildNotificationDisplayPayload,
|
||||||
calculateUnreadForRoom,
|
calculateUnreadForRoom,
|
||||||
DEFAULT_TEXT_CHANNEL_ID,
|
DEFAULT_TEXT_CHANNEL_ID,
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
isRoomMuted,
|
isRoomMuted,
|
||||||
isMessageVisibleInActiveView,
|
isMessageVisibleInActiveView,
|
||||||
resolveMessageChannelId,
|
resolveMessageChannelId,
|
||||||
|
shouldDeliverDirectMessageNotification,
|
||||||
shouldDeliverNotification
|
shouldDeliverNotification
|
||||||
} from '../../domain/logic/notification.logic';
|
} from '../../domain/logic/notification.logic';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +57,8 @@ export class NotificationsService {
|
|||||||
private readonly timeSync = inject(TimeSyncService);
|
private readonly timeSync = inject(TimeSyncService);
|
||||||
private readonly desktopNotifications = inject(DesktopNotificationService);
|
private readonly desktopNotifications = inject(DesktopNotificationService);
|
||||||
private readonly storage = inject(NotificationSettingsStorageService);
|
private readonly storage = inject(NotificationSettingsStorageService);
|
||||||
|
private readonly platform = inject(PlatformService);
|
||||||
|
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||||
|
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
@@ -83,6 +89,7 @@ export class NotificationsService {
|
|||||||
this.initialised = true;
|
this.initialised = true;
|
||||||
this.registerWindowListeners();
|
this.registerWindowListeners();
|
||||||
this.registerWindowStateListener();
|
this.registerWindowStateListener();
|
||||||
|
this.registerMobileLifecycleListener();
|
||||||
this.syncRoomCatalog(this.savedRooms());
|
this.syncRoomCatalog(this.savedRooms());
|
||||||
await this.hydrateUnreadCounts(this.savedRooms());
|
await this.hydrateUnreadCounts(this.savedRooms());
|
||||||
this.markCurrentChannelReadIfActive();
|
this.markCurrentChannelReadIfActive();
|
||||||
@@ -234,6 +241,44 @@ export class NotificationsService {
|
|||||||
await this.desktopNotifications.showNotification(payload);
|
await this.desktopNotifications.showNotification(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** System notification for an incoming direct message; sender/self filtering happens in the DM domain. */
|
||||||
|
async handleIncomingDirectMessage(input: {
|
||||||
|
id: string;
|
||||||
|
senderName: string;
|
||||||
|
content: string;
|
||||||
|
conversationVisible: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.initialised || this.isDuplicateMessage(input.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rememberMessageId(input.id);
|
||||||
|
|
||||||
|
const isWindowActive = this.isWindowActive();
|
||||||
|
const shouldDeliver = shouldDeliverDirectMessageNotification(this._settings(), {
|
||||||
|
conversationVisible: input.conversationVisible,
|
||||||
|
currentUser: this.currentUser() ?? null,
|
||||||
|
isWindowActive
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldDeliver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildDirectMessageNotificationPayload(
|
||||||
|
input,
|
||||||
|
this._settings(),
|
||||||
|
!isWindowActive,
|
||||||
|
(key, params) => this.appI18n.instant(key, params)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.shouldPlayNotificationSound()) {
|
||||||
|
this.audio.play(AppSound.Notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.desktopNotifications.showNotification(payload);
|
||||||
|
}
|
||||||
|
|
||||||
markCurrentChannelReadIfActive(): void {
|
markCurrentChannelReadIfActive(): void {
|
||||||
if (!this.initialised || !this._windowFocused() || !this._documentVisible()) {
|
if (!this.initialised || !this._windowFocused() || !this._documentVisible()) {
|
||||||
return;
|
return;
|
||||||
@@ -321,6 +366,28 @@ export class NotificationsService {
|
|||||||
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Android WebView focus/visibility events are unreliable when the app backgrounds; use Capacitor appStateChange instead. */
|
||||||
|
private registerMobileLifecycleListener(): void {
|
||||||
|
if (!this.platform.isCapacitor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.mobileLifecycle.initialize().then(() => {
|
||||||
|
this.mobileLifecycle.onAppStateChange((isActive) => {
|
||||||
|
this._windowFocused.set(isActive);
|
||||||
|
this._documentVisible.set(isActive);
|
||||||
|
this._windowMinimized.set(!isActive);
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
this.markCurrentChannelReadIfActive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncWindowAttention();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private registerWindowStateListener(): void {
|
private registerWindowStateListener(): void {
|
||||||
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
|
this.windowStateCleanup = this.desktopNotifications.onWindowStateChanged((state) => {
|
||||||
this._windowFocused.set(state.isFocused);
|
this._windowFocused.set(state.isFocused);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { Message, Room } from '../../../../shared-kernel';
|
import type {
|
||||||
|
Message,
|
||||||
|
Room,
|
||||||
|
User
|
||||||
|
} from '../../../../shared-kernel';
|
||||||
import { createDefaultNotificationSettings } from '../models/notification.model';
|
import { createDefaultNotificationSettings } from '../models/notification.model';
|
||||||
import { calculateUnreadForRoom } from './notification.logic';
|
import {
|
||||||
|
buildDirectMessageNotificationPayload,
|
||||||
|
calculateUnreadForRoom,
|
||||||
|
shouldDeliverDirectMessageNotification
|
||||||
|
} from './notification.logic';
|
||||||
|
|
||||||
function createRoom(overrides: Partial<Room> = {}): Room {
|
function createRoom(overrides: Partial<Room> = {}): Room {
|
||||||
return {
|
return {
|
||||||
@@ -29,6 +37,80 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('shouldDeliverDirectMessageNotification', () => {
|
||||||
|
const settings = createDefaultNotificationSettings();
|
||||||
|
const onlineUser = { status: 'online' } as User;
|
||||||
|
|
||||||
|
it('delivers when the conversation is not on screen', () => {
|
||||||
|
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||||
|
conversationVisible: false,
|
||||||
|
currentUser: onlineUser,
|
||||||
|
isWindowActive: true
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers when the conversation is on screen but the window is inactive', () => {
|
||||||
|
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||||
|
conversationVisible: true,
|
||||||
|
currentUser: onlineUser,
|
||||||
|
isWindowActive: false
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses when the conversation is on screen in the active window', () => {
|
||||||
|
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||||
|
conversationVisible: true,
|
||||||
|
currentUser: onlineUser,
|
||||||
|
isWindowActive: true
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses when notifications are disabled', () => {
|
||||||
|
expect(shouldDeliverDirectMessageNotification({ ...settings, enabled: false }, {
|
||||||
|
conversationVisible: false,
|
||||||
|
currentUser: onlineUser,
|
||||||
|
isWindowActive: false
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses when the user is busy', () => {
|
||||||
|
expect(shouldDeliverDirectMessageNotification(settings, {
|
||||||
|
conversationVisible: false,
|
||||||
|
currentUser: { status: 'busy' } as User,
|
||||||
|
isWindowActive: false
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDirectMessageNotificationPayload', () => {
|
||||||
|
const translate = (key: string, params?: Record<string, string | number>) =>
|
||||||
|
`${key}:${JSON.stringify(params ?? {})}`;
|
||||||
|
|
||||||
|
it('uses the sender name as title and the message preview as body', () => {
|
||||||
|
const payload = buildDirectMessageNotificationPayload(
|
||||||
|
{ senderName: 'Bob', content: 'hello there' },
|
||||||
|
createDefaultNotificationSettings(),
|
||||||
|
true,
|
||||||
|
translate
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.title).toBe('Bob');
|
||||||
|
expect(payload.body).toBe('notifications.display.preview:{"sender":"Bob","content":"hello there"}');
|
||||||
|
expect(payload.requestAttention).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the content when previews are disabled', () => {
|
||||||
|
const payload = buildDirectMessageNotificationPayload(
|
||||||
|
{ senderName: 'Bob', content: 'secret' },
|
||||||
|
{ ...createDefaultNotificationSettings(), showPreview: false },
|
||||||
|
false,
|
||||||
|
translate
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payload.body).toBe('notifications.display.newMessageHidden:{"sender":"Bob"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('calculateUnreadForRoom', () => {
|
describe('calculateUnreadForRoom', () => {
|
||||||
it('ignores messages whose channel is not part of the room catalog', () => {
|
it('ignores messages whose channel is not part of the room catalog', () => {
|
||||||
const room = createRoom();
|
const room = createRoom();
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { Message, Room } from '../../../../shared-kernel';
|
import type {
|
||||||
|
Message,
|
||||||
|
Room,
|
||||||
|
User
|
||||||
|
} from '../../../../shared-kernel';
|
||||||
import type {
|
import type {
|
||||||
NotificationDeliveryContext,
|
NotificationDeliveryContext,
|
||||||
NotificationDisplayPayload,
|
NotificationDisplayPayload,
|
||||||
@@ -116,6 +120,47 @@ export function buildNotificationDisplayPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectMessageNotificationContext {
|
||||||
|
conversationVisible: boolean;
|
||||||
|
currentUser: Pick<User, 'status'> | null;
|
||||||
|
isWindowActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DMs have no room/channel mute concept; deliver unless the conversation is
|
||||||
|
* actually on screen in an active window, notifications are off, or the user
|
||||||
|
* is busy.
|
||||||
|
*/
|
||||||
|
export function shouldDeliverDirectMessageNotification(
|
||||||
|
settings: Pick<NotificationsSettings, 'enabled'>,
|
||||||
|
context: DirectMessageNotificationContext
|
||||||
|
): boolean {
|
||||||
|
if (!settings.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.currentUser?.status === 'busy') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(context.conversationVisible && context.isWindowActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDirectMessageNotificationPayload(
|
||||||
|
message: { senderName: string; content: string },
|
||||||
|
settings: Pick<NotificationsSettings, 'showPreview'>,
|
||||||
|
requestAttention: boolean,
|
||||||
|
translate: AppTranslateFn
|
||||||
|
): NotificationDisplayPayload {
|
||||||
|
return {
|
||||||
|
title: message.senderName,
|
||||||
|
body: settings.showPreview
|
||||||
|
? formatMessagePreview(message.senderName, message.content, translate)
|
||||||
|
: translate('notifications.display.newMessageHidden', { sender: message.senderName }),
|
||||||
|
requestAttention
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function calculateUnreadForRoom(
|
export function calculateUnreadForRoom(
|
||||||
room: Room,
|
room: Room,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
|
|||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import { PlatformService } from '../../../../core/platform';
|
||||||
|
import { MobileNotificationsService } from '../../../../infrastructure/mobile/services/mobile-notifications.service';
|
||||||
|
import { DesktopNotificationService } from './desktop-notification.service';
|
||||||
|
|
||||||
|
interface ServiceContextOptions {
|
||||||
|
electronApi?: { showDesktopNotification?: ReturnType<typeof vi.fn> } | null;
|
||||||
|
isBrowser?: boolean;
|
||||||
|
isCapacitor?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(options: ServiceContextOptions = {}) {
|
||||||
|
const showMessage = vi.fn(async () => undefined);
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ElectronBridgeService,
|
||||||
|
useValue: {
|
||||||
|
getApi: vi.fn(() => options.electronApi ?? null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PlatformService,
|
||||||
|
useValue: {
|
||||||
|
isBrowser: options.isBrowser ?? false,
|
||||||
|
isCapacitor: options.isCapacitor ?? false,
|
||||||
|
isElectron: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MobileNotificationsService,
|
||||||
|
useValue: { showMessage }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: runInInjectionContext(injector, () => new DesktopNotificationService()),
|
||||||
|
showMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DesktopNotificationService', () => {
|
||||||
|
it('routes message notifications to the mobile notifications facade on Capacitor', async () => {
|
||||||
|
const { service, showMessage } = createService({ isCapacitor: true });
|
||||||
|
|
||||||
|
await service.showNotification({ title: 'general - Toju HQ', body: 'Alice: hello' });
|
||||||
|
|
||||||
|
expect(showMessage).toHaveBeenCalledWith({ title: 'general - Toju HQ', body: 'Alice: hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the Electron bridge when available', async () => {
|
||||||
|
const showDesktopNotification = vi.fn(async () => undefined);
|
||||||
|
const { service, showMessage } = createService({ electronApi: { showDesktopNotification } });
|
||||||
|
|
||||||
|
await service.showNotification({ title: 't', body: 'b' });
|
||||||
|
|
||||||
|
expect(showDesktopNotification).toHaveBeenCalled();
|
||||||
|
expect(showMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing on non-browser, non-capacitor shells without Electron', async () => {
|
||||||
|
const { service, showMessage } = createService({ isBrowser: false, isCapacitor: false });
|
||||||
|
|
||||||
|
await service.showNotification({ title: 't', body: 'b' });
|
||||||
|
|
||||||
|
expect(showMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
@@ -2,12 +2,14 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import type { WindowStateSnapshot } from '../../../../core/platform/electron/electron-api.models';
|
import type { WindowStateSnapshot } from '../../../../core/platform/electron/electron-api.models';
|
||||||
import { PlatformService } from '../../../../core/platform';
|
import { PlatformService } from '../../../../core/platform';
|
||||||
|
import { MobileNotificationsService } from '../../../../infrastructure/mobile/services/mobile-notifications.service';
|
||||||
import type { NotificationDisplayPayload } from '../../domain/models/notification.model';
|
import type { NotificationDisplayPayload } from '../../domain/models/notification.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DesktopNotificationService {
|
export class DesktopNotificationService {
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
private readonly platform = inject(PlatformService);
|
private readonly platform = inject(PlatformService);
|
||||||
|
private readonly mobileNotifications = inject(MobileNotificationsService);
|
||||||
|
|
||||||
async showNotification(payload: NotificationDisplayPayload): Promise<void> {
|
async showNotification(payload: NotificationDisplayPayload): Promise<void> {
|
||||||
const api = this.electronBridge.getApi();
|
const api = this.electronBridge.getApi();
|
||||||
@@ -17,6 +19,11 @@ export class DesktopNotificationService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.platform.isCapacitor) {
|
||||||
|
await this.mobileNotifications.showMessage({ title: payload.title, body: payload.body });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.platform.isBrowser || typeof Notification === 'undefined') {
|
if (!this.platform.isBrowser || typeof Notification === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,8 @@ Desktop plugin preferences that belong to the local user, including capability g
|
|||||||
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
|
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. HTTP(S) entrypoints are imported directly when the host serves module-compatible JavaScript; if a source host serves JavaScript with a non-module MIME type, the runtime fetches the source and imports it through a blob URL. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
|
||||||
|
|
||||||
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.
|
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.
|
||||||
|
|
||||||
|
## Cross-context feature docs
|
||||||
|
|
||||||
|
- [`agents-docs/features/plugins.md`](../../../../../agents-docs/features/plugins.md)
|
||||||
|
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — `plugin_event`, `plugin_requirements`
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
(dismissed)="cancelled.emit(undefined)"
|
(dismissed)="cancelled.emit(undefined)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed metoyou-fixed-safe-viewport z-[113] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto flex max-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|||||||
@@ -227,3 +227,12 @@ All endpoint state is persisted to localStorage under two keys:
|
|||||||
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
|
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
|
||||||
|
|
||||||
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.
|
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.
|
||||||
|
|
||||||
|
## Cross-context feature docs
|
||||||
|
|
||||||
|
Wire contracts spanning client + server (REST routes, discovery fan-out, invites) are documented in:
|
||||||
|
|
||||||
|
- [`agents-docs/features/server-directory.md`](../../../../../agents-docs/features/server-directory.md)
|
||||||
|
- [`agents-docs/features/server-discovery.md`](../../../../../agents-docs/features/server-discovery.md)
|
||||||
|
- [`agents-docs/features/invites-join-requests.md`](../../../../../agents-docs/features/invites-join-requests.md)
|
||||||
|
- [`agents-docs/features/signaling.md`](../../../../../agents-docs/features/signaling.md) — WebSocket `join_server` after REST join
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (!isMobile()) {
|
@if (showScreenShareButton()) {
|
||||||
<button
|
<button
|
||||||
(click)="toggleScreenShare()"
|
(click)="toggleScreenShare()"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+4
@@ -25,6 +25,7 @@ import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
|||||||
import { VoicePlaybackService } from '../../../../domains/voice-connection';
|
import { VoicePlaybackService } from '../../../../domains/voice-connection';
|
||||||
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/screen-share';
|
||||||
import { ViewportService } from '../../../../core/platform';
|
import { ViewportService } from '../../../../core/platform';
|
||||||
|
import { MobilePlatformService } from '../../../../infrastructure/mobile';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||||
@@ -63,7 +64,10 @@ export class FloatingVoiceControlsComponent implements OnInit {
|
|||||||
private readonly webrtcService = inject(VoiceConnectionFacade);
|
private readonly webrtcService = inject(VoiceConnectionFacade);
|
||||||
private readonly screenShareService = inject(ScreenShareFacade);
|
private readonly screenShareService = inject(ScreenShareFacade);
|
||||||
private readonly viewport = inject(ViewportService);
|
private readonly viewport = inject(ViewportService);
|
||||||
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
/** Screen share is not supported in mobile WebViews; hide the control there. */
|
||||||
|
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||||
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
private readonly voiceSessionService = inject(VoiceSessionFacade);
|
||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
|
|||||||
+10
@@ -79,6 +79,14 @@
|
|||||||
[attr.aria-hidden]="isConnected() ? null : 'true'"
|
[attr.aria-hidden]="isConnected() ? null : 'true'"
|
||||||
>
|
>
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
|
@if (mediaError(); as mediaErrorMessage) {
|
||||||
|
<p
|
||||||
|
class="mb-2 text-center text-xs text-destructive"
|
||||||
|
data-testid="voice-controls-media-error"
|
||||||
|
>
|
||||||
|
{{ mediaErrorMessage }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<div
|
<div
|
||||||
appThemeNode="voiceControlsButtons"
|
appThemeNode="voiceControlsButtons"
|
||||||
class="flex items-center justify-center gap-2"
|
class="flex items-center justify-center gap-2"
|
||||||
@@ -134,6 +142,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Screen Share Toggle -->
|
<!-- Screen Share Toggle -->
|
||||||
|
@if (showScreenShareButton()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleScreenShare()"
|
(click)="toggleScreenShare()"
|
||||||
@@ -151,6 +160,7 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Disconnect -->
|
<!-- Disconnect -->
|
||||||
<button
|
<button
|
||||||
|
|||||||
+19
-2
@@ -33,7 +33,8 @@ import { UsersActions } from '../../../../store/users/users.actions';
|
|||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
import { MobileMediaService } from '../../../../infrastructure/mobile';
|
import { ViewportService } from '../../../../core/platform';
|
||||||
|
import { MobileMediaService, MobilePlatformService } from '../../../../infrastructure/mobile';
|
||||||
import {
|
import {
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
@@ -85,6 +86,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
private readonly hostEl = inject(ElementRef);
|
private readonly hostEl = inject(ElementRef);
|
||||||
private readonly profileCard = inject(ProfileCardService);
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
private readonly mobileMedia = inject(MobileMediaService);
|
private readonly mobileMedia = inject(MobileMediaService);
|
||||||
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
|
private readonly viewport = inject(ViewportService);
|
||||||
private readonly appI18n = inject(AppI18nService);
|
private readonly appI18n = inject(AppI18nService);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
@@ -106,6 +109,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
|
isCameraEnabled = computed(() => this.webrtcService.isCameraEnabled());
|
||||||
isScreenSharing = this.screenShareService.isScreenSharing;
|
isScreenSharing = this.screenShareService.isScreenSharing;
|
||||||
showSettings = signal(false);
|
showSettings = signal(false);
|
||||||
|
/** Camera/screen-share capture problems surfaced to the user instead of being swallowed. */
|
||||||
|
mediaError = signal<string | null>(null);
|
||||||
|
/** Screen share is not supported in mobile WebViews; hide the control there. */
|
||||||
|
showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||||
|
|
||||||
toggleProfileCard(): void {
|
toggleProfileCard(): void {
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
@@ -412,6 +419,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
this.mediaError.set(null);
|
||||||
|
|
||||||
if (this.isCameraEnabled()) {
|
if (this.isCameraEnabled()) {
|
||||||
this.webrtcService.disableCamera();
|
this.webrtcService.disableCamera();
|
||||||
|
|
||||||
@@ -438,7 +447,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_error) {}
|
} catch (error) {
|
||||||
|
const errorName = error instanceof Error ? error.name : '';
|
||||||
|
const isPermissionError = errorName === 'NotAllowedError'
|
||||||
|
|| (error instanceof Error && error.message.includes('permission'));
|
||||||
|
|
||||||
|
this.mediaError.set(this.appI18n.instant(
|
||||||
|
isPermissionError ? 'call.errors.cameraPermissionDenied' : 'call.errors.cameraUnavailable'
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleScreenShare(): Promise<void> {
|
async toggleScreenShare(): Promise<void> {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@if (showScreenShareButton()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
|
class="grid h-12 w-12 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-45"
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class PrivateCallControlsComponent {
|
|||||||
readonly cameraEnabled = input.required<boolean>();
|
readonly cameraEnabled = input.required<boolean>();
|
||||||
readonly screenSharing = input.required<boolean>();
|
readonly screenSharing = input.required<boolean>();
|
||||||
readonly showSpeakerphoneButton = input(false);
|
readonly showSpeakerphoneButton = input(false);
|
||||||
|
readonly showScreenShareButton = input(true);
|
||||||
readonly speakerphoneEnabled = input(false);
|
readonly speakerphoneEnabled = input(false);
|
||||||
|
|
||||||
readonly joinRequested = output();
|
readonly joinRequested = output();
|
||||||
|
|||||||
@@ -194,6 +194,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="shrink-0 pt-3">
|
<div class="shrink-0 pt-3">
|
||||||
|
@if (callErrorMessage(); as callError) {
|
||||||
|
<p
|
||||||
|
class="mx-auto mb-2 w-full max-w-5xl px-3 text-center text-xs text-destructive"
|
||||||
|
data-testid="private-call-error"
|
||||||
|
>
|
||||||
|
{{ callError }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<app-private-call-controls
|
<app-private-call-controls
|
||||||
class="mx-auto block w-full max-w-5xl"
|
class="mx-auto block w-full max-w-5xl"
|
||||||
[connected]="isConnected()"
|
[connected]="isConnected()"
|
||||||
@@ -201,6 +209,7 @@
|
|||||||
[deafened]="isDeafened()"
|
[deafened]="isDeafened()"
|
||||||
[cameraEnabled]="isCameraEnabled()"
|
[cameraEnabled]="isCameraEnabled()"
|
||||||
[screenSharing]="isScreenSharing()"
|
[screenSharing]="isScreenSharing()"
|
||||||
|
[showScreenShareButton]="showScreenShareButton()"
|
||||||
[showSpeakerphoneButton]="showSpeakerphoneButton()"
|
[showSpeakerphoneButton]="showSpeakerphoneButton()"
|
||||||
[speakerphoneEnabled]="speakerphoneEnabled()"
|
[speakerphoneEnabled]="speakerphoneEnabled()"
|
||||||
(joinRequested)="join()"
|
(joinRequested)="join()"
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ export class PrivateCallComponent {
|
|||||||
readonly isDeafened = this.voice.isDeafened;
|
readonly isDeafened = this.voice.isDeafened;
|
||||||
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
readonly isCameraEnabled = this.voice.isCameraEnabled;
|
||||||
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
readonly isScreenSharing = this.screenShare.isScreenSharing;
|
||||||
|
readonly joinError = this.calls.joinError;
|
||||||
|
readonly cameraError = signal<string | null>(null);
|
||||||
|
readonly callErrorMessage = computed(() => this.joinError() ?? this.cameraError());
|
||||||
|
readonly showScreenShareButton = computed(() => !this.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||||
readonly remoteStreamRevision = signal(0);
|
readonly remoteStreamRevision = signal(0);
|
||||||
readonly includeSystemAudio = signal(false);
|
readonly includeSystemAudio = signal(false);
|
||||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||||
@@ -381,6 +385,8 @@ export class PrivateCallComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.cameraError.set(null);
|
||||||
|
|
||||||
if (this.isCameraEnabled()) {
|
if (this.isCameraEnabled()) {
|
||||||
this.voice.disableCamera();
|
this.voice.disableCamera();
|
||||||
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } }));
|
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: false } }));
|
||||||
@@ -388,11 +394,25 @@ export class PrivateCallComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await this.voice.enableCamera();
|
await this.voice.enableCamera();
|
||||||
|
} catch (error) {
|
||||||
|
this.cameraError.set(this.resolveCameraErrorMessage(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } }));
|
this.store.dispatch(UsersActions.updateCameraState({ userId: user.id, cameraState: { isEnabled: true } }));
|
||||||
this.bumpRemoteStreamRevision();
|
this.bumpRemoteStreamRevision();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveCameraErrorMessage(error: unknown): string {
|
||||||
|
const errorName = error instanceof Error ? error.name : '';
|
||||||
|
const isPermissionError = errorName === 'NotAllowedError'
|
||||||
|
|| (error instanceof Error && error.message.includes('permission'));
|
||||||
|
|
||||||
|
return this.i18n.instant(isPermissionError ? 'call.errors.cameraPermissionDenied' : 'call.errors.cameraUnavailable');
|
||||||
|
}
|
||||||
|
|
||||||
async toggleScreenShare(): Promise<void> {
|
async toggleScreenShare(): Promise<void> {
|
||||||
if (this.isScreenSharing()) {
|
if (this.isScreenSharing()) {
|
||||||
this.screenShare.stopScreenShare();
|
this.screenShare.stopScreenShare();
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
|
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||||
[class.hidden]="isMobile()"
|
[class.hidden]="!showScreenShareButton()"
|
||||||
(click)="toggleScreenShare()"
|
(click)="toggleScreenShare()"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
ScreenShareStartOptions
|
ScreenShareStartOptions
|
||||||
} from '../../../domains/screen-share';
|
} from '../../../domains/screen-share';
|
||||||
import { ViewportService } from '../../../core/platform';
|
import { ViewportService } from '../../../core/platform';
|
||||||
|
import { MobilePlatformService } from '../../../infrastructure/mobile';
|
||||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||||
@@ -94,7 +95,10 @@ export class VoiceWorkspaceComponent {
|
|||||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||||
private readonly screenShare = inject(ScreenShareFacade);
|
private readonly screenShare = inject(ScreenShareFacade);
|
||||||
private readonly viewport = inject(ViewportService);
|
private readonly viewport = inject(ViewportService);
|
||||||
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
readonly isMobile = this.viewport.isMobile;
|
readonly isMobile = this.viewport.isMobile;
|
||||||
|
/** Screen share is not supported in mobile WebViews; hide the control there. */
|
||||||
|
readonly showScreenShareButton = computed(() => !this.viewport.isMobile() && !this.mobilePlatform.isNativeMobile());
|
||||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[121] flex items-center justify-center px-4 pointer-events-none"
|
class="fixed metoyou-fixed-safe-viewport z-[121] flex items-center justify-center px-4 pointer-events-none"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
appThemeNode="highMemoryAlertDialog"
|
appThemeNode="highMemoryAlertDialog"
|
||||||
|
|||||||
+40
@@ -1,11 +1,13 @@
|
|||||||
import translationsEn from '../../../../../../public/i18n/en.json';
|
import translationsEn from '../../../../../../public/i18n/en.json';
|
||||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
||||||
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
|
import { resolveCallNotificationAction } from '../../logic/call-notification.rules';
|
||||||
|
import type { MessageNotificationPayload } from '../../logic/message-notification.rules';
|
||||||
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
|
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
|
||||||
import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from './capacitor-plugin-loader';
|
import { loadCapacitorLocalNotificationsPlugin, loadCapacitorPushNotificationsPlugin } from './capacitor-plugin-loader';
|
||||||
|
|
||||||
const INCOMING_CALL_CHANNEL_ID = 'toju-incoming-call';
|
const INCOMING_CALL_CHANNEL_ID = 'toju-incoming-call';
|
||||||
const ACTIVE_CALL_CHANNEL_ID = 'toju-active-call';
|
const ACTIVE_CALL_CHANNEL_ID = 'toju-active-call';
|
||||||
|
const MESSAGE_CHANNEL_ID = 'toju-messages';
|
||||||
|
|
||||||
function mobileLabel(key: string): string {
|
function mobileLabel(key: string): string {
|
||||||
const value = key.split('.').reduce<unknown>((current, part) => {
|
const value = key.split('.').reduce<unknown>((current, part) => {
|
||||||
@@ -47,6 +49,13 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
visibility: 1
|
visibility: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await LocalNotifications.createChannel({
|
||||||
|
id: MESSAGE_CHANNEL_ID,
|
||||||
|
name: mobileLabel('mobile.notifications.messagesChannel'),
|
||||||
|
importance: 4,
|
||||||
|
visibility: 1
|
||||||
|
});
|
||||||
|
|
||||||
await LocalNotifications.registerActionTypes({
|
await LocalNotifications.registerActionTypes({
|
||||||
types: [
|
types: [
|
||||||
{
|
{
|
||||||
@@ -136,6 +145,37 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
|
||||||
|
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
if (!LocalNotifications) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = await this.requestPermission();
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LocalNotifications.schedule({
|
||||||
|
notifications: [
|
||||||
|
{
|
||||||
|
id: payload.id,
|
||||||
|
title: payload.title,
|
||||||
|
body: payload.body,
|
||||||
|
channelId: MESSAGE_CHANNEL_ID,
|
||||||
|
autoCancel: true,
|
||||||
|
group: payload.tag,
|
||||||
|
extra: {
|
||||||
|
kind: 'message',
|
||||||
|
tag: payload.tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
|
async dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void> {
|
||||||
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
const LocalNotifications = await loadCapacitorLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
|||||||
+16
@@ -1,4 +1,5 @@
|
|||||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
import type { CallNotificationActionIntent, CallNotificationPayload } from '../../logic/call-notification.rules';
|
||||||
|
import type { MessageNotificationPayload } from '../../logic/message-notification.rules';
|
||||||
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
|
import type { MobileNotificationAdapter } from '../../contracts/mobile.contracts';
|
||||||
|
|
||||||
type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void;
|
type CallActionHandler = (input: { callId: string; intent: CallNotificationActionIntent }) => void;
|
||||||
@@ -50,6 +51,21 @@ export class WebMobileNotificationsAdapter implements MobileNotificationAdapter
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showMessageNotification(payload: MessageNotificationPayload): Promise<void> {
|
||||||
|
const granted = await this.requestPermission();
|
||||||
|
|
||||||
|
if (!granted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = new Notification(payload.title, {
|
||||||
|
body: payload.body,
|
||||||
|
tag: payload.tag
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.onclick = () => window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise<void> {
|
async dismissCallNotification(_callId: string, _kind: CallNotificationPayload['kind']): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules';
|
import type { CallNotificationActionIntent, CallNotificationPayload } from '../logic/call-notification.rules';
|
||||||
|
import type { MessageNotificationPayload } from '../logic/message-notification.rules';
|
||||||
import type { RuntimePlatform } from '../logic/platform-detection.rules';
|
import type { RuntimePlatform } from '../logic/platform-detection.rules';
|
||||||
|
|
||||||
export interface MobileNotificationAdapter {
|
export interface MobileNotificationAdapter {
|
||||||
initialize(): Promise<void>;
|
initialize(): Promise<void>;
|
||||||
requestPermission(): Promise<boolean>;
|
requestPermission(): Promise<boolean>;
|
||||||
showCallNotification(payload: CallNotificationPayload): Promise<void>;
|
showCallNotification(payload: CallNotificationPayload): Promise<void>;
|
||||||
|
showMessageNotification(payload: MessageNotificationPayload): Promise<void>;
|
||||||
dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void>;
|
dismissCallNotification(callId: string, kind: CallNotificationPayload['kind']): Promise<void>;
|
||||||
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void;
|
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -62,6 +62,16 @@ describe('ensure-mobile-capture-permissions', () => {
|
|||||||
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
|
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defers to WebView capture when the native prompt was dismissed', async () => {
|
||||||
|
pluginState.plugin = {
|
||||||
|
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'prompt' })),
|
||||||
|
requestCameraCapturePermissions: vi.fn(() => Promise.resolve({ camera: 'prompt' }))
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(ensureMobileVoiceCapturePermissions()).resolves.toBe(true);
|
||||||
|
await expect(ensureMobileCameraCapturePermissions()).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks capture when the native shell explicitly denies microphone access', async () => {
|
it('blocks capture when the native shell explicitly denies microphone access', async () => {
|
||||||
pluginState.plugin = {
|
pluginState.plugin = {
|
||||||
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'denied' })),
|
requestVoiceCapturePermissions: vi.fn(() => Promise.resolve({ microphone: 'denied' })),
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
import { MESSAGE_NOTIFICATION_BASE_ID, buildMessageNotification } from './message-notification.rules';
|
||||||
|
|
||||||
|
describe('buildMessageNotification', () => {
|
||||||
|
it('builds a payload with title, body and a collapse tag', () => {
|
||||||
|
const payload = buildMessageNotification({ title: 'general - Toju HQ', body: 'Alice: hello' });
|
||||||
|
|
||||||
|
expect(payload.title).toBe('general - Toju HQ');
|
||||||
|
expect(payload.body).toBe('Alice: hello');
|
||||||
|
expect(payload.tag).toBe('toju-message-general - Toju HQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives a stable numeric id from the tag so newer messages replace the same notification', () => {
|
||||||
|
const first = buildMessageNotification({ title: 'general - Toju HQ', body: 'first' });
|
||||||
|
const second = buildMessageNotification({ title: 'general - Toju HQ', body: 'second' });
|
||||||
|
|
||||||
|
expect(first.id).toBe(second.id);
|
||||||
|
expect(Number.isInteger(first.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses distinct ids for distinct tags', () => {
|
||||||
|
const general = buildMessageNotification({ title: 'general - Toju HQ', body: 'x' });
|
||||||
|
const random = buildMessageNotification({ title: 'random - Toju HQ', body: 'x' });
|
||||||
|
|
||||||
|
expect(general.id).not.toBe(random.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps ids outside the call-notification id ranges', () => {
|
||||||
|
const payload = buildMessageNotification({ title: 't', body: 'b' });
|
||||||
|
|
||||||
|
expect(payload.id).toBeGreaterThanOrEqual(MESSAGE_NOTIFICATION_BASE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors an explicit tag override', () => {
|
||||||
|
const payload = buildMessageNotification({ title: 't', body: 'b', tag: 'room-42' });
|
||||||
|
|
||||||
|
expect(payload.tag).toBe('toju-message-room-42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface MessageNotificationPayload {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base id above the incoming (1000+) and active (2000+) call notification ranges. */
|
||||||
|
export const MESSAGE_NOTIFICATION_BASE_ID = 3000;
|
||||||
|
|
||||||
|
const MESSAGE_NOTIFICATION_ID_SPAN = 9973;
|
||||||
|
|
||||||
|
/** Build a local notification payload for an incoming chat message; the tag collapses per-channel. */
|
||||||
|
export function buildMessageNotification(input: { title: string; body: string; tag?: string }): MessageNotificationPayload {
|
||||||
|
const tag = `toju-message-${input.tag ?? input.title}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: MESSAGE_NOTIFICATION_BASE_ID + hashTag(tag),
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
tag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashTag(tag: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < tag.length; index += 1) {
|
||||||
|
hash = (hash * 31 + tag.charCodeAt(index)) % MESSAGE_NOTIFICATION_ID_SPAN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
+43
-1
@@ -15,8 +15,10 @@ import {
|
|||||||
findMissingLauncherResources,
|
findMissingLauncherResources,
|
||||||
findStockCapacitorResources,
|
findStockCapacitorResources,
|
||||||
isBrandLauncherBackgroundColor,
|
isBrandLauncherBackgroundColor,
|
||||||
|
NOTIFICATION_STATUS_ICON_NAME,
|
||||||
readAdaptiveIconBackgroundColor,
|
readAdaptiveIconBackgroundColor,
|
||||||
REQUIRED_LAUNCHER_ICON_FILES,
|
REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
|
REQUIRED_NOTIFICATION_ICON_FILES,
|
||||||
REQUIRED_SPLASH_FILES,
|
REQUIRED_SPLASH_FILES,
|
||||||
resolveIconPixelSize,
|
resolveIconPixelSize,
|
||||||
SPLASH_ICON_RATIO
|
SPLASH_ICON_RATIO
|
||||||
@@ -32,7 +34,11 @@ function sha256OfResource(resRelativePath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('mobile-android-launcher-icon.rules', () => {
|
describe('mobile-android-launcher-icon.rules', () => {
|
||||||
const allRequired = [...REQUIRED_LAUNCHER_ICON_FILES, ...REQUIRED_SPLASH_FILES];
|
const allRequired = [
|
||||||
|
...REQUIRED_LAUNCHER_ICON_FILES,
|
||||||
|
...REQUIRED_SPLASH_FILES,
|
||||||
|
...REQUIRED_NOTIFICATION_ICON_FILES
|
||||||
|
];
|
||||||
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
const presentFiles = allRequired.filter((file) => existsSync(resolve(RES_DIR, file)));
|
||||||
|
|
||||||
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
|
it('keeps the brand mark inside the adaptive-icon safe zone', () => {
|
||||||
@@ -45,6 +51,42 @@ describe('mobile-android-launcher-icon.rules', () => {
|
|||||||
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
expect(findMissingLauncherResources(presentFiles)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ships a notification status-bar icon for every density', () => {
|
||||||
|
expect(findMissingLauncherResources(presentFiles, REQUIRED_NOTIFICATION_ICON_FILES)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('references the notification status icon from the Capacitor config', () => {
|
||||||
|
const capacitorConfig = readFileSync(resolve(process.cwd(), 'capacitor.config.ts'), 'utf8');
|
||||||
|
|
||||||
|
expect(capacitorConfig).toContain(`smallIcon: '${NOTIFICATION_STATUS_ICON_NAME}'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the notification status icon as an alpha-only white glyph', async () => {
|
||||||
|
const iconPath = resolve(RES_DIR, 'drawable-xxxhdpi/ic_stat_metoyou.png');
|
||||||
|
const { data, info } = await sharp(iconPath).ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
let opaquePixels = 0;
|
||||||
|
let transparentPixels = 0;
|
||||||
|
|
||||||
|
for (let offset = 0; offset < data.length; offset += info.channels) {
|
||||||
|
const alpha = data[offset + 3];
|
||||||
|
|
||||||
|
if (alpha > 224) {
|
||||||
|
opaquePixels += 1;
|
||||||
|
expect(data[offset]).toBeGreaterThan(224);
|
||||||
|
expect(data[offset + 1]).toBeGreaterThan(224);
|
||||||
|
expect(data[offset + 2]).toBeGreaterThan(224);
|
||||||
|
} else if (alpha < 32) {
|
||||||
|
transparentPixels += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(opaquePixels).toBeGreaterThan(0);
|
||||||
|
expect(transparentPixels).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('replaces every stock Capacitor placeholder with the brand asset', () => {
|
it('replaces every stock Capacitor placeholder with the brand asset', () => {
|
||||||
const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)]));
|
const hashByFile = Object.fromEntries(presentFiles.map((file) => [file, sha256OfResource(file)]));
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export const REQUIRED_LAUNCHER_ICON_FILES: readonly string[] = ANDROID_ICON_DENS
|
|||||||
LAUNCHER_ICON_BASENAMES.map((basename) => `mipmap-${density}/${basename}`)
|
LAUNCHER_ICON_BASENAMES.map((basename) => `mipmap-${density}/${basename}`)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Resource name (no extension) the Capacitor LocalNotifications config must reference as `smallIcon`. */
|
||||||
|
export const NOTIFICATION_STATUS_ICON_NAME = 'ic_stat_metoyou';
|
||||||
|
|
||||||
|
/** res-relative notification status-bar icon files (alpha-only white glyph, one per density). */
|
||||||
|
export const REQUIRED_NOTIFICATION_ICON_FILES: readonly string[] = ANDROID_ICON_DENSITIES.map(
|
||||||
|
(density) => `drawable-${density}/${NOTIFICATION_STATUS_ICON_NAME}.png`
|
||||||
|
);
|
||||||
|
|
||||||
/** res-relative splash files the brand build must contain (portrait + landscape per density, plus the base). */
|
/** res-relative splash files the brand build must contain (portrait + landscape per density, plus the base). */
|
||||||
export const REQUIRED_SPLASH_FILES: readonly string[] = [
|
export const REQUIRED_SPLASH_FILES: readonly string[] = [
|
||||||
'drawable/splash.png',
|
'drawable/splash.png',
|
||||||
|
|||||||
@@ -24,13 +24,19 @@ describe('mobile-media-permission.rules', () => {
|
|||||||
expect(isMobileCapturePermissionGranted('denied')).toBe(false);
|
expect(isMobileCapturePermissionGranted('denied')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires microphone permission for voice capture', () => {
|
it('only blocks voice capture on an explicit native denial', () => {
|
||||||
expect(isVoiceCaptureAllowed({ microphone: 'granted' })).toBe(true);
|
expect(isVoiceCaptureAllowed({ microphone: 'granted' })).toBe(true);
|
||||||
expect(isVoiceCaptureAllowed({ microphone: 'denied' })).toBe(false);
|
expect(isVoiceCaptureAllowed({ microphone: 'denied' })).toBe(false);
|
||||||
|
// Dismissed dialogs ('prompt') defer to the WebView getUserMedia permission flow.
|
||||||
|
expect(isVoiceCaptureAllowed({ microphone: 'prompt' })).toBe(true);
|
||||||
|
expect(isVoiceCaptureAllowed({ microphone: 'prompt-with-rationale' })).toBe(true);
|
||||||
|
expect(isVoiceCaptureAllowed({})).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires camera permission for camera capture', () => {
|
it('only blocks camera capture on an explicit native denial', () => {
|
||||||
expect(isCameraCaptureAllowed({ camera: 'granted' })).toBe(true);
|
expect(isCameraCaptureAllowed({ camera: 'granted' })).toBe(true);
|
||||||
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(false);
|
expect(isCameraCaptureAllowed({ camera: 'denied' })).toBe(false);
|
||||||
|
expect(isCameraCaptureAllowed({ camera: 'prompt' })).toBe(true);
|
||||||
|
expect(isCameraCaptureAllowed({})).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,12 +17,21 @@ export function shouldPreflightMobileCapturePermissions(runtime: RuntimePlatform
|
|||||||
return runtime === 'capacitor';
|
return runtime === 'capacitor';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only an explicit native denial blocks capture. Any other state (granted, a
|
||||||
|
* dismissed prompt, or an unknown value) defers to the WebView getUserMedia
|
||||||
|
* permission flow, which re-prompts through Capacitor's WebChromeClient.
|
||||||
|
*/
|
||||||
|
function isCaptureBlockedByNativeDenial(state: MobileMediaPermissionState | undefined): boolean {
|
||||||
|
return state === 'denied';
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve whether voice capture can proceed after a native permission request. */
|
/** Resolve whether voice capture can proceed after a native permission request. */
|
||||||
export function isVoiceCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
export function isVoiceCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
||||||
return isMobileCapturePermissionGranted(result.microphone);
|
return !isCaptureBlockedByNativeDenial(result.microphone);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve whether camera capture can proceed after a native permission request. */
|
/** Resolve whether camera capture can proceed after a native permission request. */
|
||||||
export function isCameraCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
export function isCameraCaptureAllowed(result: MobileCapturePermissionResult): boolean {
|
||||||
return isMobileCapturePermissionGranted(result.camera);
|
return !isCaptureBlockedByNativeDenial(result.camera);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
const pluginState = vi.hoisted(() => ({
|
||||||
|
plugin: null as null | {
|
||||||
|
startVoiceForegroundService: () => Promise<void>;
|
||||||
|
stopVoiceForegroundService: () => Promise<void>;
|
||||||
|
},
|
||||||
|
isNative: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../adapters/capacitor/metoyou-mobile.plugin', () => ({
|
||||||
|
loadMetoyouMobilePlugin: vi.fn(() => Promise.resolve(pluginState.plugin))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./platform-detection.rules', () => ({
|
||||||
|
isCapacitorNativeRuntime: vi.fn(() => pluginState.isNative)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from './mobile-voice-foreground-session';
|
||||||
|
|
||||||
|
describe('mobile-voice-foreground-session', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
pluginState.isNative = true;
|
||||||
|
pluginState.plugin = {
|
||||||
|
startVoiceForegroundService: vi.fn(async () => undefined),
|
||||||
|
stopVoiceForegroundService: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset internal session flag between tests.
|
||||||
|
await stopMobileVoiceForegroundSession();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the native foreground service on Capacitor shells', async () => {
|
||||||
|
await startMobileVoiceForegroundSession();
|
||||||
|
|
||||||
|
expect(pluginState.plugin?.startVoiceForegroundService).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing off Capacitor shells', async () => {
|
||||||
|
pluginState.isNative = false;
|
||||||
|
|
||||||
|
await startMobileVoiceForegroundSession();
|
||||||
|
|
||||||
|
expect(pluginState.plugin?.startVoiceForegroundService).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the native foreground service after a start', async () => {
|
||||||
|
await startMobileVoiceForegroundSession();
|
||||||
|
await stopMobileVoiceForegroundSession();
|
||||||
|
|
||||||
|
expect(pluginState.plugin?.stopVoiceForegroundService).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows native bridge failures', async () => {
|
||||||
|
pluginState.plugin = {
|
||||||
|
startVoiceForegroundService: vi.fn(() => Promise.reject(new Error('UNIMPLEMENTED'))),
|
||||||
|
stopVoiceForegroundService: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a missing plugin gracefully', async () => {
|
||||||
|
pluginState.plugin = null;
|
||||||
|
|
||||||
|
await expect(startMobileVoiceForegroundSession()).resolves.toBeUndefined();
|
||||||
|
await expect(stopMobileVoiceForegroundSession()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { loadMetoyouMobilePlugin } from '../adapters/capacitor/metoyou-mobile.plugin';
|
||||||
|
import { isCapacitorNativeRuntime } from './platform-detection.rules';
|
||||||
|
|
||||||
|
let sessionActive = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep Android microphone capture alive while any voice session (voice channel
|
||||||
|
* or direct call) is active by running the native foreground service. Without
|
||||||
|
* it Android kills WebRTC capture shortly after the app backgrounds.
|
||||||
|
*/
|
||||||
|
export async function startMobileVoiceForegroundSession(): Promise<void> {
|
||||||
|
if (!isCapacitorNativeRuntime() || sessionActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!plugin?.startVoiceForegroundService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await plugin.startVoiceForegroundService();
|
||||||
|
sessionActive = true;
|
||||||
|
} catch {
|
||||||
|
// Native bridge unavailable; capture continues while the app stays foregrounded.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the Android voice foreground service once no voice session remains. */
|
||||||
|
export async function stopMobileVoiceForegroundSession(): Promise<void> {
|
||||||
|
if (!isCapacitorNativeRuntime() || !sessionActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionActive = false;
|
||||||
|
|
||||||
|
const plugin = await loadMetoyouMobilePlugin();
|
||||||
|
|
||||||
|
if (!plugin?.stopVoiceForegroundService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await plugin.stopVoiceForegroundService();
|
||||||
|
} catch {
|
||||||
|
// Service already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Injector, runInInjectionContext } from '@angular/core';
|
||||||
|
|
||||||
|
import { MobileAppLifecycleService } from './mobile-app-lifecycle.service';
|
||||||
|
import { MobilePlatformService } from './mobile-platform.service';
|
||||||
|
import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.service';
|
||||||
|
|
||||||
|
type VisibilityListener = () => void;
|
||||||
|
|
||||||
|
interface DocumentStub {
|
||||||
|
hidden: boolean;
|
||||||
|
listeners: VisibilityListener[];
|
||||||
|
addEventListener: (type: string, listener: VisibilityListener) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDocumentStub(): DocumentStub {
|
||||||
|
const stub: DocumentStub = {
|
||||||
|
hidden: false,
|
||||||
|
listeners: [],
|
||||||
|
addEventListener(type: string, listener: VisibilityListener) {
|
||||||
|
if (type === 'visibilitychange') {
|
||||||
|
stub.listeners.push(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(globalThis as { document?: unknown }).document = stub;
|
||||||
|
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService() {
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: MobilePlatformService,
|
||||||
|
useValue: {
|
||||||
|
refreshRuntimeDetection: vi.fn(),
|
||||||
|
runtime: vi.fn(() => 'browser')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MobileRuntimePermissionsService,
|
||||||
|
useValue: {
|
||||||
|
initialize: vi.fn(async () => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return runInInjectionContext(injector, () => new MobileAppLifecycleService());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MobileAppLifecycleService', () => {
|
||||||
|
let documentStub: DocumentStub;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
documentStub = installDocumentStub();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (globalThis as { document?: unknown }).document;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fans app-state changes out to every registered handler', async () => {
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
|
||||||
|
service.onAppStateChange(first);
|
||||||
|
service.onAppStateChange(second);
|
||||||
|
|
||||||
|
documentStub.hidden = true;
|
||||||
|
documentStub.listeners.forEach((listener) => listener());
|
||||||
|
|
||||||
|
expect(first).toHaveBeenCalledWith(false);
|
||||||
|
expect(second).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps handlers registered before initialize', async () => {
|
||||||
|
const service = createService();
|
||||||
|
const handler = vi.fn();
|
||||||
|
|
||||||
|
service.onAppStateChange(handler);
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
documentStub.hidden = false;
|
||||||
|
documentStub.listeners.forEach((listener) => listener());
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,10 +12,15 @@ import { MobileRuntimePermissionsService } from './mobile-runtime-permissions.se
|
|||||||
export class MobileAppLifecycleService {
|
export class MobileAppLifecycleService {
|
||||||
private readonly mobilePlatform = inject(MobilePlatformService);
|
private readonly mobilePlatform = inject(MobilePlatformService);
|
||||||
private readonly runtimePermissions = inject(MobileRuntimePermissionsService);
|
private readonly runtimePermissions = inject(MobileRuntimePermissionsService);
|
||||||
|
private readonly appStateHandlers = new Set<(isActive: boolean) => void>();
|
||||||
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
private adapter: MobileAppLifecycleAdapter = new WebMobileAppLifecycleAdapter();
|
||||||
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
private adapterReady: Promise<MobileAppLifecycleAdapter> | null = null;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
|
||||||
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
return;
|
return;
|
||||||
@@ -30,8 +35,15 @@ export class MobileAppLifecycleService {
|
|||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Register an app foreground/background listener; every registered handler is invoked (fan-out). */
|
||||||
onAppStateChange(handler: (isActive: boolean) => void): void {
|
onAppStateChange(handler: (isActive: boolean) => void): void {
|
||||||
this.adapter.onAppStateChange(handler);
|
this.appStateHandlers.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchAppStateChange(isActive: boolean): void {
|
||||||
|
for (const handler of this.appStateHandlers) {
|
||||||
|
handler(isActive);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
|
private ensureAdapter(): Promise<MobileAppLifecycleAdapter> {
|
||||||
@@ -46,6 +58,7 @@ export class MobileAppLifecycleService {
|
|||||||
}
|
}
|
||||||
).then((adapter) => {
|
).then((adapter) => {
|
||||||
this.adapter = adapter;
|
this.adapter = adapter;
|
||||||
|
this.adapter.onAppStateChange((isActive) => this.dispatchAppStateChange(isActive));
|
||||||
return adapter;
|
return adapter;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
|
|
||||||
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
|
import type { CallNotificationActionIntent } from '../logic/call-notification.rules';
|
||||||
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
|
import { buildIncomingCallNotification, buildInCallNotification } from '../logic/call-notification.rules';
|
||||||
|
import { buildMessageNotification } from '../logic/message-notification.rules';
|
||||||
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
import { resolveMobileAdapter } from '../logic/mobile-capacitor-adapter.rules';
|
||||||
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
|
import type { MobileNotificationAdapter } from '../contracts/mobile.contracts';
|
||||||
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
|
import { WebMobileNotificationsAdapter } from '../adapters/web/web-mobile-notifications.adapter';
|
||||||
@@ -44,6 +45,13 @@ export class MobileNotificationsService {
|
|||||||
await adapter.showCallNotification(buildInCallNotification(input));
|
await adapter.showCallNotification(buildInCallNotification(input));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async showMessage(input: { title: string; body: string; tag?: string }): Promise<void> {
|
||||||
|
await this.initialize();
|
||||||
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
await adapter.showMessageNotification(buildMessageNotification(input));
|
||||||
|
}
|
||||||
|
|
||||||
async dismissIncomingCall(callId: string): Promise<void> {
|
async dismissIncomingCall(callId: string): Promise<void> {
|
||||||
const adapter = await this.ensureAdapter();
|
const adapter = await this.ensureAdapter();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../../mobile/logic/ensure-mobile-capture-permissions';
|
import { ensureMobileCameraCapturePermissions, ensureMobileVoiceCapturePermissions } from '../../mobile/logic/ensure-mobile-capture-permissions';
|
||||||
|
import { startMobileVoiceForegroundSession, stopMobileVoiceForegroundSession } from '../../mobile/logic/mobile-voice-foreground-session';
|
||||||
import { ChatEvent } from '../../../shared-kernel';
|
import { ChatEvent } from '../../../shared-kernel';
|
||||||
import { LatencyProfile } from '../realtime.constants';
|
import { LatencyProfile } from '../realtime.constants';
|
||||||
import { PeerData } from '../realtime.types';
|
import { PeerData } from '../realtime.types';
|
||||||
@@ -248,6 +249,7 @@ export class MediaManager {
|
|||||||
|
|
||||||
this.isVoiceActive = true;
|
this.isVoiceActive = true;
|
||||||
this.voiceConnected$.next();
|
this.voiceConnected$.next();
|
||||||
|
void startMobileVoiceForegroundSession();
|
||||||
return this.localMediaStream;
|
return this.localMediaStream;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to getUserMedia', error);
|
this.logger.error('Failed to getUserMedia', error);
|
||||||
@@ -288,6 +290,7 @@ export class MediaManager {
|
|||||||
this.currentVoiceRoomId = undefined;
|
this.currentVoiceRoomId = undefined;
|
||||||
this.currentVoiceServerId = undefined;
|
this.currentVoiceServerId = undefined;
|
||||||
this.allowedVoicePeerIds.clear();
|
this.allowedVoicePeerIds.clear();
|
||||||
|
void stopMobileVoiceForegroundSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -315,6 +318,7 @@ export class MediaManager {
|
|||||||
this.bindLocalTracksToAllPeers();
|
this.bindLocalTracksToAllPeers();
|
||||||
this.isVoiceActive = true;
|
this.isVoiceActive = true;
|
||||||
this.voiceConnected$.next();
|
this.voiceConnected$.next();
|
||||||
|
void startMobileVoiceForegroundSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (showPanel() && isOpen()) {
|
@if (showPanel() && isOpen()) {
|
||||||
<div class="pointer-events-none fixed inset-0 z-[79]">
|
<div class="pointer-events-none fixed metoyou-fixed-safe-viewport z-[79]">
|
||||||
<section
|
<section
|
||||||
class="pointer-events-auto absolute flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto absolute flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
[class.bottom-20]="!detached()"
|
[class.bottom-20]="!detached()"
|
||||||
|
|||||||
+2
-2
@@ -4,9 +4,9 @@
|
|||||||
(dismissed)="cancelled.emit(undefined)"
|
(dismissed)="cancelled.emit(undefined)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed metoyou-fixed-safe-viewport z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto w-full max-w-2xl rounded-2xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto max-h-full w-full max-w-2xl overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(keydown.enter)="$event.stopPropagation()"
|
(keydown.enter)="$event.stopPropagation()"
|
||||||
(keydown.space)="$event.stopPropagation()"
|
(keydown.space)="$event.stopPropagation()"
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
(dismissed)="cancel()"
|
(dismissed)="cancel()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed metoyou-fixed-safe-viewport z-[111] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<section
|
<section
|
||||||
appThemeNode="screenShareSourcePicker"
|
appThemeNode="screenShareSourcePicker"
|
||||||
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
|
class="pointer-events-auto w-full max-w-6xl rounded-2xl border border-border bg-card shadow-2xl"
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ const LEGACY_ICON_PX = { mdpi: 48, hdpi: 72, xhdpi: 96, xxhdpi: 144, xxxhdpi: 19
|
|||||||
/** Adaptive foreground canvas edge length per density (108dp). */
|
/** Adaptive foreground canvas edge length per density (108dp). */
|
||||||
const FOREGROUND_PX = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };
|
const FOREGROUND_PX = { mdpi: 108, hdpi: 162, xhdpi: 216, xxhdpi: 324, xxxhdpi: 432 };
|
||||||
|
|
||||||
|
/** Notification status-bar icon edge length per density (24dp). */
|
||||||
|
const NOTIFICATION_ICON_PX = { mdpi: 24, hdpi: 36, xhdpi: 48, xxhdpi: 72, xxxhdpi: 96 };
|
||||||
|
|
||||||
|
/** Luminance cut separating the white cat glyph from the purple disc when building the alpha mask. */
|
||||||
|
const NOTIFICATION_ICON_LUMINANCE_THRESHOLD = 160;
|
||||||
|
|
||||||
/** Portrait splash dimensions per density; landscape swaps width/height. */
|
/** Portrait splash dimensions per density; landscape swaps width/height. */
|
||||||
const SPLASH_PORTRAIT = {
|
const SPLASH_PORTRAIT = {
|
||||||
mdpi: [320, 480],
|
mdpi: [320, 480],
|
||||||
@@ -106,6 +112,36 @@ async function generateLauncherIcons() {
|
|||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android status-bar icons are rendered as alpha-only silhouettes, so extract the
|
||||||
|
* white cat glyph from the brand mark (luminance threshold) and paint it white.
|
||||||
|
*/
|
||||||
|
async function notificationStatusIcon(size) {
|
||||||
|
const alphaMask = await sharp(SOURCE_ICON)
|
||||||
|
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||||
|
.removeAlpha()
|
||||||
|
.greyscale()
|
||||||
|
.threshold(NOTIFICATION_ICON_LUMINANCE_THRESHOLD)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return sharp({ create: { width: size, height: size, channels: 3, background: { r: 255, g: 255, b: 255 } } })
|
||||||
|
.joinChannel(alphaMask)
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateNotificationStatusIcons() {
|
||||||
|
const written = [];
|
||||||
|
|
||||||
|
for (const [density, size] of Object.entries(NOTIFICATION_ICON_PX)) {
|
||||||
|
const icon = await notificationStatusIcon(size);
|
||||||
|
written.push(await writePng(icon, `drawable-${density}/ic_stat_metoyou.png`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
async function generateAdaptiveBackgroundColor() {
|
async function generateAdaptiveBackgroundColor() {
|
||||||
const xml = `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${BRAND_BACKGROUND_HEX}</color>\n</resources>\n`;
|
const xml = `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${BRAND_BACKGROUND_HEX}</color>\n</resources>\n`;
|
||||||
const outPath = resolve(RES_DIR, 'values/ic_launcher_background.xml');
|
const outPath = resolve(RES_DIR, 'values/ic_launcher_background.xml');
|
||||||
@@ -134,7 +170,8 @@ async function main() {
|
|||||||
const written = [
|
const written = [
|
||||||
...(await generateLauncherIcons()),
|
...(await generateLauncherIcons()),
|
||||||
await generateAdaptiveBackgroundColor(),
|
await generateAdaptiveBackgroundColor(),
|
||||||
...(await generateSplashScreens())
|
...(await generateSplashScreens()),
|
||||||
|
...(await generateNotificationStatusIcons())
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log(`Generated ${written.length} Android brand resources from ${SOURCE_ICON}:`);
|
console.log(`Generated ${written.length} Android brand resources from ${SOURCE_ICON}:`);
|
||||||
|
|||||||
Reference in New Issue
Block a user