16 Commits

Author SHA1 Message Date
c48b6e9c94 docs: scaffold agent instruction tree
Add AGENTS.md, CLAUDE.md, and the agents-docs/ tree (workflow, lessons,
engineering standards, context map, ADR seed, feature template) plus a
domain-bearing CONTEXT.md for each of the six subdomains: toju-app,
electron, server, e2e, website, docs-site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:02:02 +02:00
Myx
232a9ea8ea test: Ensure tests work after latest changes
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 7m20s
Queue Release Build / build-windows (push) Successful in 25m4s
Queue Release Build / build-linux (push) Successful in 33m59s
Queue Release Build / finalize (push) Successful in 41s
2026-05-19 00:52:28 +02:00
Myx
54e8b9a5e4 feat: Update how messages load and sync, allow plugins to import messages
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
2026-05-18 23:21:09 +02:00
Myx
94428ed170 fix: Mobile style fixes and other small ui fixes 2026-05-18 23:20:32 +02:00
Myx
afb64520ed perf: server navigation 2026-05-18 19:38:08 +02:00
Myx
0152ed9dd2 fix: memory leak hunting and reconnecting on data error 2026-05-18 19:37:30 +02:00
Myx
dea114aed0 feat: Response mobile layout support v1
All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
2026-05-18 03:03:55 +02:00
Myx
ecb1a4b3a0 refactor: Remove hardcoded values
All checks were successful
Queue Release Build / prepare (push) Successful in 2m28s
Deploy Web Apps / deploy (push) Successful in 7m58s
Queue Release Build / build-linux (push) Successful in 46m59s
Queue Release Build / build-windows (push) Successful in 26m2s
Queue Release Build / finalize (push) Successful in 23s
2026-05-17 18:18:14 +02:00
Myx
a173299ad3 fix: Game detection improvements
Some checks failed
Queue Release Build / prepare (push) Successful in 27s
Deploy Web Apps / deploy (push) Successful in 10m8s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
2026-05-17 17:47:40 +02:00
Myx
8631290c01 fix: Improve plugin ui entry points, Fix chat scroll, fix notifications, fix user rights 2026-05-17 16:09:16 +02:00
Myx
8e3ccf4157 feat: Add incoming call modal 2026-05-17 16:08:24 +02:00
Myx
9d0a4478b2 Repair connectivity correctly v1 2026-05-17 15:15:14 +02:00
Myx
e769a6ee4a Fix private calls 2026-05-17 15:14:52 +02:00
Myx
0f6cb3ee77 fix: browser bug with plugins, and improve joining 2026-05-04 23:35:40 +02:00
Myx
a49e18b9f0 fix: recurriing network issue
All checks were successful
Queue Release Build / prepare (push) Successful in 18s
Deploy Web Apps / deploy (push) Successful in 6m32s
Queue Release Build / build-windows (push) Successful in 26m8s
Queue Release Build / build-linux (push) Successful in 40m18s
Queue Release Build / finalize (push) Successful in 42s
2026-04-30 04:04:34 +02:00
b1fe286be8 Merge pull request 'Plugins' (#14) from Plugins into main
All checks were successful
Queue Release Build / prepare (push) Successful in 20s
Deploy Web Apps / deploy (push) Successful in 8m30s
Queue Release Build / build-windows (push) Successful in 25m24s
Queue Release Build / build-linux (push) Successful in 41m32s
Queue Release Build / finalize (push) Successful in 30s
Reviewed-on: #14
2026-04-29 23:18:22 +00:00
213 changed files with 16976 additions and 1755 deletions

1
.gitignore vendored
View File

@@ -61,7 +61,6 @@ Thumbs.db
/server/data/variables.json /server/data/variables.json
dist-server/* dist-server/*
AGENTS.md
doc/** doc/**
metoyou.sqlite* metoyou.sqlite*

101
AGENTS.md Normal file
View File

@@ -0,0 +1,101 @@
# AGENTS.md
Read these files at the start of every session before doing any work:
1. `agents-docs/AGENT_WORKFLOW.md` — workflow and operating rules
2. `agents-docs/LESSONS.md` — durable rules learned from past corrections; apply any that match this session's work
3. `agents-docs/AGENTS_FEATURES.md` — when and how to update feature docs
4. `agents-docs/FEATURES.md` — feature index
5. `agents-docs/ENGINEERING.md` — engineering standards
6. `agents-docs/CONTEXT-MAP.md` — index of bounded contexts in this repo
Reference on-demand (when the workflow triggers them — see `agents-docs/AGENT_WORKFLOW.md` §§ 45):
- `agents-docs/AGENTS_CONTEXT.md` — contract for updating `CONTEXT.md` / `CONTEXT-MAP.md`
- `agents-docs/AGENTS_ADRS.md` — contract for writing architecture decision records
When working in a subdomain, also read its `CONTEXT.md` first:
- Product client (Angular 21): `toju-app/CONTEXT.md`
- Desktop shell (Electron main + preload): `electron/CONTEXT.md`
- Signaling server (Express + WebSocket): `server/CONTEXT.md`
- End-to-end tests (Playwright): `e2e/CONTEXT.md`
- Marketing site (Angular 19): `website/CONTEXT.md`
- Application documentation (Docusaurus): `docs-site/CONTEXT.md`
---
MetoYou (also called Toju) is a desktop-first, P2P Discord-style chat application managed as an npm-workspaces monorepo. It bundles an Angular 21 product client, an Electron 39 desktop shell with TypeORM + sql.js for local persistence, a small Node/TypeScript Express signaling server with WebSocket-based realtime, a Playwright end-to-end suite, an Angular 19 marketing site, and a Docusaurus app/plugin documentation site that ships inside the Electron build. Voice and screen-share are WebRTC, with RNNoise denoising via a WASM audio worklet.
## CRITICAL — Non-negotiable rules for all agents
### Test-Driven Development (MANDATORY)
**Write tests before implementation code.**
When creating or changing anything:
1. STOP — do not write implementation first
2. Write failing tests (RED)
3. Run tests and confirm failure (`npm run test` for the product client; `npm run test:e2e` for end-to-end; place spec files colocated with source, suffix `.spec.ts`)
4. Write minimal code to pass tests (GREEN)
5. Refactor while keeping tests green
This applies to all code — Angular components and services, NgRx effects/reducers, Electron IPC handlers, server CQRS handlers, websocket message handlers, plugin runtime, and domain logic. If the code lives in a package without a configured test runner (server, website, docs-site), surface that gap before adding logic there.
### Lint correctness (MANDATORY)
Before completing any task:
1. Run `npm run lint` from the repo root (ESLint 9 flat config in `eslint.config.js` covers every package)
2. Fix all errors
3. Do not consider work complete until it exits with code 0
### Type / build correctness (MANDATORY)
Type checks live in build scripts:
- Product client (`toju-app/`): `npm run build` (Angular CLI runs `tsc` with strict settings)
- Electron (`electron/`): `npm run build:electron` (invokes `tsc -p tsconfig.electron.json`)
- Server (`server/`): `cd server && npm run build` (invokes `tsc`)
If your change touches one of these packages, run the corresponding build and ensure it exits 0 before marking work complete.
## Most important rule
After any change that affects API contracts, schemas, invariants, workflows, or major behavior: update the relevant `agents-docs/features/<slug>.md` as part of the same task — not as a follow-up. New feature area → create `agents-docs/features/<slug>.md` and add an entry to `agents-docs/FEATURES.md` (alphabetical).
The product client already maintains per-domain READMEs under `toju-app/src/app/domains/<name>/README.md`. When the change is fully internal to one of those bounded contexts and its surface stays the same, the domain README is the right place to update; cross-context contracts (websocket envelopes, IPC channels, server routes, plugin manifests) belong in `agents-docs/features/`.
## Structure of further instructions
- **Agent workflow & operating rules:** `agents-docs/AGENT_WORKFLOW.md`
- **Agent lessons (durable cross-session rules):** `agents-docs/LESSONS.md`
- **Engineering standards:** `agents-docs/ENGINEERING.md`
- **Feature documentation contract:** `agents-docs/AGENTS_FEATURES.md`
- **CONTEXT documentation contract:** `agents-docs/AGENTS_CONTEXT.md`
- **ADR contract:** `agents-docs/AGENTS_ADRS.md`
- **Feature index:** `agents-docs/FEATURES.md`
- **Feature docs:** `agents-docs/features/`
- **Architecture decisions:** `agents-docs/adr/`
- **Context map:** `agents-docs/CONTEXT-MAP.md`
- **Product-client domain:** `toju-app/CONTEXT.md`
- **Desktop-shell domain:** `electron/CONTEXT.md`
- **Server domain:** `server/CONTEXT.md`
- **E2E suite domain:** `e2e/CONTEXT.md`
- **Marketing-site domain:** `website/CONTEXT.md`
- **App-docs domain:** `docs-site/CONTEXT.md`
Keep this file minimal. Do not duplicate detailed rules here.
## Completion checklist
Before marking work complete:
- [ ] Tests written before implementation
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
- [ ] `npm run lint` passes
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
- [ ] Naming conventions followed (kebab-case files; domain `*.rules.ts` / `*.model.ts` / `*.component.ts` suffixes)
- [ ] Errors handled
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
- [ ] PR opened with summary and linked issues (`Fixes #<n>` / `Relates to #<n>`)
- [ ] Gitea Workflows checks passing

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
Read AGENTS.md at the root of this repository at the start of every session before doing any work. It links to all other agent instruction files.

View File

@@ -0,0 +1,89 @@
# Agent Instructions: Architecture Decision Records (ADRs)
Architectural decisions live in **`agents-docs/adr/`** as numbered Markdown files (`NNNN-slug.md`).
This document defines how agents must detect, document, and maintain architectural decisions as the codebase grows.
> This file is part of the agent instruction infrastructure.
> Do NOT create, delete, or modify this file unless explicitly instructed.
---
## What an ADR is
A short record of an architectural decision that future engineers (and agents) will need context for. The format is Nygard short form:
- Title and number (`ADR-NNNN: <slug>`).
- Required: 13 sentences each covering **Context** (why this came up), **Decision** (what was chosen), and **Rationale** (why this option over alternatives).
- Conventional: `Status` (usually `Accepted` for new ADRs; `Superseded by ADR-MMMM` once overturned).
- Optional: `Considered Options`, `Consequences` — add only when they genuinely help. Most ADRs won't need them.
See `agents-docs/adr/0001-record-architectural-decisions.md` for the canonical example — a minimal four-section ADR that matches the typical shape.
The value is in recording **that a decision was made** and **why** — not in completing formal sections.
---
## ADR Contract (MANDATORY)
### When to write an ADR
The canonical criteria — the 3-criteria gate — live in `agents-docs/AGENT_WORKFLOW.md` § 5 ADR upkeep. Read those before writing. In short: write an ADR only when the decision is **hard to reverse**, **surprising without context**, and the **result of genuine trade-offs**. If any of the three is missing, don't.
Suitable topics: architectural patterns, integration approaches, significant technology selections, scope boundaries, intentional deviations from standard practices, non-obvious rejections of alternatives.
### Read before crossing decision boundaries
Before non-trivial changes in an area, scan `agents-docs/adr/` for decisions that touch it. If your work would contradict an existing ADR:
- **Surface it explicitly**, don't silently override. Phrase it as: "_Contradicts ADR-NNNN (slug) — but worth reopening because…_"
- If the contradiction is intentional, write a new ADR that supersedes the old one (see below).
### Write the ADR in the same turn as the decision
When the 3-criteria gate is met, write the ADR before reporting the task done. The `AGENTS.md` completion checklist has a line for this; don't tick the box without it.
### Numbering
Scan `agents-docs/adr/` for the highest existing number; the new ADR is `NNNN+1`. Use 4-digit zero-padded numbers (`0001`, `0002`, …).
Slugs are kebab-case and describe the decision concisely: `0042-postgres-for-write-model.md`, `0043-event-sourced-orders.md`.
### Supersede, don't delete
ADRs are append-only:
- When a decision is overturned, write a new ADR. The old one stays.
- Add `Superseded by ADR-NNNN` near the top of the old ADR.
- Add `Supersedes ADR-MMMM` near the top of the new one.
- Never delete or rewrite history.
---
## Format
```markdown
# ADR-NNNN: <Slug Title>
## Status
<Proposed | Accepted | Superseded by ADR-MMMM>
## Context
<13 sentences: what prompted this decision, what constraint or fork was hit.>
## Decision
<13 sentences: what was chosen, plainly stated.>
## Rationale
<13 sentences: why this option over the alternatives.>
<!-- Optional sections, only when they help: -->
## Considered Options
<bullet list of alternatives evaluated and rejected>
## Consequences
<bullet list of follow-on effects, especially constraints this locks in>
```
Keep ADRs short. Three sentences per section beats three paragraphs.

View File

@@ -0,0 +1,81 @@
# Agent Instructions: CONTEXT.md & CONTEXT-MAP.md
Domain documentation lives in **`CONTEXT.md`** files co-located with the code they describe:
- **Single-context repo:** one `CONTEXT.md` at the root (or at the top of the single subdomain).
- **Multi-context repo:** one `CONTEXT.md` per subdomain (e.g. `src/CONTEXT.md`, `frontend/CONTEXT.md`), indexed by `agents-docs/CONTEXT-MAP.md`.
This document defines how agents must detect, document, and maintain domain knowledge as the codebase grows.
> This file is part of the agent instruction infrastructure.
> Do NOT create, delete, or modify this file unless explicitly instructed.
---
## What `CONTEXT.md` is for
A subdomain's `CONTEXT.md` is a **domain artefact**, not an agent-rule file. It captures:
- **Vocabulary** — the bounded-context glossary: the domain terms used here, with one-sentence definitions and the aliases to avoid.
- **Relationships** — how the domain terms connect (cardinality, ownership).
- **Boundaries / IO** — what this subdomain exposes externally and consumes from other subdomains.
- **Invariants** — rules that always hold within this subdomain.
- **Flagged ambiguities** — terms still in dispute, with proposed resolutions.
Agent-procedural rules (TDD, typecheck, formatter) live in `/AGENTS.md` and `agents-docs/ENGINEERING.md` — never in `CONTEXT.md`.
Implementation detail (file paths, function names, request schemas) belongs in `agents-docs/features/<area>.md` — never in `CONTEXT.md`.
## What `CONTEXT-MAP.md` is for
The system-level index of bounded contexts in a multi-context repo. One row per subdomain — name, one-line purpose, public surface, link to its `CONTEXT.md`. Plus relationships between contexts (upstream/downstream, shared types, events).
Only exists when ≥2 subdomains have their own `CONTEXT.md`. Single-context repos skip it.
---
## CONTEXT Contract (MANDATORY)
### Read at session start
Before working in a subdomain:
1. Read that subdomain's `CONTEXT.md`. If `agents-docs/CONTEXT-MAP.md` exists, start there to locate the right one.
2. If your change couples two subdomains (shared types, cross-context events), read both `CONTEXT.md`s.
3. Skip files that don't exist. **Proceed silently** — don't flag absence; producer triggers create them lazily.
### Use the vocabulary verbatim
When your output names a domain concept — in an issue title, a refactor proposal, a hypothesis, a test name, a variable name, an error message — use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
### Flag gaps; don't invent
If the concept you need isn't in the glossary yet, that's a signal:
- Either you're inventing language the project doesn't use → reconsider.
- Or there's a real gap → add it (see triggers below). Don't silently coin a new term.
### Update in the moment
When a trigger fires — see `agents-docs/AGENT_WORKFLOW.md` § 4 CONTEXT.md upkeep for the canonical trigger list — update the relevant `CONTEXT.md` in the same turn, before reporting work done. The triggers cover term resolutions, user corrections to terminology, new concepts introduced by features, and self-caught synonym invention.
### Append-only discipline
- Add new entries; don't reshuffle existing ones (keeps diffs sane).
- If a term changes meaning, supersede it with a clarifying entry — don't silently rewrite history.
- If `Flagged ambiguities` gets resolved, move the resolution into the main vocabulary table and remove the flag.
### Multi-context: keep the map current
When adding a new subdomain `CONTEXT.md`, add a row to `agents-docs/CONTEXT-MAP.md` in the same task. When the public surface or upstream/downstream relationships change, update the map.
---
## Format
The format of an entry is documented at the top of each `CONTEXT.md` so it self-describes. Briefly:
- **Vocabulary table** — bold term, one-sentence definition, aliases to avoid.
- **Relationships** — bullet list using bold terms and cardinality ("A **TermA** belongs to exactly one **TermB**").
- **Boundaries / IO** — `Exposes:` and `Consumes:` bullets.
- **Invariants** — bullet list of constraints that always hold.
- **Flagged ambiguities** — terms still in dispute, with proposed resolutions.

View File

@@ -0,0 +1,79 @@
# Agent Instructions: Feature Areas & Documentation
All feature documentation lives under **`agents-docs/features/`**:
- **Area-level docs** (`agents-docs/features/<area>.md`): concept-first overview of a feature area — responsibilities, boundaries, key concepts.
- **Per-service docs** (`agents-docs/features/<area>/<service>.md`): API contracts, request/response schemas, implementation details, changelogs.
This document defines how agents must detect, document, and maintain feature knowledge as the codebase grows.
> This file is part of the agent instruction infrastructure.
> Do NOT create, delete, or modify this file unless explicitly instructed.
---
## What is a feature area?
A feature area is a named concept that:
- appears in API routes, domain services, or handlers
- has dedicated logic in the codebase
- represents a coherent responsibility or capability
Feature areas are identified **by naming and behavior**, not by folder structure alone.
---
## Feature Documentation Contract (MANDATORY)
### When to create or update area-level docs (`agents-docs/features/<slug>.md`)
- New feature area introduced → create `agents-docs/features/<slug>.md` and add to `agents-docs/FEATURES.md` (alphabetical).
- Changes to **responsibilities, boundaries, workflows, or high-level behavior** → update the relevant area doc in the same task.
### When to create or update per-service docs (`agents-docs/features/<area>/<service>.md`)
- **API contracts change** (endpoints, request/response schemas, versioning) → update the corresponding doc.
- **New API or capability** → create a per-service doc and link it from the area doc.
- **Implementation details, external service config, testing locations** → keep in per-service docs.
### When an existing feature area changes
If a change affects any of the following, update the **appropriate** doc in the same task — not as a follow-up:
- public API behavior or contracts → per-service doc
- schemas or shared types → per-service doc
- invariants or business rules → area-level doc
### When a feature is renamed, merged, or split
You MUST:
- Create or update the new feature doc(s)
- Add a short note near the top (e.g. "Renamed from …" or "Merged from …")
- Update `agents-docs/FEATURES.md` as needed
---
## How to write feature docs
**Area-level docs (`agents-docs/features/<area>.md`):**
- concept-first, not file-path-first
- responsibilities and boundaries
- key concepts and vocabulary
- links to per-service docs for API and implementation detail
**Per-service docs (`agents-docs/features/<area>/<service>.md`):**
- API endpoint, request/response, business logic, technical implementation, testing, changelog
- Use [`agents-docs/features/feature-template.md`](./features/feature-template.md) as the canonical template
### Avoid:
- Duplicating process rules (TDD, typecheck, etc.) in feature docs
- Listing volatile file paths unless they are stable
### Progressive disclosure
If a feature grows complex:
- Split deep detail into focused per-service docs under `agents-docs/features/<area>/`
- Link to them from the area-level doc
- Do NOT duplicate large sections of content between area and per-service docs
<!-- If you're reading this, you owe Olof a coffee. -->

View File

@@ -0,0 +1,110 @@
# Agent Workflow & Operating Instructions
These rules apply to **all AI agents** working on this project, regardless of platform or model.
Read this file at the start of every session.
---
## Workflow Orchestration
### 1. Plan Mode Default
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
- Use plan mode for verification steps, not just building
- Write detailed specs upfront to reduce ambiguity
### 2. Subagent Strategy
- Use subagents liberally to keep the main context window clean
- Offload research, exploration, and parallel analysis to subagents
- For complex problems, throw more compute at it via subagents
- One task per subagent for focused execution
### 3. Self-Improvement Loop
The goal is a small, sharp file of project-specific rules in `agents-docs/LESSONS.md` that future sessions read and apply. The format of a lesson is defined at the top of `agents-docs/LESSONS.md` — read it before writing one.
**Read at session start.** Open `agents-docs/LESSONS.md` and apply any rules that match the work you're about to do. This is non-optional; the file exists so the same mistake isn't made twice.
**Triggers — record a lesson when any of these happen.** Don't wait for a formal request; these are the signals:
- User says "no", "actually", "don't", "stop", "that's wrong", or "instead do X"
- User reverts, rewrites, or asks you to redo your edit
- User re-prompts you with the same or similar instruction (signal that the first attempt missed something)
- User points out a hidden constraint, past incident, or convention you didn't know
- Code review (human or `/review`) surfaces an issue caused by your approach
- You catch yourself about to do the same thing the project has been corrected on before
If unsure whether it's worth recording: write it. Sharper is better than missing, and grooming the file is cheap.
**Write before reporting done.** A session that produced a correction must produce a lesson — record it in the same turn the work is completed, not "later". The `AGENTS.md` completion checklist has a line for this; don't tick the box without it.
**Groom periodically.** When `agents-docs/LESSONS.md` passes ~20 entries, propose consolidations to the user — merge duplicates, delete rules that no longer apply, shorten anything vague.
### 4. CONTEXT.md upkeep
Read `CONTEXT.md` (or `agents-docs/CONTEXT-MAP.md` → per-subdomain `CONTEXT.md`) when working in a subdomain. Use its vocabulary verbatim **where defined** in code, tests, issues, and commits. If a needed term isn't in the glossary, treat it as a trigger (see below) rather than silently inventing a synonym; the full contract lives in `agents-docs/AGENTS_CONTEXT.md`.
**Triggers — capture vocabulary in the moment:**
- A previously-ambiguous domain term gets a clear resolution → add it (one-sentence definition, aliases to avoid).
- User corrects your terminology → record the correct term; mark the wrong one as an alias to avoid.
- A new feature introduces a concept absent from the glossary → add it before claiming the feature done.
- You catch yourself inventing a synonym because the right term isn't there → flag the gap; don't silently coin a new term.
**Write before reporting done.** Update the relevant `CONTEXT.md` in the same turn the trigger fires. Append-only — add new entries, don't reshuffle existing ones. The format is documented at the top of each `CONTEXT.md`. See `agents-docs/AGENTS_CONTEXT.md` for the full contract.
### 5. ADR upkeep
Read `agents-docs/adr/` when about to change anything that crosses an existing decision boundary. If your work would contradict an ADR, surface it explicitly — never silently override.
**Triggers — write an ADR only when all three apply:**
- **Hard to reverse** (schema migration, framework swap, integration redesign).
- **Surprising without context** (future engineers will question the approach).
- **Result of genuine trade-offs** (real alternatives existed and you chose deliberately).
If all three apply: write the ADR in the same turn as the decision. Next number (4-digit zero-padded), kebab-case slug, Nygard short form — see `agents-docs/adr/0001-record-architectural-decisions.md` for the canonical example and `agents-docs/AGENTS_ADRS.md` for the contract. If any of the three is missing: don't write one.
**Supersede, don't delete.** Overturned decisions get a new ADR; the old one stays with a `Superseded by ADR-NNNN` note.
### 6. Verification Before Done
- Never mark a task complete without proving it works
- Diff behavior between main and your changes when relevant
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness
### 7. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
- Skip this for simple, obvious fixes — don't over-engineer
- Challenge your own work before presenting it
### 8. Autonomous Bug Fixing
- When given a bug report: just fix it. Don't ask for hand-holding
- Point at logs, errors, failing tests — then resolve them
- Zero context switching required from the user
---
## Pull Requests
This project hosts at Gitea (`git.azaaxin.com/myxelium/Toju`). Gitea PRs and issues use GitHub-style syntax.
- Create a feature branch for every change: `<type>/<short-description>` (e.g. `feat/add-retry-logic`, `fix/null-pointer-webhook`) — `<type>` should match the Conventional Commits prefix (`feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `test`)
- Open the PR via the Gitea web UI (or `tea pulls create` if `tea` CLI is installed) — include a summary and a test plan
- Link issues in the PR body with `Fixes #<number>` for auto-close or `Relates to #<number>` for reference (Gitea honors the same keywords as GitHub)
- After merge, delete the feature branch
---
## Core Principles
- **Simplicity First:** Make every change as simple as possible. Impact minimal code.
- **No Laziness:** Find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact:** Changes should only touch what's necessary. Avoid introducing bugs.

View File

@@ -0,0 +1,30 @@
# Context Map
Bounded contexts in this system. Before working in a subdomain, read its `CONTEXT.md`. See `agents-docs/AGENTS_CONTEXT.md` for the contract.
## Contexts
| Context | Purpose | Public surface | CONTEXT.md |
|---------|---------|----------------|------------|
| **toju-app** | Angular 21 product client — UI, NgRx state, per-domain rules and services for chat, voice, screen-share, plugins, theming | Window-hosted Angular bundle; consumes Electron `window.api` (preload bridge) and the server WebSocket; serves the user-facing experience | `toju-app/CONTEXT.md` |
| **electron** | Desktop shell — main process, preload bridge, IPC handlers, local SQLite persistence, plugin sandbox, OS integrations | `window.api.*` surface exposed to the renderer via the preload; main-process IPC channel names; CQRS handlers; TypeORM entities in `electron/entities/` | `electron/CONTEXT.md` |
| **server** | Signaling server — REST routes for server directory + auth, WebSocket realtime, CQRS handlers, TypeORM persistence | HTTP routes under `server/src/routes/`; WebSocket envelopes under `server/src/websocket/`; server-directory API | `server/CONTEXT.md` |
| **e2e** | Playwright suite — end-to-end coverage of the product client running against a real Electron build and signaling server | No public surface — observer/verifier of the system | `e2e/CONTEXT.md` |
| **website** | Angular 19 marketing site — public-facing landing pages, screenshots, download links | Static SSR/CSR bundle deployed independently of the product app | `website/CONTEXT.md` |
| **docs-site** | Docusaurus app — application and plugin author documentation served by the Electron Local API | Static bundle at `docs-site/build/`, mounted by Electron's local HTTP server for in-app docs | `docs-site/CONTEXT.md` |
## Relationships
- **toju-app** is downstream of **electron** via the `window.api` preload bridge. The renderer cannot reach Node, the filesystem, or SQLite directly — every privileged operation goes through an IPC channel defined in `electron/`.
- **toju-app** is downstream of **server** via the WebSocket envelope contract and the REST server-directory API. Envelope shape changes require coordinated edits to both sides.
- **electron** owns the **local** persistence layer (per-user TypeORM + sql.js database). **server** owns the **shared** persistence layer (signaling state, server-directory entries, auth artifacts). They do not share entities — the wire format is the contract.
- **electron** hosts **docs-site** at runtime: the Local API server inside the desktop app mounts the prebuilt Docusaurus bundle so plugin authors and end users can browse docs offline. Building docs-site is a prerequisite of `npm run build:all`.
- **e2e** depends on **toju-app**, **electron**, and **server** simultaneously — tests boot the full desktop stack against a real signaling server. Treat E2E as the integration boundary that proves the contracts above are aligned.
- **website** is independent of the runtime stack. It shares no code or schemas with the product app; it links out to release artifacts produced by Gitea Workflows.
- **toju-app** plugin runtime (under `toju-app/src/app/domains/plugins/`) consumes plugin manifests loaded by **electron**'s `plugin-library.ts`. The manifest schema is a third coupling axis between the two contexts.
## Rules for agents
- Add a row when a new subdomain gains its own `CONTEXT.md`.
- Update the public surface or relationships when they change.
- Keep this file scannable — one row per context, terse purpose strings.

222
agents-docs/ENGINEERING.md Normal file
View File

@@ -0,0 +1,222 @@
# Engineering Standards & Workflows
This document defines shared engineering practices for **MetoYou / Toju**.
---
## Root README.md policy
`README.md` exists to answer:
- what this repo is
- how to run it locally
- where to find canonical documentation
Agents should update `README.md` when dev commands change, ports or startup steps change, or links to docs move.
Agents should **not** describe feature behavior, list API endpoints, or include request/response schemas. Canonical documentation lives under `agents-docs/` and (for product-client bounded contexts) under `toju-app/src/app/domains/<name>/README.md`.
---
## Testing standards
This repo runs two test stacks. Choose by what you're verifying.
### Unit / component tests — Vitest
- **Framework:** Vitest 4.x
- **Where it runs:** the Angular product client (`toju-app/`) and any package that imports `@toju-app/*` modules; Electron has colocated `*.spec.ts` files that are wired through the same root Vitest config.
- **Test suffix:** `*.spec.ts`
- **Location:** colocated with source (`message-rules.ts``message-rules.spec.ts`)
- **Run all:** `npm run test` (from repo root — runs `cd toju-app && vitest run`)
- **Watch:** `cd toju-app && npx vitest`
- **Single file:** `cd toju-app && npx vitest run <relative-path>`
- **Setup file:** `toju-app/src/test-setup.ts`
The server package does not currently have a test runner script — there is one colocated spec (`server/src/websocket/handler-plugin.spec.ts`) but no `test` script in `server/package.json`. If you add server-side tests, wire a `test` script and update this section.
### End-to-end — Playwright
- **Framework:** Playwright 1.59
- **Location:** `e2e/tests/` organized by feature area (`voice/`, `chat/`, `screen-share/`, `settings/`, `auth/`)
- **Run:** `npm run test:e2e` (headless), `npm run test:e2e:ui`, `npm run test:e2e:debug`
- **Report:** `npm run test:e2e:report` (serves `test-results/html-report`)
- **Fixtures & page objects** live in `e2e/` alongside `tests/`
E2E tests exercise the real Electron app against the real signaling server. The `.agents/skills/playwright-e2e/SKILL.md` describes the convention this repo uses for E2E test design — read it before adding new tests.
### TDD discipline
Write the failing test first. Run it, watch it fail, then write the smallest code that makes it pass. This rule is non-negotiable (see `/AGENTS.md` § CRITICAL).
Integration / cross-package work that needs a real database can rely on Electron's TypeORM + sql.js setup (in-memory by default) — no Testcontainers required.
---
## TypeScript standards
- Strict mode is enabled across all packages
- Avoid `any` unless absolutely necessary; document why if used
- Prettier (`.prettierrc.json`: `printWidth: 150`, single quotes, no trailing commas) handles formatting of Angular HTML templates only — ESLint stylistic rules handle TypeScript/JavaScript formatting
- Angular CLI / `tsc -p tsconfig.electron.json` / `cd server && tsc` perform the actual type checks; there is no single repo-wide `typecheck` script
- The repository uses npm workspaces (`npm@10.9.2`); cross-package imports go through workspace package names, not relative `../../` paths
---
## Naming conventions
Files and folders are predominantly **kebab-case**, with a few well-established suffixes:
- Angular components: `chat-messages.component.ts`, `user-list.component.html`, `*.component.scss`
- Angular services: `link-metadata.service.ts`
- Angular directives: `chat-image-proxy-fallback.directive.ts`
- Domain rules (pure functions): `message.rules.ts`, `link-embed.rules.ts`
- Domain models: `chat-messages.model.ts`
- NgRx slices: `chat.actions.ts`, `chat.reducer.ts`, `chat.effects.ts`, `chat.selectors.ts`
- CQRS handlers (server and electron): `registerUser.ts`, `deleteServer.ts`, `upsertServer.ts`**camelCase** for handler files (mirrors the command/query name)
- Test files: `<name>.spec.ts` (Vitest), `<feature>.spec.ts` (Playwright)
- Migrations (TypeORM): `<timestamp>-<name>.ts` in `electron/migrations/` and `server/migrations/`
Types, interfaces, classes, and Angular component classes: `PascalCase`. Functions, variables, NgRx action props: `camelCase`. Constants: `SCREAMING_SNAKE_CASE`.
When in doubt, mimic the closest existing file in the same folder.
---
## Error handling
- Use typed errors. Never `throw 'string literal'`
- Never swallow errors silently — at minimum, log with enough context to find the call site
- Centralize cross-cutting error handling: Express error middleware on the server, NgRx effect `catchError` in the product client, and IPC error envelopes in Electron handlers
- Surfacing errors to the user is a UX concern — degrade gracefully (toast, retry button, offline banner) rather than crashing the renderer
---
## Database guidelines
Persistence uses **TypeORM 0.3** with **sql.js / SQLite** in both the Electron desktop shell and the signaling server.
- **Electron data source:** `electron/data-source.ts` — entities in `electron/entities/`, migrations in `electron/migrations/`
- **Server data source:** wired up under `server/src/db/` — entities in `server/src/entities/`, migrations in `server/src/migrations/`
- Always write a migration for schema changes. Generate with `npm run migration:generate` (Electron) or the equivalent inside `server/`
- Run pending migrations: `npm run migration:run` (Electron)
- Never edit a migration after it has shipped — write a new one
- Entity classes use TypeORM decorators; keep persistence concerns out of domain `*.rules.ts` files
- Schema changes are usually **hard to reverse** and **surprising without context** — see `agents-docs/AGENTS_ADRS.md` for when to also write an ADR
---
## Realtime, IPC, and plugins
These are the three cross-context contracts that change most often. Treat each as a public contract that requires `agents-docs/features/` updates when it changes:
- **WebSocket messages** between client and server — schemas live under `server/src/websocket/` and `toju-app/src/app/infrastructure/realtime/`
- **IPC channels** between Electron preload and renderer — surface defined in `electron/preload.ts` and the `api/` directory
- **Plugin manifests** consumed by `electron/plugin-library.ts` — the runtime contract that third-party plugins depend on
Behavioral changes to any of these qualify as a feature-doc update under the rule in `/AGENTS.md`.
---
## CI/CD
- CI runs on **Gitea Workflows** (a GitHub Actionscompatible runner) — workflow files in `.gitea/workflows/`:
- `release-draft.yml` — queues release builds on push to `main` / `master`
- `publish-draft-release.yml` — publishes draft releases
- `deploy-web-apps.yml` — deploys the marketing site and Docusaurus docs
- All checks must pass before merging a PR
- Workflow status is visible in the Gitea PR view; use the web UI or `tea` CLI to inspect runs
There is **no pre-commit hook** configured (no Husky, no pre-commit, no lefthook). Lint/build are enforced by CI, not by local hooks.
---
## Commit message conventions
Use **Conventional Commits** with no scope. The recent history is consistent on this:
```
feat: Update how messages load and sync, allow plugins to import messages
fix: Mobile style fixes and other small ui fixes
perf: server navigation
refactor: Remove hardcoded values
test: Ensure tests work after latest changes
```
Allowed prefixes (observed across the last 100 commits): `feat`, `fix`, `chore`, `docs`, `perf`, `refactor`, `test`. Subject is sentence case with no trailing period.
If your change resolves a Gitea issue, add `Fixes #<n>` (or `Relates to #<n>`) in the PR body — Gitea supports the same auto-close keywords as GitHub.
---
## Issue linking
- Issues live in the Gitea instance at `git.azaaxin.com/myxelium/Toju`
- Reference them in PR bodies with `Fixes #<n>` (auto-closes on merge) or `Relates to #<n>` (cross-reference only)
- Commits themselves do not need issue numbers — keep subjects clean and Conventional
---
## Commands reference
Run these from the repository root unless otherwise noted.
```bash
# --- setup ---
npm install # install root + workspaces
cd server && npm install # server has its own lockfile
cd website && npm install # only if working on the marketing site
cd docs-site && npm install # only if working on app/plugin docs
# --- common dev flows ---
npm run dev # full stack: server + Angular client + Electron (via dev.sh)
npm run start # Angular product client only (ng serve on :4200)
npm run electron:dev # Angular client + Electron, no signaling server
npm run server:dev # signaling server only (ts-node-dev)
# --- testing ---
npm run test # toju-app Vitest suite
npm run test:e2e # Playwright (headless)
npm run test:e2e:ui # Playwright UI mode
npm run test:e2e:debug # Playwright debug
npm run test:e2e:report # serve last Playwright HTML report
# --- type / build (also serves as typecheck) ---
npm run build # Angular product client → dist/client
npm run build:electron # tsc -p tsconfig.electron.json → dist/electron
npm run build:docs # Docusaurus → docs-site/build
cd server && npm run build # server tsc
npm run build:all # all of the above
# --- lint / format ---
npm run lint # eslint .
npm run lint:fix # format + sort:props + eslint --fix
npm run format # prettier on Angular HTML templates only
npm run format:check # prettier --check on HTML templates
# --- database migrations (Electron) ---
npm run migration:generate # autogenerate from entity diff
npm run migration:create # empty migration scaffold
npm run migration:run # apply pending
npm run migration:revert # roll back last
```
---
## Completion checklist
Before marking work complete:
- [ ] Tests written before implementation
- [ ] All tests passing (`npm run test`, plus `npm run test:e2e` if behavior is user-visible)
- [ ] `npm run lint` passes
- [ ] Affected package builds: `npm run build` / `npm run build:electron` / `cd server && npm run build`
- [ ] Naming conventions followed
- [ ] Errors handled
- [ ] Security considered (no secrets in code, no plaintext token logging, no IPC handler accepting arbitrary file paths)
- [ ] Feature docs updated if contract/schema/invariant changed (see `agents-docs/AGENTS_FEATURES.md`)
- [ ] `CONTEXT.md` updated if a domain term was resolved or introduced (see `agents-docs/AGENTS_CONTEXT.md`)
- [ ] ADR written if a hard-to-reverse decision was made (see `agents-docs/AGENTS_ADRS.md`)
- [ ] Lesson recorded in `agents-docs/LESSONS.md` if this session produced a correction, revert, or hidden constraint (see triggers in `agents-docs/AGENT_WORKFLOW.md`)
- [ ] PR opened with summary and linked issues
- [ ] Gitea Workflows checks passing

26
agents-docs/FEATURES.md Normal file
View File

@@ -0,0 +1,26 @@
# Feature Areas
This index represents the known feature areas in the system.
It must stay accurate as new features are introduced, renamed, merged, or removed.
---
## Feature list (alphabetical)
_No cross-context feature docs have been written yet._
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.
`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.
---
## Rules for agents
- Introducing a new feature area requires:
- creating `agents-docs/features/<feature>.md` (use `agents-docs/features/feature-template.md`)
- adding it to this list (alphabetical)
- Renaming or merging features requires updating links and notes
- If the change is fully contained inside one product-client domain, prefer updating `toju-app/src/app/domains/<name>/README.md` over adding a top-level feature doc
- This file should remain concise and navigable

38
agents-docs/LESSONS.md Normal file
View File

@@ -0,0 +1,38 @@
# Agent Lessons
Durable rules for AI agents working on this project. Read this file at session start. Append to it when this session produces a correction worth remembering.
## How to use this file
**At session start:** scan the rules below. If any match the work you're about to do, apply them.
**During the session:** if the user corrects you, reverts your edit, or re-prompts with the same instruction — that is a signal to record a lesson before closing the task. See the trigger list in `agents-docs/AGENT_WORKFLOW.md`.
**Format of a lesson:** every entry uses the four-slot template below. Brevity matters — if you can't state the rule in one sentence, the lesson isn't sharp enough yet.
```markdown
### <short imperative title>
- **Trigger:** what you were about to do that turned out wrong (one line, concrete enough to pattern-match against)
- **Rule:** what to do instead (one sentence, imperative voice)
- **Why:** the consequence of getting it wrong — past incident, hidden constraint, user preference
- **Example:** one concrete instance, ideally a code or command snippet
```
**Keep lessons sharp.** Tag each rule with one or two tags in square brackets after the title (e.g. `[testing] [migrations]`) so future agents can grep for relevance. If a rule no longer applies, delete it — stale rules drown the real ones.
---
## Lessons
### Verify lint exits 0 before claiming done [verification]
- **Trigger:** about to report a task as complete after running tests but skipping ESLint.
- **Rule:** run `npm run lint` from the repo root and confirm exit code 0 before any "done" claim.
- **Why:** `npm run test` only runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config in `eslint.config.js`) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent.
- **Example:** `npm run lint && echo OK` — only claim done after seeing `OK`. For Electron type errors specifically, also confirm `npm run build:electron` succeeds (it invokes `tsc -p tsconfig.electron.json`).
<!--
Add new lessons above this comment, newest at the top.
Delete this example once the project has accumulated 2-3 real lessons.
-->

View File

@@ -0,0 +1,13 @@
# ADR-0001: Record Architectural Decisions
## Status
Accepted
## Context
We need a lightweight way to record architectural decisions so that future agents and engineers can understand *why* the system looks the way it does, not just *what* it does. Without ADRs, decisions live in PR descriptions, chat logs, or nowhere — and get re-litigated on every refactor.
## Decision
We use Architecture Decision Records (ADRs) in the Nygard short form. Each ADR lives at `agents-docs/adr/NNNN-slug.md` with a 4-digit zero-padded number, monotonically increasing. The minimum content is a title plus 13 sentences each for Context, Decision, and Rationale. Add `Status`, `Considered Options`, or `Consequences` only when they genuinely help.
## Rationale
Nygard short form is the lowest-friction format that still captures the *why*. Heavier templates (MADR, full IEEE 1471) routinely don't get written — the bar to start one is too high. ADRs are append-only: a superseded decision gets a new ADR with a `Supersedes ADR-NNNN` note while the old one stays in place. The 3-criteria gate (hard to reverse, surprising without context, genuine trade-offs) keeps the directory from filling with trivia. See `agents-docs/AGENTS_ADRS.md` for the full contract.

View File

@@ -0,0 +1,183 @@
# [Feature Name]
> **Area:** [area-name]
> **Status:** Active | In Progress | Deprecated
> **Last updated:** YYYY-MM-DD
## Overview
One paragraph describing what this feature does and why it exists.
## Responsibilities
- What this feature is responsible for
- Its boundaries — what it does NOT own
## Key concepts
- **ConceptA**: short definition
- **ConceptB**: short definition
---
## API Endpoint
### Endpoint Details
- **Method**: [GET | POST | PUT | PATCH | DELETE]
- **Path**: `/api/v1/[feature-path]`
- **Authentication**: [Required | Optional | None]
- **Rate Limiting**: [Yes — describe | No]
### Request Schema
```json
{
"field": "type — description"
}
```
**Required fields:**
- `field` (type, constraints): description
**Optional fields:**
- `field` (type): description. Defaults to "X" if not provided.
### Response Schema
```json
{
"field": "type — description"
}
```
### Error Responses
- **400 Bad Request**: [specific causes]
- **401 Unauthorized**: missing or invalid authentication
- **404 Not Found**: [when this applies]
- **500 Internal Server Error**: [specific causes]
---
## Business Logic
### Core Functionality
1. **Step 1**: description
2. **Step 2**: description
3. **Step 3**: description
### Business Rules
- Rule 1
- Rule 2
### Data Flow
```
Input → Validation → [Processing Steps] → Response
```
### Dependencies
- **Service/Library**: what it's used for
- **External API**: what it's used for
- **Database**: what tables/collections are involved
---
## Technical Implementation
### Service Layer
- **Location**: `path/to/service`
- **Key methods**: `methodName()` — description
### Controller / Handler
- **Location**: `path/to/handler`
- **Responsibilities**: request validation, service invocation, response formatting
### Repository / Data Access
- **Location**: `path/to/repository`
- **Tables/Collections**: list the relevant database objects
- **Migrations**: reference the migration that created/modified the schema
### Key Types
- `TypeName`: description of what it represents
---
## Configuration
### Environment Variables
- `VAR_NAME`: description (required | optional, default: X)
### Feature Flags
- [List any feature flags, or "None"]
---
## Testing
### Unit Tests
- **Location**: `path/to/tests`
- **Key scenarios**: list the most important test cases
### Integration Tests
- **Location**: `path/to/integration/tests`
- **Setup**: describe any required infrastructure (database, external services, etc.)
- **Mocking**: what external services are mocked and how
---
## Error Handling & Edge Cases
### Common Errors
- **Error scenario**: how it's handled
### Edge Cases
- **Edge case**: expected behavior
---
## Security Considerations
- Authentication requirements
- Authorization / access control
- Input validation and sanitization
- Data privacy considerations
---
## Performance Considerations
- Expected response times
- Known bottlenecks
- Caching strategy (if any)
---
## Known Issues and Limitations
1. **Limitation**: description
---
## Related Features
- **[Related Feature]**: brief description of relationship
## Changelog
| Date | Change |
|------|--------|
| YYYY-MM-DD | Initial documentation |

44
docs-site/CONTEXT.md Normal file
View File

@@ -0,0 +1,44 @@
# Application Documentation (docs-site)
Owns the Docusaurus-based application and plugin-author documentation. The build output (`docs-site/build/`) is bundled into the Electron app and served by the Local API server at runtime, so documentation is available offline inside the desktop client.
> **Format reference:**
> - **Vocabulary** — bold term, one-sentence definition, aliases to avoid.
> - **Relationships** — bullets with bold terms and cardinality.
> - **Boundaries / IO** — what this subdomain exposes and consumes.
> - **Invariants** — rules that always hold.
> - **Flagged ambiguities** — terms in dispute with proposed resolutions.
>
> See `agents-docs/AGENTS_CONTEXT.md` for the contract. Update in the same turn a trigger fires (see `agents-docs/AGENT_WORKFLOW.md` § CONTEXT.md upkeep).
## Vocabulary
| Term | Definition | Aliases to avoid |
|------|------------|------------------|
| **App docs** | End-user-facing documentation for the MetoYou desktop client. | "manual" |
| **Plugin docs** | Developer-facing reference for the plugin runtime — manifest format, lifecycle hooks, host APIs. Authoritative source for the plugin contract surface. | "API docs" |
| **Local API server** | The Electron in-process HTTP server that mounts `docs-site/build/` so the renderer can browse docs offline. Defined under `electron/api/`. | "embedded server" |
## Relationships
- **Plugin docs** describe contracts implemented in `toju-app/src/app/shared-kernel/plugin-system.contracts.ts` (renderer side) and `electron/plugin-library.ts` (host side) — keep them in lockstep with code changes.
- The **build output** at `docs-site/build/` is a deploy artifact for the **electron** Local API server; `npm run build:all` requires `npm run build:docs` to have run.
- The site is also deployed publicly via `.gitea/workflows/deploy-web-apps.yml` for browsing outside the app.
## Boundaries / IO
- **Exposes:** static Docusaurus bundle at `docs-site/build/`, mounted by Electron's Local API server and also deployed as a public static site.
- **Consumes:** Markdown sources under `docs-site/docs/`, plus any code-derived references (e.g. OpenAPI documents from `electron/api/openapi.ts`).
## Invariants
- Plugin-contract documentation must match the code; if the manifest schema or lifecycle changes, **plugin docs** and `agents-docs/features/<plugin-doc>.md` both update in the same task.
- Build artifacts (`docs-site/build/`) are generated, not committed.
## Flagged ambiguities
- _None recorded yet._
---
*Agent-procedural rules (TDD, lint, build) live in `/AGENTS.md`. This file is the bounded-context domain artefact for the documentation site.*

View File

@@ -8,18 +8,18 @@ This page maps the app routes and important DOM areas. It is useful for plugin a
## Angular Routes ## Angular Routes
| Route | Component | Purpose | | Route | Component | Purpose |
| --- | --- | --- | | ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
| `/` | Redirect | Redirects to `/search`. | | `/` | Redirect | Redirects to `/search`. |
| `/login` | `LoginComponent` | User login. | | `/login` | `LoginComponent` | User login. |
| `/register` | `RegisterComponent` | User registration. | | `/register` | `RegisterComponent` | User registration. |
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. | | `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
| `/search` | `ServerSearchComponent` | Search and join servers. | | `/search` | `ServerSearchComponent` | Search and join servers. |
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. | | `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. | | `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. | | `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. | | `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. | | `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. | | `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
## Page Shell ## Page Shell
@@ -46,6 +46,7 @@ The server page is the most important page for plugins.
<section>Text Channels</section> <section>Text Channels</section>
<section>Voice Channels</section> <section>Voice Channels</section>
<section data-testid="plugin-room-side-panel"> <section data-testid="plugin-room-side-panel">
<button>View plugins</button>
<app-plugin-render-host></app-plugin-render-host> <app-plugin-render-host></app-plugin-render-host>
</section> </section>
<section>Members</section> <section>Members</section>
@@ -135,11 +136,11 @@ Prefer plugin APIs over DOM selectors. When direct DOM mounting is necessary, us
Common targets: Common targets:
| Selector | Area | | Selector | Area |
| --- | --- | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `body` | Global overlays or modals. | | `body` | Global overlays or modals. |
| `app-chat-messages` | Main text channel surface. | | `app-chat-messages` | Main text channel surface. |
| `app-rooms-side-panel` | Server side panel. | | `app-rooms-side-panel` | Server side panel. |
| `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar. | | `[data-testid="plugin-room-side-panel"]` | Plugin side-panel area in the server sidebar, including the View plugins trigger for `registerToolbarAction()` tiles. |
Avoid depending on Tailwind utility classes; they are layout details and may change. Avoid depending on Tailwind utility classes; they are layout details and may change.

View File

@@ -54,12 +54,12 @@ There are three communication boundaries a plugin author must understand:
1. Signaling plane 1. Signaling plane
Angular renderer <-> WebSocket signaling server Angular renderer <-> WebSocket signaling server
Used for identity, joining servers, presence, typing, plugin requirements, Used for identity, joining servers, presence, typing, plugin requirements,
server-relayed plugin events, WebRTC offers, answers, and ICE candidates. server-relayed plugin events, WebRTC offers, answers, and ICE candidates.
2. Peer plane 2. Peer plane
Angular renderer <-> WebRTC peer connections <-> other clients Angular renderer <-> WebRTC peer connections <-> other clients
Used for media and data-channel events: chat messages, message sync, Used for media and data-channel events: chat messages, message sync,
attachments, voice state, screen/camera state, and plugin message bus data. attachments, voice state, screen/camera state, and plugin message bus data.
3. Desktop/local plane 3. Desktop/local plane
Angular renderer <-> Electron preload bridge <-> Electron main process Angular renderer <-> Electron preload bridge <-> Electron main process
@@ -71,18 +71,18 @@ Plugins run only in the renderer. They do not run in Electron main and do not ru
Choose communication APIs like this: Choose communication APIs like this:
| Need | Use | Notes | | Need | Use | Notes |
| --- | --- | --- | | ------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- |
| Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. | | Visible normal chat message | `api.messages.send` | Persists locally, updates chat UI, broadcasts peer chat event. |
| Visible bot-style message | `api.server.registerPluginUser` plus `api.messages.sendAsPluginUser` | Requires `users.manage` and `messages.send`. | | Visible bot-style message | `api.server.registerPluginUser` plus `api.messages.sendAsPluginUser` | Requires `users.manage` and `messages.send`. |
| Plugin state sync between connected clients | `api.messageBus.publish` and `api.messageBus.subscribe` | P2P data-channel envelope, not a visible chat message. | | Plugin state sync between connected clients | `api.messageBus.publish` and `api.messageBus.subscribe` | P2P data-channel envelope, not a visible chat message. |
| Plugin state sync plus recent chat snapshot | `api.messageBus.publish` with `includeLatestMessages` | Also needs `messages.read`. | | Plugin state sync plus recent chat snapshot | `api.messageBus.publish` with `includeLatestMessages` | Also needs `messages.read`. |
| Metadata through signaling server | `api.events.publishServer` and `api.events.subscribeServer` | Event must be declared in manifest. | | Metadata through signaling server | `api.events.publishServer` and `api.events.subscribeServer` | Event must be declared in manifest. |
| Low-level peer data | `api.p2p.broadcastData` or `api.p2p.sendData` | Prefer message bus for structured topics/subscriptions. | | Low-level peer data | `api.p2p.broadcastData` or `api.p2p.sendData` | Prefer message bus for structured topics/subscriptions. |
| Local user preferences | `api.clientData` | User-scoped local storage/database. | | Local user preferences | `api.clientData` | User-scoped local storage/database. |
| Local per-server plugin data | `api.serverData` | User-scoped and current-server-scoped local storage/database. | | Local per-server plugin data | `api.serverData` | User-scoped and current-server-scoped local storage/database. |
| App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. | | App UI extension | `api.ui.*` | Prefer registered contributions over DOM mounting. |
| Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. | | Audio/video/voice effects | `api.media.*` | Browser media APIs and voice facade. |
## How The App Looks ## How The App Looks
@@ -122,47 +122,51 @@ Main server page shape:
Important routes: Important routes:
| Route | Purpose | | Route | Purpose |
| --- | --- | | ------------------------------- | ------------------------------------------------------------------- |
| `/search` | Search and join servers. | | `/search` | Search and join servers. |
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. | | `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
| `/dm` and `/dm/:conversationId` | Direct-message workspace. | | `/dm` and `/dm/:conversationId` | Direct-message workspace. |
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. | | `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
| `/plugin-store` | Browse and install plugins. | | `/plugin-store` | Browse and install plugins. |
| `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. | | `/plugins/:pluginId/:pageId` | Host for pages registered with `api.ui.registerAppPage`. |
Direct DOM mounting is a last resort. Route-specific targets may not exist when `activate` runs. If `api.ui.mountElement` cannot find the target, it throws `Plugin mount target not found: <selector>` and plugin activation fails. Direct DOM mounting is a last resort. Route-specific targets may not exist when `activate` runs. If `api.ui.mountElement` cannot find the target, it throws `Plugin mount target not found: <selector>` and plugin activation fails.
Stable direct-mount targets when necessary: Stable direct-mount targets when necessary:
| Selector | Area | | Selector | Area |
| --- | --- | | ---------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `body` | Safest global target for overlays, badges, and modals. It exists during activation. | | `body` | Safest global target for overlays, badges, and modals. It exists during activation. |
| `app-chat-messages` | Main text channel surface. Use only after checking the element exists. | | `app-chat-messages` | Main text channel surface. Use only after checking the element exists. |
| `app-rooms-side-panel` | Server side panel. Use only after checking the element exists. Prefer `registerSidePanel` for plugin sidebar content. | | `app-rooms-side-panel` | Server side panel. Use only after checking the element exists. Prefer `registerSidePanel` for plugin sidebar content. |
Do not mount directly into `[data-testid="plugin-room-side-panel"]`. That area is owned by the plugin side-panel registry and is rendered only on the server page. For server sidebar UI, use: Do not mount directly into `[data-testid="plugin-room-side-panel"]`. That area is owned by the plugin side-panel registry and is rendered only on the server page. For server sidebar UI, use:
```js ```js
context.subscriptions.push(api.ui.registerSidePanel('control-panel', { context.subscriptions.push(
label: 'Control Panel', api.ui.registerSidePanel('control-panel', {
order: 20, label: 'Control Panel',
render: () => { order: 20,
const root = document.createElement('section'); render: () => {
const button = document.createElement('button'); const root = document.createElement('section');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Run Action'; button.textContent = 'Run Action';
button.addEventListener('click', () => { button.addEventListener('click', () => {
api.logger.info('Side-panel action clicked'); api.logger.info('Side-panel action clicked');
}); });
root.append(button); root.append(button);
return root; return root;
} }
})); })
);
``` ```
For small command-style plugin entries, use `api.ui.registerToolbarAction()`. Those actions appear as icon tiles in the server side panel's View plugins menu and receive `source: 'toolbarAction'` in their action context.
Do not depend on Tailwind classes or internal styling classes. Do not depend on Tailwind classes or internal styling classes.
## Manifest ## Manifest
@@ -300,10 +304,10 @@ Validation rules:
Scope meanings: Scope meanings:
| Scope | Meaning | | Scope | Meaning |
| --- | --- | | ------------------- | --------------------------------------------------------------------------------------------- |
| `client` or omitted | Installed globally for this local user/client. | | `client` or omitted | Installed globally for this local user/client. |
| `server` | Installed for a specific chat server as local client plugin plus server requirement metadata. | | `server` | Installed for a specific chat server as local client plugin plus server requirement metadata. |
Most generated plugins should use `kind: "client"`. Use `kind: "library"` only for dependency metadata with no executable entrypoint. Most generated plugins should use `kind: "client"`. Use `kind: "library"` only for dependency metadata with no executable entrypoint.
@@ -326,10 +330,7 @@ interface TojuClientPluginModule {
ready?: (context: TojuPluginActivationContext) => Promise<void> | void; ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void; deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void; onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
onServerRequirementsChanged?: ( onServerRequirementsChanged?: (context: TojuPluginActivationContext, snapshot: PluginRequirementsSnapshot) => Promise<void> | void;
context: TojuPluginActivationContext,
snapshot: PluginRequirementsSnapshot
) => Promise<void> | void;
} }
``` ```
@@ -579,9 +580,20 @@ interface ChannelPermissionOverride {
## Full Plugin API Types ## Full Plugin API Types
```ts ```ts
interface PluginApiProfileUpdate { displayName: string; description?: string } interface PluginApiProfileUpdate {
interface PluginApiAvatarUpdate { avatarUrl: string; avatarMime: string; avatarHash: string } displayName: string;
interface PluginApiChannelRequest { name: string; id?: string; position?: number } description?: string;
}
interface PluginApiAvatarUpdate {
avatarUrl: string;
avatarMime: string;
avatarHash: string;
}
interface PluginApiChannelRequest {
name: string;
id?: string;
position?: number;
}
interface PluginApiServerSettingsUpdate { interface PluginApiServerSettingsUpdate {
name?: string; name?: string;
description?: string; description?: string;
@@ -590,10 +602,24 @@ interface PluginApiServerSettingsUpdate {
password?: string; password?: string;
maxUsers?: number; maxUsers?: number;
} }
interface PluginApiPluginUserRequest { displayName: string; id?: string; avatarUrl?: string } interface PluginApiPluginUserRequest {
interface PluginApiMessageAsPluginUserRequest { pluginUserId: string; content: string; channelId?: string } displayName: string;
interface PluginApiAudioClipRequest { url: string; volume?: number } id?: string;
interface PluginApiCustomStreamRequest { stream: MediaStream; label?: string } avatarUrl?: string;
}
interface PluginApiMessageAsPluginUserRequest {
pluginUserId: string;
content: string;
channelId?: string;
}
interface PluginApiAudioClipRequest {
url: string;
volume?: number;
}
interface PluginApiCustomStreamRequest {
stream: MediaStream;
label?: string;
}
type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual'; type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
interface PluginApiActionContext { interface PluginApiActionContext {
@@ -660,13 +686,41 @@ interface PluginApiMessageBusSubscription {
handler: (event: PluginApiMessageBusEnvelope) => void; handler: (event: PluginApiMessageBusEnvelope) => void;
} }
interface PluginApiPageContribution { label: string; path: string; render: () => HTMLElement | string } interface PluginApiPageContribution {
interface PluginApiSettingsPageContribution { label: string; settingsKey?: string; order?: number; render: () => HTMLElement | string } label: string;
interface PluginApiPanelContribution { label: string; order?: number; render: () => HTMLElement | string } path: string;
interface PluginApiChannelSectionContribution { label: string; type?: 'audio' | 'video' | 'custom'; order?: number } render: () => HTMLElement | string;
interface PluginApiActionContribution { label: string; icon?: string; run: (context: PluginApiActionContext) => Promise<void> | void } }
interface PluginApiEmbedRendererContribution { embedType: string; render: (payload: unknown) => HTMLElement | string } interface PluginApiSettingsPageContribution {
interface PluginApiDomMountRequest { target: Element | string; element: HTMLElement; position?: InsertPosition } label: string;
settingsKey?: string;
order?: number;
render: () => HTMLElement | string;
}
interface PluginApiPanelContribution {
label: string;
order?: number;
render: () => HTMLElement | string;
}
interface PluginApiChannelSectionContribution {
label: string;
type?: 'audio' | 'video' | 'custom';
order?: number;
}
interface PluginApiActionContribution {
label: string;
icon?: string;
run: (context: PluginApiActionContext) => Promise<void> | void;
}
interface PluginApiEmbedRendererContribution {
embedType: string;
render: (payload: unknown) => HTMLElement | string;
}
interface PluginApiDomMountRequest {
target: Element | string;
element: HTMLElement;
position?: InsertPosition;
}
interface TojuClientPluginApi { interface TojuClientPluginApi {
readonly context: { getCurrent: () => PluginApiActionContext }; readonly context: { getCurrent: () => PluginApiActionContext };
@@ -890,10 +944,7 @@ Capabilities: `messages.read`, `messages.send`, `messages.editOwn`, `messages.de
```js ```js
const visibleMessages = api.messages.readCurrent(); const visibleMessages = api.messages.readCurrent();
const sent = api.messages.send( const sent = api.messages.send('Build completed successfully. Docs are ready for review.', 'general');
'Build completed successfully. Docs are ready for review.',
'general'
);
api.messages.edit(sent.id, 'Build completed successfully. Docs and plugin examples are ready.'); api.messages.edit(sent.id, 'Build completed successfully. Docs and plugin examples are ready.');
api.messages.delete(sent.id); api.messages.delete(sent.id);
@@ -1115,88 +1166,110 @@ Desktop uses Electron's local database when available, with renderer localStorag
Capabilities: Capabilities:
| Method | Required capability | | Method | Required capability |
| --- | --- | | ------------------------ | -------------------- |
| `registerAppPage` | `ui.pages` | | `registerAppPage` | `ui.pages` |
| `registerSettingsPage` | `ui.settings` | | `registerSettingsPage` | `ui.settings` |
| `registerSidePanel` | `ui.sidePanel` | | `registerSidePanel` | `ui.sidePanel` |
| `registerChannelSection` | `ui.channelsSection` | | `registerChannelSection` | `ui.channelsSection` |
| `registerComposerAction` | `ui.pages` | | `registerComposerAction` | `ui.pages` |
| `registerProfileAction` | `ui.pages` | | `registerProfileAction` | `ui.pages` |
| `registerToolbarAction` | `ui.pages` | | `registerToolbarAction` | `ui.pages` |
| `registerEmbedRenderer` | `ui.embeds` | | `registerEmbedRenderer` | `ui.embeds` |
| `mountElement` | `ui.dom` | | `mountElement` | `ui.dom` |
Register side panel: Register side panel:
```js ```js
context.subscriptions.push(api.ui.registerSidePanel('summary', { context.subscriptions.push(
label: 'Plugin Summary', api.ui.registerSidePanel('summary', {
order: 10, label: 'Plugin Summary',
render: () => { order: 10,
const root = document.createElement('aside'); render: () => {
const heading = document.createElement('h2'); const root = document.createElement('aside');
const text = document.createElement('p'); const heading = document.createElement('h2');
const text = document.createElement('p');
heading.textContent = 'Plugin Summary'; heading.textContent = 'Plugin Summary';
text.textContent = 'No active tasks.'; text.textContent = 'No active tasks.';
root.append(heading, text); root.append(heading, text);
return root; return root;
} }
})); })
);
``` ```
Use `registerSidePanel` for content that belongs in the server sidebar plugin area. Do not query `[data-testid="plugin-room-side-panel"]` and pass it to `mountElement`; that route-specific host may not exist while the plugin activates. Use `registerSidePanel` for content that belongs in the server sidebar plugin area. Do not query `[data-testid="plugin-room-side-panel"]` and pass it to `mountElement`; that route-specific host may not exist while the plugin activates.
Register toolbar action for the View plugins menu:
```js
context.subscriptions.push(
api.ui.registerToolbarAction('quick-status', {
icon: 'QS',
label: 'Quick Status',
run: (actionContext) => {
api.logger.info('Quick Status clicked', {
serverId: actionContext.server?.id,
source: actionContext.source
});
}
})
);
```
Register app page: Register app page:
```js ```js
context.subscriptions.push(api.ui.registerAppPage('dashboard', { context.subscriptions.push(
label: 'Build Dashboard', api.ui.registerAppPage('dashboard', {
path: '/plugins/example.build-dashboard/dashboard', label: 'Build Dashboard',
render: () => { path: '/plugins/example.build-dashboard/dashboard',
const root = document.createElement('section'); render: () => {
const title = document.createElement('h1'); const root = document.createElement('section');
const button = document.createElement('button'); const title = document.createElement('h1');
const output = document.createElement('p'); const button = document.createElement('button');
const output = document.createElement('p');
title.textContent = 'Build Dashboard'; title.textContent = 'Build Dashboard';
button.type = 'button'; button.type = 'button';
button.textContent = 'Send status'; button.textContent = 'Send status';
output.textContent = 'Idle.'; output.textContent = 'Idle.';
button.addEventListener('click', () => { button.addEventListener('click', () => {
const message = api.messages.send('Build dashboard status: ready.'); const message = api.messages.send('Build dashboard status: ready.');
output.textContent = `Sent message ${message.id}`; output.textContent = `Sent message ${message.id}`;
}); });
root.append(title, button, output); root.append(title, button, output);
return root; return root;
} }
})); })
);
``` ```
Register actions: Register actions:
```js ```js
context.subscriptions.push(api.ui.registerComposerAction('insert-template', { context.subscriptions.push(
label: 'Insert Template', api.ui.registerComposerAction('insert-template', {
icon: 'file-text', label: 'Insert Template',
run: (actionContext) => { icon: 'file-text',
api.messages.send( run: (actionContext) => {
'Template: Please review the latest build notes.', api.messages.send('Template: Please review the latest build notes.', actionContext.textChannel?.id);
actionContext.textChannel?.id }
); })
} );
}));
context.subscriptions.push(api.ui.registerToolbarAction('post-standup', { context.subscriptions.push(
label: 'Post Standup', api.ui.registerToolbarAction('post-standup', {
icon: 'megaphone', label: 'Post Standup',
run: () => { icon: 'megaphone',
api.messages.send('Standup starts now. Join the voice channel when ready.'); run: () => {
} api.messages.send('Standup starts now. Join the voice channel when ready.');
})); }
})
);
``` ```
Mount DOM directly: Mount DOM directly:
@@ -1210,11 +1283,13 @@ banner.textContent = 'Plugin banner mounted in chat messages.';
const target = document.querySelector('app-chat-messages'); const target = document.querySelector('app-chat-messages');
if (target) { if (target) {
context.subscriptions.push(api.ui.mountElement('chat-banner', { context.subscriptions.push(
target, api.ui.mountElement('chat-banner', {
element: banner, target,
position: 'afterbegin' element: banner,
})); position: 'afterbegin'
})
);
} }
``` ```
@@ -1224,56 +1299,58 @@ Global overlay example:
const badge = document.createElement('div'); const badge = document.createElement('div');
badge.textContent = 'Plugin active'; badge.textContent = 'Plugin active';
context.subscriptions.push(api.ui.mountElement('global-badge', { context.subscriptions.push(
target: 'body', api.ui.mountElement('global-badge', {
element: badge, target: 'body',
position: 'beforeend' element: badge,
})); position: 'beforeend'
})
);
``` ```
`mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload. `mountElement` tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload.
## Capability Cheat Sheet ## Capability Cheat Sheet
| API call group | Capabilities | | API call group | Capabilities |
| --- | --- | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `profile.getCurrent` | `profile.read` | | `profile.getCurrent` | `profile.read` |
| `profile.update`, `profile.updateAvatar` | `profile.write` | | `profile.update`, `profile.updateAvatar` | `profile.write` |
| `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` | | `users.getCurrent`, `users.list`, `users.readMembers` | `users.read` |
| `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` | | `users.kick`, `users.ban`, `server.registerPluginUser` | `users.manage` |
| `roles.list` | `roles.read` | | `roles.list` | `roles.read` |
| `users.setRole`, `roles.setAssignments` | `roles.manage` | | `users.setRole`, `roles.setAssignments` | `roles.manage` |
| `server.getCurrent` | `server.read` | | `server.getCurrent` | `server.read` |
| `server.updatePermissions`, `server.updateSettings` | `server.manage` | | `server.updatePermissions`, `server.updateSettings` | `server.manage` |
| `channels.list`, `channels.select` | `channels.read` | | `channels.list`, `channels.select` | `channels.read` |
| `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` | | `channels.addAudioChannel`, `channels.addVideoChannel`, `channels.rename`, `channels.remove` | `channels.manage` |
| `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` | | `messages.readCurrent`, `messages.subscribeTyping` | `messages.read` |
| `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` | | `messages.send`, `messages.sendAsPluginUser`, `messages.setTyping` | `messages.send` |
| `messages.edit` | `messages.editOwn` | | `messages.edit` | `messages.editOwn` |
| `messages.delete` | `messages.deleteOwn` | | `messages.delete` | `messages.deleteOwn` |
| `messages.moderateDelete` | `messages.moderate` | | `messages.moderateDelete` | `messages.moderate` |
| `messages.sync` | `messages.sync` | | `messages.sync` | `messages.sync` |
| `events.publishServer` | `events.server.publish` | | `events.publishServer` | `events.server.publish` |
| `events.subscribeServer` | `events.server.subscribe` | | `events.subscribeServer` | `events.server.subscribe` |
| `events.publishP2p` | `events.p2p.publish` | | `events.publishP2p` | `events.p2p.publish` |
| `events.subscribeP2p` | `events.p2p.subscribe` | | `events.subscribeP2p` | `events.p2p.subscribe` |
| `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true | | `messageBus.publish` | `events.p2p.publish`, plus `messages.read` when `includeLatestMessages` is true |
| `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` | | `messageBus.sendLatestMessages` | `events.p2p.publish`, `messages.read` |
| `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true | | `messageBus.subscribe` | `events.p2p.subscribe`, plus `messages.read` when `replayLatest` is true |
| `p2p.*` | `p2p.data` | | `p2p.*` | `p2p.data` |
| `media.playAudioClip` | `media.playAudio` | | `media.playAudioClip` | `media.playAudio` |
| `media.addCustomAudioStream` | `media.addAudioStream` | | `media.addCustomAudioStream` | `media.addAudioStream` |
| `media.addCustomVideoStream` | `media.addVideoStream` | | `media.addCustomVideoStream` | `media.addVideoStream` |
| `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` | | `media.setInputVolume`, `media.setOutputVolume` | `audio.volume` |
| `clientData.*`, `storage.*` | `storage.local` | | `clientData.*`, `storage.*` | `storage.local` |
| `serverData.read` | `storage.serverData.read` | | `serverData.read` | `storage.serverData.read` |
| `serverData.write`, `serverData.remove` | `storage.serverData.write` | | `serverData.write`, `serverData.remove` | `storage.serverData.write` |
| `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` | | `ui.registerAppPage`, composer/profile/toolbar actions | `ui.pages` |
| `ui.registerSettingsPage` | `ui.settings` | | `ui.registerSettingsPage` | `ui.settings` |
| `ui.registerSidePanel` | `ui.sidePanel` | | `ui.registerSidePanel` | `ui.sidePanel` |
| `ui.registerChannelSection` | `ui.channelsSection` | | `ui.registerChannelSection` | `ui.channelsSection` |
| `ui.registerEmbedRenderer` | `ui.embeds` | | `ui.registerEmbedRenderer` | `ui.embeds` |
| `ui.mountElement` | `ui.dom` | | `ui.mountElement` | `ui.dom` |
## Complete Example Plugin ## Complete Example Plugin
@@ -1319,25 +1396,31 @@ export function activate(context) {
api.logger.info('Voice Notes activated'); api.logger.info('Voice Notes activated');
context.subscriptions.push(api.messageBus.subscribe({ context.subscriptions.push(
topic: BUS_TOPIC, api.messageBus.subscribe({
replayLatest: false, topic: BUS_TOPIC,
handler: (event) => { replayLatest: false,
api.logger.debug('Received voice notes draft update', event.payload); handler: (event) => {
} api.logger.debug('Received voice notes draft update', event.payload);
})); }
})
);
context.subscriptions.push(api.ui.registerSidePanel('voice-notes-panel', { context.subscriptions.push(
label: 'Voice Notes', api.ui.registerSidePanel('voice-notes-panel', {
order: 20, label: 'Voice Notes',
render: () => renderPanel(context) order: 20,
})); render: () => renderPanel(context)
})
);
context.subscriptions.push(api.ui.registerAppPage('voice-notes', { context.subscriptions.push(
label: 'Voice Notes', api.ui.registerAppPage('voice-notes', {
path: '/plugins/example.voice-notes/voice-notes', label: 'Voice Notes',
render: () => renderPanel(context) path: '/plugins/example.voice-notes/voice-notes',
})); render: () => renderPanel(context)
})
);
} }
function renderPanel(context) { function renderPanel(context) {
@@ -1352,9 +1435,7 @@ function renderPanel(context) {
const current = api.context.getCurrent(); const current = api.context.getCurrent();
heading.textContent = 'Voice Notes'; heading.textContent = 'Voice Notes';
meta.textContent = current.voiceChannel meta.textContent = current.voiceChannel ? `Connected to ${current.voiceChannel.name}` : 'Not connected to a voice channel.';
? `Connected to ${current.voiceChannel.name}`
: 'Not connected to a voice channel.';
textarea.rows = 6; textarea.rows = 6;
textarea.placeholder = 'Write notes from the current voice session.'; textarea.placeholder = 'Write notes from the current voice session.';
save.type = 'button'; save.type = 'button';
@@ -1363,16 +1444,19 @@ function renderPanel(context) {
post.textContent = 'Post Notes'; post.textContent = 'Post Notes';
status.textContent = 'Loading draft...'; status.textContent = 'Loading draft...';
void api.serverData.read(DRAFT_KEY).then((value) => { void api.serverData
if (value && typeof value === 'object' && typeof value.text === 'string') { .read(DRAFT_KEY)
textarea.value = value.text; .then((value) => {
} if (value && typeof value === 'object' && typeof value.text === 'string') {
textarea.value = value.text;
}
status.textContent = 'Draft loaded.'; status.textContent = 'Draft loaded.';
}).catch((error) => { })
api.logger.warn('Could not load voice notes draft', error); .catch((error) => {
status.textContent = 'Could not load draft.'; api.logger.warn('Could not load voice notes draft', error);
}); status.textContent = 'Could not load draft.';
});
save.addEventListener('click', async () => { save.addEventListener('click', async () => {
const draft = { const draft = {
@@ -1429,4 +1513,4 @@ export function deactivate(context) {
- Local REST API: Developer Guide -> Local REST API. - Local REST API: Developer Guide -> Local REST API.
- Plugin manifest: Plugin Development -> Manifest Model. - Plugin manifest: Plugin Development -> Manifest Model.
- Capabilities: Plugin Development -> Capabilities. - Capabilities: Plugin Development -> Capabilities.
- Focused plugin API examples: Plugin Development -> API Reference and its API subpages. - Focused plugin API examples: Plugin Development -> API Reference and its API subpages.

View File

@@ -60,24 +60,24 @@ interface PluginApiAvatarUpdate {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------ | --------------- | ------------------------------------------------- |
| `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. | | `profile.getCurrent()` | `profile.read` | Returns the current `User` or `null`. |
| `profile.update(profile)` | `profile.write` | Updates display name and optional description. | | `profile.update(profile)` | `profile.write` | Updates display name and optional description. |
| `profile.updateAvatar(avatar)` | `profile.write` | Updates avatar URL, MIME type, and hash metadata. | | `profile.updateAvatar(avatar)` | `profile.write` | Updates avatar URL, MIME type, and hash metadata. |
## Users and Roles ## Users and Roles
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ----------------------------------- | -------------- | --------------------------------- |
| `users.getCurrent()` | `users.read` | Returns current `User` or `null`. | | `users.getCurrent()` | `users.read` | Returns current `User` or `null`. |
| `users.list()` | `users.read` | Returns known users. | | `users.list()` | `users.read` | Returns known users. |
| `users.readMembers()` | `users.read` | Returns active room members. | | `users.readMembers()` | `users.read` | Returns active room members. |
| `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. | | `users.setRole(userId, role)` | `roles.manage` | Updates a user's role. |
| `users.kick(userId)` | `users.manage` | Kicks a user. | | `users.kick(userId)` | `users.manage` | Kicks a user. |
| `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. | | `users.ban(userId, reason?)` | `users.manage` | Bans a user with optional reason. |
| `roles.list()` | `roles.read` | Returns room roles. | | `roles.list()` | `roles.read` | Returns room roles. |
| `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. | | `roles.setAssignments(assignments)` | `roles.manage` | Replaces role assignments. |
## Server ## Server
@@ -98,12 +98,12 @@ interface PluginApiPluginUserRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | --------------------------------------- | --------------- | -------------------------------------------- |
| `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. | | `server.getCurrent()` | `server.read` | Returns the current `Room` or `null`. |
| `server.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. | | `server.registerPluginUser(request)` | `users.manage` | Adds a plugin-owned user and returns its id. |
| `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. | | `server.updatePermissions(permissions)` | `server.manage` | Updates partial room permissions. |
| `server.updateSettings(settings)` | `server.manage` | Updates room settings. | | `server.updateSettings(settings)` | `server.manage` | Updates room settings. |
## Channels ## Channels
@@ -115,14 +115,14 @@ interface PluginApiChannelRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ----------------------------------- | ----------------- | ---------------------------------- |
| `channels.list()` | `channels.read` | Returns current room channels. | | `channels.list()` | `channels.read` | Returns current room channels. |
| `channels.select(channelId)` | `channels.read` | Selects a channel. | | `channels.select(channelId)` | `channels.read` | Selects a channel. |
| `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. | | `channels.addAudioChannel(request)` | `channels.manage` | Adds a voice channel. |
| `channels.addVideoChannel(request)` | `channels.manage` | Registers a video channel section. | | `channels.addVideoChannel(request)` | `channels.manage` | Registers a video channel section. |
| `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. | | `channels.rename(channelId, name)` | `channels.manage` | Renames a channel. |
| `channels.remove(channelId)` | `channels.manage` | Removes a channel. | | `channels.remove(channelId)` | `channels.manage` | Removes a channel. |
## Messages ## Messages
@@ -134,17 +134,17 @@ interface PluginApiMessageAsPluginUserRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------------------ | -------------------- | -------------------------------------------------- |
| `messages.readCurrent()` | `messages.read` | Returns current visible messages. | | `messages.readCurrent()` | `messages.read` | Returns current visible messages. |
| `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. | | `messages.send(content, channelId?)` | `messages.send` | Sends a message and returns the created `Message`. |
| `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. | | `messages.sendAsPluginUser(request)` | `messages.send` | Emits a message from a registered plugin user. |
| `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. | | `messages.setTyping(isTyping, channelId?)` | `messages.send` | Broadcasts current typing state for a channel. |
| `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. | | `messages.subscribeTyping(handler)` | `messages.read` | Subscribes to peer typing state. |
| `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. | | `messages.edit(messageId, content)` | `messages.editOwn` | Edits a plugin message. |
| `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. | | `messages.delete(messageId)` | `messages.deleteOwn` | Deletes a plugin message. |
| `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. | | `messages.moderateDelete(messageId)` | `messages.moderate` | Performs a moderation delete. |
| `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. | | `messages.sync(messages)` | `messages.sync` | Syncs an array of messages into state. |
## Events ## Events
@@ -167,12 +167,12 @@ interface PluginEventEnvelope<TPayload = unknown> {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------------------ | ------------------------- | ----------------------------------------------------------- |
| `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. | | `events.publishServer(eventName, payload)` | `events.server.publish` | Sends a declared plugin event through the signaling server. |
| `events.subscribeServer(subscription)` | `events.server.subscribe` | Subscribes to a declared server plugin event. | | `events.subscribeServer(subscription)` | `events.server.subscribe` | Subscribes to a declared server plugin event. |
| `events.publishP2p(eventName, payload)` | `events.p2p.publish` | Sends a declared plugin event over peer paths. | | `events.publishP2p(eventName, payload)` | `events.p2p.publish` | Sends a declared plugin event over peer paths. |
| `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. | | `events.subscribeP2p(subscription)` | `events.p2p.subscribe` | Registers a P2P event subscription. |
## Message Bus ## Message Bus
@@ -215,11 +215,11 @@ interface PluginApiMessageBusSubscription {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- |
| `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. | | `messageBus.publish(request)` | `events.p2p.publish`, optionally `messages.read` | Publishes a plugin-bus event, optionally including latest messages. |
| `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | Sends a latest-message snapshot. | | `messageBus.sendLatestMessages(request?)` | `events.p2p.publish` and `messages.read` | Sends a latest-message snapshot. |
| `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. | | `messageBus.subscribe(subscription)` | `events.p2p.subscribe`, optionally `messages.read` | Subscribes to plugin-bus events, optionally replaying latest messages. |
## P2P and Media ## P2P and Media
@@ -235,30 +235,30 @@ interface PluginApiCustomStreamRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------------------ | ---------------------- | --------------------------------------------- |
| `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. | | `p2p.connectedPeers()` | `p2p.data` | Returns connected peer ids. |
| `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. | | `p2p.broadcastData(eventName, payload)` | `p2p.data` | Broadcasts plugin data. |
| `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. | | `p2p.sendData(peerId, eventName, payload)` | `p2p.data` | Sends plugin data targeted to a peer. |
| `media.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. | | `media.playAudioClip(request)` | `media.playAudio` | Plays an audio URL at optional volume. |
| `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. | | `media.addCustomAudioStream(request)` | `media.addAudioStream` | Contributes an audio `MediaStream`. |
| `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. | | `media.addCustomVideoStream(request)` | `media.addVideoStream` | Registers a video `MediaStream` contribution. |
| `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. | | `media.setInputVolume(volume)` | `audio.volume` | Sets local input volume. |
| `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. | | `media.setOutputVolume(volume)` | `audio.volume` | Sets local output volume. |
## Storage ## Storage
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------ | -------------------------- | --------------------------------------- |
| `clientData.read(key)` | `storage.local` | Reads async plugin-local data. | | `clientData.read(key)` | `storage.local` | Reads async plugin-local data. |
| `clientData.write(key, value)` | `storage.local` | Writes async plugin-local data. | | `clientData.write(key, value)` | `storage.local` | Writes async plugin-local data. |
| `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. | | `clientData.remove(key)` | `storage.local` | Removes async plugin-local data. |
| `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server data. | | `serverData.read(key)` | `storage.serverData.read` | Reads local per-user/per-server data. |
| `serverData.write(key, value)` | `storage.serverData.write` | Writes local per-user/per-server data. | | `serverData.write(key, value)` | `storage.serverData.write` | Writes local per-user/per-server data. |
| `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. | | `serverData.remove(key)` | `storage.serverData.write` | Removes local per-user/per-server data. |
| `storage.get(key)` | `storage.local` | Legacy synchronous local read. | | `storage.get(key)` | `storage.local` | Legacy synchronous local read. |
| `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. | | `storage.set(key, value)` | `storage.local` | Legacy synchronous local write. |
| `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. | | `storage.remove(key)` | `storage.local` | Legacy synchronous local remove. |
## UI Contributions ## UI Contributions
@@ -306,24 +306,24 @@ interface PluginApiDomMountRequest {
} }
``` ```
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | --------------------------------------------- | -------------------- | --------------------------------------------------------------- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. | | `ui.registerAppPage(id, contribution)` | `ui.pages` | Adds a plugin app page. |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. | | `ui.registerSettingsPage(id, contribution)` | `ui.settings` | Adds a plugin settings page. |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. | | `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | Adds a side panel. |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. | | `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | Adds a channel section. |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. | | `ui.registerComposerAction(id, contribution)` | `ui.pages` | Adds a composer action. |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. | | `ui.registerProfileAction(id, contribution)` | `ui.pages` | Adds a profile action. |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds a toolbar action. | | `ui.registerToolbarAction(id, contribution)` | `ui.pages` | Adds an action tile to the server side panel View plugins menu. |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. | | `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | Adds an embed renderer. |
| `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. | | `ui.mountElement(id, request)` | `ui.dom` | Mounts plugin-owned DOM into a target element or selector. |
## Context and Logger ## Context and Logger
| Method | Capability | Description | | Method | Capability | Description |
| --- | --- | --- | | ------------------------------ | ---------- | -------------------------------------------------------------------------- |
| `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. | | `context.getCurrent()` | None | Reads current user, server, active text channel, and active voice channel. |
| `logger.debug(message, data?)` | None | Writes a debug plugin log entry. | | `logger.debug(message, data?)` | None | Writes a debug plugin log entry. |
| `logger.info(message, data?)` | None | Writes an info plugin log entry. | | `logger.info(message, data?)` | None | Writes an info plugin log entry. |
| `logger.warn(message, data?)` | None | Writes a warning plugin log entry. | | `logger.warn(message, data?)` | None | Writes a warning plugin log entry. |
| `logger.error(message, data?)` | None | Writes an error plugin log entry. | | `logger.error(message, data?)` | None | Writes an error plugin log entry. |

View File

@@ -37,21 +37,23 @@ Example context shape:
## Action Context ## Action Context
Composer, toolbar, and profile actions receive context directly. Composer, toolbar, and profile actions receive context directly. Toolbar actions are launched from the server side panel's View plugins menu and report `source: 'toolbarAction'`.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('where-am-i', { context.subscriptions.push(
label: 'Where am I?', context.api.ui.registerToolbarAction('where-am-i', {
run: (actionContext) => { label: 'Where am I?',
context.api.logger.info('Toolbar action context', { run: (actionContext) => {
source: actionContext.source, context.api.logger.info('Toolbar action context', {
serverId: actionContext.server?.id, source: actionContext.source,
textChannelId: actionContext.textChannel?.id, serverId: actionContext.server?.id,
voiceChannelId: actionContext.voiceChannel?.id textChannelId: actionContext.textChannel?.id,
}); voiceChannelId: actionContext.voiceChannel?.id
} });
})); }
})
);
} }
``` ```
@@ -70,4 +72,4 @@ export function activate(context) {
} }
``` ```
Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents. Logs are visible in the Plugin Manager. Avoid logging passwords, bearer tokens, or private message contents.

View File

@@ -10,17 +10,17 @@ Prefer registered UI contributions over direct DOM mounting. Contribution APIs l
## Required Capabilities ## Required Capabilities
| Method | Capability | | Method | Capability |
| --- | --- | | --------------------------------------------- | -------------------- |
| `ui.registerAppPage(id, contribution)` | `ui.pages` | | `ui.registerAppPage(id, contribution)` | `ui.pages` |
| `ui.registerSettingsPage(id, contribution)` | `ui.settings` | | `ui.registerSettingsPage(id, contribution)` | `ui.settings` |
| `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` | | `ui.registerSidePanel(id, contribution)` | `ui.sidePanel` |
| `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` | | `ui.registerChannelSection(id, contribution)` | `ui.channelsSection` |
| `ui.registerComposerAction(id, contribution)` | `ui.pages` | | `ui.registerComposerAction(id, contribution)` | `ui.pages` |
| `ui.registerProfileAction(id, contribution)` | `ui.pages` | | `ui.registerProfileAction(id, contribution)` | `ui.pages` |
| `ui.registerToolbarAction(id, contribution)` | `ui.pages` | | `ui.registerToolbarAction(id, contribution)` | `ui.pages` |
| `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` | | `ui.registerEmbedRenderer(id, contribution)` | `ui.embeds` |
| `ui.mountElement(id, request)` | `ui.dom` | | `ui.mountElement(id, request)` | `ui.dom` |
Every registration returns a disposable. Push it into `context.subscriptions`. Every registration returns a disposable. Push it into `context.subscriptions`.
@@ -28,15 +28,17 @@ Every registration returns a disposable. Push it into `context.subscriptions`.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerAppPage('dashboard', { context.subscriptions.push(
label: 'Raid Dashboard', context.api.ui.registerAppPage('dashboard', {
path: '/plugins/example.raid-helper/dashboard', label: 'Raid Dashboard',
render: () => { path: '/plugins/example.raid-helper/dashboard',
const root = document.createElement('section'); render: () => {
root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>'; const root = document.createElement('section');
return root; root.innerHTML = '<h1>Raid Dashboard</h1><p>Tonight: dungeon practice.</p>';
} return root;
})); }
})
);
} }
``` ```
@@ -46,22 +48,24 @@ The page is hosted by `/plugins/:pluginId/:pageId`.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerSettingsPage('preferences', { context.subscriptions.push(
label: 'Raid Helper', context.api.ui.registerSettingsPage('preferences', {
settingsKey: 'raid-helper', label: 'Raid Helper',
order: 20, settingsKey: 'raid-helper',
render: () => { order: 20,
const wrapper = document.createElement('section'); render: () => {
const label = document.createElement('label'); const wrapper = document.createElement('section');
const checkbox = document.createElement('input'); const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox'; checkbox.type = 'checkbox';
checkbox.checked = true; checkbox.checked = true;
label.append(checkbox, ' Enable ready-check reminders'); label.append(checkbox, ' Enable ready-check reminders');
wrapper.append(label); wrapper.append(label);
return wrapper; return wrapper;
} }
})); })
);
} }
``` ```
@@ -71,23 +75,26 @@ Use `ui.registerSidePanel` for content that belongs in the server sidebar plugin
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerSidePanel('soundboard', { context.subscriptions.push(
label: 'Soundboard', context.api.ui.registerSidePanel('soundboard', {
order: 10, label: 'Soundboard',
render: () => { order: 10,
const panel = document.createElement('div'); render: () => {
const button = document.createElement('button'); const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Play chime'; button.textContent = 'Play chime';
button.onclick = () => context.api.media.playAudioClip({ button.onclick = () =>
url: 'https://cdn.example.com/chime.wav', context.api.media.playAudioClip({
volume: 0.6 url: 'https://cdn.example.com/chime.wav',
}); volume: 0.6
panel.append(button); });
return panel; panel.append(button);
} return panel;
})); }
})
);
} }
``` ```
@@ -97,11 +104,13 @@ Capabilities required: `ui.sidePanel` and `media.playAudio`.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerChannelSection('events', { context.subscriptions.push(
label: 'Event Rooms', context.api.ui.registerChannelSection('events', {
type: 'custom', label: 'Event Rooms',
order: 50 type: 'custom',
})); order: 50
})
);
} }
``` ```
@@ -109,16 +118,15 @@ export function activate(context) {
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerComposerAction('insert-standup', { context.subscriptions.push(
icon: 'ST', context.api.ui.registerComposerAction('insert-standup', {
label: 'Insert standup prompt', icon: 'ST',
run: (actionContext) => { label: 'Insert standup prompt',
context.api.messages.send( run: (actionContext) => {
'Standup: yesterday I..., today I..., blocked by...', context.api.messages.send('Standup: yesterday I..., today I..., blocked by...', actionContext.textChannel?.id);
actionContext.textChannel?.id }
); })
} );
}));
} }
``` ```
@@ -128,45 +136,65 @@ Capabilities required: `ui.pages` and `messages.send`.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerProfileAction('wave', { context.subscriptions.push(
label: 'Wave', context.api.ui.registerProfileAction('wave', {
run: (actionContext) => { label: 'Wave',
context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`); run: (actionContext) => {
} context.api.messages.send(`Waving at ${actionContext.user?.displayName ?? 'someone'}!`);
})); }
})
);
} }
``` ```
## Toolbar Action ## Toolbar Action
Toolbar actions are command-style plugin entries shown in the server side panel's View plugins menu. Use them for small actions that should be easy to launch from a server, such as opening a plugin page, sending a status message, starting a timer, or toggling a plugin feature.
The View plugins link appears in `[data-testid="plugin-room-side-panel"]` when the plugin side-panel area is rendered. Opening it shows an overlay menu, positioned like profile-card overlays, with registered actions laid out as plugin icon tiles. The `icon` field can be short text such as `RH`, an emoji, or an image URL; when omitted, MetoYou falls back to initials from the plugin/action labels.
Toolbar action callbacks receive an action context with `source: 'toolbarAction'`, the current user, current server, active text channel, and current voice channel when available.
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerToolbarAction('open-dashboard', { context.subscriptions.push(
label: 'Raid Helper', context.api.ui.registerToolbarAction('open-dashboard', {
run: () => { icon: 'RH',
context.api.logger.info('Open the Raid Helper plugin page from /plugins/example.raid-helper/dashboard'); label: 'Raid Helper',
} run: (actionContext) => {
})); context.api.logger.info('Raid Helper opened', {
channelId: actionContext.textChannel?.id,
serverId: actionContext.server?.id
});
}
})
);
} }
``` ```
Capabilities required: `ui.pages`. Add any capability your action uses, such as `messages.send` or `server.read`.
Use `registerSidePanel` instead when the plugin needs persistent sidebar content, and use `registerAppPage` when the plugin needs a full-page workflow.
## Embed Renderer ## Embed Renderer
```js ```js
export function activate(context) { export function activate(context) {
context.subscriptions.push(context.api.ui.registerEmbedRenderer('raid-card', { context.subscriptions.push(
embedType: 'raid.card', context.api.ui.registerEmbedRenderer('raid-card', {
render: (payload) => { embedType: 'raid.card',
const card = document.createElement('article'); render: (payload) => {
const title = document.createElement('h3'); const card = document.createElement('article');
const body = document.createElement('p'); const title = document.createElement('h3');
const body = document.createElement('p');
title.textContent = payload?.title ?? 'Raid'; title.textContent = payload?.title ?? 'Raid';
body.textContent = payload?.description ?? 'No description provided.'; body.textContent = payload?.description ?? 'No description provided.';
card.append(title, body); card.append(title, body);
return card; return card;
} }
})); })
);
} }
``` ```
@@ -202,11 +230,13 @@ export function activate(context) {
badge.style.color = 'white'; badge.style.color = 'white';
badge.style.borderRadius = '6px'; badge.style.borderRadius = '6px';
context.subscriptions.push(context.api.ui.mountElement('active-badge', { context.subscriptions.push(
target: 'body', context.api.ui.mountElement('active-badge', {
position: 'beforeend', target: 'body',
element: badge position: 'beforeend',
})); element: badge
})
);
} }
``` ```
@@ -224,12 +254,14 @@ export function activate(context) {
const banner = document.createElement('div'); const banner = document.createElement('div');
banner.textContent = 'Raid helper active in this chat.'; banner.textContent = 'Raid helper active in this chat.';
context.subscriptions.push(context.api.ui.mountElement('chat-banner', { context.subscriptions.push(
target, context.api.ui.mountElement('chat-banner', {
position: 'afterbegin', target,
element: banner position: 'afterbegin',
})); element: banner
})
);
} }
``` ```
The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible. The runtime tags plugin-owned DOM and removes it on unload, but plugins should still keep mounts minimal and accessible.

View File

@@ -6,44 +6,44 @@ sidebar_position: 3
Capabilities protect privileged app surfaces. A plugin must declare a capability in its manifest and the user must grant it before the runtime allows the corresponding API call. Capabilities protect privileged app surfaces. A plugin must declare a capability in its manifest and the user must grant it before the runtime allows the corresponding API call.
| Capability | API areas | Notes | | Capability | API areas | Notes |
| --- | --- | --- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `profile.read` | `profile.getCurrent()` | Reads the current user. | | `profile.read` | `profile.getCurrent()` | Reads the current user. |
| `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. | | `profile.write` | `profile.update()`, `profile.updateAvatar()` | Updates local profile fields and avatar metadata. |
| `users.read` | `users.getCurrent()`, `users.list()`, `users.readMembers()` | Reads users and server members. | | `users.read` | `users.getCurrent()`, `users.list()`, `users.readMembers()` | Reads users and server members. |
| `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. | | `users.manage` | `users.kick()`, `users.ban()`, `server.registerPluginUser()` | Can create plugin users and moderate members. |
| `roles.read` | `roles.list()` | Reads server roles. | | `roles.read` | `roles.list()` | Reads server roles. |
| `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user roles. | | `roles.manage` | `roles.setAssignments()`, `users.setRole()` | Changes role assignments or user roles. |
| `messages.read` | `messages.readCurrent()`, message bus latest snapshots | Reads current channel messages. | | `messages.read` | `messages.readCurrent()`, message bus latest snapshots | Reads current channel messages. |
| `messages.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. | | `messages.send` | `messages.send()`, `messages.sendAsPluginUser()` | Sends messages as the current user or registered plugin user. |
| `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. | | `messages.editOwn` | `messages.edit()` | Edits plugin-owned messages. |
| `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. | | `messages.deleteOwn` | `messages.delete()` | Deletes plugin-owned messages. |
| `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. | | `messages.moderate` | `messages.moderateDelete()` | Moderation delete path. |
| `messages.sync` | `messages.sync()` | Syncs message arrays into client state. | | `messages.sync` | `messages.sync()`, `messages.import()`, `attachments.import()` | Syncs message arrays, imports historical messages locally, or imports files for those messages. |
| `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. | | `channels.read` | `channels.list()`, `channels.select()` | Reads and selects channels. |
| `channels.manage` | `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. | | `channels.manage` | `channels.addTextChannel()`, `channels.addAudioChannel()`, `channels.addVideoChannel()`, `channels.remove()`, `channels.rename()` | Mutates channel or channel-section state. |
| `server.read` | `server.getCurrent()` | Reads active server. | | `server.read` | `server.getCurrent()` | Reads active server. |
| `server.manage` | `server.updatePermissions()`, `server.updateSettings()` | Updates server permissions or settings. | | `server.manage` | `server.updateIcon()`, `server.updatePermissions()`, `server.updateSettings()` | Updates server icon, permissions, or settings. `server.updateIcon()` resolves when the local icon update has been persisted or rejects if the current user is not allowed to manage the server icon. |
| `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. | | `p2p.data` | `p2p.connectedPeers()`, `p2p.broadcastData()`, `p2p.sendData()` | Uses plugin peer data paths. |
| `p2p.media` | Reserved peer media features. | Included for media-facing plugins. | | `p2p.media` | Reserved peer media features. | Included for media-facing plugins. |
| `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. | | `media.playAudio` | `media.playAudioClip()` | Plays an audio URL locally. |
| `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. | | `media.addAudioStream` | `media.addCustomAudioStream()` | Adds a custom stream to voice handling. |
| `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. | | `media.addVideoStream` | `media.addCustomVideoStream()` | Registers custom video stream contribution. |
| `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. | | `audio.volume` | `media.setInputVolume()`, `media.setOutputVolume()` | Adjusts local voice volume. |
| `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. | | `audio.effects` | Reserved audio effect features. | Included for audio processing plugins. |
| `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. | | `ui.settings` | `ui.registerSettingsPage()` | Adds settings pages. |
| `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and actions. | | `ui.pages` | `ui.registerAppPage()`, `ui.registerComposerAction()`, `ui.registerProfileAction()`, `ui.registerToolbarAction()` | Adds app pages and action entry points, including View plugins menu actions. |
| `ui.sidePanel` | `ui.registerSidePanel()` | Adds side panels. | | `ui.sidePanel` | `ui.registerSidePanel()` | Adds side panels. |
| `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. | | `ui.channelsSection` | `ui.registerChannelSection()` | Adds channel sections. |
| `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. | | `ui.embeds` | `ui.registerEmbedRenderer()` | Renders custom embeds. |
| `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. | | `ui.dom` | `ui.mountElement()` | Mounts plugin-owned DOM into app targets. |
| `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. | | `storage.local` | `storage.*`, `clientData.*` | Reads and writes plugin-local data. |
| `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. | | `storage.serverData.read` | `serverData.read()` | Reads local per-user/per-server plugin data. |
| `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. | | `storage.serverData.write` | `serverData.write()`, `serverData.remove()` | Writes or removes local per-user/per-server plugin data. |
| `events.server.publish` | `events.publishServer()` | Publishes declared server plugin events. | | `events.server.publish` | `events.publishServer()` | Publishes declared server plugin events. |
| `events.server.subscribe` | `events.subscribeServer()` | Subscribes to declared server plugin events. | | `events.server.subscribe` | `events.subscribeServer()` | Subscribes to declared server plugin events. |
| `events.p2p.publish` | `events.publishP2p()`, `messageBus.publish()`, `messageBus.sendLatestMessages()` | Publishes declared P2P/plugin bus events. | | `events.p2p.publish` | `events.publishP2p()`, `messageBus.publish()`, `messageBus.sendLatestMessages()` | Publishes declared P2P/plugin bus events. |
| `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. | | `events.p2p.subscribe` | `events.subscribeP2p()`, `messageBus.subscribe()` | Subscribes to declared P2P/plugin bus events. |
## Recommended Practice ## Recommended Practice

View File

@@ -27,7 +27,7 @@ The manifest file can be named `toju-plugin.json` or `plugin.json`. Entrypoints
"schemaVersion": 1, "schemaVersion": 1,
"id": "example.hello-world", "id": "example.hello-world",
"title": "Hello World", "title": "Hello World",
"description": "Adds a toolbar action that sends a message.", "description": "Adds a View plugins menu action that sends a message.",
"version": "1.0.0", "version": "1.0.0",
"kind": "client", "kind": "client",
"scope": "client", "scope": "client",
@@ -49,6 +49,7 @@ export function activate(context) {
api.logger.info('Hello World activated'); api.logger.info('Hello World activated');
const disposable = api.ui.registerToolbarAction('hello', { const disposable = api.ui.registerToolbarAction('hello', {
icon: 'HI',
label: 'Hello', label: 'Hello',
run: () => api.messages.send('Hello from my plugin') run: () => api.messages.send('Hello from my plugin')
}); });
@@ -65,15 +66,17 @@ export function deactivate(context) {
} }
``` ```
`registerToolbarAction()` adds an action tile to the server side panel's View plugins menu. Use `icon` for the tile badge and keep the `label` short enough to scan in a grid.
## Lifecycle Hooks ## Lifecycle Hooks
| Hook | When it runs | Use it for | | Hook | When it runs | Use it for |
| --- | --- | --- | | ------------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------- |
| `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. | | `activate(context)` | During explicit plugin activation. | Register UI, subscribe to events, initialize state. |
| `ready(context)` | After the load-order pass has activated ready plugins. | Cross-plugin coordination that needs other plugins loaded. | | `ready(context)` | After the load-order pass has activated ready plugins. | Cross-plugin coordination that needs other plugins loaded. |
| `deactivate(context)` | During unload or reload. | Flush state and log shutdown. Disposables are also cleaned up by the host. | | `deactivate(context)` | During unload or reload. | Flush state and log shutdown. Disposables are also cleaned up by the host. |
| `onPluginDataChanged(context, event)` | When plugin data changes are observed. | React to plugin-scoped persistence changes. | | `onPluginDataChanged(context, event)` | When plugin data changes are observed. | React to plugin-scoped persistence changes. |
| `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. | | `onServerRequirementsChanged(context, snapshot)` | When server plugin requirements change. | Adapt to required, optional, blocked, or incompatible server plugins. |
## Cleanup ## Cleanup

View File

@@ -13,7 +13,7 @@ sidebar_position: 5
"schemaVersion": 1, "schemaVersion": 1,
"id": "example.toolbar-message", "id": "example.toolbar-message",
"title": "Toolbar Message", "title": "Toolbar Message",
"description": "Adds a toolbar action that sends a reusable message.", "description": "Adds a View plugins menu action that sends a reusable message.",
"version": "1.0.0", "version": "1.0.0",
"kind": "client", "kind": "client",
"scope": "client", "scope": "client",
@@ -33,13 +33,18 @@ sidebar_position: 5
export function activate(context) { export function activate(context) {
const { api } = context; const { api } = context;
context.subscriptions.push(api.ui.registerToolbarAction('standup-message', { context.subscriptions.push(
label: 'Standup', api.ui.registerToolbarAction('standup-message', {
run: () => api.messages.send('Standup: yesterday, today, blocked') icon: 'ST',
})); label: 'Standup',
run: (actionContext) => api.messages.send('Standup: yesterday, today, blocked', actionContext.textChannel?.id)
})
);
} }
``` ```
The action appears as a tile in the server side panel's View plugins menu and runs with `source: 'toolbarAction'`.
## Settings Page Plugin ## Settings Page Plugin
```json ```json
@@ -67,19 +72,21 @@ export function activate(context) {
export function activate(context) { export function activate(context) {
const { api } = context; const { api } = context;
context.subscriptions.push(api.ui.registerSettingsPage('preferences', { context.subscriptions.push(
label: 'Example Preferences', api.ui.registerSettingsPage('preferences', {
render: () => { label: 'Example Preferences',
const root = document.createElement('section'); render: () => {
const button = document.createElement('button'); const root = document.createElement('section');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Remember preference'; button.textContent = 'Remember preference';
button.onclick = () => api.storage.set('enabled', true); button.onclick = () => api.storage.set('enabled', true);
root.append(button); root.append(button);
return root; return root;
} }
})); })
);
} }
``` ```
@@ -99,13 +106,7 @@ A server-scoped plugin can be installed as a server requirement and auto-install
"apiVersion": "1.0.0", "apiVersion": "1.0.0",
"compatibility": { "minimumTojuVersion": "1.0.0" }, "compatibility": { "minimumTojuVersion": "1.0.0" },
"entrypoint": "./main.js", "entrypoint": "./main.js",
"capabilities": [ "capabilities": ["server.read", "users.manage", "ui.sidePanel", "media.playAudio", "messages.send"],
"server.read",
"users.manage",
"ui.sidePanel",
"media.playAudio",
"messages.send"
],
"pluginUser": { "pluginUser": {
"displayName": "Soundboard", "displayName": "Soundboard",
"label": "Audio helper" "label": "Audio helper"
@@ -121,23 +122,25 @@ export function activate(context) {
displayName: 'Soundboard' displayName: 'Soundboard'
}); });
context.subscriptions.push(api.ui.registerSidePanel('sounds', { context.subscriptions.push(
label: 'Soundboard', api.ui.registerSidePanel('sounds', {
render: () => { label: 'Soundboard',
const panel = document.createElement('div'); render: () => {
const button = document.createElement('button'); const panel = document.createElement('div');
const button = document.createElement('button');
button.type = 'button'; button.type = 'button';
button.textContent = 'Play chime'; button.textContent = 'Play chime';
button.onclick = async () => { button.onclick = async () => {
await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 }); await api.media.playAudioClip({ url: './chime.wav', volume: 0.7 });
api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' }); api.messages.sendAsPluginUser({ pluginUserId: botId, content: 'Played chime' });
}; };
panel.append(button); panel.append(button);
return panel; return panel;
} }
})); })
);
} }
``` ```
@@ -162,12 +165,14 @@ export function activate(context) {
export function activate(context) { export function activate(context) {
const { api } = context; const { api } = context;
context.subscriptions.push(api.messageBus.subscribe({ context.subscriptions.push(
topic: 'poll:votes', api.messageBus.subscribe({
replayLatest: true, topic: 'poll:votes',
latestMessageLimit: 20, replayLatest: true,
handler: (event) => api.logger.info('Vote received', event.payload) latestMessageLimit: 20,
})); handler: (event) => api.logger.info('Vote received', event.payload)
})
);
api.messageBus.publish({ api.messageBus.publish({
topic: 'poll:votes', topic: 'poll:votes',
@@ -192,10 +197,12 @@ export function activate(context) {
badge.style.right = '1rem'; badge.style.right = '1rem';
badge.style.bottom = '1rem'; badge.style.bottom = '1rem';
context.subscriptions.push(context.api.ui.mountElement('active-badge', { context.subscriptions.push(
target: 'body', context.api.ui.mountElement('active-badge', {
element: badge target: 'body',
})); element: badge
})
);
} }
``` ```

View File

@@ -8,11 +8,11 @@ Plugins add features to MetoYou. They can add pages, buttons, panels, settings,
## Types of Plugins ## Types of Plugins
| Type | What it means | | Type | What it means |
| --- | --- | | -------------- | ----------------------------------------------------------------------------------------------------- |
| Client plugin | Installed for your app. It follows you across servers when active. | | Client plugin | Installed for your app. It follows you across servers when active. |
| Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. | | Server plugin | Installed for a specific server. It may be required, recommended, optional, blocked, or incompatible. |
| Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. | | Library plugin | Shared plugin code used by other plugins. It is not normally something users interact with directly. |
## Install from the Plugin Store ## Install from the Plugin Store
@@ -26,6 +26,10 @@ Plugins add features to MetoYou. They can add pages, buttons, panels, settings,
Server-scoped plugins installed to the server you are currently viewing are enabled and activated automatically after install, so their panels, actions, or embeds can appear immediately. Server-scoped plugins installed to the server you are currently viewing are enabled and activated automatically after install, so their panels, actions, or embeds can appear immediately.
## Use Plugin Actions
When plugins add quick actions to a server, the server side panel shows a View plugins link in the plugin area. Open it to see a grid of plugin action tiles. Selecting a tile runs that plugin's action in the current server and channel context.
## Install a Local Plugin ## Install a Local Plugin
Desktop builds can discover local plugin folders from the app data plugins directory. Desktop builds can discover local plugin folders from the app data plugins directory.
@@ -40,12 +44,12 @@ Desktop builds can discover local plugin folders from the app data plugins direc
When a server uses plugins, MetoYou may show a prompt. When a server uses plugins, MetoYou may show a prompt.
| Status | Meaning | | Status | Meaning |
| --- | --- | | ------------ | --------------------------------------------------------------------------------- |
| Required | You must install the plugin to join or continue using that server. | | Required | You must install the plugin to join or continue using that server. |
| Recommended | The server suggests the plugin, but you can choose. | | Recommended | The server suggests the plugin, but you can choose. |
| Optional | The plugin is available for the server, but not required. | | Optional | The plugin is available for the server, but not required. |
| Blocked | The server marks the plugin as not allowed. | | Blocked | The server marks the plugin as not allowed. |
| Incompatible | The plugin version does not work with your app version or the server requirement. | | Incompatible | The plugin version does not work with your app version or the server requirement. |
Required plugins are still installed locally on your device. The signaling server stores requirement metadata only; it does not run plugin code. Required plugins are still installed locally on your device. The signaling server stores requirement metadata only; it does not run plugin code.
@@ -56,13 +60,13 @@ Plugins must ask for capabilities before using sensitive features.
Examples: Examples:
| Capability area | Why a plugin might ask | | Capability area | Why a plugin might ask |
| --- | --- | | --------------- | -------------------------------------------------------------------------- |
| Messages | Send messages, read current messages, moderate messages, or render embeds. | | Messages | Send messages, read current messages, moderate messages, or render embeds. |
| Users and roles | Read member lists, create plugin users, or manage users. | | Users and roles | Read member lists, create plugin users, or manage users. |
| Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. | | Voice and media | Play audio, add an audio stream, add a video stream, or adjust volume. |
| UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. | | UI | Add pages, settings pages, side panels, toolbar buttons, or DOM elements. |
| Storage | Save plugin preferences locally or per server. | | Storage | Save plugin preferences locally or per server. |
Only grant capabilities to plugins you trust. Only grant capabilities to plugins you trust.
@@ -79,4 +83,4 @@ The Plugin Manager lets you:
## Plugin Safety Notes ## Plugin Safety Notes
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it. Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it.

50
e2e/CONTEXT.md Normal file
View File

@@ -0,0 +1,50 @@
# End-to-End Suite (e2e)
Owns Playwright-based end-to-end verification of the desktop product. Tests boot the real Electron application against the real signaling server and exercise user-visible flows across chat, voice, screen-share, settings, plugins, and auth.
> **Format reference:**
> - **Vocabulary** — bold term, one-sentence definition, aliases to avoid.
> - **Relationships** — bullets with bold terms and cardinality.
> - **Boundaries / IO** — what this subdomain exposes and consumes.
> - **Invariants** — rules that always hold.
> - **Flagged ambiguities** — terms in dispute with proposed resolutions.
>
> See `agents-docs/AGENTS_CONTEXT.md` for the contract. Update in the same turn a trigger fires (see `agents-docs/AGENT_WORKFLOW.md` § CONTEXT.md upkeep).
## Vocabulary
| Term | Definition | Aliases to avoid |
|------|------------|------------------|
| **Feature area** | A top-level folder under `tests/` (`auth/`, `chat/`, `voice/`, `screen-share/`, `settings/`, `plugins/`) corresponding to a slice of user-visible behavior. | "category", "section" |
| **Page object** | A test-side abstraction over a screen or panel that exposes user-intent methods rather than raw selectors. | "page model" |
| **Fixture** | A Playwright `test.extend(...)` setup that prepares one or more user/app instances before a test runs. | "helper" |
| **Pair test** | An E2E test that boots two Electron instances simultaneously to verify P2P flows (calls, screen-share, transfers). | "multi-client test" |
## Relationships
- A **Feature area** owns one or more `*.spec.ts` files plus its own **Page objects** and **Fixtures**.
- A **Pair test** depends on the **server** subdomain being reachable — both clients connect to the same signaling server.
- **Page objects** depend only on the rendered DOM produced by **toju-app**; if a selector changes, only the page object should need updating.
- The suite as a whole depends on **electron** (built via `npm run build:electron`) and a usable **server** (`npm run server:dev` or `npm run dev`).
## Boundaries / IO
- **Exposes:** test results (HTML report at `test-results/html-report`, JUnit/JSON output on CI). No production surface.
- **Consumes:**
- The built product (toju-app + electron) — typically launched via Playwright's Electron support.
- The signaling server (started before the suite runs).
- System resources: audio devices for voice tests, screen-capture for screen-share tests. The `.agents/skills/playwright-e2e/SKILL.md` documents how the suite handles the multi-client setup.
## Invariants
- Tests interact only through **Page objects** and **Fixtures** — no raw `page.click('.css-class-name')` scattered across specs.
- Tests must clean up state between runs — a flaky run that leaves cruft in the local database or signaling server is a bug, not an environment issue.
- The suite must run headless on CI (`npm run test:e2e`); the `ui` and `debug` variants exist for local development only.
## Flagged ambiguities
- _None recorded yet — add entries when a test concept (e.g. "pair test" vs "multi-client test") resists clean definition._
---
*Agent-procedural rules (TDD, lint) live in `/AGENTS.md`. Practical patterns for writing Playwright tests on this project live in `.agents/skills/playwright-e2e/SKILL.md`. This file is the bounded-context domain artefact for the E2E suite.*

View File

@@ -11,6 +11,7 @@ import { type Page } from '@playwright/test';
export async function installWebRTCTracking(page: Page): Promise<void> { export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
const connections: RTCPeerConnection[] = []; const connections: RTCPeerConnection[] = [];
const dataChannels: RTCDataChannel[] = [];
const syntheticMediaResources: { const syntheticMediaResources: {
audioCtx: AudioContext; audioCtx: AudioContext;
source?: AudioScheduledSourceNode; source?: AudioScheduledSourceNode;
@@ -18,20 +19,40 @@ export async function installWebRTCTracking(page: Page): Promise<void> {
}[] = []; }[] = [];
(window as any).__rtcConnections = connections; (window as any).__rtcConnections = connections;
(window as any).__rtcDataChannels = dataChannels;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[]; (window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources; (window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
const OriginalRTCPeerConnection = window.RTCPeerConnection; const OriginalRTCPeerConnection = window.RTCPeerConnection;
const trackDataChannel = (channel: RTCDataChannel) => {
if (dataChannels.includes(channel)) {
return;
}
dataChannels.push(channel);
};
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) { (window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args); const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
const originalCreateDataChannel = pc.createDataChannel.bind(pc);
connections.push(pc); connections.push(pc);
pc.createDataChannel = ((label: string, options?: RTCDataChannelInit) => {
const channel = originalCreateDataChannel(label, options);
trackDataChannel(channel);
return channel;
}) as RTCPeerConnection['createDataChannel'];
pc.addEventListener('connectionstatechange', () => { pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState; (window as any).__lastRtcState = pc.connectionState;
}); });
pc.addEventListener('datachannel', (event: RTCDataChannelEvent) => {
trackDataChannel(event.channel);
});
pc.addEventListener('track', (event: RTCTrackEvent) => { pc.addEventListener('track', (event: RTCTrackEvent) => {
(window as any).__rtcRemoteTracks.push({ (window as any).__rtcRemoteTracks.push({
kind: event.track.kind, kind: event.track.kind,
@@ -211,6 +232,66 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe
); );
} }
/** Returns the number of tracked RTCDataChannels in the open state. */
export async function getOpenDataChannelCount(page: Page): Promise<number> {
return page.evaluate(
() => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open'
).length ?? 0
);
}
/** Wait until the expected number of tracked RTCDataChannels are open. */
export async function waitForOpenDataChannelCount(page: Page, expectedCount: number, timeout = 45_000): Promise<void> {
await page.waitForFunction(
(count) => ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined)?.filter(
(channel) => channel.readyState === 'open'
).length === count,
expectedCount,
{ timeout }
);
}
/** Close every currently-open RTCDataChannel and return how many were closed. */
export async function closeOpenDataChannels(page: Page): Promise<number> {
return page.evaluate(() => {
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let closed = 0;
for (const channel of channels) {
if (channel.readyState !== 'open') {
continue;
}
channel.close();
closed++;
}
return closed;
});
}
/** Dispatch a synthetic data-channel error event on each open channel. */
export async function dispatchDataChannelErrors(page: Page): Promise<number> {
return page.evaluate(() => {
const channels = ((window as any).__rtcDataChannels as RTCDataChannel[] | undefined) ?? [];
let dispatched = 0;
for (const channel of channels) {
if (channel.readyState !== 'open') {
continue;
}
channel.dispatchEvent(new Event('error'));
dispatched++;
}
return dispatched;
});
}
/** /**
* Resume all suspended AudioContext instances created by the synthetic * Resume all suspended AudioContext instances created by the synthetic
* media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so * media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so

View File

@@ -35,6 +35,18 @@ test.describe('Direct message flow', () => {
}); });
}); });
test('delivers a live DM to the recipient conversation', async ({ createClient }) => {
const scenario = await createDmScenario(createClient);
const liveMessage = `Live DM ${uniqueName('msg')}`;
await openDmFromRoomUserCard(scenario.alice.page, 'Bob');
await scenario.alice.page.getByTestId('dm-input').fill(liveMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await openDmFromRoomUserCard(scenario.bob.page, 'Alice');
await expect(scenario.bob.page.locator('app-dm-chat').getByText(liveMessage)).toBeVisible({ timeout: 20_000 });
});
test('shows friend and message actions on the search people list', async ({ createClient }) => { test('shows friend and message actions on the search people list', async ({ createClient }) => {
const scenario = await createDmScenario(createClient); const scenario = await createDmScenario(createClient);
@@ -110,6 +122,15 @@ async function registerUser(page: Page, username: string, displayName: string):
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
} }
async function openDmFromRoomUserCard(page: Page, displayName: string): Promise<void> {
const userCard = page.locator('[data-testid^="room-user-card-"]', { hasText: displayName }).first();
await expect(userCard).toBeVisible({ timeout: 20_000 });
await userCard.getByRole('button', { name: `Message ${displayName}` }).click();
await expect(page).toHaveURL(/\/dm\//, { timeout: 15_000 });
await expect(page.getByRole('heading', { name: displayName })).toBeVisible({ timeout: 10_000 });
}
function uniqueName(prefix: string): string { function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36) return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`; .slice(2, 8)}`;

View File

@@ -39,7 +39,6 @@ test.describe('Plugin API multi-user runtime', () => {
await closeSettingsModal(scenario.bob.page); await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 }); await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 }); await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
}); });
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => { await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
@@ -150,7 +149,7 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL); await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
await page.getByRole('button', { name: 'Add Source' }).click(); await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click(); await page.locator('article', { hasText: PLUGIN_TITLE }).getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Install and Activate' }).click(); await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 }); await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });

View File

@@ -33,9 +33,11 @@ test.describe('Plugin manager UI', () => {
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json'); await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
await page.getByRole('button', { name: 'Add Source' }).click(); await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'Readme' }).click(); const pluginCard = page.locator('article', { hasText: 'E2E All API Plugin' });
await pluginCard.getByRole('button', { name: 'Readme' }).click();
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click(); await pluginCard.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' }); const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
await expect(installDialog).toBeVisible({ timeout: 10_000 }); await expect(installDialog).toBeVisible({ timeout: 10_000 });

View File

@@ -0,0 +1,181 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
closeOpenDataChannels,
dispatchDataChannelErrors,
dumpRtcDiagnostics,
getOpenDataChannelCount,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
interface VoiceClient extends Client {
displayName: string;
username: string;
}
const USER_PASSWORD = 'TestPass123!';
const VOICE_CHANNEL = 'General';
test.describe('Voice data-channel recovery', () => {
test('keeps two users hearing each other after a data-channel error and close', async ({ createClient }) => {
test.setTimeout(240_000);
const clients = await createVoiceScenario(createClient, 2, `DC Recovery Duo ${Date.now()}`);
const [alice, bob] = clients;
await assertMeshAudio(clients, 1, 'initial two-user voice');
await test.step('A non-fatal data-channel error does not interrupt audio', async () => {
const dispatched = await dispatchDataChannelErrors(alice.page);
expect(dispatched).toBeGreaterThan(0);
await waitForOpenDataChannelCount(alice.page, 1, 15_000);
await waitForOpenDataChannelCount(bob.page, 1, 15_000);
await assertMeshAudio(clients, 1, 'after synthetic data-channel error');
});
await test.step('A closed data channel is rebuilt and audio resumes both ways', async () => {
const closed = await closeOpenDataChannels(alice.page);
expect(closed).toBeGreaterThan(0);
await waitForConnectedPeerCount(alice.page, 1, 60_000);
await waitForConnectedPeerCount(bob.page, 1, 60_000);
await waitForOpenDataChannelCount(alice.page, 1, 60_000);
await waitForOpenDataChannelCount(bob.page, 1, 60_000);
await assertMeshAudio(clients, 1, 'after data-channel close recovery');
});
});
test('heals a three-user voice mesh when one client loses every data channel', async ({ createClient }) => {
test.setTimeout(300_000);
const clients = await createVoiceScenario(createClient, 3, `DC Recovery Trio ${Date.now()}`);
const bob = clients[1];
await assertMeshAudio(clients, 2, 'initial three-user mesh');
await test.step('Bob loses all control channels and the full mesh recovers', async () => {
const closed = await closeOpenDataChannels(bob.page);
expect(closed).toBe(2);
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 2, 90_000);
await waitForOpenDataChannelCount(client.page, 2, 90_000);
}
await assertMeshAudio(clients, 2, 'after full control-channel recovery');
});
});
});
async function createVoiceScenario(
createClient: () => Promise<Client>,
userCount: number,
serverName: string
): Promise<VoiceClient[]> {
const clients: VoiceClient[] = [];
for (let index = 0; index < userCount; index++) {
const client = await createClient();
const displayName = `DC Voice ${index + 1}`;
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
displayName,
username: `dc_voice_${Date.now()}_${index + 1}`
});
}
await test.step('Register clients', async () => {
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(client.username, client.displayName, USER_PASSWORD);
await expect(client.page).toHaveURL(/\/search/, { timeout: 20_000 });
}
});
await test.step('Create and join server', async () => {
const hostSearch = new ServerSearchPage(clients[0].page);
await hostSearch.createServer(serverName, { description: 'Data-channel recovery voice test' });
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
for (const client of clients.slice(1)) {
const searchPage = new ServerSearchPage(client.page);
await searchPage.joinServerFromSearch(serverName);
await expect(client.page).toHaveURL(/\/room\//, { timeout: 20_000 });
}
});
await test.step('Join everyone to voice', async () => {
const hostRoom = new ChatRoomPage(clients[0].page);
await hostRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(VOICE_CHANNEL);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
const expectedRemotePeers = clients.length - 1;
for (const client of clients) {
await waitForConnectedPeerCount(client.page, expectedRemotePeers, 90_000);
await waitForOpenDataChannelCount(client.page, expectedRemotePeers, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
}
});
return clients;
}
async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
async function assertMeshAudio(
clients: readonly VoiceClient[],
expectedRemotePeers: number,
label: string
): Promise<void> {
for (const client of clients) {
try {
await waitForAllPeerAudioFlow(client.page, expectedRemotePeers, 60_000);
} catch (error) {
const dataChannelCount = await getOpenDataChannelCount(client.page);
console.log(`[${client.displayName} ${label} data channels] ${dataChannelCount}`);
console.log(`[${client.displayName} ${label} RTC]\n${await dumpRtcDiagnostics(client.page)}`);
throw error;
}
}
}

View File

@@ -0,0 +1,745 @@
import { expect, type Page } from '@playwright/test';
import { test, type Client } from '../../fixtures/multi-client';
import {
closeOpenDataChannels,
dumpRtcDiagnostics,
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAllPeerAudioFlow,
waitForAudioFlow,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount,
waitForInboundVideoFlow,
waitForOutboundVideoFlow,
waitForPeerConnected
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
interface DirectCallScenario {
alice: Client;
bob: Client;
charlie?: Client;
aliceUserId: string;
bobUserId: string;
charlieUserId?: string;
}
interface AudioFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
interface VideoFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
const USER_PASSWORD = 'TestPass123!';
test.describe('Direct private calls', () => {
test.describe.configure({ timeout: 240_000 });
test('two users can ring, answer, chat, see self voice indicators, and exchange audio', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient, { includeCharlie: true });
const callMessage = `Call chat ${uniqueName('msg')}`;
const privateOnlyMessage = `Private before group ${uniqueName('msg')}`;
const groupMessage = `Group call chat ${uniqueName('msg')}`;
await test.step('Alice starts a call from the search people card', async () => {
await disableLastViewedChatResume(scenario.alice.page);
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
const bobPeopleCard = scenario.alice.page.locator(`[data-testid="user-card-${scenario.bobUserId}"]`, { hasText: 'Bob' }).first();
await expect(bobPeopleCard).toBeVisible({ timeout: 20_000 });
await bobPeopleCard.hover();
await bobPeopleCard.getByRole('button', { name: 'Call Bob' }).click();
await expect(scenario.alice.page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(scenario.alice.page.locator('app-private-call')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
});
await test.step('Alice starts sharing before Bob joins', async () => {
await scenario.alice.page.getByRole('button', { name: 'Share screen' }).click();
await expect(scenario.alice.page.getByRole('button', { name: 'Stop sharing screen' })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Bob receives a ringing call and the ring stops when he answers', async () => {
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallNotificationCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
await answerIncomingCall(scenario.bob.page);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await expect
.poll(async () => await getCallAudioPauseCount(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(0);
});
await test.step('WebRTC connects and late-join screen share is visible', async () => {
await waitForPeerConnected(scenario.alice.page, 45_000);
await waitForPeerConnected(scenario.bob.page, 45_000);
await expectParticipantConnected(scenario.alice.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.alice.page, scenario.bobUserId);
await expectParticipantConnected(scenario.bob.page, scenario.aliceUserId);
await expectParticipantConnected(scenario.bob.page, scenario.bobUserId);
const aliceVideo = await waitForOutboundVideoFlow(scenario.alice.page, 30_000);
const bobVideo = await waitForInboundVideoFlow(scenario.bob.page, 30_000);
if (!isOutboundVideoFlowing(aliceVideo) || !isInboundVideoFlowing(bobVideo)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectOutboundVideoFlow(aliceVideo, 'Alice late-join direct call screen share');
expectInboundVideoFlow(bobVideo, 'Bob late-join direct call screen share');
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
});
await test.step('Audio flows in both directions', async () => {
await waitForAudioStatsPresent(scenario.alice.page, 30_000);
await waitForAudioStatsPresent(scenario.bob.page, 30_000);
const aliceDelta = await waitForAudioFlow(scenario.alice.page, 45_000);
const bobDelta = await waitForAudioFlow(scenario.bob.page, 45_000);
if (!isAudioFlowing(aliceDelta) || !isAudioFlowing(bobDelta)) {
console.log('[Alice direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.alice.page)));
console.log('[Bob direct-call RTC]\n' + (await dumpRtcDiagnostics(scenario.bob.page)));
}
expectAudioFlow(aliceDelta, 'Alice direct call');
expectAudioFlow(bobDelta, 'Bob direct call');
});
await test.step('Adding a third participant converts the call chat to an empty group chat', async () => {
if (!scenario.charlie || !scenario.charlieUserId) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
const charlie = scenario.charlie;
await scenario.alice.page.getByTestId('dm-input').fill(privateOnlyMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toBeVisible({ timeout: 20_000 });
await scenario.alice.page.getByLabel('Add user to call').selectOption(scenario.charlieUserId);
await scenario.alice.page.getByRole('button', { name: 'Add user' }).click();
await expect(scenario.alice.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0, {
timeout: 20_000
});
await expect(scenario.alice.page.locator('[data-testid^="dm-rail-item-dm-group-"]')).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.alice.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(1, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(0);
await answerIncomingCall(charlie.page);
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(privateOnlyMessage)).toHaveCount(0);
await waitForConnectedPeerCount(scenario.alice.page, 2, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 2, 45_000);
await waitForConnectedPeerCount(charlie.page, 2, 45_000);
await Promise.all([
waitForAllPeerAudioFlow(scenario.alice.page, 2, 45_000),
waitForAllPeerAudioFlow(scenario.bob.page, 2, 45_000),
waitForAllPeerAudioFlow(charlie.page, 2, 45_000)
]);
await expectParticipantConnected(scenario.alice.page, scenario.charlieUserId);
await expectParticipantConnected(scenario.bob.page, scenario.charlieUserId);
await expectParticipantConnected(charlie.page, scenario.aliceUserId);
await expectParticipantConnected(charlie.page, scenario.bobUserId);
await expectParticipantConnected(charlie.page, scenario.charlieUserId);
await scenario.alice.page.getByTestId('dm-input').fill(groupMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
await expect(charlie.page.locator('app-private-call aside app-dm-chat').getByText(groupMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Private call streams can switch between all-stream and focused viewing', async () => {
await scenario.bob.page.getByRole('button', { name: 'Turn camera on' }).click();
await expect(scenario.bob.page.getByRole('button', { name: 'Turn camera off' })).toBeVisible({ timeout: 20_000 });
await expect
.poll(async () => await privateCallGridStreamCount(scenario.bob.page), {
timeout: 30_000,
intervals: [500, 1_000]
})
.toBeGreaterThanOrEqual(2);
await scenario.bob.page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.first()
.click();
await expect(scenario.bob.page.getByTestId('private-call-focused-stream')).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('private-call-show-all-streams')).toBeVisible({ timeout: 20_000 });
await assertSustainedMediaFlow(scenario.alice.page, scenario.bob.page, 'direct call screen share and camera');
await scenario.bob.page.getByTestId('private-call-focused-stream').dblclick();
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await exitFullscreen(scenario.bob.page);
await expect
.poll(async () => await hasFullscreenElement(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(false);
await scenario.bob.page.getByTestId('private-call-show-all-streams').click();
await expect(scenario.bob.page.getByTestId('private-call-stream-grid')).toBeVisible({ timeout: 20_000 });
});
await test.step('Both clients show their own speaking indicator', async () => {
await expect(scenario.alice.page.getByTestId(`call-participant-${scenario.aliceUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
await expect(scenario.bob.page.getByTestId(`call-participant-${scenario.bobUserId}`)).toHaveClass(/ring-emerald-400/, {
timeout: 20_000
});
});
await test.step('Private call layout does not require vertical scrolling', async () => {
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect
.poll(async () => await privateCallMainHasNoVerticalOverflow(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(true);
await expect(scenario.alice.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call article').filter({ hasText: /Connected|Speaking/ })).toHaveCount(0);
await expect(scenario.alice.page.getByText('No live streams yet')).toHaveCount(0);
await expect(scenario.bob.page.getByText('No live streams yet')).toHaveCount(0);
const originalWidth = await privateCallChatWidth(scenario.alice.page);
const resizer = scenario.alice.page.getByTestId('private-call-chat-resizer');
const box = await resizer.boundingBox();
expect(box, 'private call chat resizer should be measurable').not.toBeNull();
if (box) {
await scenario.alice.page.mouse.move(box.x + box.width / 2, box.y + 20);
await scenario.alice.page.mouse.down();
await scenario.alice.page.mouse.move(box.x - 96, box.y + 20);
await scenario.alice.page.mouse.up();
}
await expect
.poll(async () => await privateCallChatWidth(scenario.alice.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBeGreaterThan(originalWidth + 40);
});
await test.step('Embedded call chat syncs and does not expose another call button', async () => {
await expect(scenario.alice.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await expect(scenario.bob.page.locator('app-private-call aside').getByRole('button', { name: /Call/i })).toHaveCount(0);
await scenario.bob.page.getByTestId('dm-input').fill('typing from Bob');
await expect(scenario.alice.page.getByTestId('dm-typing-indicator')).toContainText('Bob is typing', { timeout: 20_000 });
await scenario.alice.page.getByTestId('dm-input').fill(callMessage);
await scenario.alice.page.getByTestId('dm-input').press('Enter');
await expect(scenario.bob.page.locator('app-private-call aside app-dm-chat').getByText(callMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Group chat call button rings every other participant', async () => {
if (!scenario.charlie) {
throw new Error('Expected direct-call scenario to include Charlie.');
}
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await scenario.charlie.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.charlie.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
const bobPlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.bob.page);
const charliePlayCountBeforeGroupCall = await getCallAudioPlayCount(scenario.charlie.page);
await scenario.alice.page
.locator('app-dm-chat header')
.getByRole('button', { name: /Call/i })
.click();
await expect(scenario.alice.page).toHaveURL(/\/call\/dm-group-/, { timeout: 20_000 });
await expect
.poll(async () => await getCallAudioPlayCount(scenario.bob.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(bobPlayCountBeforeGroupCall);
await expect
.poll(async () => await getCallAudioPlayCount(scenario.charlie.page), {
timeout: 20_000,
intervals: [500, 1_000]
})
.toBeGreaterThan(charliePlayCountBeforeGroupCall);
await answerIncomingCall(scenario.bob.page);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
await answerIncomingCall(scenario.charlie.page);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.charlie.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
test('keeps private-call audio flowing after the data channel closes', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient);
await test.step('Alice starts a private call and Bob joins', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await answerIncomingCall(scenario.bob.page);
await waitForConnectedPeerCount(scenario.alice.page, 1, 45_000);
await waitForConnectedPeerCount(scenario.bob.page, 1, 45_000);
await waitForOpenDataChannelCount(scenario.alice.page, 1, 45_000);
await waitForOpenDataChannelCount(scenario.bob.page, 1, 45_000);
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 45_000);
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 45_000);
});
await test.step('Data-channel recovery keeps the call audible', async () => {
const closed = await closeOpenDataChannels(scenario.alice.page);
expect(closed).toBeGreaterThan(0);
await waitForOpenDataChannelCount(scenario.alice.page, 1, 60_000);
await waitForOpenDataChannelCount(scenario.bob.page, 1, 60_000);
await waitForAllPeerAudioFlow(scenario.alice.page, 1, 60_000);
await waitForAllPeerAudioFlow(scenario.bob.page, 1, 60_000);
});
});
test('missing and ended private calls do not leave stale call controls behind', async ({ createClient }) => {
const scenario = await createDirectCallScenario(createClient);
await test.step('Unknown call routes render an inert empty state', async () => {
await scenario.alice.page.goto('/call/not-a-real-call', { waitUntil: 'domcontentloaded' });
await expect(scenario.alice.page.getByText('No active call for this route.')).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByRole('button', { name: 'Join call' })).toHaveCount(0);
});
await test.step('Caller leaving before answer clears recipient call route and rail icon', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await expect(incomingCallDialog(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await scenario.alice.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect(incomingCallDialog(scenario.bob.page)).toHaveCount(0, { timeout: 20_000 });
await expect(scenario.bob.page.getByRole('button', { name: 'Open private call' })).toHaveCount(0);
await expect(scenario.bob.page.locator('[data-testid^="server-rail-call-"]')).toHaveCount(0);
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
await test.step('Leaving an answered call clears local ringing and returns to DM', async () => {
await startCallFromSearch(scenario.alice.page, scenario.bobUserId, 'Bob');
await answerIncomingCall(scenario.bob.page);
await scenario.bob.page.getByRole('button', { name: 'Leave call' }).click();
await expect(scenario.bob.page).toHaveURL(/\/dm\//, { timeout: 20_000 });
await expect
.poll(async () => await getActiveCallAudioLoops(scenario.bob.page), {
timeout: 10_000,
intervals: [250, 500]
})
.toBe(0);
});
});
});
async function createDirectCallScenario(
createClient: () => Promise<Client>,
options: { includeCharlie?: boolean } = {}
): Promise<DirectCallScenario> {
const suffix = uniqueName('direct-call');
const serverName = `Direct Call Server ${suffix}`;
const alice = await createClient();
const bob = await createClient();
const charlie = options.includeCharlie ? await createClient() : undefined;
await installDirectCallInstrumentation(alice.page);
await installDirectCallInstrumentation(bob.page);
if (charlie) {
await installDirectCallInstrumentation(charlie.page);
}
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
if (charlie) {
await registerUser(charlie.page, `charlie_${suffix}`, 'Charlie');
}
const aliceUserId = await getCurrentUserId(alice.page);
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(serverName, { description: 'E2E direct call discovery server' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(alice.page).waitForReady();
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.joinServerFromSearch(serverName);
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(bob.page).waitForReady();
if (charlie) {
const charlieSearch = new ServerSearchPage(charlie.page);
await charlieSearch.joinServerFromSearch(serverName);
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatMessagesPage(charlie.page).waitForReady();
}
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
const charlieRoomCard = charlie ? alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Charlie' }).first() : null;
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
if (charlieRoomCard) {
await expect(charlieRoomCard).toBeVisible({ timeout: 20_000 });
}
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
const charlieUserCardTestId = charlieRoomCard ? await charlieRoomCard.getAttribute('data-testid') : null;
const charlieUserId = charlieUserCardTestId?.replace('room-user-card-', '');
if (!aliceUserId || !bobUserId || (charlie && !charlieUserId)) {
throw new Error('Expected direct-call scenario users to expose stable ids.');
}
return {
alice,
bob,
charlie,
aliceUserId,
bobUserId,
charlieUserId
};
}
async function installDirectCallInstrumentation(page: Page): Promise<void> {
await installWebRTCTracking(page);
await installAutoResumeAudioContext(page);
await page.addInitScript(() => {
localStorage.setItem(
'metoyou_voice_settings',
JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
})
);
const OriginalAudio = window.Audio;
const callAudioState = {
activeLoops: 0,
pauseCount: 0,
playCount: 0
};
const callNotificationState = {
count: 0,
bodies: [] as string[],
titles: [] as string[]
};
(window as Window & { __callAudioState?: typeof callAudioState }).__callAudioState = callAudioState;
(window as Window & { __callNotificationState?: typeof callNotificationState }).__callNotificationState = callNotificationState;
function isCallAudio(audio: HTMLAudioElement): boolean {
return audio.src.includes('/assets/audio/call.wav') || audio.src.endsWith('assets/audio/call.wav');
}
(window as unknown as { Audio: typeof Audio }).Audio = function(this: HTMLAudioElement, src?: string) {
const audio = new OriginalAudio(src);
const originalPlay = audio.play.bind(audio);
const originalPause = audio.pause.bind(audio);
audio.play = () => {
if (isCallAudio(audio)) {
callAudioState.playCount += 1;
if (audio.loop) {
callAudioState.activeLoops += 1;
}
}
return originalPlay();
};
audio.pause = () => {
if (isCallAudio(audio)) {
callAudioState.pauseCount += 1;
if (audio.loop && callAudioState.activeLoops > 0) {
callAudioState.activeLoops -= 1;
}
}
return originalPause();
};
return audio;
} as typeof Audio;
window.Audio.prototype = OriginalAudio.prototype;
Object.setPrototypeOf(window.Audio, OriginalAudio);
class MockNotification {
static permission: NotificationPermission = 'granted';
onclick: ((this: Notification, ev: Event) => unknown) | null = null;
constructor(title: string, options?: NotificationOptions) {
callNotificationState.count += 1;
callNotificationState.titles.push(title);
callNotificationState.bodies.push(options?.body ?? '');
}
static async requestPermission(): Promise<NotificationPermission> {
return 'granted';
}
close(): void {}
}
(window as unknown as { Notification: typeof Notification }).Notification = MockNotification as unknown as typeof Notification;
});
}
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await registerPage.register(username, displayName, USER_PASSWORD);
await expect(page).toHaveURL(/\/search/, { timeout: 20_000 });
}
async function startCallFromSearch(page: Page, userId: string, displayName: string): Promise<void> {
await disableLastViewedChatResume(page);
await page.goto('/search', { waitUntil: 'domcontentloaded' });
const peopleCard = page.locator(`[data-testid="user-card-${userId}"]`, { hasText: displayName }).first();
await expect(peopleCard).toBeVisible({ timeout: 20_000 });
await peopleCard.hover();
await peopleCard.getByRole('button', { name: `Call ${displayName}` }).click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
}
function incomingCallDialog(page: Page) {
return page.getByRole('dialog', { name: /is calling/ });
}
async function answerIncomingCall(page: Page): Promise<void> {
const dialog = incomingCallDialog(page);
if (await dialog.isVisible({ timeout: 5_000 }).catch(() => false)) {
await dialog.getByRole('button', { name: 'Answer' }).click();
} else {
await page.getByRole('button', { name: 'Open private call' }).last().click();
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
const joinButton = page.getByRole('button', { name: 'Join call' });
if (await joinButton.isVisible({ timeout: 5_000 }).catch(() => false)) {
await joinButton.click();
}
}
await expect(page).toHaveURL(/\/call\//, { timeout: 20_000 });
await expect(page.getByRole('button', { name: 'Leave call' })).toBeVisible({ timeout: 20_000 });
}
async function getCurrentUserId(page: Page): Promise<string> {
return await page.evaluate(() => localStorage.getItem('metoyou_currentUserId') ?? '');
}
async function getCallAudioPlayCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { playCount: number } }).__callAudioState?.playCount ?? 0);
}
async function getCallAudioPauseCount(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { pauseCount: number } }).__callAudioState?.pauseCount ?? 0);
}
async function getCallNotificationCount(page: Page): Promise<number> {
return await page.evaluate(() => (
window as Window & { __callNotificationState?: { count: number } }
).__callNotificationState?.count ?? 0);
}
async function getActiveCallAudioLoops(page: Page): Promise<number> {
return await page.evaluate(() => (window as Window & { __callAudioState?: { activeLoops: number } }).__callAudioState?.activeLoops ?? 0);
}
async function expectParticipantConnected(page: Page, userId: string | undefined): Promise<void> {
if (!userId) {
throw new Error('Expected a stable participant id.');
}
await expect(page.getByTestId(`call-participant-${userId}`)).not.toHaveClass(/opacity-55/, { timeout: 20_000 });
}
async function assertSustainedMediaFlow(senderPage: Page, receiverPage: Page, label: string): Promise<void> {
for (let sample = 0; sample < 3; sample++) {
const [
senderAudio,
receiverAudio,
outboundVideo,
inboundVideo
] = await Promise.all([
waitForAudioFlow(senderPage, 30_000),
waitForAudioFlow(receiverPage, 30_000),
waitForOutboundVideoFlow(senderPage, 30_000),
waitForInboundVideoFlow(receiverPage, 30_000)
]);
expectAudioFlow(senderAudio, `${label} sender sample ${sample + 1}`);
expectAudioFlow(receiverAudio, `${label} receiver sample ${sample + 1}`);
expectOutboundVideoFlow(outboundVideo, `${label} outbound sample ${sample + 1}`);
expectInboundVideoFlow(inboundVideo, `${label} inbound sample ${sample + 1}`);
}
}
async function privateCallMainHasNoVerticalOverflow(page: Page): Promise<boolean> {
return await page.locator('app-private-call > section > main').evaluate((main) => main.scrollHeight <= main.clientHeight + 1);
}
async function privateCallGridStreamCount(page: Page): Promise<number> {
return await page
.getByTestId('private-call-stream-grid')
.locator('app-voice-workspace-stream-tile')
.count();
}
async function privateCallChatWidth(page: Page): Promise<number> {
return await page.locator('app-private-call aside').evaluate((aside) => aside.getBoundingClientRect().width);
}
async function hasFullscreenElement(page: Page): Promise<boolean> {
return await page.evaluate(() => document.fullscreenElement !== null);
}
async function exitFullscreen(page: Page): Promise<void> {
await page.evaluate(async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
});
}
function expectAudioFlow(delta: AudioFlowDelta, label: string): void {
expect(delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0, `${label} should send audio`).toBe(true);
expect(delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0, `${label} should receive audio`).toBe(true);
}
function expectOutboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isOutboundVideoFlowing(delta), `${label} should send video`).toBe(true);
}
function expectInboundVideoFlow(delta: VideoFlowDelta, label: string): void {
expect(isInboundVideoFlowing(delta), `${label} should receive video`).toBe(true);
}
function isAudioFlowing(delta: AudioFlowDelta): boolean {
return (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0)
&& (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0);
}
function isOutboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
}
function isInboundVideoFlowing(delta: VideoFlowDelta): boolean {
return delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

59
electron/CONTEXT.md Normal file
View File

@@ -0,0 +1,59 @@
# Desktop Shell (electron)
Owns the desktop runtime: the Electron main process, the preload bridge that exposes `window.api` to the renderer, IPC handlers, the local TypeORM + sql.js database, the plugin loader, OS-integration adapters (window controls, idle detection, game detection, audio), update flow, and the Local API server that hosts the Docusaurus bundle inside the app.
> **Format reference:**
> - **Vocabulary** — bold term, one-sentence definition, aliases to avoid.
> - **Relationships** — bullets with bold terms and cardinality.
> - **Boundaries / IO** — what this subdomain exposes and consumes.
> - **Invariants** — rules that always hold.
> - **Flagged ambiguities** — terms in dispute with proposed resolutions.
>
> See `agents-docs/AGENTS_CONTEXT.md` for the contract. Update in the same turn a trigger fires (see `agents-docs/AGENT_WORKFLOW.md` § CONTEXT.md upkeep).
## Vocabulary
| Term | Definition | Aliases to avoid |
|------|------------|------------------|
| **Preload bridge** | `electron/preload.ts` — the only surface that the renderer can call into Node through; exposes `window.api.*` after `contextBridge.exposeInMainWorld`. | "preloader" |
| **IPC handler** | A `main`-process function registered against an IPC channel name; lives under `electron/ipc/` (system, window-controls) and `electron/cqrs.ts`. | "rpc handler" |
| **CQRS handler** | Command or query handler dispatched through `electron/ipc/cqrs.ts`; pattern shared with `server/src/cqrs/`. | "command processor" |
| **Local API server** | An in-process HTTP server (`electron/api/local-api-server.ts`) that serves the prebuilt Docusaurus docs and OpenAPI views to the renderer over `http://localhost:<port>/`. | "internal API" |
| **Plugin library** | The plugin loader (`electron/plugin-library.ts`) — resolves manifests, validates entry points, and prepares the sandbox the renderer mounts plugins into. | "plugin manager" |
| **Data archive** | The export/import format implemented in `electron/data-archive.ts` for moving a user's local database between installs. | "backup" |
## Relationships
- The **Preload bridge** is the only path between the **Renderer** (toju-app) and **Main**; the renderer cannot import Electron, Node, or TypeORM directly.
- An **IPC channel** maps 1:1 to a method on `window.api.*`. Adding a method on the preload requires registering its handler in `electron/ipc/` or `electron/cqrs.ts`.
- The **Plugin library** loads manifests at startup and on user action; it owns the contract the renderer's *plugins* domain consumes (defined in `toju-app/src/app/shared-kernel/plugin-system.contracts.ts`).
- TypeORM **migrations** in `electron/migrations/` are applied on startup against the per-user SQLite file resolved by `electron/runtime-paths.ts`.
- The **Local API server** serves the Docusaurus bundle built into `docs-site/build/` and the OpenAPI artifacts under `electron/api/openapi.ts`.
## Boundaries / IO
- **Exposes:**
- `window.api.*` surface (preload bridge) — the canonical IPC contract for the renderer.
- IPC channel names registered in `electron/ipc/index.ts`, `electron/ipc/cqrs.ts`, `electron/ipc/system.ts`, `electron/ipc/window-controls.ts`.
- Local API HTTP endpoints (`electron/api/router.ts`) under `http://localhost:<port>/`.
- Plugin host contract (`electron/plugin-library.ts`) — defines what plugin manifests must declare and what the plugin runtime can call back into.
- **Consumes:**
- The renderer (toju-app) via IPC `invoke`/`handle` and event emitters.
- The local SQLite database via `electron/data-source.ts` and entities under `electron/entities/`.
- OS APIs: window controls, idle detection (`electron/idle/`), game detection (`electron/game-detection/`), process list (`electron/process-list.ts`).
- The audio worklet bundle (`toju-app/public/rnnoise-worklet.js` built from `@timephy/rnnoise-wasm`).
## Invariants
- The **Renderer** never has direct access to Node, the filesystem, or the database — every privileged operation goes through an IPC handler.
- Every schema change is accompanied by a **TypeORM migration**; the database is never mutated outside the migration system.
- IPC handler errors are translated to typed error envelopes before crossing back into the renderer — the renderer never sees a raw `Error` from main.
- The **Preload bridge** exposes a frozen, allow-listed set of methods; adding a method requires touching both `preload.ts` and the matching handler.
## Flagged ambiguities
- _None recorded yet — add entries when an IPC channel name or plugin contract term resists clean definition._
---
*Agent-procedural rules (TDD, lint, build) live in `/AGENTS.md`. Cross-context contract details (IPC envelope shapes, plugin manifest schema) belong in `agents-docs/features/`. This file is the bounded-context domain artefact for the desktop shell.*

View File

@@ -16,7 +16,8 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
| --- | --- | | --- | --- |
| `main.ts` | Electron app bootstrap and process entry point | | `main.ts` | Electron app bootstrap and process entry point |
| `preload.ts` | Typed renderer-facing preload bridge | | `preload.ts` | Typed renderer-facing preload bridge |
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection | | `process-list.ts` | Linux/Windows process-name scan used as a fallback when foreground detection is unavailable |
| `game-detection/` | Foreground-window detection (`get-windows` + Hyprland/Sway fallbacks) plus pure heuristics scoring and ignore-list filtering |
| `app/` | App lifecycle and startup composition | | `app/` | App lifecycle and startup composition |
| `ipc/` | Renderer-invoked IPC handlers | | `ipc/` | Renderer-invoked IPC handlers |
| `cqrs/` | Local database command/query handlers and mappings | | `cqrs/` | Local database command/query handlers and mappings |

View File

@@ -7,20 +7,36 @@ import { getCurrentUserScope } from '../../current-user-scope';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) { export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity); const repo = dataSource.getRepository(MessageEntity);
const { roomId, limit = 100, offset = 0 } = query.payload; const { roomId, limit = 100, offset = 0, channelId, beforeTimestamp } = query.payload;
const currentUserId = await getCurrentUserScope(dataSource); const currentUserId = await getCurrentUserScope(dataSource);
if (!currentUserId) { if (!currentUserId) {
return []; return [];
} }
const rows = await repo.find({ const rowsQuery = repo.createQueryBuilder('message')
where: { roomId, ownerUserId: currentUserId }, .where('message.roomId = :roomId', { roomId })
order: { timestamp: 'ASC' }, .andWhere('message.ownerUserId = :currentUserId', { currentUserId })
take: limit, .orderBy('message.timestamp', 'DESC')
skip: offset .take(limit)
}); .skip(offset);
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? [])); if (channelId === 'general') {
rowsQuery.andWhere('(message.channelId = :channelId OR message.channelId IS NULL OR message.channelId = :emptyChannelId)', {
channelId,
emptyChannelId: ''
});
} else if (channelId) {
rowsQuery.andWhere('message.channelId = :channelId', { channelId });
}
if (typeof beforeTimestamp === 'number') {
rowsQuery.andWhere('message.timestamp < :beforeTimestamp', { beforeTimestamp });
}
const rows = await rowsQuery.getMany();
const chronologicalRows = [...rows].reverse();
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, chronologicalRows.map((row) => row.id));
return chronologicalRows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
} }

View File

@@ -230,7 +230,16 @@ export type Command =
| SaveMetaCommand | SaveMetaCommand
| ClearAllDataCommand; | ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } export interface GetMessagesQuery {
type: typeof QueryType.GetMessages;
payload: {
roomId: string;
limit?: number;
offset?: number;
channelId?: string;
beforeTimestamp?: number;
};
}
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } } export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } } export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }

View File

@@ -41,7 +41,14 @@ const RETRYABLE_SAVE_ERROR_CODES = new Set([
'EBUSY' 'EBUSY'
]); ]);
let saveQueue: Promise<void> = Promise.resolve(); interface PendingSaveWaiter {
reject: (error: unknown) => void;
resolve: () => void;
}
let pendingSaveSnapshot: Buffer | null = null;
let pendingSaveWaiters: PendingSaveWaiter[] = [];
let saveInProgress = false;
function wait(ms: number): Promise<void> { function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
@@ -146,16 +153,51 @@ async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
} }
} }
function settleSaveWaiters(waiters: PendingSaveWaiter[], error?: unknown): void {
for (const waiter of waiters) {
if (error === undefined) {
waiter.resolve();
} else {
waiter.reject(error);
}
}
}
async function drainDatabaseSaveQueue(): Promise<void> {
if (saveInProgress) {
return;
}
saveInProgress = true;
try {
while (pendingSaveSnapshot) {
const snapshot = pendingSaveSnapshot;
const waiters = pendingSaveWaiters;
pendingSaveSnapshot = null;
pendingSaveWaiters = [];
try {
await writeDatabaseSnapshot(snapshot);
settleSaveWaiters(waiters);
} catch (error) {
settleSaveWaiters(waiters, error);
}
}
} finally {
saveInProgress = false;
}
}
async function atomicSave(data: Uint8Array): Promise<void> { async function atomicSave(data: Uint8Array): Promise<void> {
const snapshot = Buffer.from(data); const snapshot = Buffer.from(data);
const saveTask = saveQueue.then(
() => writeDatabaseSnapshot(snapshot),
() => writeDatabaseSnapshot(snapshot)
);
saveQueue = saveTask.catch(() => {}); return new Promise<void>((resolve, reject) => {
pendingSaveSnapshot = snapshot;
return saveTask; pendingSaveWaiters.push({ resolve, reject });
void drainDatabaseSaveQueue();
});
} }
export async function initializeDatabase(): Promise<void> { export async function initializeDatabase(): Promise<void> {

View File

@@ -18,6 +18,7 @@ export interface DesktopSettings {
autoStart: boolean; autoStart: boolean;
closeToTray: boolean; closeToTray: boolean;
hardwareAcceleration: boolean; hardwareAcceleration: boolean;
ignoredGameProcesses: string[];
localApi: LocalApiSettings; localApi: LocalApiSettings;
manifestUrls: string[]; manifestUrls: string[];
preferredVersion: string | null; preferredVersion: string | null;
@@ -42,6 +43,7 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoStart: true, autoStart: true,
closeToTray: true, closeToTray: true,
hardwareAcceleration: true, hardwareAcceleration: true,
ignoredGameProcesses: [],
localApi: { ...DEFAULT_LOCAL_API_SETTINGS }, localApi: { ...DEFAULT_LOCAL_API_SETTINGS },
manifestUrls: [], manifestUrls: [],
preferredVersion: null, preferredVersion: null,
@@ -80,6 +82,31 @@ function normalizeManifestUrls(value: unknown): string[] {
return manifestUrls; return manifestUrls;
} }
function normalizeIgnoredGameProcesses(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const ignored: string[] = [];
for (const entry of value) {
if (typeof entry !== 'string') {
continue;
}
const trimmed = entry.trim().toLowerCase()
.replace(/\.(exe|bin|app|out)$/iu, '');
if (!trimmed || trimmed.length > 96 || ignored.includes(trimmed)) {
continue;
}
ignored.push(trimmed);
}
return ignored.sort();
}
function normalizePort(value: unknown, fallback: number): number { function normalizePort(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) { if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback; return fallback;
@@ -171,6 +198,7 @@ export function readDesktopSettings(): DesktopSettings {
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean' hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
? parsed.hardwareAcceleration ? parsed.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
ignoredGameProcesses: normalizeIgnoredGameProcesses(parsed.ignoredGameProcesses),
localApi: normalizeLocalApiSettings(parsed.localApi), localApi: normalizeLocalApiSettings(parsed.localApi),
manifestUrls: normalizeManifestUrls(parsed.manifestUrls), manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
preferredVersion: normalizePreferredVersion(parsed.preferredVersion) preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
@@ -200,6 +228,7 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean' hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration ? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration, : DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
ignoredGameProcesses: normalizeIgnoredGameProcesses(mergedSettings.ignoredGameProcesses),
localApi: normalizeLocalApiSettings(mergedSettings.localApi), localApi: normalizeLocalApiSettings(mergedSettings.localApi),
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls), manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion), preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),

View File

@@ -0,0 +1,268 @@
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
/**
* Structured snapshot of the currently focused window. Returned by
* detectActiveWindow() and consumed by the game-detection orchestrator.
*
* Field availability varies by platform/compositor; consumers must treat all
* optional fields as best-effort. `processName` is required because the
* heuristic engine refuses to score a candidate without it.
*/
export interface ActiveWindowSnapshot {
processName: string;
executablePath?: string;
windowTitle?: string;
pid?: number;
bounds?: { width: number; height: number };
isFullscreen?: boolean;
/** Where the snapshot came from, for diagnostics. */
source: 'get-windows' | 'hyprctl' | 'swaymsg' | 'xprop';
}
let cachedDynamicImport: ((specifier: string) => Promise<unknown>) | null = null;
function importEsm<T>(specifier: string): Promise<T> {
if (!cachedDynamicImport) {
// Built via the Function constructor so the TypeScript compiler does not
// down-level the `import()` call to `require()` under module: commonjs.
cachedDynamicImport = new Function('s', 'return import(s)') as (specifier: string) => Promise<unknown>;
}
return cachedDynamicImport(specifier) as Promise<T>;
}
interface GetWindowsModule {
activeWindow: (options?: { accessibilityPermission?: boolean; screenRecordingPermission?: boolean }) => Promise<GetWindowsResult | undefined>;
}
interface GetWindowsResult {
platform: 'macos' | 'linux' | 'windows';
title: string;
id: number;
bounds: { x: number; y: number; width: number; height: number };
owner: { name: string; processId: number; path: string };
}
export async function detectActiveWindow(): Promise<ActiveWindowSnapshot | null> {
const getWindowsResult = await tryGetWindows();
if (getWindowsResult) {
return getWindowsResult;
}
if (process.platform === 'linux') {
return await detectLinuxActiveWindowViaCompositor();
}
return null;
}
async function tryGetWindows(): Promise<ActiveWindowSnapshot | null> {
try {
const mod = await importEsm<GetWindowsModule>('get-windows');
const result = await mod.activeWindow({
accessibilityPermission: false,
screenRecordingPermission: false
});
if (!result || !result.owner?.name) {
return null;
}
return {
processName: result.owner.name,
executablePath: result.owner.path || undefined,
windowTitle: result.title || undefined,
pid: result.owner.processId,
bounds: result.bounds
? { width: result.bounds.width, height: result.bounds.height }
: undefined,
isFullscreen: isFullscreenFromBounds(result.bounds),
source: 'get-windows'
};
} catch {
return null;
}
}
function isFullscreenFromBounds(bounds: { x?: number; y?: number; width?: number; height?: number } | undefined): boolean {
if (!bounds || typeof bounds.width !== 'number' || typeof bounds.height !== 'number') {
return false;
}
// Cheap proxy: anything ≥1920x1080 is treated as fullscreen-ish. This is
// intentionally loose because we already gate on focus and exe path.
return bounds.width >= 1920 && bounds.height >= 1080;
}
async function detectLinuxActiveWindowViaCompositor(): Promise<ActiveWindowSnapshot | null> {
const hypr = await tryHyprctl();
if (hypr) {
return hypr;
}
const sway = await trySwaymsg();
if (sway) {
return sway;
}
return null;
}
interface HyprlandActiveWindow {
address?: string;
pid?: number;
title?: string;
class?: string;
initialClass?: string;
fullscreen?: number | boolean;
fullscreenClient?: number | boolean;
size?: [number, number];
}
async function tryHyprctl(): Promise<ActiveWindowSnapshot | null> {
if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) {
return null;
}
try {
const { stdout } = await execFileAsync('hyprctl', ['activewindow', '-j'], {
timeout: 2_000,
maxBuffer: 256 * 1024
});
const parsed = JSON.parse(stdout) as HyprlandActiveWindow;
if (!parsed?.pid) {
return null;
}
return await snapshotFromPid(parsed.pid, {
windowTitle: parsed.title,
processNameHint: parsed.class || parsed.initialClass,
bounds: parsed.size ? { width: parsed.size[0], height: parsed.size[1] } : undefined,
isFullscreen: !!parsed.fullscreen || !!parsed.fullscreenClient,
source: 'hyprctl'
});
} catch {
return null;
}
}
interface SwayTreeNode {
focused?: boolean;
pid?: number;
name?: string;
app_id?: string;
window_properties?: { class?: string };
fullscreen_mode?: number;
rect?: { width?: number; height?: number };
nodes?: SwayTreeNode[];
floating_nodes?: SwayTreeNode[];
}
async function trySwaymsg(): Promise<ActiveWindowSnapshot | null> {
if (!process.env.SWAYSOCK && !process.env.I3SOCK) {
return null;
}
try {
const { stdout } = await execFileAsync('swaymsg', ['-t', 'get_tree'], {
timeout: 2_000,
maxBuffer: 2 * 1024 * 1024
});
const tree = JSON.parse(stdout) as SwayTreeNode;
const focused = findFocusedSwayNode(tree);
if (!focused?.pid) {
return null;
}
return await snapshotFromPid(focused.pid, {
windowTitle: focused.name,
processNameHint: focused.app_id || focused.window_properties?.class,
bounds: focused.rect
? { width: focused.rect.width ?? 0, height: focused.rect.height ?? 0 }
: undefined,
isFullscreen: (focused.fullscreen_mode ?? 0) > 0,
source: 'swaymsg'
});
} catch {
return null;
}
}
function findFocusedSwayNode(node: SwayTreeNode): SwayTreeNode | null {
if (node.focused && node.pid) {
return node;
}
for (const child of node.nodes ?? []) {
const found = findFocusedSwayNode(child);
if (found) {
return found;
}
}
for (const child of node.floating_nodes ?? []) {
const found = findFocusedSwayNode(child);
if (found) {
return found;
}
}
return null;
}
interface SnapshotFromPidOptions {
windowTitle?: string;
processNameHint?: string;
bounds?: { width: number; height: number };
isFullscreen?: boolean;
source: ActiveWindowSnapshot['source'];
}
async function snapshotFromPid(pid: number, options: SnapshotFromPidOptions): Promise<ActiveWindowSnapshot | null> {
let executablePath: string | undefined;
let processName = options.processNameHint?.trim() || '';
try {
executablePath = await fs.promises.readlink(`/proc/${pid}/exe`);
if (!processName) {
processName = path.basename(executablePath);
}
} catch {
/* /proc/<pid>/exe is restricted for foreign-uid processes; that's fine. */
}
if (!processName) {
try {
processName = (await fs.promises.readFile(`/proc/${pid}/comm`, 'utf8')).trim();
} catch {
/* ignored */
}
}
if (!processName) {
return null;
}
return {
processName,
executablePath,
windowTitle: options.windowTitle?.trim() || undefined,
pid,
bounds: options.bounds,
isFullscreen: options.isFullscreen,
source: options.source
};
}

View File

@@ -0,0 +1,402 @@
import * as path from 'path';
/**
* Pure scoring/filtering helpers for game detection. Lives in the main process
* and is exercised by both the foreground-window detector and the legacy
* process-name scanner.
*
* The goal is to dramatically reduce the false-positive rate compared to the
* previous "send every running process name to RAWG" approach by combining
* multiple signals (window focus, executable path, engine markers, blacklist,
* user-managed ignore list) into a confidence score.
*/
export interface GameCandidateInput {
/** Lower-cased base name without extension (e.g. "stardewvalley"). */
processName: string;
/** Original process name as reported by the OS (for display). */
rawProcessName?: string;
executablePath?: string;
windowTitle?: string;
pid?: number;
bounds?: { width: number; height: number } | undefined;
isFullscreen?: boolean;
source: 'foreground' | 'process-scan';
/** User-managed ignore list, already lower-cased. */
ignoredProcessNames: ReadonlySet<string>;
/** True when an engine signature file was found beside the executable. */
hasEngineSignature?: boolean;
}
export interface ScoredGameCandidate {
processName: string;
rawProcessName: string;
executablePath?: string;
windowTitle?: string;
pid?: number;
isFullscreen: boolean;
bounds?: { width: number; height: number };
confidence: number;
source: 'foreground' | 'process-scan';
reasons: string[];
}
export const MIN_GAME_CONFIDENCE = 55;
/**
* Processes that are commonly misclassified as games. Lower-cased base names.
* Note: we deliberately blacklist Electron/Chromium/IDE/launcher/comm apps.
*/
export const HARDCODED_IGNORED_PROCESSES: ReadonlySet<string> = new Set([
'1password',
'7zfm',
'agent',
'audiodg',
'bash',
'baloo',
'baloo_file',
'baloorunner',
'bluetoothuiservice',
'brave',
'brave-browser',
'chrome',
'cmd',
'code',
'code-insiders',
'conhost',
'cursor',
'csrss',
'ctfmon',
'dbus',
'dbus-daemon',
'discord',
'discordcanary',
'discordptb',
'dolphin',
'dwm',
'electron',
'epicgameslauncher',
'epicgames',
'explorer',
'fcitx5',
'firefox',
'fontdrvhost',
'gameoverlayui',
'gamemoded',
'gamemode-launcher',
'gamescopereaper',
'gnome-shell',
'gnome-software',
'gnome-terminal',
'init',
'java',
'javaw',
'kdeconnect',
'kdeconnectd',
'kded5',
'kded6',
'keepass',
'keepassxc',
'kernel_task',
'krunner',
'ksmserver',
'lockapp',
'logioptionsplus',
'logitechg',
'login',
'metoyou',
'msedge',
'msedgewebview2',
'msteams',
'node',
'npm',
'nvcontainer',
'nvidia-broadcast',
'nvidia-share',
'nvidia-smi',
'obs',
'obs64',
'obs-studio',
'pipewire',
'plasmashell',
'pluma',
'powershell',
'pwsh',
'pulseaudio',
'remoteapps-service',
'rundll32',
'runtimebroker',
'screen',
'searchapp',
'searchhost',
'shellexperiencehost',
'signal',
'slack',
'spotify',
'spotifywebhelper',
'sshd',
'startmenuexperiencehost',
'steam',
'steamservice',
'steamwebhelper',
'svchost',
'system',
'systemd',
'systemsettings',
'systemsoundsservice',
'taskhost',
'taskhostw',
'taskmgr',
'teams',
'telegram',
'telegramdesktop',
'textinputhost',
'thunderbird',
'tracker-miner-fs',
'tray',
'utilman',
'vivaldi',
'whatsapp',
'wininit',
'winlogon',
'xdg-desktop-portal',
'xorg',
'xwayland',
'yakuake',
'zoom'
]);
const GENERIC_SUFFIX_NAMES = [
'agent',
'browser',
'daemon',
'helper',
'indexer',
'launcher',
'monitor',
'renderer',
'runner',
'service',
'tray',
'updater',
'watcher',
'worker',
'portal',
'sync',
'broker',
'host'
].join('|');
const IGNORE_NAME_PATTERNS: readonly RegExp[] = [
new RegExp(`(^|[-_\\s.])(${GENERIC_SUFFIX_NAMES})([-_\\s.]|$)`, 'iu'),
/^kworker/i,
/^kthread/i,
/^kpipefs/i,
/^(at-spi|gvfs|ibus|kded|kglobalaccel|knotify|polkit|pulse|systemd)/i
];
/** Known game install root markers, case-insensitive substrings of the exe path. */
const KNOWN_GAME_PATH_MARKERS: readonly RegExp[] = [
/[\\/]steamapps[\\/]common[\\/]/i,
/[\\/]steamlibrary[\\/]/i,
/[\\/]epic games[\\/]/i,
/[\\/]epicgameslauncher[\\/]/i,
/[\\/]gog galaxy[\\/]games[\\/]/i,
/[\\/]gog\.com[\\/]games[\\/]/i,
/[\\/]gog games[\\/]/i,
/[\\/]ea games[\\/]/i,
/[\\/]origin games[\\/]/i,
/[\\/]battle\.net[\\/]/i,
/[\\/]ubisoft[\\/]/i,
/[\\/]riot games[\\/]/i,
/[\\/]itch[\\/]apps[\\/]/i,
/[\\/]\.itch[\\/]apps[\\/]/i,
/[\\/]heroic[\\/]games[\\/]/i,
/[\\/]lutris[\\/]/i,
/[\\/]games[\\/]/i,
// Proton / Wine prefixes used by Steam/Lutris/Heroic
/[\\/]proton[\\/]/i,
/[\\/]pfx[\\/]drive_c[\\/]/i,
/[\\/]\.wine[\\/]drive_c[\\/]program files[\\/]/i
];
/** Path segments that strongly indicate the process is NOT a game. */
const NON_GAME_PATH_MARKERS: readonly RegExp[] = [
/[\\/]appdata[\\/]local[\\/]temp[\\/]/i,
/[\\/]temp[\\/]/i,
/[\\/]node_modules[\\/]/i,
/[\\/]chromium[\\/]/i,
/[\\/]appdata[\\/]roaming[\\/]discord[\\/]/i,
/[\\/]appdata[\\/]roaming[\\/]spotify[\\/]/i,
/[\\/]windows[\\/]system32[\\/]/i,
/[\\/]windows[\\/]syswow64[\\/]/i,
/[\\/]\.cache[\\/]/i,
/[\\/]snap[\\/]firefox[\\/]/i,
/[\\/]snap[\\/]spotify[\\/]/i
];
/** File names placed beside a game's executable that reveal its engine. */
export const ENGINE_SIGNATURE_FILES: readonly string[] = [
'UnityPlayer.dll',
'libUnityPlayer.so',
'UnityCrashHandler64.exe',
'UnityCrashHandler32.exe',
// Unreal Engine: foo-Win64-Shipping.exe sits in <Game>/Binaries/Win64/
'UnrealEditor.exe',
'UE4PrereqSetup_x64.exe',
'UE4Game.dll',
'UE5Game.dll',
// Godot
'Godot.exe',
'libgodot.so',
// Source engine
'tier0.dll',
'engine.dll',
'hl2.exe',
// RPG Maker
'nw.dll',
// CryEngine
'CryGameSDK.dll'
];
export function normalizeProcessKey(value: string): string {
return path.basename(value.trim())
.replace(/\.(exe|bin|app|out)$/iu, '')
.replace(/[_-]+/gu, ' ')
.replace(/\s+/gu, ' ')
.trim()
.toLowerCase();
}
export function shouldIgnoreProcess(
processName: string,
userIgnored: ReadonlySet<string>
): boolean {
const key = normalizeProcessKey(processName);
if (!key) {
return true;
}
if (userIgnored.has(key) || HARDCODED_IGNORED_PROCESSES.has(key)) {
return true;
}
if (/^\d+$/.test(key)) {
return true;
}
if (key.length < 4) {
return true;
}
return IGNORE_NAME_PATTERNS.some((pattern) => pattern.test(key));
}
export function pathMatchesKnownGameRoot(executablePath: string | undefined): boolean {
if (!executablePath) {
return false;
}
return KNOWN_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath));
}
export function pathMatchesNonGameRoot(executablePath: string | undefined): boolean {
if (!executablePath) {
return false;
}
return NON_GAME_PATH_MARKERS.some((pattern) => pattern.test(executablePath));
}
interface ConfidenceScore {
confidence: number;
reasons: string[];
}
function computeConfidence(input: GameCandidateInput, rawProcessName: string): ConfidenceScore {
let confidence = 0;
const reasons: string[] = [];
const add = (points: number, reason: string): void => {
confidence += points;
reasons.push(reason);
};
if (input.source === 'foreground') {
add(35, 'foreground-window');
}
if (pathMatchesKnownGameRoot(input.executablePath)) {
add(30, 'known-game-folder');
}
if (input.hasEngineSignature) {
add(25, 'engine-signature');
}
if (input.isFullscreen) {
add(15, 'fullscreen');
}
const width = input.bounds?.width ?? 0;
const height = input.bounds?.height ?? 0;
if (width >= 800 && height >= 600) {
add(5, 'large-window');
}
const title = input.windowTitle?.trim() ?? '';
if (title.length >= 3 && /[A-Za-z]/u.test(title)) {
add(10, 'window-title');
}
if (/[A-Z]/u.test(rawProcessName) && /[a-z]/u.test(rawProcessName)) {
add(3, 'mixed-case-name');
}
if (input.executablePath && /\.exe$/iu.test(input.executablePath)) {
confidence += 2;
}
return { confidence: Math.min(100, confidence), reasons };
}
export function scoreCandidate(input: GameCandidateInput): ScoredGameCandidate | null {
const rawProcessName = input.rawProcessName ?? input.processName;
const normalizedKey = normalizeProcessKey(input.processName);
if (!normalizedKey) {
return null;
}
if (shouldIgnoreProcess(normalizedKey, input.ignoredProcessNames)) {
return null;
}
if (pathMatchesNonGameRoot(input.executablePath)) {
return null;
}
const { confidence, reasons } = computeConfidence(input, rawProcessName);
const title = input.windowTitle?.trim() ?? '';
// Process-scan candidates must clear a higher bar: without a foreground or
// path signal the confidence will stay below the threshold, which is the
// whole point - no more silent RAWG lookups for arbitrary processes.
return {
processName: normalizedKey,
rawProcessName,
executablePath: input.executablePath,
windowTitle: title || undefined,
pid: input.pid,
isFullscreen: !!input.isFullscreen,
bounds: input.bounds,
confidence,
source: input.source,
reasons
};
}
/** Returns whether a confidence score clears the "report to peers" threshold. */
export function meetsGameConfidence(candidate: ScoredGameCandidate | null): boolean {
return !!candidate && candidate.confidence >= MIN_GAME_CONFIDENCE;
}

View File

@@ -0,0 +1,119 @@
import * as fs from 'fs';
import * as path from 'path';
import { detectActiveWindow } from './active-window';
import {
ENGINE_SIGNATURE_FILES,
GameCandidateInput,
MIN_GAME_CONFIDENCE,
ScoredGameCandidate,
scoreCandidate,
shouldIgnoreProcess
} from './heuristics';
import { listRunningProcessNames } from '../process-list';
import { readDesktopSettings } from '../desktop-settings';
/**
* Public result of a detection scan. The renderer prefers `candidate` and only
* falls back to `fallbackProcessNames` when no focused candidate clears the
* minimum confidence threshold. The fallback list is intentionally trimmed and
* pre-filtered so the renderer never sees obvious non-games like Spotify.
*/
export interface GameDetectionResult {
candidate: ScoredGameCandidate | null;
/**
* Filtered list of plausible game process names. Empty when the focused
* candidate already crossed the threshold (so the renderer skips fallback
* matching). Capped to keep RAWG quota usage predictable.
*/
fallbackProcessNames: string[];
}
const MAX_FALLBACK_PROCESSES = 8;
export async function detectActiveGame(): Promise<GameDetectionResult> {
const ignoredProcessNames = getUserIgnoredProcesses();
const active = await detectActiveWindow();
let candidate: ScoredGameCandidate | null = null;
if (active) {
const hasEngineSignature = await detectEngineSignature(active.executablePath);
const input: GameCandidateInput = {
processName: active.processName,
rawProcessName: active.processName,
executablePath: active.executablePath,
windowTitle: active.windowTitle,
pid: active.pid,
bounds: active.bounds,
isFullscreen: active.isFullscreen,
source: 'foreground',
ignoredProcessNames,
hasEngineSignature
};
candidate = scoreCandidate(input);
}
if (candidate && candidate.confidence >= MIN_GAME_CONFIDENCE) {
return { candidate, fallbackProcessNames: [] };
}
const fallbackProcessNames = await collectFallbackProcessNames(ignoredProcessNames);
return { candidate, fallbackProcessNames };
}
async function collectFallbackProcessNames(ignoredProcessNames: ReadonlySet<string>): Promise<string[]> {
try {
const names = await listRunningProcessNames();
const filtered: string[] = [];
for (const name of names) {
if (filtered.length >= MAX_FALLBACK_PROCESSES) {
break;
}
if (!shouldIgnoreProcess(name, ignoredProcessNames)) {
filtered.push(name);
}
}
return filtered;
} catch {
return [];
}
}
async function detectEngineSignature(executablePath: string | undefined): Promise<boolean> {
if (!executablePath) {
return false;
}
try {
const directory = path.dirname(executablePath);
const entries = await fs.promises.readdir(directory).catch(() => []);
const lowerEntries = new Set(entries.map((entry) => entry.toLowerCase()));
if (ENGINE_SIGNATURE_FILES.some((file) => lowerEntries.has(file.toLowerCase()))) {
return true;
}
// Unreal Engine ships executables ending in "-Win64-Shipping.exe" or
// "-Linux-Shipping" inside <Game>/Binaries/<Platform>/.
return entries.some((entry) => /-(win64|win32|linux)-shipping(\.exe)?$/i.test(entry));
} catch {
return false;
}
}
function getUserIgnoredProcesses(): ReadonlySet<string> {
try {
const stored = readDesktopSettings().ignoredGameProcesses ?? [];
return new Set(stored.map((entry) => entry.trim().toLowerCase()).filter(Boolean));
} catch {
return new Set();
}
}
export type { ScoredGameCandidate } from './heuristics';

View File

@@ -12,9 +12,10 @@ import {
import * as fs from 'fs'; import * as fs from 'fs';
import * as fsp from 'fs/promises'; import * as fsp from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath, pathToFileURL } from 'url';
import { import {
getDesktopSettingsSnapshot, getDesktopSettingsSnapshot,
readDesktopSettings,
updateDesktopSettings, updateDesktopSettings,
type DesktopSettings type DesktopSettings
} from '../desktop-settings'; } from '../desktop-settings';
@@ -58,8 +59,12 @@ import {
openCurrentDataFolder openCurrentDataFolder
} from '../data-management'; } from '../data-management';
import { listRunningProcessNames } from '../process-list'; import { listRunningProcessNames } from '../process-list';
import { detectActiveGame } from '../game-detection';
const DEFAULT_MIME_TYPE = 'application/octet-stream'; const DEFAULT_MIME_TYPE = 'application/octet-stream';
const MAX_ACTIVE_DESKTOP_NOTIFICATIONS = 20;
const activeDesktopNotifications = new Set<Notification>();
const desktopNotificationCleanups = new Map<Notification, () => void>();
const FILE_CLIPBOARD_FORMATS = [ const FILE_CLIPBOARD_FORMATS = [
'x-special/gnome-copied-files', 'x-special/gnome-copied-files',
'text/uri-list', 'text/uri-list',
@@ -325,6 +330,18 @@ export function setupSystemHandlers(): void {
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames()); ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
ipcMain.handle('get-active-game-candidate', async () => await detectActiveGame());
ipcMain.handle('get-ignored-game-processes', () => {
return readDesktopSettings().ignoredGameProcesses;
});
ipcMain.handle('set-ignored-game-processes', (_event, list: unknown) => {
const snapshot = updateDesktopSettings({ ignoredGameProcesses: Array.isArray(list) ? list : [] });
return snapshot.ignoredGameProcesses;
});
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => { ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
return await prepareLinuxScreenShareAudioRouting(); return await prepareLinuxScreenShareAudioRouting();
}); });
@@ -385,9 +402,16 @@ export function setupSystemHandlers(): void {
icon: getWindowIconPath(), icon: getWindowIconPath(),
silent: true silent: true
}); });
const cleanup = () => {
notification.on('click', () => { notification.removeListener('click', handleClick);
notification.removeListener('close', cleanup);
notification.removeListener('failed', cleanup);
activeDesktopNotifications.delete(notification);
desktopNotificationCleanups.delete(notification);
};
const handleClick = () => {
if (!mainWindow) { if (!mainWindow) {
cleanup();
return; return;
} }
@@ -400,7 +424,26 @@ export function setupSystemHandlers(): void {
} }
mainWindow.focus(); mainWindow.focus();
}); cleanup();
notification.close();
};
notification.on('click', handleClick);
notification.once('close', cleanup);
notification.once('failed', cleanup);
activeDesktopNotifications.add(notification);
desktopNotificationCleanups.set(notification, cleanup);
while (activeDesktopNotifications.size > MAX_ACTIVE_DESKTOP_NOTIFICATIONS) {
const oldestNotification = activeDesktopNotifications.values().next().value;
if (!oldestNotification) {
break;
}
desktopNotificationCleanups.get(oldestNotification)?.();
oldestNotification.close();
}
notification.show(); notification.show();
} catch { } catch {
@@ -519,12 +562,46 @@ export function setupSystemHandlers(): void {
} }
}); });
ipcMain.handle('get-file-url', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) {
return null;
}
try {
await fsp.access(filePath, fs.constants.F_OK);
return pathToFileURL(filePath).toString();
} catch {
return null;
}
});
ipcMain.handle('read-file', async (_event, filePath: string) => { ipcMain.handle('read-file', async (_event, filePath: string) => {
const data = await fsp.readFile(filePath); const data = await fsp.readFile(filePath);
return data.toString('base64'); return data.toString('base64');
}); });
ipcMain.handle('read-file-chunk', async (_event, filePath: string, start: number, end: number) => {
const fileHandle = await fsp.open(filePath, 'r');
try {
const safeStart = Math.max(0, Math.trunc(start));
const safeEnd = Math.max(safeStart, Math.trunc(end));
const buffer = Buffer.alloc(safeEnd - safeStart);
const result = await fileHandle.read(buffer, 0, buffer.length, safeStart);
return buffer.subarray(0, result.bytesRead).toString('base64');
} finally {
await fileHandle.close();
}
});
ipcMain.handle('get-file-size', async (_event, filePath: string) => {
const stats = await fsp.stat(filePath);
return stats.size;
});
ipcMain.handle('read-clipboard-files', async () => { ipcMain.handle('read-clipboard-files', async () => {
return await readClipboardFiles(); return await readClipboardFiles();
}); });
@@ -536,6 +613,13 @@ export function setupSystemHandlers(): void {
return true; return true;
}); });
ipcMain.handle('append-file', async (_event, filePath: string, base64Data: string) => {
const buffer = Buffer.from(base64Data, 'base64');
await fsp.appendFile(filePath, buffer);
return true;
});
ipcMain.handle('delete-file', async (_event, filePath: string) => { ipcMain.handle('delete-file', async (_event, filePath: string) => {
try { try {
await fsp.unlink(filePath); await fsp.unlink(filePath);
@@ -567,6 +651,60 @@ export function setupSystemHandlers(): void {
cancelled: false }; cancelled: false };
}); });
ipcMain.handle('save-existing-file-as', async (_event, sourceFilePath: string, defaultFileName: string) => {
if (typeof sourceFilePath !== 'string' || !sourceFilePath.trim()) {
return { saved: false,
cancelled: false };
}
const stats = await fsp.stat(sourceFilePath);
if (!stats.isFile()) {
return { saved: false,
cancelled: false };
}
const result = await dialog.showSaveDialog({
defaultPath: defaultFileName || path.basename(sourceFilePath)
});
if (result.canceled || !result.filePath) {
return { saved: false,
cancelled: true };
}
await fsp.copyFile(sourceFilePath, result.filePath);
return { saved: true,
cancelled: false };
});
ipcMain.handle('open-file-path', async (_event, filePath: string) => {
if (typeof filePath !== 'string' || !filePath.trim()) {
return { opened: false,
reason: 'missing-path' };
}
try {
const stats = await fsp.stat(filePath);
if (!stats.isFile()) {
return { opened: false,
reason: 'not-a-file' };
}
const error = await shell.openPath(filePath);
return error
? { opened: false,
reason: error }
: { opened: true };
} catch (error) {
return { opened: false,
reason: error instanceof Error ? error.message : 'open-failed' };
}
});
ipcMain.handle('ensure-dir', async (_event, dirPath: string) => { ipcMain.handle('ensure-dir', async (_event, dirPath: string) => {
await fsp.mkdir(dirPath, { recursive: true }); await fsp.mkdir(dirPath, { recursive: true });
return true; return true;

View File

@@ -203,6 +203,24 @@ export interface ContextMenuParams {
}; };
} }
export interface ActiveGameCandidate {
processName: string;
rawProcessName: string;
executablePath?: string;
windowTitle?: string;
pid?: number;
isFullscreen: boolean;
bounds?: { width: number; height: number };
confidence: number;
source: 'foreground' | 'process-scan';
reasons: string[];
}
export interface ActiveGameCandidateResult {
candidate: ActiveGameCandidate | null;
fallbackProcessNames: string[];
}
export interface ElectronAPI { export interface ElectronAPI {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -212,6 +230,9 @@ export interface ElectronAPI {
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>; getRunningProcessNames: () => Promise<string[]>;
getActiveGameCandidate: () => Promise<ActiveGameCandidateResult>;
getIgnoredGameProcesses: () => Promise<string[]>;
setIgnoredGameProcesses: (list: string[]) => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>; deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
@@ -282,9 +303,15 @@ export interface ElectronAPI {
onDeepLinkReceived: (listener: (url: string) => void) => () => void; onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>; readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>; readFile: (filePath: string) => Promise<string>;
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
getFileSize: (filePath: string) => Promise<number>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
fileExists: (filePath: string) => Promise<boolean>; fileExists: (filePath: string) => Promise<boolean>;
getFileUrl: (filePath: string) => Promise<string | null>;
deleteFile: (filePath: string) => Promise<boolean>; deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
@@ -308,6 +335,9 @@ const electronAPI: ElectronAPI = {
openExternal: (url) => ipcRenderer.invoke('open-external', url), openExternal: (url) => ipcRenderer.invoke('open-external', url),
getSources: () => ipcRenderer.invoke('get-sources'), getSources: () => ipcRenderer.invoke('get-sources'),
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'), getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
getActiveGameCandidate: () => ipcRenderer.invoke('get-active-game-candidate'),
getIgnoredGameProcesses: () => ipcRenderer.invoke('get-ignored-game-processes'),
setIgnoredGameProcesses: (list) => ipcRenderer.invoke('set-ignored-game-processes', list),
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'), prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'), activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'), deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
@@ -404,9 +434,15 @@ const electronAPI: ElectronAPI = {
}, },
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'), readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath), readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
readFileChunk: (filePath, start, end) => ipcRenderer.invoke('read-file-chunk', filePath, start, end),
getFileSize: (filePath) => ipcRenderer.invoke('get-file-size', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
appendFile: (filePath, data) => ipcRenderer.invoke('append-file', filePath, data),
saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data), saveFileAs: (defaultFileName, data) => ipcRenderer.invoke('save-file-as', defaultFileName, data),
saveExistingFileAs: (sourceFilePath, defaultFileName) => ipcRenderer.invoke('save-existing-file-as', sourceFilePath, defaultFileName),
openFilePath: (filePath) => ipcRenderer.invoke('open-file-path', filePath),
fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath), fileExists: (filePath) => ipcRenderer.invoke('file-exists', filePath),
getFileUrl: (filePath) => ipcRenderer.invoke('get-file-url', filePath),
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath), deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),

957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,7 @@
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"cytoscape": "^3.33.1", "cytoscape": "^3.33.1",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"get-windows": "^9.3.0",
"mermaid": "^11.12.3", "mermaid": "^11.12.3",
"ngx-remark": "^0.2.2", "ngx-remark": "^0.2.2",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
@@ -100,6 +101,7 @@
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"simple-peer": "^9.11.1", "simple-peer": "^9.11.1",
"sql.js": "^1.13.0", "sql.js": "^1.13.0",
"swiper": "^12.1.4",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"uuid": "^13.0.0", "uuid": "^13.0.0",
@@ -155,8 +157,13 @@
"!node_modules", "!node_modules",
"dist/client/**/*", "dist/client/**/*",
"dist/electron/**/*", "dist/electron/**/*",
"node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*", "node_modules/{abbrev,agent-base,ansi-regex,ansi-styles,ansis,app-root-path,applescript,aproba,are-we-there-yet,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,cacache,call-bind,call-bind-apply-helpers,call-bound,chownr,cliui,color-support,concat-map,console-control-strings,cross-spawn,dayjs,debug,dedent,define-data-property,delegates,detect-libc,dotenv,dunder-proto,electron-updater,emoji-regex,env-paths,es-define-property,es-errors,es-object-atoms,escalade,exponential-backoff,fdir,for-each,foreground-child,fs-extra,fs-minipass,function-bind,gauge,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,get-windows,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,has-unicode,hasown,http-cache-semantics,http-proxy-agent,https-proxy-agent,iconv-lite,ieee754,imurmurhash,inherits,ip-address,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,make-dir,make-fetch-happen,math-intrinsics,minimatch,minimist,minipass,minipass-collect,minipass-fetch,minipass-flush,minipass-pipeline,minipass-sized,minizlib,mkdirp,ms,negotiator,node-addon-api,node-fetch,node-gyp,nopt,npmlog,object-assign,p-map,package-json-from-dist,path-is-absolute,path-key,path-scurry,picomatch,pify,possible-typed-array-names,proc-log,readable-stream,reflect-metadata,retry,rimraf,safe-buffer,safer-buffer,sax,semver,set-blocking,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,smart-buffer,socks,socks-proxy-agent,sql-highlight,sql.js,ssri,string-width,string-width-cjs,string_decoder,strip-ansi,strip-ansi-cjs,tar,tiny-typed-emitter,tinyglobby,to-buffer,tr46,tslib,typed-array-buffer,typeorm,unique-filename,unique-slug,universalify,untildify,util-deprecate,uuid,webidl-conversions,whatwg-url,which,which-typed-array,wide-align,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
"node_modules/@gar/promise-retry/**/*",
"node_modules/@isaacs/cliui/**/*", "node_modules/@isaacs/cliui/**/*",
"node_modules/@isaacs/fs-minipass/**/*",
"node_modules/@mapbox/node-pre-gyp/**/*",
"node_modules/@npmcli/agent/**/*",
"node_modules/@npmcli/fs/**/*",
"node_modules/@pkgjs/parseargs/**/*", "node_modules/@pkgjs/parseargs/**/*",
"node_modules/@sqltools/formatter/**/*", "node_modules/@sqltools/formatter/**/*",
"!node_modules/**/test/**/*", "!node_modules/**/test/**/*",

57
server/CONTEXT.md Normal file
View File

@@ -0,0 +1,57 @@
# Signaling Server (server)
Owns the shared, internet-reachable runtime: HTTP routes for server directory / invites / join requests / link metadata, WebSocket signaling between clients (P2P session setup, presence, status), CQRS command and query handlers, and the shared TypeORM + sql.js persistence layer that holds signaling state.
> **Format reference:**
> - **Vocabulary** — bold term, one-sentence definition, aliases to avoid.
> - **Relationships** — bullets with bold terms and cardinality.
> - **Boundaries / IO** — what this subdomain exposes and consumes.
> - **Invariants** — rules that always hold.
> - **Flagged ambiguities** — terms in dispute with proposed resolutions.
>
> See `agents-docs/AGENTS_CONTEXT.md` for the contract. Update in the same turn a trigger fires (see `agents-docs/AGENT_WORKFLOW.md` § CONTEXT.md upkeep).
## Vocabulary
| Term | Definition | Aliases to avoid |
|------|------------|------------------|
| **Envelope** | The on-the-wire shape of a WebSocket message — `type`, `payload`, and routing metadata, typed in `src/websocket/types.ts` and mirrored in `toju-app/src/app/shared-kernel/signaling-contracts.ts`. | "packet", "frame" |
| **Handler** | A WebSocket message handler registered in `src/websocket/handler.ts`; one per envelope type. | "listener" |
| **CQRS command/query** | A typed request dispatched through `src/cqrs/` — commands mutate state, queries read it; both return a typed result. | "action" (NgRx term) |
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
## Relationships
- A **WebSocket connection** carries many **Envelopes**; each envelope is routed to exactly one **Handler**.
- A **Route** (HTTP) may dispatch zero or more **CQRS commands/queries** to mutate or read persistent state.
- The **Server directory** depends on **Invites** and **Join requests** — listing, accepting, and revoking flows are split across `routes/servers.ts`, `routes/invites.ts`, `routes/join-requests.ts`.
- **Persistence** entities in `src/entities/` are owned by this subdomain and never shipped to the renderer; the wire envelope is the contract instead.
- **SSRF guard** is consumed by `link-metadata`, `proxy`, and `klipy` routes that fetch user-supplied URLs.
## Boundaries / IO
- **Exposes:**
- HTTP routes under `src/routes/`: `health`, `users`, `servers`, `invites`, `join-requests`, `games`, `klipy`, `link-metadata`, `proxy`, `plugin-support`, `openapi-docs`.
- WebSocket envelopes typed in `src/websocket/types.ts` — the realtime contract shared with `toju-app/src/app/shared-kernel/signaling-contracts.ts`.
- OpenAPI document served by `openapi-docs` route.
- **Consumes:**
- The shared TypeORM SQLite database via `src/db/` (entities in `src/entities/`, migrations in `src/migrations/`).
- `data/variables.json` for runtime configuration; `.env` for `PORT` / SSL toggles.
- Optional outbound HTTP for link previews and klipy (all gated by **SSRF guard**).
## Invariants
- Every database schema change ships as a TypeORM **migration**; the live database is never mutated outside the migration system.
- WebSocket **Envelope** types are defined once in `src/websocket/types.ts` and **must** stay structurally compatible with `toju-app/src/app/shared-kernel/signaling-contracts.ts` — drift between the two is a wire-protocol break.
- User-supplied URLs are **never** fetched without going through `ssrf-guard.ts`.
- Secrets (klipy API key, OAuth tokens, signing keys) live in `data/variables.json` or environment variables — never in code, never in logs.
## Flagged ambiguities
- _None recorded yet — add entries when an envelope type or route name resists clean definition._
---
*Agent-procedural rules (TDD, lint, build) live in `/AGENTS.md`. WebSocket envelope schemas and HTTP request/response shapes belong in `agents-docs/features/` when they cross subdomain boundaries. This file is the bounded-context domain artefact for the signaling server.*

Binary file not shown.

View File

@@ -196,9 +196,8 @@ router.get('/link-metadata', async (req, res) => {
const cached = metadataCache.get(url); const cached = metadataCache.get(url);
if (cached) { if (cached) {
const { cachedAt, ...metadata } = cached; const { cachedAt: _cachedAt, ...metadata } = cached;
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
return res.json(metadata); return res.json(metadata);
} }

View File

@@ -286,6 +286,26 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
} }
} }
function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!serverId || !user.serverIds.has(serverId)) {
return;
}
broadcastToServer(
serverId,
{
...message,
type: 'voice_state',
serverId,
oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName)
},
user.oderId
);
}
function handleTyping(user: ConnectedUser, message: WsMessage): void { function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general'; const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
@@ -461,6 +481,13 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
case 'offer': case 'offer':
case 'answer': case 'answer':
case 'ice_candidate': case 'ice_candidate':
case 'direct-message':
case 'direct-message-status':
case 'direct-message-mutation':
case 'direct-message-typing':
case 'direct-message-sync-request':
case 'direct-message-sync':
case 'direct-call':
case 'server_icon_peer_request': case 'server_icon_peer_request':
case 'server_icon_peer_data': case 'server_icon_peer_data':
forwardRtcMessage(user, message); forwardRtcMessage(user, message);
@@ -470,6 +497,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
handleChatMessage(user, message); handleChatMessage(user, message);
break; break;
case 'voice_state':
handleVoiceState(user, message);
break;
case 'typing': case 'typing':
handleTyping(user, message); handleTyping(user, message);
break; break;

51
toju-app/CONTEXT.md Normal file
View File

@@ -0,0 +1,51 @@
# Product Client (toju-app)
Owns the user-facing Angular 21 desktop chat experience: rendering and orchestrating chat, voice, screen-share, plugin UI, theming, and identity flows on top of the Electron `window.api` bridge and the server WebSocket. Houses every bounded context the end user interacts with, organized DDD-style under `src/app/domains/`.
> **Format reference:**
> - **Vocabulary** — bold term, one-sentence definition, aliases to avoid.
> - **Relationships** — bullets with bold terms and cardinality.
> - **Boundaries / IO** — what this subdomain exposes and consumes.
> - **Invariants** — rules that always hold.
> - **Flagged ambiguities** — terms in dispute with proposed resolutions.
>
> See `agents-docs/AGENTS_CONTEXT.md` for the contract. Update in the same turn a trigger fires (see `agents-docs/AGENT_WORKFLOW.md` § CONTEXT.md upkeep).
## Vocabulary
| Term | Definition | Aliases to avoid |
|------|------------|------------------|
| **Domain** | A bounded context under `src/app/domains/<name>/` that owns its own models, services, NgRx slice, and components — e.g. *chat*, *voice-session*, *plugins*. | "module", "feature" (Angular reserves these for different things) |
| **Shared kernel** | Cross-domain contracts in `src/app/shared-kernel/` — wire-format models, P2P transfer utilities, plugin contracts, signaling contracts — imported by multiple domains. | "common", "core" |
| **Infrastructure** | Technical-runtime concerns shared by domains: `persistence/` (client-side store wiring) and `realtime/` (WebSocket adapter). Not a domain. | "shared", "lib" |
| **Rules file** | A pure-function module suffixed `*.rules.ts` that encodes domain logic without Angular or NgRx dependencies — easy to unit-test. | "helpers", "utils" |
## Relationships
- A **Domain** owns zero or more **Components**, **Services**, **NgRx slices**, and **Rules files**.
- A **Domain** may consume **Shared kernel** contracts but must never import from another **Domain** directly — cross-domain coupling goes through the shared kernel or NgRx events.
- The **Realtime** infrastructure adapts server WebSocket envelopes (defined in `src/app/shared-kernel/signaling-contracts.ts` and mirrored in `server/src/websocket/types.ts`) into NgRx actions consumed by domains.
- The **Plugins** domain consumes plugin manifests loaded by Electron's `plugin-library.ts` and exposes a sandboxed runtime that other domains may hook into.
## Boundaries / IO
- **Exposes:** the Angular SPA bundle served at `:4200` in dev (`ng serve`) or mounted by Electron in production; NgRx store events that other domains in this subdomain consume; UI surface to the end user.
- **Consumes:**
- `window.api.*` IPC surface exposed by Electron preload (defined in `electron/preload.ts` and `electron/api/`).
- WebSocket envelopes from `server/src/websocket/` (typed by `shared-kernel/signaling-contracts.ts`).
- REST endpoints from the server's `server/src/routes/` (server directory, invites, join requests, link metadata, klipy).
- Plugin manifests resolved by Electron's plugin library.
## Invariants
- A **Domain** never imports from another **Domain** directly — only through the **Shared kernel** or NgRx actions.
- **Rules files** stay framework-free (no Angular, no NgRx) so they can be Vitest-tested as plain functions.
- Wire-format types (anything that crosses the WebSocket or IPC boundary) live in **Shared kernel**, never inside a single domain.
## Flagged ambiguities
- _None recorded yet — add entries when a domain term resists clean definition._
---
*Agent-procedural rules (TDD, lint, etc.) live in `/AGENTS.md`. Per-domain implementation detail lives in `src/app/domains/<name>/README.md`. This file is the bounded-context domain artefact for the product client as a whole.*

View File

@@ -0,0 +1,8 @@
(function registerMetoYouVlcPlaceholder(globalScope) {
globalScope.MetoYouVlcJs = {
isPlaceholder: true,
createPlayer() {
throw new Error('Experimental VLC.js playback is enabled, but no VLC.js runtime is bundled. Replace /vlcjs/metoyou-vlc-player.js with a runtime adapter to enable playback.');
}
};
})(window);

View File

@@ -3,14 +3,16 @@
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground" class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
> >
<div <div
class="grid h-full min-h-0 min-w-0 overflow-hidden" class="h-full min-h-0 min-w-0 overflow-hidden"
[ngStyle]="appShellLayoutStyles()" [class.grid]="!isMobile()"
[class.flex]="isMobile()"
[ngStyle]="isMobile() ? null : appShellLayoutStyles()"
> >
<aside <aside
appThemeNode="serversRail" appThemeNode="serversRail"
class="min-h-0 overflow-hidden bg-transparent" class="min-h-0 overflow-hidden bg-transparent"
[class.hidden]="isThemeStudioFullscreen()" [class.hidden]="isThemeStudioFullscreen() || isMobile()"
[ngStyle]="serversRailLayoutStyles()" [ngStyle]="isMobile() ? null : serversRailLayoutStyles()"
> >
<app-servers-rail class="block h-full" /> <app-servers-rail class="block h-full" />
</aside> </aside>
@@ -18,9 +20,12 @@
<main <main
appThemeNode="appWorkspace" appThemeNode="appWorkspace"
class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background" class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background"
[ngStyle]="appWorkspaceShellStyles()" [class.flex-1]="isMobile()"
[ngStyle]="isMobile() ? null : appWorkspaceShellStyles()"
> >
<app-title-bar class="block shrink-0" /> @if (!isMobile()) {
<app-title-bar class="block shrink-0" />
}
<div class="relative min-h-0 flex-1 overflow-hidden"> <div class="relative min-h-0 flex-1 overflow-hidden">
@if (isThemeStudioFullscreen()) { @if (isThemeStudioFullscreen()) {
@@ -88,6 +93,16 @@
</main> </main>
</div> </div>
@if (isMobile() && directCalls.mobileOverlaySession(); as call) {
<div class="absolute inset-0 z-[70]">
<app-private-call
class="block h-full w-full"
[callIdInput]="call.callId"
[overlayMode]="true"
/>
</div>
}
@if (isThemeStudioFullscreen()) { @if (isThemeStudioFullscreen()) {
<div <div
#themeStudioControlsRef #themeStudioControlsRef
@@ -149,6 +164,7 @@
<app-floating-voice-controls /> <app-floating-voice-controls />
} }
<app-settings-modal /> <app-settings-modal />
<app-incoming-call-modal />
<app-screen-share-source-picker /> <app-screen-share-source-picker />
<app-native-context-menu /> <app-native-context-menu />
<app-debug-console [showLauncher]="false" /> <app-debug-console [showLauncher]="false" />

View File

@@ -44,6 +44,21 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent) import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
}, },
{
path: 'pm',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'pm/:conversationId',
loadComponent: () =>
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
},
{
path: 'call/:callId',
loadComponent: () =>
import('./features/direct-call/private-call.component').then((module) => module.PrivateCallComponent)
},
{ {
path: 'settings', path: 'settings',
loadComponent: () => loadComponent: () =>

View File

@@ -30,12 +30,15 @@ import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications'; import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service'; import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionFacade } from './domains/voice-session'; import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform'; import { ExternalLinkService, ViewportService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.service'; import { UserStatusService } from './core/services/user-status.service';
import { GameActivityService } from './domains/game-activity'; import { GameActivityService } from './domains/game-activity';
import { PluginBootstrapService } from './domains/plugins'; import { PluginBootstrapService } from './domains/plugins';
import { DirectCallService } from './domains/direct-call';
import { IncomingCallModalComponent } from './domains/direct-call/feature/incoming-call-modal/incoming-call-modal.component';
import { PrivateCallComponent } from './features/direct-call/private-call.component';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component'; import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component'; import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -63,10 +66,12 @@ import {
ServersRailComponent, ServersRailComponent,
TitleBarComponent, TitleBarComponent,
FloatingVoiceControlsComponent, FloatingVoiceControlsComponent,
IncomingCallModalComponent,
SettingsModalComponent, SettingsModalComponent,
DebugConsoleComponent, DebugConsoleComponent,
ScreenShareSourcePickerComponent, ScreenShareSourcePickerComponent,
NativeContextMenuComponent, NativeContextMenuComponent,
PrivateCallComponent,
ThemeNodeDirective, ThemeNodeDirective,
ThemePickerOverlayComponent ThemePickerOverlayComponent
], ],
@@ -96,9 +101,12 @@ export class App implements OnInit, OnDestroy {
readonly theme = inject(ThemeService); readonly theme = inject(ThemeService);
readonly voiceSession = inject(VoiceSessionFacade); readonly voiceSession = inject(VoiceSessionFacade);
readonly externalLinks = inject(ExternalLinkService); readonly externalLinks = inject(ExternalLinkService);
readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
readonly electronBridge = inject(ElectronBridgeService); readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService); readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService); readonly gameActivity = inject(GameActivityService);
readonly directCalls = inject(DirectCallService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null); readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null); readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -117,7 +125,11 @@ export class App implements OnInit, OnDestroy {
return this.settingsModal.activePage() === 'theme' return this.settingsModal.activePage() === 'theme'
&& this.settingsModal.themeStudioMinimized(); && this.settingsModal.themeStudioMinimized();
}); });
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm')); readonly isDirectMessageRoute = computed(() => {
const routePath = this.getRoutePath(this.currentRouteUrl());
return routePath.startsWith('/dm') || routePath.startsWith('/pm') || routePath.startsWith('/call');
});
readonly desktopUpdateNoticeKey = computed(() => { readonly desktopUpdateNoticeKey = computed(() => {
const updateState = this.desktopUpdateState(); const updateState = this.desktopUpdateState();
@@ -255,11 +267,22 @@ export class App implements OnInit, OnDestroy {
if (!currentUserId) { if (!currentUserId) {
if (!this.isPublicRoute(currentUrl)) { if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/login'], { // On mobile, new/unauthenticated visitors landing on the app root or
queryParams: { // /search should stay on /search (which already exposes a login CTA).
returnUrl: currentUrl // The login form has no mobile chrome / back button, so dropping new
} // users straight onto it leaves them with no way to navigate away.
}).catch(() => {}); const currentPath = this.getRoutePath(currentUrl);
const isSearchLanding = currentPath === '/' || currentPath === '/search';
if (this.isMobile() && isSearchLanding) {
this.router.navigate(['/search'], { replaceUrl: true }).catch(() => {});
} else {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: currentUrl
}
}).catch(() => {});
}
} }
} else { } else {
this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(UsersActions.loadCurrentUser());

View File

@@ -215,6 +215,24 @@ export interface ContextMenuParams {
}; };
} }
export interface ActiveGameCandidate {
processName: string;
rawProcessName: string;
executablePath?: string;
windowTitle?: string;
pid?: number;
isFullscreen: boolean;
bounds?: { width: number; height: number };
confidence: number;
source: 'foreground' | 'process-scan';
reasons: string[];
}
export interface ActiveGameCandidateResult {
candidate: ActiveGameCandidate | null;
fallbackProcessNames: string[];
}
export interface ElectronApi { export interface ElectronApi {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -223,6 +241,9 @@ export interface ElectronApi {
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>; getRunningProcessNames: () => Promise<string[]>;
getActiveGameCandidate?: () => Promise<ActiveGameCandidateResult>;
getIgnoredGameProcesses?: () => Promise<string[]>;
setIgnoredGameProcesses?: (list: string[]) => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>; activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>; deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
@@ -262,9 +283,15 @@ export interface ElectronApi {
onDeepLinkReceived: (listener: (url: string) => void) => () => void; onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>; readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>; readFile: (filePath: string) => Promise<string>;
readFileChunk: (filePath: string, start: number, end: number) => Promise<string>;
getFileSize: (filePath: string) => Promise<number>;
writeFile: (filePath: string, data: string) => Promise<boolean>; writeFile: (filePath: string, data: string) => Promise<boolean>;
appendFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
saveExistingFileAs?: (sourceFilePath: string, defaultFileName: string) => Promise<{ saved: boolean; cancelled: boolean }>;
openFilePath?: (filePath: string) => Promise<{ opened: boolean; reason?: string }>;
fileExists: (filePath: string) => Promise<boolean>; fileExists: (filePath: string) => Promise<boolean>;
getFileUrl: (filePath: string) => Promise<string | null>;
deleteFile: (filePath: string) => Promise<boolean>; deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void; onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;

View File

@@ -1,2 +1,3 @@
export * from './platform.service'; export * from './platform.service';
export * from './external-link.service'; export * from './external-link.service';
export * from './viewport.service';

View File

@@ -0,0 +1,69 @@
import {
DestroyRef,
Injectable,
NgZone,
computed,
inject,
signal
} from '@angular/core';
/**
* Tracks viewport-level UX traits used to switch between desktop and mobile layouts.
*
* `isMobile` follows the Tailwind `md` breakpoint (max-width: 767.98px). It is the
* single source of truth for whether the UI should render in mobile mode - components
* and templates should use this signal rather than ad-hoc `window.innerWidth` checks.
*
* `isTouch` is a best-effort hint indicating coarse pointer / touch capability. It is
* stable for the lifetime of the page and does not flip when devices are connected.
*/
@Injectable({ providedIn: 'root' })
export class ViewportService {
/** Pixel breakpoint that separates mobile from tablet/desktop layouts. Matches Tailwind `md`. */
static readonly MOBILE_MAX_WIDTH = 767.98;
/** True when the viewport is in mobile mode (width <= MOBILE_MAX_WIDTH). */
readonly isMobile = computed(() => this.isMobileSignal());
/** True when the primary pointer is coarse (touch screen). */
readonly isTouch = computed(() => this.isTouchSignal());
/** Convenience: true when running on a non-mobile viewport. */
readonly isDesktop = computed(() => !this.isMobileSignal());
private readonly zone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
private readonly mobileQuery: MediaQueryList | null;
private readonly touchQuery: MediaQueryList | null;
private readonly isMobileSignal = signal(false);
private readonly isTouchSignal = signal(false);
constructor() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
this.mobileQuery = null;
this.touchQuery = null;
return;
}
this.mobileQuery = window.matchMedia(`(max-width: ${ViewportService.MOBILE_MAX_WIDTH}px)`);
this.touchQuery = window.matchMedia('(pointer: coarse)');
this.isMobileSignal.set(this.mobileQuery.matches);
this.isTouchSignal.set(this.touchQuery.matches);
const onMobileChange = (event: MediaQueryListEvent) => {
this.zone.run(() => this.isMobileSignal.set(event.matches));
};
const onTouchChange = (event: MediaQueryListEvent) => {
this.zone.run(() => this.isTouchSignal.set(event.matches));
};
this.mobileQuery.addEventListener('change', onMobileChange);
this.touchQuery.addEventListener('change', onTouchChange);
this.destroyRef.onDestroy(() => {
this.mobileQuery?.removeEventListener('change', onMobileChange);
this.touchQuery?.removeEventListener('change', onTouchChange);
});
}
}

View File

@@ -7,6 +7,7 @@ import { Injectable, signal } from '@angular/core';
* Each key maps to a file in `src/assets/audio/`. * Each key maps to a file in `src/assets/audio/`.
*/ */
export enum AppSound { export enum AppSound {
Call = 'call',
Joining = 'joining', Joining = 'joining',
Leave = 'leave', Leave = 'leave',
Notification = 'notification' Notification = 'notification'
@@ -38,6 +39,8 @@ export class NotificationAudioService {
private readonly sources = new Map<AppSound, string>(); private readonly sources = new Map<AppSound, string>();
private readonly activeLoops = new Map<AppSound, HTMLAudioElement>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
@@ -142,4 +145,37 @@ export class NotificationAudioService {
}); });
}); });
} }
playLoop(sound: AppSound, volumeOverride?: number): void {
if (this.dndMuted() || this.activeLoops.has(sound))
return;
const src = this.sources.get(sound) ?? this.resolveAudioUrl(sound);
const vol = volumeOverride ?? this.notificationVolume();
if (vol === 0)
return;
const audio = new Audio(src);
audio.loop = true;
audio.preload = 'auto';
audio.volume = Math.max(0, Math.min(1, vol));
this.activeLoops.set(sound, audio);
audio.play().catch(() => {
this.activeLoops.delete(sound);
});
}
stop(sound: AppSound): void {
const audio = this.activeLoops.get(sound);
if (!audio)
return;
audio.pause();
audio.currentTime = 0;
audio.remove();
this.activeLoops.delete(sound);
}
} }

View File

@@ -13,7 +13,9 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` | | **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` | | **direct-call** | Direct and small-group private calls initiated from people cards and direct messages | `DirectCallService` |
| **experimental-media** | Optional media playback experiments kept isolated from the default attachment path | `ExperimentalMediaSettingsService` |
| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` | | **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` | | **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
@@ -32,6 +34,8 @@ The larger domains also keep longer design notes in their own folders:
- [authentication/README.md](authentication/README.md) - [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md) - [chat/README.md](chat/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)
- [experimental-media/README.md](experimental-media/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)

View File

@@ -0,0 +1,49 @@
import type { Room } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import { normalizeRoomAccessControl } from './room.rules';
function buildRoom(overrides: Partial<Room> = {}): Room {
return {
id: 'room-1',
name: 'Room',
hostId: 'host-1',
isPrivate: false,
createdAt: 1,
userCount: 1,
members: [
{
id: 'user-1',
oderId: 'oder-1',
username: 'alice',
displayName: 'Alice',
role: 'admin',
joinedAt: 1,
lastSeenAt: 1
}
],
...overrides
};
}
describe('normalizeRoomRoleAssignments', () => {
it('uses legacy member roles when assignments are missing', () => {
const room = normalizeRoomAccessControl(buildRoom());
expect(room.roleAssignments).toEqual([
{
userId: 'user-1',
oderId: 'oder-1',
roleIds: [SYSTEM_ROLE_IDS.admin]
}
]);
expect(room.members?.[0]?.role).toBe('admin');
});
it('honors an explicit empty assignment list', () => {
const room = normalizeRoomAccessControl(buildRoom({ roleAssignments: [] }));
expect(room.roleAssignments).toEqual([]);
expect(room.members?.[0]?.role).toBe('member');
});
});

View File

@@ -45,6 +45,7 @@ export function normalizeRoomRoleAssignments(
): RoomRoleAssignment[] { ): RoomRoleAssignment[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone)); const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const normalizedByUserKey = new Map<string, RoomRoleAssignment>(); const normalizedByUserKey = new Map<string, RoomRoleAssignment>();
const hasExplicitAssignments = Array.isArray(assignments);
for (const assignment of assignments ?? []) { for (const assignment of assignments ?? []) {
if (!assignment || typeof assignment !== 'object') { if (!assignment || typeof assignment !== 'object') {
@@ -72,7 +73,7 @@ export function normalizeRoomRoleAssignments(
}); });
} }
if (normalizedByUserKey.size > 0) { if (hasExplicitAssignments) {
return sortAssignments(Array.from(normalizedByUserKey.values())); return sortAssignments(Array.from(normalizedByUserKey.values()));
} }

View File

@@ -76,6 +76,8 @@ graph TD
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one. Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
When Electron serves a file from disk, the sender reads one chunk at a time and uses the buffered data-channel send path so large saved media does not get loaded into renderer memory or flood the receiver.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant S as Sender participant S as Sender
@@ -90,12 +92,12 @@ sequenceDiagram
loop Every 64 KB chunk loop Every 64 KB chunk
S->>R: file-chunk (attachmentId, index, data, progress, speed) S->>R: file-chunk (attachmentId, index, data, progress, speed)
Note over R: Append to chunk buffer Note over R: Append to chunk buffer, or append media directly to disk on Electron
Note over R: Update progress + EWMA speed Note over R: Update progress + EWMA speed
end end
Note over R: All chunks received Note over R: All chunks received
Note over R: Reassemble blob Note over R: Reassemble blob, or open completed Electron media from disk
Note over R: shouldPersistDownloadedAttachment? Save to disk Note over R: shouldPersistDownloadedAttachment? Save to disk
``` ```
@@ -131,17 +133,27 @@ When the user navigates to a room, the manager watches the route and decides whi
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`. The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.
## Persistence ## Persistence
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket: On Electron, local audio/video uploads are played through the original filesystem path when Electron exposes one, and received audio/video downloads are appended to an app-data file as chunks arrive. Completed audio/video downloads are then played through a file-backed media URL instead of being reloaded into a renderer `Blob`, which avoids full-file renderer memory pressure during download, startup restore, and playback. The storage path for downloaded server-room files is resolved per room and bucket:
``` ```
{appDataPath}/{serverId}/{roomName}/{bucket}/{attachmentId}.{ext?} {appDataPath}/server/{roomName}/{bucket}/{attachmentId}.{ext?}
``` ```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other. Direct-message attachments use the conversation id instead of the server-room path:
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only. ```
{appDataPath}/direct-messages/{conversationId}/{bucket}/{attachmentId}.{ext?}
```
Room and conversation names are sanitised to remove filesystem-unsafe characters. The bucket is `video`, `audio`, `image`, or `files` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On Electron, saved audio/video records are restored as file-backed URLs; other restored files still need their bytes loaded when a `Blob` URL is required. On browser builds, files stay in memory only.
## Runtime store ## Runtime store

View File

@@ -35,6 +35,7 @@ export class AttachmentManagerService {
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false; private isDatabaseInitialised = false;
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
constructor() { constructor() {
effect(() => { effect(() => {
@@ -79,27 +80,23 @@ export class AttachmentManagerService {
} }
async requestAutoDownloadsForRoom(roomId: string): Promise<void> { async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!roomId || !this.isRoomWatched(roomId)) if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0)
return; return;
if (this.database.isReady()) { const activeRequest = this.autoDownloadRequestsByRoom.get(roomId);
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) { if (activeRequest) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId); return activeRequest;
await this.requestAutoDownloadsForMessage(message.id);
}
return;
} }
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) { const request = this.runAutoDownloadsForRoom(roomId).finally(() => {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId); if (this.autoDownloadRequestsByRoom.get(roomId) === request) {
this.autoDownloadRequestsByRoom.delete(roomId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
} }
} });
this.autoDownloadRequestsByRoom.set(roomId, request);
return request;
} }
async deleteForMessage(messageId: string): Promise<void> { async deleteForMessage(messageId: string): Promise<void> {
@@ -180,6 +177,31 @@ export class AttachmentManagerService {
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file); await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
} }
private async runAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!this.isRoomWatched(roomId)) {
return;
}
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
await this.requestAutoDownloadsForMessage(message.id);
}
return;
}
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
}
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> { private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
if (!messageId) if (!messageId)
return; return;

View File

@@ -70,17 +70,20 @@ export class AttachmentPersistenceService {
} catch { /* persistence is best-effort */ } } catch { /* persistence is best-effort */ }
} }
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> { async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<string | null> {
try { try {
const roomName = await this.resolveCurrentRoomName(); const storageContainer = await this.resolveStorageContainerName(attachment);
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName); const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, storageContainer);
if (!diskPath) if (!diskPath)
return; return null;
attachment.savedPath = diskPath; attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment); void this.persistAttachmentMeta(attachment);
return diskPath;
} catch { /* disk save is best-effort */ } } catch { /* disk save is best-effort */ }
return null;
} }
async initFromDatabase(): Promise<void> { async initFromDatabase(): Promise<void> {
@@ -120,6 +123,10 @@ export class AttachmentPersistenceService {
}); });
} }
async resolveStorageContainerName(attachment: Pick<Attachment, 'messageId'>): Promise<string> {
return this.runtimeStore.getMessageRoomId(attachment.messageId) ?? await this.resolveCurrentRoomName();
}
private async loadFromDatabase(): Promise<void> { private async loadFromDatabase(): Promise<void> {
try { try {
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
@@ -176,6 +183,11 @@ export class AttachmentPersistenceService {
continue; continue;
if (attachment.savedPath) { if (attachment.savedPath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) {
hasChanges = true;
continue;
}
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath); const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
if (savedBase64) { if (savedBase64) {
@@ -186,6 +198,11 @@ export class AttachmentPersistenceService {
} }
if (attachment.filePath) { if (attachment.filePath) {
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) {
hasChanges = true;
continue;
}
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath); const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
if (originalBase64) { if (originalBase64) {
@@ -222,6 +239,26 @@ export class AttachmentPersistenceService {
); );
} }
private async restoreMediaAttachmentFromFileUrl(attachment: Attachment, filePath: string): Promise<boolean> {
if (!this.isPlayableMedia(attachment)) {
return false;
}
const fileUrl = await this.attachmentStorage.getFileUrl(filePath);
if (!fileUrl) {
return false;
}
attachment.objectUrl = fileUrl;
attachment.available = true;
return true;
}
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
}
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> { private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
const retainedSavedPaths = new Set<string>(); const retainedSavedPaths = new Set<string>();

View File

@@ -49,6 +49,11 @@ export class AttachmentTransferTransportService {
diskPath: string, diskPath: string,
isCancelled: () => boolean isCancelled: () => boolean
): Promise<void> { ): Promise<void> {
if (this.attachmentStorage.canReadFileChunks()) {
await this.streamFileFromDiskChunksToPeer(targetPeerId, messageId, fileId, diskPath, isCancelled);
return;
}
const base64Full = await this.attachmentStorage.readFile(diskPath); const base64Full = await this.attachmentStorage.readFile(diskPath);
if (!base64Full) if (!base64Full)
@@ -78,7 +83,45 @@ export class AttachmentTransferTransportService {
data: base64Chunk data: base64Chunk
}; };
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
}
}
private async streamFileFromDiskChunksToPeer(
targetPeerId: string,
messageId: string,
fileId: string,
diskPath: string,
isCancelled: () => boolean
): Promise<void> {
const fileSize = await this.attachmentStorage.getFileSize(diskPath);
if (fileSize === null)
return;
const totalChunks = Math.ceil(fileSize / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (isCancelled())
break;
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
const end = Math.min(fileSize, start + FILE_CHUNK_SIZE_BYTES);
const base64Chunk = await this.attachmentStorage.readFileChunk(diskPath, start, end);
if (base64Chunk === null)
return;
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64Chunk
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
} }
} }
} }

View File

@@ -28,6 +28,22 @@ import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
interface DiskReceiveAssembly {
path: string;
receivedCount: number;
receivedIndexes: Set<number>;
total: number;
}
interface ValidFileChunkPayload {
data: string;
fileId: string;
fromPeerId?: string;
index: number;
messageId: string;
total: number;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferService { export class AttachmentTransferService {
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
@@ -36,6 +52,9 @@ export class AttachmentTransferService {
private readonly persistence = inject(AttachmentPersistenceService); private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService); private readonly transport = inject(AttachmentTransferTransportService);
private readonly diskReceiveAssemblies = new Map<string, DiskReceiveAssembly>();
private readonly diskReceiveChains = new Map<string, Promise<void>>();
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> { getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {}; const result: Record<string, AttachmentMeta[]> = {};
@@ -174,10 +193,19 @@ export class AttachmentTransferService {
attachments.push(attachment); attachments.push(attachment);
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file); this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
try { const fileUrl = attachment.filePath && this.isPlayableMedia(attachment)
attachment.objectUrl = URL.createObjectURL(file); ? await this.attachmentStorage.getFileUrl(attachment.filePath)
: null;
if (fileUrl) {
attachment.objectUrl = fileUrl;
attachment.available = true; attachment.available = true;
} catch { /* non-critical */ } } else {
try {
attachment.objectUrl = URL.createObjectURL(file);
attachment.available = true;
} catch { /* non-critical */ }
}
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file); void this.persistence.saveFileToDisk(attachment, file);
@@ -257,6 +285,19 @@ export class AttachmentTransferService {
if (!attachment) if (!attachment)
return; return;
if (this.shouldReceiveToDisk(attachment)) {
this.enqueueDiskFileChunk(attachment, {
data,
fileId,
fromPeerId,
index,
messageId,
total
});
return;
}
const decodedBytes = this.transport.decodeBase64(data); const decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`; const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId); const requestKey = this.buildRequestKey(messageId, fileId);
@@ -274,7 +315,7 @@ export class AttachmentTransferService {
this.updateTransferProgress(attachment, decodedBytes, fromPeerId); this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
this.runtimeStore.touch(); this.runtimeStore.touch();
this.finalizeTransferIfComplete(attachment, assemblyKey, total); void this.finalizeTransferIfComplete(attachment, assemblyKey, total);
} }
async handleFileRequest(payload: FileRequestPayload): Promise<void> { async handleFileRequest(payload: FileRequestPayload): Promise<void> {
@@ -375,6 +416,7 @@ export class AttachmentTransferService {
this.runtimeStore.deleteChunkBuffer(assemblyKey); this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey); this.runtimeStore.deleteChunkCount(assemblyKey);
void this.deleteDiskReceiveAssembly(assemblyKey);
attachment.receivedBytes = 0; attachment.receivedBytes = 0;
attachment.speedBps = 0; attachment.speedBps = 0;
@@ -533,11 +575,11 @@ export class AttachmentTransferService {
attachment.lastUpdateMs = now; attachment.lastUpdateMs = now;
} }
private finalizeTransferIfComplete( private async finalizeTransferIfComplete(
attachment: Attachment, attachment: Attachment,
assemblyKey: string, assemblyKey: string,
total: number total: number
): void { ): Promise<void> {
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0; const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey); const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
@@ -551,16 +593,167 @@ export class AttachmentTransferService {
const blob = new Blob(completeBuffer, { type: attachment.mime }); const blob = new Blob(completeBuffer, { type: attachment.mime });
attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob);
if (shouldPersistDownloadedAttachment(attachment)) {
void this.persistence.saveFileToDisk(attachment, blob);
}
this.runtimeStore.deleteChunkBuffer(assemblyKey); this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey); this.runtimeStore.deleteChunkCount(assemblyKey);
if (shouldPersistDownloadedAttachment(attachment)) {
const diskPath = await this.persistence.saveFileToDisk(attachment, blob);
const fileUrl = diskPath && this.isPlayableMedia(attachment)
? await this.attachmentStorage.getFileUrl(diskPath)
: null;
if (fileUrl) {
attachment.objectUrl = fileUrl;
} else {
attachment.objectUrl = URL.createObjectURL(blob);
}
} else {
attachment.objectUrl = URL.createObjectURL(blob);
}
attachment.available = true;
this.runtimeStore.touch(); this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment); void this.persistence.persistAttachmentMeta(attachment);
} }
private isPlayableMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/');
}
private shouldReceiveToDisk(attachment: Attachment): boolean {
return this.isPlayableMedia(attachment) && !attachment.filePath && this.attachmentStorage.canWriteFiles();
}
private enqueueDiskFileChunk(
attachment: Attachment,
payload: ValidFileChunkPayload
): void {
const assemblyKey = `${payload.messageId}:${payload.fileId}`;
const previous = this.diskReceiveChains.get(assemblyKey) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(() => this.handleDiskFileChunk(attachment, assemblyKey, payload))
.catch((error: unknown) => this.handleDiskReceiveFailure(attachment, assemblyKey, error));
this.diskReceiveChains.set(assemblyKey, next);
void next.finally(() => {
if (this.diskReceiveChains.get(assemblyKey) === next) {
this.diskReceiveChains.delete(assemblyKey);
}
});
}
private async handleDiskFileChunk(
attachment: Attachment,
assemblyKey: string,
payload: ValidFileChunkPayload
): Promise<void> {
const decodedBytes = this.transport.decodeBase64(payload.data);
const requestKey = this.buildRequestKey(payload.messageId, payload.fileId);
this.runtimeStore.deletePendingRequest(requestKey);
this.clearAttachmentRequestError(attachment);
const assembly = await this.getOrCreateDiskReceiveAssembly(attachment, assemblyKey, payload.total);
if (!assembly) {
throw new Error('Could not prepare media download on disk.');
}
if (assembly.receivedIndexes.has(payload.index)) {
return;
}
if (payload.index !== assembly.receivedCount) {
throw new Error('Received media chunks out of order. Retry the download.');
}
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
if (!didAppend) {
throw new Error('Could not write media download to disk.');
}
assembly.receivedIndexes.add(payload.index);
assembly.receivedCount += 1;
this.updateTransferProgress(attachment, decodedBytes, payload.fromPeerId);
this.runtimeStore.touch();
if (assembly.receivedCount < assembly.total && (attachment.receivedBytes ?? 0) < attachment.size) {
return;
}
const fileUrl = await this.attachmentStorage.getFileUrl(assembly.path);
if (!fileUrl) {
throw new Error('Could not open completed media download from disk.');
}
attachment.savedPath = assembly.path;
attachment.objectUrl = fileUrl;
attachment.available = true;
this.diskReceiveAssemblies.delete(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
private async getOrCreateDiskReceiveAssembly(
attachment: Attachment,
assemblyKey: string,
total: number
): Promise<DiskReceiveAssembly | null> {
const existing = this.diskReceiveAssemblies.get(assemblyKey);
if (existing) {
return existing;
}
const storageContainer = await this.persistence.resolveStorageContainerName(attachment);
const path = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
if (!path) {
return null;
}
const assembly: DiskReceiveAssembly = {
path,
receivedCount: 0,
receivedIndexes: new Set<number>(),
total
};
this.diskReceiveAssemblies.set(assemblyKey, assembly);
return assembly;
}
private async handleDiskReceiveFailure(
attachment: Attachment,
assemblyKey: string,
error: unknown
): Promise<void> {
await this.deleteDiskReceiveAssembly(assemblyKey);
attachment.available = false;
attachment.objectUrl = undefined;
attachment.receivedBytes = 0;
attachment.speedBps = 0;
attachment.startedAtMs = undefined;
attachment.lastUpdateMs = undefined;
attachment.requestError = error instanceof Error && error.message
? error.message
: 'Media download failed. Retry the download.';
this.runtimeStore.touch();
}
private async deleteDiskReceiveAssembly(assemblyKey: string): Promise<void> {
const assembly = this.diskReceiveAssemblies.get(assemblyKey);
this.diskReceiveAssemblies.delete(assemblyKey);
if (assembly?.path) {
await this.attachmentStorage.deleteFile(assembly.path);
}
}
} }

View File

@@ -1,2 +1,5 @@
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */ /** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
/** Maximum browser-only audio/video size that renders with an inline media player. */
export const MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB

View File

@@ -7,10 +7,24 @@ import {
sanitizeAttachmentRoomName sanitizeAttachmentRoomName
} from '../util/attachment-storage.util'; } from '../util/attachment-storage.util';
const DIRECT_MESSAGE_STORAGE_PREFIX = 'direct-message:';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentStorageService { export class AttachmentStorageService {
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
canWriteFiles(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.appendFile && !!electronApi.writeFile && !!electronApi.getAppDataPath;
}
canReadFileChunks(): boolean {
const electronApi = this.electronBridge.getApi();
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
}
async resolveExistingPath( async resolveExistingPath(
attachment: Pick<Attachment, 'filePath' | 'savedPath'> attachment: Pick<Attachment, 'filePath' | 'savedPath'>
): Promise<string | null> { ): Promise<string | null> {
@@ -41,10 +55,73 @@ export class AttachmentStorageService {
} }
} }
async getFileSize(filePath: string): Promise<number | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileSize || !filePath) {
return null;
}
try {
return await electronApi.getFileSize(filePath);
} catch {
return null;
}
}
async readFileChunk(filePath: string, start: number, end: number): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.readFileChunk || !filePath) {
return null;
}
try {
return await electronApi.readFileChunk(filePath, start, end);
} catch {
return null;
}
}
async getFileUrl(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.getFileUrl || !filePath) {
return null;
}
try {
return await electronApi.getFileUrl(filePath);
} catch {
return null;
}
}
async saveBlob( async saveBlob(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>, attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
blob: Blob, blob: Blob,
roomName: string roomName: string
): Promise<string | null> {
const diskPath = await this.createWritableFile(attachment, roomName);
if (!diskPath) {
return null;
}
try {
const arrayBuffer = await blob.arrayBuffer();
await this.writeBase64(diskPath, this.arrayBufferToBase64(arrayBuffer));
return diskPath;
} catch {
return null;
}
}
async createWritableFile(
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
roomName: string
): Promise<string | null> { ): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath(); const appDataPath = await this.resolveAppDataPath();
@@ -54,14 +131,12 @@ export class AttachmentStorageService {
} }
try { try {
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`; const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
await electronApi.ensureDir(directoryPath); await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer();
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`; const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer)); await this.writeBase64(diskPath, '');
return diskPath; return diskPath;
} catch { } catch {
@@ -69,6 +144,20 @@ export class AttachmentStorageService {
} }
} }
async appendBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi?.appendFile || !filePath) {
return false;
}
try {
return await electronApi.appendFile(filePath, base64Data);
} catch {
return false;
}
}
async deleteFile(filePath: string): Promise<void> { async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
@@ -95,6 +184,18 @@ export class AttachmentStorageService {
} }
} }
private resolveStorageDirectoryPath(appDataPath: string, containerName: string, mime: string): string {
const bucket = resolveAttachmentStorageBucket(mime);
if (containerName.startsWith(DIRECT_MESSAGE_STORAGE_PREFIX)) {
const conversationId = containerName.slice(DIRECT_MESSAGE_STORAGE_PREFIX.length);
return `${appDataPath}/direct-messages/${sanitizeAttachmentRoomName(conversationId)}/${bucket}`;
}
return `${appDataPath}/server/${sanitizeAttachmentRoomName(containerName)}/${bucket}`;
}
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> { private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
@@ -117,6 +218,16 @@ export class AttachmentStorageService {
return null; return null;
} }
private async writeBase64(filePath: string, base64Data: string): Promise<boolean> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return false;
}
return await electronApi.writeFile(filePath, base64Data);
}
private arrayBufferToBase64(buffer: ArrayBuffer): string { private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = ''; let binary = '';

View File

@@ -70,7 +70,7 @@ graph TD
## Message lifecycle ## Message lifecycle
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations. Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Live room chat also emits a narrow `chat_message` signaling fallback so peers can receive text while the data channel is unavailable. Editing and deletion are sender-only operations.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram

View File

@@ -1,5 +1,11 @@
/** Maximum number of recent messages to include in sync inventories. */ /** Maximum number of messages to include in sync inventories.
export const INVENTORY_LIMIT = 1000; *
* The inventory protocol now ships every message in the room (id, ts, rc, ac)
* chunked at `CHUNK_SIZE`, so peers converge on the full history regardless
* of how lopsided their message counts are. The constant remains as a safety
* ceiling for pathological rooms.
*/
export const INVENTORY_LIMIT = 1_000_000;
/** Number of messages per chunk for inventory / batch transfers. */ /** Number of messages per chunk for inventory / batch transfers. */
export const CHUNK_SIZE = 200; export const CHUNK_SIZE = 200;
@@ -14,7 +20,7 @@ export const SYNC_POLL_SLOW_MS = 900_000;
export const SYNC_TIMEOUT_MS = 5_000; export const SYNC_TIMEOUT_MS = 5_000;
/** Large limit used for legacy full-sync operations. */ /** Large limit used for legacy full-sync operations. */
export const FULL_SYNC_LIMIT = 10_000; export const FULL_SYNC_LIMIT = 1_000_000;
/** Inventory item representing a message's sync state. */ /** Inventory item representing a message's sync state. */
export interface InventoryItem { export interface InventoryItem {

View File

@@ -11,6 +11,8 @@
[isAdmin]="isAdmin()" [isAdmin]="isAdmin()"
[bottomPadding]="composerBottomPadding()" [bottomPadding]="composerBottomPadding()"
[conversationKey]="conversationKey()" [conversationKey]="conversationKey()"
[loadingOlder]="loadingOlder()"
[conversationExhausted]="conversationExhausted()"
(replyRequested)="setReplyTo($event)" (replyRequested)="setReplyTo($event)"
(deleteRequested)="handleDeleteRequested($event)" (deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)" (editSaved)="handleEditSaved($event)"
@@ -20,6 +22,7 @@
(imageOpened)="openLightbox($event)" (imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)" (imageContextMenuRequested)="openImageContextMenu($event)"
(embedRemoved)="handleEmbedRemoved($event)" (embedRemoved)="handleEmbedRemoved($event)"
(loadOlderRequested)="handleLoadOlderRequested($event)"
/> />
<div <div
@@ -40,31 +43,43 @@
</div> </div>
@if (showKlipyGifPicker()) { @if (showKlipyGifPicker()) {
<div @if (isMobile()) {
class="fixed inset-0 z-[89]" <app-bottom-sheet (dismissed)="closeKlipyGifPicker()">
(click)="closeKlipyGifPicker()" <div appThemeNode="chatGifPickerSurface">
(keydown.enter)="closeKlipyGifPicker()" <app-klipy-gif-picker
(keydown.space)="closeKlipyGifPicker()" [signalSource]="currentRoom()"
tabindex="0" (gifSelected)="handleKlipyGifSelected($event)"
role="button" (closed)="closeKlipyGifPicker()"
aria-label="Close GIF picker" />
style="-webkit-app-region: no-drag" </div>
></div> </app-bottom-sheet>
} @else {
<div class="pointer-events-none fixed inset-0 z-[90]">
<div <div
appThemeNode="chatGifPickerSurface" class="fixed inset-0 z-[89]"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]" (click)="closeKlipyGifPicker()"
[style.bottom.px]="composerBottomPadding() + 8" (keydown.enter)="closeKlipyGifPicker()"
[style.right.px]="klipyGifPickerAnchorRight()" (keydown.space)="closeKlipyGifPicker()"
> tabindex="0"
<app-klipy-gif-picker role="button"
[signalSource]="currentRoom()" aria-label="Close GIF picker"
(gifSelected)="handleKlipyGifSelected($event)" style="-webkit-app-region: no-drag"
(closed)="closeKlipyGifPicker()" ></div>
/>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</div> </div>
</div> }
} }
<app-chat-message-overlays <app-chat-message-overlays

View File

@@ -8,15 +8,21 @@ import {
inject, inject,
signal signal
} from '@angular/core'; } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent } from '../../../../shared';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions'; import { MessagesActions } from '../../../../store/messages/messages.actions';
import { import {
selectAllMessages, selectAllMessages,
selectConversationExhausted,
selectMessagesLoading, selectMessagesLoading,
selectMessagesLoadingOlder,
selectMessagesSyncing selectMessagesSyncing
} from '../../../../store/messages/messages.selectors'; } from '../../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
@@ -45,6 +51,7 @@ import {
KlipyGifPickerComponent, KlipyGifPickerComponent,
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent, ChatMessageOverlaysComponent,
BottomSheetComponent,
ThemeNodeDirective ThemeNodeDirective
], ],
templateUrl: './chat-messages.component.html', templateUrl: './chat-messages.component.html',
@@ -52,12 +59,16 @@ import {
}) })
export class ChatMessagesComponent { export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
@ViewChild(ChatMessageListComponent) messageList?: ChatMessageListComponent;
private readonly electronBridge = inject(ElectronBridgeService); private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade); private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
readonly allMessages = this.store.selectSignal(selectAllMessages); readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
@@ -65,6 +76,7 @@ export class ChatMessagesComponent {
readonly loading = this.store.selectSignal(selectMessagesLoading); readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing); readonly syncing = this.store.selectSignal(selectMessagesSyncing);
readonly loadingOlder = this.store.selectSignal(selectMessagesLoadingOlder);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin); readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
@@ -76,6 +88,12 @@ export class ChatMessagesComponent {
}); });
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`); readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly conversationExhausted = toSignal(
toObservable(this.conversationKey).pipe(
switchMap((key) => this.store.select(selectConversationExhausted(key)))
),
{ initialValue: false }
);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom())); readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly composerBottomPadding = signal(140); readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16); readonly klipyGifPickerAnchorRight = signal(16);
@@ -98,6 +116,8 @@ export class ChatMessagesComponent {
} }
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void { handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
this.messageList?.scrollToBottomAfterLocalSend();
this.store.dispatch( this.store.dispatch(
MessagesActions.sendMessage({ MessagesActions.sendMessage({
content: event.content, content: event.content,
@@ -204,6 +224,22 @@ export class ChatMessagesComponent {
); );
} }
handleLoadOlderRequested(event: { beforeTimestamp: number; limit: number }): void {
const roomId = this.currentRoom()?.id;
if (!roomId)
return;
this.store.dispatch(
MessagesActions.loadOlderMessages({
roomId,
channelId: this.activeChannelId() ?? 'general',
beforeTimestamp: event.beforeTimestamp,
limit: event.limit
})
);
}
toggleKlipyGifPicker(): void { toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker(); const nextState = !this.showKlipyGifPicker();
@@ -278,6 +314,19 @@ export class ChatMessagesComponent {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
if (electronApi) { if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment); const blob = await this.getAttachmentBlob(attachment);
if (blob) { if (blob) {
@@ -326,6 +375,9 @@ export class ChatMessagesComponent {
if (!attachment.objectUrl) if (!attachment.objectUrl)
return null; return null;
if (attachment.objectUrl.startsWith('file:'))
return null;
try { try {
const response = await fetch(attachment.objectUrl); const response = await fetch(attachment.objectUrl);
@@ -335,6 +387,10 @@ export class ChatMessagesComponent {
} }
} }
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> { private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();

View File

@@ -6,6 +6,10 @@
[attr.data-message-id]="msg.id" [attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30" class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted" [class.opacity-50]="msg.isDeleted"
(touchstart)="onMessageTouchStart($event)"
(touchend)="onMessageTouchEnd()"
(touchmove)="onMessageTouchEnd()"
(touchcancel)="onMessageTouchEnd()"
> >
<div <div
appThemeNode="chatMessageAvatar" appThemeNode="chatMessageAvatar"
@@ -112,7 +116,8 @@
type="button" type="button"
class="font-semibold text-primary underline-offset-4 hover:underline" class="font-semibold text-primary underline-offset-4 hover:underline"
(click)="openMissingPluginStore(missingEmbed)" (click)="openMissingPluginStore(missingEmbed)"
>store</button >
store</button
>. >.
</article> </article>
} }
@@ -359,6 +364,30 @@
</button> </button>
} }
} @else { } @else {
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openAttachmentExternally(att)"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
</button>
}
@if (att.canUseExperimentalPlayer) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openExperimentalPlayer(att)"
>
<ng-icon
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
</button>
}
<button <button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground" class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)" (click)="downloadAttachment(att)"
@@ -368,6 +397,30 @@
} }
} @else { } @else {
<div class="text-xs text-muted-foreground">Shared from your device</div> <div class="text-xs text-muted-foreground">Shared from your device</div>
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openAttachmentExternally(att)"
>
<ng-icon
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
</button>
}
@if (att.canUseExperimentalPlayer) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
(click)="openExperimentalPlayer(att)"
>
<ng-icon
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
</button>
}
} }
</div> </div>
</div> </div>
@@ -379,6 +432,22 @@
</div> </div>
} }
</div> </div>
@if (att.experimentalPlayerActive && att.objectUrl) {
@defer {
<app-experimental-vlc-player
[src]="att.objectUrl"
[filename]="att.filename"
[mime]="att.mime"
[sizeLabel]="formatBytes(att.size)"
(closed)="closeExperimentalPlayer()"
(downloadRequested)="downloadAttachment(att)"
/>
} @loading {
<div class="mt-2 max-w-xl rounded-md border border-border bg-secondary/20 p-3 text-xs text-muted-foreground">
Loading experimental player...
</div>
}
}
} }
} }
</div> </div>
@@ -404,7 +473,7 @@
} }
</div> </div>
@if (!msg.isDeleted) { @if (!msg.isDeleted && !isMobile()) {
<div <div
appThemeNode="chatMessageActions" appThemeNode="chatMessageActions"
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100" class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
@@ -469,4 +538,83 @@
} }
</div> </div>
} }
<ng-template #mobileSheetTpl>
<app-bottom-sheet
title="Message"
ariaLabel="Message actions"
(dismissed)="closeMobileActions()"
>
<div class="flex flex-col py-1">
<div class="px-3 pb-2 pt-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">React</p>
<div class="mt-2 grid grid-cols-8 gap-1">
@for (emoji of commonEmojis; track emoji) {
<button
type="button"
class="rounded p-1 text-xl transition-colors hover:bg-secondary"
(click)="onMobileReact(emoji)"
>
{{ emoji }}
</button>
}
</div>
</div>
<div class="my-1 h-px bg-border"></div>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileReply()"
>
<ng-icon
name="lucideReply"
class="h-5 w-5 text-muted-foreground"
/>
<span>Reply</span>
</button>
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileCopy()"
>
<ng-icon
name="lucideCopy"
class="h-5 w-5 text-muted-foreground"
/>
<span>Copy message content</span>
</button>
@if (isOwnMessage()) {
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors hover:bg-secondary"
(click)="onMobileEdit()"
>
<ng-icon
name="lucideEdit"
class="h-5 w-5 text-muted-foreground"
/>
<span>Edit</span>
</button>
}
@if (isOwnMessage() || isAdmin()) {
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-destructive transition-colors hover:bg-destructive/10"
(click)="onMobileDelete()"
>
<ng-icon
name="lucideTrash2"
class="h-5 w-5"
/>
<span>Delete</span>
</button>
}
</div>
</app-bottom-sheet>
</ng-template>
</div> </div>

View File

@@ -2,25 +2,34 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { import {
ChangeDetectionStrategy,
Component, Component,
computed, computed,
ElementRef, ElementRef,
effect, effect,
inject, inject,
input, input,
OnDestroy,
output, output,
signal, signal,
ViewChild TemplateRef,
ViewChild,
ViewContainerRef
} from '@angular/core'; } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideCheck, lucideCheck,
lucideCopy,
lucideDownload, lucideDownload,
lucideEdit, lucideEdit,
lucideExpand, lucideExpand,
lucideExternalLink,
lucideImage, lucideImage,
lucidePlay,
lucideReply, lucideReply,
lucideSmile, lucideSmile,
lucideTrash2, lucideTrash2,
@@ -29,8 +38,15 @@ import {
import { import {
Attachment, Attachment,
AttachmentFacade, AttachmentFacade,
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
MAX_AUTO_SAVE_SIZE_BYTES MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment'; } from '../../../../../attachment';
import { PlatformService, ViewportService } from '../../../../../../core/platform';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import {
ExperimentalMediaSettingsService
} from '../../../../../experimental-media';
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
import { KlipyService } from '../../../../application/services/klipy.service'; import { KlipyService } from '../../../../application/services/klipy.service';
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules'; import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
import { import {
@@ -43,6 +59,7 @@ import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins'; import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
import { import {
BottomSheetComponent,
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
ChatVideoPlayerComponent, ChatVideoPlayerComponent,
ProfileCardService, ProfileCardService,
@@ -81,6 +98,9 @@ const RICH_MARKDOWN_PATTERNS = [
]; ];
interface ChatMessageAttachmentViewModel extends Attachment { interface ChatMessageAttachmentViewModel extends Attachment {
canOpenExternally: boolean;
canUseExperimentalPlayer: boolean;
experimentalPlayerActive: boolean;
isAudio: boolean; isAudio: boolean;
isUploader: boolean; isUploader: boolean;
isVideo: boolean; isVideo: boolean;
@@ -112,15 +132,20 @@ interface MissingPluginEmbedFallback {
ChatLinkEmbedComponent, ChatLinkEmbedComponent,
UserAvatarComponent, UserAvatarComponent,
PluginRenderHostComponent, PluginRenderHostComponent,
ThemeNodeDirective ExperimentalVlcPlayerComponent,
ThemeNodeDirective,
BottomSheetComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideCheck, lucideCheck,
lucideCopy,
lucideDownload, lucideDownload,
lucideEdit, lucideEdit,
lucideExpand, lucideExpand,
lucideExternalLink,
lucideImage, lucideImage,
lucidePlay,
lucideReply, lucideReply,
lucideSmile, lucideSmile,
lucideTrash2, lucideTrash2,
@@ -129,20 +154,34 @@ interface MissingPluginEmbedFallback {
], ],
templateUrl: './chat-message-item.component.html', templateUrl: './chat-message-item.component.html',
styleUrl: './chat-message-item.component.scss', styleUrl: './chat-message-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
style: 'display: contents;' style: 'display: contents;'
} }
}) })
export class ChatMessageItemComponent { export class ChatMessageItemComponent implements OnDestroy {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>; @ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('mobileSheetTpl') mobileSheetTpl?: TemplateRef<unknown>;
private readonly attachmentsSvc = inject(AttachmentFacade); private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly pluginRequirements = inject(PluginRequirementStateService); private readonly pluginRequirements = inject(PluginRequirementStateService);
private readonly pluginUi = inject(PluginUiRegistryService); private readonly pluginUi = inject(PluginUiRegistryService);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly platform = inject(PlatformService);
private readonly experimentalMedia = inject(ExperimentalMediaSettingsService);
private readonly profileCard = inject(ProfileCardService); private readonly profileCard = inject(ProfileCardService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly viewport = inject(ViewportService);
private readonly overlay = inject(Overlay);
private readonly viewContainerRef = inject(ViewContainerRef);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
readonly isMobile = this.viewport.isMobile;
readonly mobileSheetOpen = signal(false);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
private readonly experimentalPlayerAttachmentId = signal<string | null>(null);
private readonly mediaSupportCache = new Map<string, boolean>();
readonly message = input.required<Message>(); readonly message = input.required<Message>();
readonly repliedMessage = input<Message | undefined>(); readonly repliedMessage = input<Message | undefined>();
@@ -340,6 +379,116 @@ export class ChatMessageItemComponent {
this.deleteRequested.emit(this.message()); this.deleteRequested.emit(this.message());
} }
onMessageTouchStart(event: TouchEvent): void {
if (!this.isMobile() || this.message().isDeleted) {
return;
}
if (event.touches.length !== 1) {
this.clearLongPressTimer();
return;
}
if (this.isEditableTarget(event.target)) {
this.clearLongPressTimer();
return;
}
this.clearLongPressTimer();
this.longPressTimer = window.setTimeout(() => {
this.longPressTimer = null;
this.openMobileSheet();
}, 500);
}
onMessageTouchEnd(): void {
this.clearLongPressTimer();
}
private clearLongPressTimer(): void {
if (this.longPressTimer !== null) {
window.clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
private isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
return target.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]') !== null;
}
closeMobileActions(): void {
this.detachMobileSheet();
}
private openMobileSheet(): void {
if (this.mobileSheetOverlayRef || !this.mobileSheetTpl) {
this.mobileSheetOpen.set(true);
return;
}
const overlayRef = this.overlay.create({
positionStrategy: this.overlay.position().global(),
scrollStrategy: this.overlay.scrollStrategies.block(),
hasBackdrop: false,
panelClass: 'metoyou-chat-actions-sheet-pane'
});
const portal = new TemplatePortal(this.mobileSheetTpl, this.viewContainerRef);
overlayRef.attach(portal);
this.mobileSheetOverlayRef = overlayRef;
this.mobileSheetOpen.set(true);
}
private detachMobileSheet(): void {
this.mobileSheetOpen.set(false);
if (this.mobileSheetOverlayRef) {
this.mobileSheetOverlayRef.dispose();
this.mobileSheetOverlayRef = null;
}
}
ngOnDestroy(): void {
this.clearLongPressTimer();
this.detachMobileSheet();
}
onMobileReact(emoji: string): void {
this.addReaction(emoji);
this.closeMobileActions();
}
onMobileReply(): void {
this.requestReply();
this.closeMobileActions();
}
onMobileEdit(): void {
this.startEdit();
this.closeMobileActions();
}
onMobileDelete(): void {
this.requestDelete();
this.closeMobileActions();
}
async onMobileCopy(): Promise<void> {
const text = this.message().content;
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard API unavailable; silently ignore.
}
this.closeMobileActions();
}
removeEmbed(url: string): void { removeEmbed(url: string): void {
this.embedRemoved.emit({ this.embedRemoved.emit({
messageId: this.message().id, messageId: this.message().id,
@@ -539,13 +688,51 @@ export class ChatMessageItemComponent {
this.downloadRequested.emit(attachment); this.downloadRequested.emit(attachment);
} }
openExperimentalPlayer(attachment: Attachment): void {
if (!attachment.available || !attachment.objectUrl) {
return;
}
this.experimentalPlayerAttachmentId.set(attachment.id);
}
async openAttachmentExternally(attachment: Attachment): Promise<void> {
const diskPath = this.getAttachmentDiskPath(attachment);
const electronApi = this.electronBridge.getApi();
if (!diskPath || !electronApi?.openFilePath) {
return;
}
await electronApi.openFilePath(diskPath);
}
closeExperimentalPlayer(): void {
this.experimentalPlayerAttachmentId.set(null);
}
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel { private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment); const isRawVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment); const isRawAudio = this.isAudioAttachment(attachment);
const isRawPlayableMedia = isRawVideo || isRawAudio;
const isNativePlayableMedia = this.canPlayMediaType(attachment.mime);
const shouldUseDefaultFileInterface = isRawPlayableMedia &&
(!isNativePlayableMedia ||
(this.platform.isBrowser && attachment.size > MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES));
const isVideo = isRawVideo && !shouldUseDefaultFileInterface;
const isAudio = isRawAudio && !shouldUseDefaultFileInterface;
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES; const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
const canUseExperimentalPlayer = this.experimentalMedia.vlcJsPlaybackEnabled() &&
shouldUseDefaultFileInterface &&
isRawPlayableMedia &&
attachment.available &&
!!attachment.objectUrl;
return { return {
...attachment, ...attachment,
canOpenExternally: this.platform.isElectron && attachment.available && !!this.getAttachmentDiskPath(attachment),
canUseExperimentalPlayer,
experimentalPlayerActive: canUseExperimentalPlayer && this.experimentalPlayerAttachmentId() === attachment.id,
isAudio, isAudio,
isUploader: this.isUploader(attachment), isUploader: this.isUploader(attachment),
isVideo, isVideo,
@@ -572,6 +759,30 @@ export class ChatMessageItemComponent {
private getLiveAttachment(attachmentId: string): Attachment | undefined { private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId); return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
} }
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private canPlayMediaType(mime: string): boolean {
if (!mime.startsWith('video/') && !mime.startsWith('audio/')) {
return false;
}
const cached = this.mediaSupportCache.get(mime);
if (cached !== undefined) {
return cached;
}
const element = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
const canPlay = element.canPlayType(mime) !== '';
this.mediaSupportCache.set(mime, canPlay);
return canPlay;
}
} }
function parsePluginEmbedToken(content: string): PluginEmbedToken | null { function parsePluginEmbedToken(content: string): PluginEmbedToken | null {

View File

@@ -5,9 +5,10 @@ import {
input input
} from '@angular/core'; } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { environment } from '../../../../../../../../environments/environment';
import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules'; import { extractYoutubeVideoId } from '../../../../../domain/rules/link-embed.rules';
const YOUTUBE_EMBED_FALLBACK_ORIGIN = 'https://toju.app'; const YOUTUBE_EMBED_FALLBACK_ORIGIN = environment.publicOrigin;
function resolveYoutubeClientOrigin(): string { function resolveYoutubeClientOrigin(): string {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {

View File

@@ -2,6 +2,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
AfterViewChecked, AfterViewChecked,
ChangeDetectionStrategy,
Component, Component,
ElementRef, ElementRef,
OnDestroy, OnDestroy,
@@ -48,6 +49,7 @@ declare global {
ThemeNodeDirective ThemeNodeDirective
], ],
templateUrl: './chat-message-list.component.html', templateUrl: './chat-message-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
style: 'display: contents;' style: 'display: contents;'
} }
@@ -82,6 +84,16 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
readonly imageOpened = output<Attachment>(); readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>(); readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>(); readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
/**
* Emitted when the user scrolls up past the in-store window and the
* component needs the parent to fetch an older page from the DB.
*/
readonly loadOlderRequested = output<{ beforeTimestamp: number; limit: number }>();
/** True while a DB-backed older-page fetch dispatched by the parent is in flight. */
readonly loadingOlder = input(false);
/** True once the parent has paginated all the way back to the start of DB history. */
readonly conversationExhausted = input(false);
private readonly PAGE_SIZE = 50; private readonly PAGE_SIZE = 50;
@@ -141,19 +153,65 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return lookup; return lookup;
}); });
private initialScrollObserver: MutationObserver | null = null; /**
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null; * O(1) index of messages by id, built once per `allMessages()` change.
* Used by `findRepliedMessage` so each rendered row doing a reply lookup
* costs a Map.get instead of an Array.find over the full message list.
*/
private readonly messagesById = computed<ReadonlyMap<string, Message>>(() => {
const index = new Map<string, Message>();
for (const message of this.allMessages()) {
index.set(message.id, message);
}
return index;
});
private bottomScrollObserver: MutationObserver | null = null;
private bottomScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null; private boundOnImageLoad: (() => void) | null = null;
private localSendScrollPending = false;
private localSendScrollTimer: ReturnType<typeof setTimeout> | null = null;
private isAutoScrolling = false; private isAutoScrolling = false;
private lastMessageCount = 0; private lastMessageCount = 0;
private initialScrollPending = true; private initialScrollPending = true;
private prismHighlightScheduled = false; private prismHighlightScheduled = false;
/**
* Set when an older-page DB fetch is in flight. While true, the
* `onMessagesChanged` effect treats incoming message-count growth as a
* prepend (older history arriving) and preserves the user's scroll
* position instead of running sticky-bottom / new-messages-indicator
* logic.
*/
private pendingOlderFetchScrollHeight: number | null = null;
private readonly onConversationChanged = effect(() => { private readonly onConversationChanged = effect(() => {
void this.conversationKey(); void this.conversationKey();
this.resetScrollingState(); this.resetScrollingState();
}); });
/**
* Clears the in-flight older-fetch flag when the parent reports the
* load has finished (regardless of how many rows were returned, even
* zero). Without this, `loadingMore` would stick on if the DB had no
* rows older than the cursor.
*/
private readonly onLoadingOlderChanged = effect(() => {
const inFlight = this.loadingOlder();
if (!inFlight && this.pendingOlderFetchScrollHeight !== null) {
// If onMessagesChanged already consumed the pending state because
// rows arrived, this is a no-op; otherwise we clear it now.
queueMicrotask(() => {
if (this.pendingOlderFetchScrollHeight !== null) {
this.pendingOlderFetchScrollHeight = null;
this.loadingMore.set(false);
}
});
}
});
private readonly onMessagesChanged = effect(() => { private readonly onMessagesChanged = effect(() => {
const currentCount = this.channelMessages().length; const currentCount = this.channelMessages().length;
const element = this.messagesContainer?.nativeElement; const element = this.messagesContainer?.nativeElement;
@@ -168,12 +226,49 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return; return;
} }
// Handle older-history backfill: messages were prepended, not appended.
// Reveal the new rows by widening the display window, and preserve the
// user's visual scroll position across the height change. We skip the
// sticky-bottom / new-messages-indicator logic entirely for this path.
if (this.pendingOlderFetchScrollHeight !== null && currentCount > this.lastMessageCount) {
const previousScrollHeight = this.pendingOlderFetchScrollHeight;
const previousScrollTop = element.scrollTop;
const newlyLoaded = currentCount - this.lastMessageCount;
this.pendingOlderFetchScrollHeight = null;
this.displayLimit.update((limit) => limit + newlyLoaded);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const container = this.messagesContainer?.nativeElement;
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = previousScrollTop + (newScrollHeight - previousScrollHeight);
}
this.loadingMore.set(false);
});
});
this.lastMessageCount = currentCount;
return;
}
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight; const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount; const newMessages = currentCount > this.lastMessageCount;
const forceLocalSendScroll = this.shouldForceLocalSendScroll();
if (newMessages) { if (newMessages) {
if (distanceFromBottom <= 300) { if (forceLocalSendScroll || distanceFromBottom <= 300) {
this.scheduleScrollToBottomSmooth(); if (forceLocalSendScroll) {
this.clearLocalSendScrollPending();
this.scheduleScrollToBottomAfterRender(true);
} else {
this.scheduleScrollToBottomSmooth();
}
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} else { } else {
queueMicrotask(() => this.showNewMessagesBar.set(true)); queueMicrotask(() => this.showNewMessagesBar.set(true));
@@ -198,7 +293,8 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.isAutoScrolling = false; this.isAutoScrolling = false;
}); });
this.startInitialScrollWatch(); this.clearLocalSendScrollPending();
this.startBottomScrollWatch();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length; this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight(); this.scheduleCodeHighlight();
@@ -214,14 +310,15 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
this.clearLocalSendScrollPending();
} }
findRepliedMessage(messageId?: string | null): Message | undefined { findRepliedMessage(messageId?: string | null): Message | undefined {
if (!messageId) if (!messageId)
return undefined; return undefined;
return this.allMessages().find((message) => message.id === messageId); return this.messagesById().get(messageId);
} }
onScroll(): void { onScroll(): void {
@@ -237,36 +334,72 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} }
if (this.initialScrollObserver) { if (this.bottomScrollObserver) {
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
} }
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) { if (element.scrollTop < 150 && !this.loadingMore()) {
this.loadMore(); const canFetchOlderFromDb =
!this.hasMoreMessages()
&& !this.conversationExhausted()
&& !this.loadingOlder()
&& this.channelMessages().length > 0;
if (this.hasMoreMessages() || canFetchOlderFromDb) {
this.loadMore();
}
} }
} }
loadMore(): void { loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages()) if (this.loadingMore())
return; return;
this.loadingMore.set(true); // Case 1: there are still in-store messages above the rendered window.
// Just widen the display window and preserve scroll position.
if (this.hasMoreMessages()) {
this.loadingMore.set(true);
const element = this.messagesContainer?.nativeElement; const element = this.messagesContainer?.nativeElement;
const previousScrollHeight = element?.scrollHeight ?? 0; const previousScrollHeight = element?.scrollHeight ?? 0;
this.displayLimit.update((limit) => limit + this.PAGE_SIZE); this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (element) { requestAnimationFrame(() => {
const newScrollHeight = element.scrollHeight; if (element) {
const newScrollHeight = element.scrollHeight;
element.scrollTop += newScrollHeight - previousScrollHeight; element.scrollTop += newScrollHeight - previousScrollHeight;
} }
this.loadingMore.set(false); this.loadingMore.set(false);
});
}); });
return;
}
// Case 2: in-store window is exhausted. Ask the parent to fetch the
// next older page from the DB. The parent dispatches loadOlderMessages
// and the resulting store update is handled by onMessagesChanged via
// pendingOlderFetchScrollHeight (prepend-aware scroll preservation).
if (this.loadingOlder() || this.conversationExhausted())
return;
const all = this.channelMessages();
if (all.length === 0)
return;
const oldest = all[0];
const element = this.messagesContainer?.nativeElement;
this.loadingMore.set(true);
this.pendingOlderFetchScrollHeight = element?.scrollHeight ?? 0;
this.loadOlderRequested.emit({
beforeTimestamp: oldest.timestamp,
limit: this.PAGE_SIZE
}); });
} }
@@ -275,6 +408,13 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
} }
scrollToBottomAfterLocalSend(): void {
this.localSendScrollPending = true;
this.showNewMessagesBar.set(false);
this.scheduleScrollToBottomAfterRender(true);
this.armLocalSendScrollTimeout();
}
scrollToMessage(messageId: string): void { scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement; const container = this.messagesContainer?.nativeElement;
@@ -336,54 +476,44 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
private resetScrollingState(): void { private resetScrollingState(): void {
this.initialScrollPending = true; this.initialScrollPending = true;
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
this.clearLocalSendScrollPending();
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = 0; this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE); this.displayLimit.set(this.PAGE_SIZE);
this.pendingOlderFetchScrollHeight = null;
this.loadingMore.set(false);
} }
private startInitialScrollWatch(): void { private startBottomScrollWatch(): void {
this.stopInitialScrollWatch(); this.stopBottomScrollWatch();
const element = this.messagesContainer?.nativeElement; const element = this.messagesContainer?.nativeElement;
if (!element) if (!element)
return; return;
const snapToBottom = () => { this.bottomScrollObserver = new MutationObserver(() => {
const container = this.messagesContainer?.nativeElement; requestAnimationFrame(() => this.scrollToBottomInstant());
if (!container)
return;
this.isAutoScrolling = true;
container.scrollTop = container.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
};
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snapToBottom);
}); });
this.initialScrollObserver.observe(element, { this.bottomScrollObserver.observe(element, {
childList: true, childList: true,
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ['src'] attributeFilter: ['src']
}); });
this.boundOnImageLoad = () => requestAnimationFrame(snapToBottom); this.boundOnImageLoad = () => requestAnimationFrame(() => this.scrollToBottomInstant());
element.addEventListener('load', this.boundOnImageLoad, true); element.addEventListener('load', this.boundOnImageLoad, true);
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000); this.bottomScrollTimer = setTimeout(() => this.stopBottomScrollWatch(), 5000);
} }
private stopInitialScrollWatch(): void { private stopBottomScrollWatch(): void {
if (this.initialScrollObserver) { if (this.bottomScrollObserver) {
this.initialScrollObserver.disconnect(); this.bottomScrollObserver.disconnect();
this.initialScrollObserver = null; this.bottomScrollObserver = null;
} }
if (this.boundOnImageLoad && this.messagesContainer) { if (this.boundOnImageLoad && this.messagesContainer) {
@@ -392,12 +522,41 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
this.boundOnImageLoad = null; this.boundOnImageLoad = null;
} }
if (this.initialScrollTimer) { if (this.bottomScrollTimer) {
clearTimeout(this.initialScrollTimer); clearTimeout(this.bottomScrollTimer);
this.initialScrollTimer = null; this.bottomScrollTimer = null;
} }
} }
private armLocalSendScrollTimeout(): void {
if (this.localSendScrollTimer) {
clearTimeout(this.localSendScrollTimer);
}
this.localSendScrollTimer = setTimeout(() => {
this.localSendScrollPending = false;
this.localSendScrollTimer = null;
}, 1000);
}
private clearLocalSendScrollPending(): void {
this.localSendScrollPending = false;
if (this.localSendScrollTimer) {
clearTimeout(this.localSendScrollTimer);
this.localSendScrollTimer = null;
}
}
private shouldForceLocalSendScroll(): boolean {
if (!this.localSendScrollPending)
return false;
const latestMessage = this.channelMessages().at(-1);
return !!latestMessage && latestMessage.senderId === this.currentUserId();
}
private getMessageDateTimestamp(message: Message): number { private getMessageDateTimestamp(message: Message): number {
return message.timestamp || getMessageTimestamp(message); return message.timestamp || getMessageTimestamp(message);
} }
@@ -424,6 +583,31 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
} }
} }
private scrollToBottomInstant(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
this.isAutoScrolling = true;
element.scrollTop = element.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
}
private scheduleScrollToBottomAfterRender(watchForLayoutChanges = false): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.scrollToBottomInstant();
if (watchForLayoutChanges) {
this.startBottomScrollWatch();
}
});
});
}
private scheduleScrollToBottomSmooth(): void { private scheduleScrollToBottomSmooth(): void {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => this.scrollToBottomSmooth()); requestAnimationFrame(() => this.scrollToBottomSmooth());

View File

@@ -4,27 +4,29 @@
aria-label="KLIPY GIF picker" aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)" style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
> >
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4"> @if (!isMobile()) {
<div> <div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div> <div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3> <div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<p class="mt-1 text-sm text-muted-foreground"> <h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }} <p class="mt-1 text-sm text-muted-foreground">
</p> {{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</div> </p>
</div>
<button <button
type="button" type="button"
(click)="close()" (click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker" aria-label="Close GIF picker"
> >
<ng-icon <ng-icon
name="lucideX" name="lucideX"
class="h-4 w-4" class="h-4 w-4"
/> />
</button> </button>
</div> </div>
}
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4"> <div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
<label class="relative block"> <label class="relative block">
@@ -37,7 +39,7 @@
type="text" type="text"
[ngModel]="searchQuery" [ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)" (ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY" [placeholder]="isMobile() ? 'Search KLIPY and add a gif to the chat' : 'Search KLIPY'"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary" class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/> />
</label> </label>
@@ -80,12 +82,14 @@
</div> </div>
</div> </div>
} @else { } @else {
<div class="columns-[12rem] gap-4"> <div [class]="isMobile() ? 'grid grid-cols-2 gap-2' : 'columns-[12rem] gap-4'">
@for (gif of results(); track gif.id) { @for (gif of results(); track gif.id) {
<button <button
type="button" type="button"
(click)="selectGif(gif)" (click)="selectGif(gif)"
class="group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30" [class]="isMobile()
? 'group block w-full overflow-hidden rounded-xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'
: 'group mx-auto mb-4 inline-block w-full max-w-[15.5rem] break-inside-avoid align-top overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30'"
> >
<div <div
class="relative flex items-center justify-center overflow-hidden bg-secondary/30" class="relative flex items-center justify-center overflow-hidden bg-secondary/30"
@@ -104,30 +108,55 @@
KLIPY KLIPY
</span> </span>
</div> </div>
<div class="px-3 py-2"> @if (!isMobile()) {
<p class="truncate text-xs font-medium text-foreground"> <div class="px-3 py-2">
{{ gif.title || 'KLIPY GIF' }} <p class="truncate text-xs font-medium text-foreground">
</p> {{ gif.title || 'KLIPY GIF' }}
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p> </p>
</div> <p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
}
</button> </button>
} }
</div> </div>
@if (isMobile() && hasNext()) {
<div class="mt-3 flex justify-center">
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/80 bg-background/60 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
[attr.aria-label]="loading() ? 'Loading more GIFs' : 'Load more GIFs'"
>
@if (loading()) {
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
} @else {
<ng-icon
name="lucideChevronDown"
class="h-5 w-5"
/>
}
</button>
</div>
}
} }
</div> </div>
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4"> @if (!isMobile()) {
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p> <div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) { @if (hasNext()) {
<button <button
type="button" type="button"
(click)="loadMore()" (click)="loadMore()"
[disabled]="loading()" [disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
> >
{{ loading() ? 'Loading...' : 'Load more' }} {{ loading() ? 'Loading...' : 'Load more' }}
</button> </button>
} }
</div> </div>
}
</div> </div>

View File

@@ -17,6 +17,7 @@ import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { import {
lucideChevronDown,
lucideImage, lucideImage,
lucideSearch, lucideSearch,
lucideX lucideX
@@ -24,6 +25,7 @@ import {
import { KlipyGif, KlipyService } from '../../application/services/klipy.service'; import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory'; import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive'; import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
import { ViewportService } from '../../../../core/platform';
const KLIPY_CARD_MIN_WIDTH = 140; const KLIPY_CARD_MIN_WIDTH = 140;
const KLIPY_CARD_MAX_WIDTH = 248; const KLIPY_CARD_MAX_WIDTH = 248;
@@ -42,6 +44,7 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({
lucideChevronDown,
lucideImage, lucideImage,
lucideSearch, lucideSearch,
lucideX lucideX
@@ -58,6 +61,8 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
private currentPage = 1; private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null; private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0; private requestId = 0;

View File

@@ -0,0 +1,18 @@
# Direct Call Domain
Direct calls coordinate private voice sessions started from people cards, direct-message headers, or active-call rail icons. The domain owns call session state and call-control events; media capture, camera, screen sharing, playback, and voice activity stay in the existing voice and screen-share domains.
## Flow
1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session.
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
3. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. The ring stops when the recipient joins, declines, leaves, or the call ends.
4. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
5. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
6. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
8. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification.
Two-person calls use the one-to-one direct-message conversation id as their call id. Converted group calls keep the original call id for media routing but point `conversationId` at the new group chat so active streams stay connected while the chat history boundary changes.

View File

@@ -0,0 +1,598 @@
import {
Injector,
runInInjectionContext,
signal,
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
ɵEffectScheduler as EffectScheduler
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
ChatEvent,
DirectMessageParticipant,
User
} from '../../../../shared-kernel';
import type { DirectMessageConversation } from '../../../direct-message';
import type { DirectCallSession } from '../../domain/models/direct-call.model';
import { DirectCallService } from './direct-call.service';
const alice = createUser('alice', 'Alice');
const bob = createUser('bob', 'Bob');
const charlie = createUser('charlie', 'Charlie');
describe('DirectCallService', () => {
it('only keeps sessions visible while a participant is joined', () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
expect(context.service.hasOngoingActivity(createSession('calling', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('ringing', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('connected', true))).toBe(true);
expect(context.service.hasOngoingActivity(createSession('connected', false))).toBe(false);
expect(context.service.hasOngoingActivity(createSession('ended', true))).toBe(false);
});
it('keeps a locally left call visible only until the last peer leaves', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', true);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
context.service.leaveCall(session.callId);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
context.directCallEvents.next(createCallEvent('leave', bob, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
});
it('hides an incoming call after the last joined participant leaves before answer', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(1));
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob');
context.directCallEvents.next(createCallEvent('leave', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.visibleActiveSessions()).toHaveLength(0));
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
expect(context.service.incomingCall()).toBeNull();
});
it('suppresses incoming call audio and modal state while do not disturb is active', async () => {
const busyBob = { ...bob, status: 'busy' as const };
const context = createServiceContext({ currentUser: busyBob, allUsers: [alice, busyBob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).not.toBeNull());
expect(context.audio.playLoop).not.toHaveBeenCalled();
await vi.waitFor(() => expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call));
expect(context.service.incomingCall()).toBeNull();
});
it('ignores incoming call events when the current user is not a participant', async () => {
const context = createServiceContext({ currentUser: charlie, allUsers: [alice, bob, charlie] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')).toBeNull());
expect(context.audio.playLoop).not.toHaveBeenCalled();
expect(context.directMessages.createConversation).not.toHaveBeenCalled();
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
});
it('answers an incoming call from the modal action', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
await context.service.answerIncomingCall('dm-alice-bob');
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
expect(context.service.incomingCall()).toBeNull();
});
it('declines an incoming call from the modal action', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
context.service.declineIncomingCall('dm-alice-bob');
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('alice', expect.objectContaining({
directCall: expect.objectContaining({
action: 'leave',
callId: 'dm-alice-bob'
}),
type: 'direct-call'
}));
expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended');
expect(context.service.incomingCall()).toBeNull();
});
it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', true);
session.participants.alice.joined = false;
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startCall(bob);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.service.joinCall).toHaveBeenCalledWith('dm-alice-bob');
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-alice-bob']);
});
it('reuses an existing group call by conversation id instead of creating a duplicate call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
bob,
charlie
] });
const session = createGroupSession('dm-original-call', 'dm-group-live', [
alice,
bob,
charlie
]);
const conversation = createGroupConversation('dm-group-live', [
alice,
bob,
charlie
]);
session.participants.alice.joined = false;
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
context.service.joinCall = vi.fn(async () => undefined);
await context.service.startConversationCall(conversation);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.service.joinCall).toHaveBeenCalledWith('dm-original-call');
expect(context.directMessages.createGroupConversation).not.toHaveBeenCalled();
expect(context.delivery.sendCallEvent).not.toHaveBeenCalled();
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-original-call']);
});
it('leaves a joined call before joining a different call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
bob,
charlie
] });
const firstSession = createSession('connected', true);
const nextSession = createDirectSession('dm-alice-charlie', alice, charlie, 'connected', false);
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(firstSession);
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(nextSession);
await context.service.joinCall(nextSession.callId);
expect(context.service.sessionById(firstSession.callId)?.participants.alice.joined).toBe(false);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
directCall: expect.objectContaining({
action: 'leave',
callId: firstSession.callId
}),
type: 'direct-call'
}));
});
it('disconnects the current voice channel before joining a call', async () => {
const voiceConnectedAlice: User = {
...alice,
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
roomId: 'voice-room-1',
serverId: 'server-1'
}
};
const context = createServiceContext({ currentUser: voiceConnectedAlice, allUsers: [voiceConnectedAlice, bob] });
const session = createSession('connected', false);
session.participants.bob.joined = true;
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession(session);
await context.service.joinCall(session.callId);
expect(context.voice.stopVoiceHeartbeat).toHaveBeenCalled();
expect(context.voice.disableVoice).toHaveBeenCalled();
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'voice-state',
voiceState: expect.objectContaining({
isConnected: false,
roomId: 'voice-room-1',
serverId: 'server-1'
})
}));
expect(context.voiceSession.endSession).toHaveBeenCalled();
});
it('starts group calls by keeping the rail-visible call session and ringing every other participant', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [
alice,
bob,
charlie
] });
const conversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
]);
context.service.joinCall = vi.fn(async (callId: string) => {
const session = context.service.sessionById(callId);
if (!session) {
return;
}
(context.service as DirectCallService & { upsertSession: (nextSession: DirectCallSession) => void }).upsertSession({
...session,
status: 'connected',
participants: {
...session.participants,
alice: {
...session.participants.alice,
joined: true
}
}
});
});
await context.service.startConversationCall(conversation);
expect(context.service.visibleActiveSessions()).toHaveLength(1);
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('bob', expect.objectContaining({
directCall: expect.objectContaining({
action: 'ring',
callId: 'dm-group-test'
}),
type: 'direct-call'
}));
expect(context.delivery.sendCallEvent).toHaveBeenCalledWith('charlie', expect.objectContaining({
directCall: expect.objectContaining({
action: 'ring',
callId: 'dm-group-test'
}),
type: 'direct-call'
}));
expect(context.router.navigate).toHaveBeenCalledWith(['/call', 'dm-group-test']);
});
});
interface ServiceContextOptions {
allUsers: User[];
currentUser: User;
}
interface ServiceContext {
audio: {
playLoop: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
delivery: {
sendCallEvent: ReturnType<typeof vi.fn>;
};
directCallEvents: Subject<ChatEvent>;
directMessages: {
createConversation: ReturnType<typeof vi.fn>;
createGroupConversation: ReturnType<typeof vi.fn>;
openConversation: ReturnType<typeof vi.fn>;
};
router: {
navigate: ReturnType<typeof vi.fn>;
};
service: DirectCallService;
voice: {
broadcastMessage: ReturnType<typeof vi.fn>;
disableVoice: ReturnType<typeof vi.fn>;
stopVoiceHeartbeat: ReturnType<typeof vi.fn>;
};
voiceSession: {
endSession: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const currentUser = signal<User | null>(options.currentUser);
const allUsers = signal<User[]>(options.allUsers);
const directCallEvents = new Subject<ChatEvent>();
const router = {
navigate: vi.fn(async () => true)
};
const store = {
dispatch: vi.fn(),
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return currentUser;
}
if (selector === selectAllUsers) {
return allUsers;
}
throw new Error('Unexpected selector requested by DirectCallService test.');
})
};
const directMessages = {
createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)),
createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
...createGroupConversation(conversationId, participants.map(participantToUser)),
title
})),
openConversation: vi.fn(async () => undefined)
};
const delivery = {
directCallEvents$: directCallEvents.asObservable(),
sendCallEvent: vi.fn(() => true)
};
const audio = {
playLoop: vi.fn(),
stop: vi.fn()
};
const voice = {
broadcastMessage: vi.fn(),
disableVoice: vi.fn(),
ensureSignalingConnected: vi.fn(async () => true),
isDeafened: vi.fn(() => false),
isMuted: vi.fn(() => false),
setLocalStream: vi.fn(async () => undefined),
startVoiceHeartbeat: vi.fn(),
stopVoiceHeartbeat: vi.fn(),
syncOutgoingVoiceRouting: vi.fn(),
toggleMute: vi.fn()
};
const voiceSession = {
endSession: vi.fn()
};
const injector = Injector.create({
providers: [
{
provide: ChangeDetectionScheduler,
useValue: {
notify: vi.fn()
}
},
{
provide: EffectScheduler,
useValue: {
add: vi.fn(),
flush: vi.fn(),
remove: vi.fn(),
schedule: vi.fn()
}
},
{
provide: DirectMessageService,
useValue: directMessages
},
{
provide: NotificationAudioService,
useValue: audio
},
{
provide: PeerDeliveryService,
useValue: delivery
},
{
provide: Router,
useValue: router
},
{
provide: Store,
useValue: store
},
{
provide: VoiceActivityService,
useValue: {
trackLocalMic: vi.fn(),
untrackLocalMic: vi.fn()
}
},
{
provide: VoiceConnectionFacade,
useValue: voice
},
{
provide: VoiceSessionFacade,
useValue: voiceSession
},
{
provide: VoicePlaybackService,
useValue: {
playPendingStreams: vi.fn(),
teardownAll: vi.fn()
}
}
]
});
return {
audio,
delivery,
directCallEvents,
directMessages,
router,
service: runInInjectionContext(injector, () => new DirectCallService()),
voice,
voiceSession
};
}
function createCallEvent(action: 'leave' | 'ring', sender: User, participantIds: string[]): ChatEvent {
return {
type: 'direct-call',
directCall: {
action,
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
sender: toParticipant(sender),
participantIds,
participants: [alice, bob].map(toParticipant)
}
};
}
function createSession(status: DirectCallSession['status'], joined: boolean): DirectCallSession {
return {
callId: 'dm-alice-bob',
conversationId: 'dm-alice-bob',
createdAt: 10,
initiatorId: 'alice',
participantIds: ['alice', 'bob'],
participants: {
alice: {
userId: 'alice',
profile: toParticipant(alice),
joined
},
bob: {
userId: 'bob',
profile: toParticipant(bob),
joined: false
}
},
status
};
}
function createDirectSession(
callId: string,
currentUser: User,
peer: User,
status: DirectCallSession['status'],
joined: boolean
): DirectCallSession {
const currentParticipant = toParticipant(currentUser);
const peerParticipant = toParticipant(peer);
return {
callId,
conversationId: callId,
createdAt: 10,
initiatorId: currentParticipant.userId,
participantIds: [currentParticipant.userId, peerParticipant.userId],
participants: {
[currentParticipant.userId]: {
userId: currentParticipant.userId,
profile: currentParticipant,
joined
},
[peerParticipant.userId]: {
userId: peerParticipant.userId,
profile: peerParticipant,
joined: false
}
},
status
};
}
function createGroupSession(callId: string, conversationId: string, users: User[]): DirectCallSession {
const participants = users.map(toParticipant);
return {
callId,
conversationId,
createdAt: 10,
initiatorId: participants[0].userId,
participantIds: participants.map((participant) => participant.userId),
participants: Object.fromEntries(participants.map((participant) => [
participant.userId,
{
userId: participant.userId,
profile: participant,
joined: false
}
])),
status: 'connected'
};
}
function createDirectConversation(currentUser: User, peer: User): DirectMessageConversation {
const participants = [toParticipant(currentUser), toParticipant(peer)];
const participantIds = participants.map((participant) => participant.userId).sort();
return {
id: `dm-${participantIds.join('-')}`,
kind: 'direct',
lastMessageAt: 10,
messages: [],
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
participants: participantIds,
unreadCount: 0
};
}
function createGroupConversation(conversationId: string, users: User[]): DirectMessageConversation {
const participants = users.map(toParticipant);
const participantIds = participants.map((participant) => participant.userId).sort();
return {
id: conversationId,
kind: 'group',
lastMessageAt: 10,
messages: [],
participantProfiles: Object.fromEntries(participants.map((participant) => [participant.userId, participant])),
participants: participantIds,
title: participants.map((participant) => participant.displayName).join(', '),
unreadCount: 0
};
}
function participantToUser(participant: DirectMessageParticipant): User {
return createUser(participant.userId, participant.displayName);
}
function toParticipant(user: User): DirectMessageParticipant {
return {
userId: user.oderId || user.id,
username: user.username,
displayName: user.displayName
};
}
function createUser(id: string, displayName: string): User {
return {
id,
oderId: id,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}

View File

@@ -0,0 +1,935 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import {
VoiceActivityService,
VoiceConnectionFacade,
VoicePlaybackService
} from '../../../voice-connection';
import { VoiceSessionFacade } from '../../../voice-session';
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
import type { DirectMessageConversation } from '../../../direct-message';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import {
DirectCallEventPayload,
DirectMessageParticipant,
User
} from '../../../../shared-kernel';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
import { toDirectMessageParticipant } from '../../../direct-message';
@Injectable({ providedIn: 'root' })
export class DirectCallService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly delivery = inject(PeerDeliveryService);
private readonly directMessages = inject(DirectMessageService);
private readonly audio = inject(NotificationAudioService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly playback = inject(VoicePlaybackService);
private readonly viewport = inject(ViewportService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
readonly visibleActiveSessions = computed(() => this.activeSessions().filter((session) => this.hasOngoingActivity(session)));
readonly incomingCall = computed<DirectCallSession | null>(() => {
if (this.isDoNotDisturb()) {
return null;
}
const meId = this.currentUserId();
if (!meId) {
return null;
}
return [...this.activeSessions()]
.sort((left, right) => right.createdAt - left.createdAt)
.find((session) => session.status === 'ringing'
&& this.currentSession()?.callId !== session.callId
&& !session.participants[meId]?.joined
&& this.hasConnectedParticipant(session)) ?? null;
});
readonly currentSession = signal<DirectCallSession | null>(null);
readonly hasActiveCall = computed(() => this.visibleActiveSessions().length > 0);
readonly mobileOverlaySession = computed(() => {
const callId = this.mobileOverlayCallId();
if (!callId) {
return null;
}
return this.visibleActiveSessions().find((session) => session.callId === callId) ?? null;
});
constructor() {
this.delivery.directCallEvents$.subscribe((event) => {
if (event.directCall) {
void this.handleIncomingCallEvent(event.directCall);
}
});
effect(() => {
const session = this.currentSession();
if (!session || session.status === 'ended') {
return;
}
const peerIds = this.remoteParticipantIds(session);
this.voice.syncOutgoingVoiceRouting(peerIds);
});
effect(() => {
if (this.incomingCall() && !this.isDoNotDisturb()) {
return;
}
this.audio.stop(AppSound.Call);
});
effect(() => {
if (this.mobileOverlayCallId() && !this.mobileOverlaySession()) {
this.mobileOverlayCallId.set(null);
}
});
}
sessionById(callId: string | null | undefined): DirectCallSession | null {
if (!callId) {
return null;
}
return this.sessionsSignal().find((session) => session.callId === callId) ?? null;
}
isCallingUser(user: User): boolean {
const userId = this.userKey(user);
return this.visibleActiveSessions().some((session) => session.participantIds.includes(userId));
}
isCallingConversation(conversationId: string | null | undefined): boolean {
if (!conversationId) {
return false;
}
return this.visibleActiveSessions().some((session) => session.callId === conversationId || session.conversationId === conversationId);
}
hasConnectedParticipant(session: DirectCallSession | null | undefined): boolean {
if (!session || session.status === 'ended') {
return false;
}
return Object.values(session.participants).some((participant) => participant.joined);
}
hasOngoingActivity(session: DirectCallSession | null | undefined): boolean {
return this.hasConnectedParticipant(session);
}
async startCall(user: User): Promise<DirectCallSession> {
const conversation = await this.directMessages.createConversation(user);
const me = this.requireCurrentUser();
const meParticipant = toDirectMessageParticipant(me);
const peerParticipant = toDirectMessageParticipant(user);
const participantIds = this.uniqueIds([meParticipant.userId, peerParticipant.userId]);
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
if (activeSession) {
return await this.rejoinLiveSession(activeSession);
}
const existing = this.sessionById(conversation.id);
const session = existing ?? this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
initiatorId: meParticipant.userId,
participantIds,
participants: [meParticipant, peerParticipant],
status: 'calling'
});
this.upsertSession(session);
this.currentSession.set(session);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.openCallView(session.callId);
return session;
}
async startConversationCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
if (this.isGroupConversation(conversation)) {
return await this.startGroupCall(conversation);
}
const meId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== meId);
if (!peerId) {
throw new Error('Direct message conversation has no recipient to call.');
}
const peer = this.userForParticipant(peerId) ?? participantToUser(this.participantFromConversation(conversation, peerId));
return await this.startCall(peer);
}
async openCall(callId: string): Promise<void> {
const session = this.sessionById(callId);
if (session?.conversationId) {
await this.directMessages.openConversation(session.conversationId);
}
this.currentSession.set(session);
}
async openCallView(callId: string): Promise<void> {
if (this.viewport.isMobile()) {
await this.openMobileCallOverlay(callId);
return;
}
await this.router.navigate(['/call', callId]);
}
async openMobileCallOverlay(callId: string): Promise<void> {
await this.openCall(callId);
this.mobileOverlayCallId.set(callId);
}
closeMobileCallOverlay(): void {
this.mobileOverlayCallId.set(null);
}
async answerIncomingCall(callId: string): Promise<void> {
const session = this.sessionById(callId);
if (!session || session.status === 'ended') {
return;
}
this.audio.stop(AppSound.Call);
this.currentSession.set(session);
await this.joinCall(callId);
await this.router.navigate(['/call', callId]);
}
declineIncomingCall(callId: string): void {
const session = this.sessionById(callId);
if (!session || session.status === 'ended') {
return;
}
const meId = this.currentUserId();
const nextSession = meId
? {
...this.markParticipantJoined(session, meId, false, 'ended'),
status: 'ended' as const
}
: {
...session,
status: 'ended' as const
};
this.audio.stop(AppSound.Call);
if (meId) {
this.broadcastCallEvent('leave', session);
}
this.upsertSession(nextSession);
if (this.currentSession()?.callId === callId) {
this.currentSession.set(null);
}
}
async joinCall(callId: string, notifyPeers = true): Promise<void> {
const session = this.sessionById(callId);
const me = this.requireCurrentUser();
const meId = this.userKey(me);
if (!session) {
return;
}
this.leaveOtherJoinedCalls(callId);
this.leaveCurrentVoiceTargetForCall(callId);
this.audio.stop(AppSound.Call);
const ok = await this.voice.ensureSignalingConnected();
if (!ok || !navigator.mediaDevices?.getUserMedia) {
return;
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: false
}
});
await this.voice.setLocalStream(stream);
this.voiceActivity.trackLocalMic(meId, stream);
this.voice.startVoiceHeartbeat(session.callId, session.callId);
this.updateLocalVoiceState(session, true);
this.playback.playPendingStreams({
isConnected: true,
outputVolume: 1,
isDeafened: this.voice.isDeafened()
});
const nextSession = this.markParticipantJoined(session, meId, true, 'connected');
this.upsertSession(nextSession);
this.currentSession.set(nextSession);
if (notifyPeers) {
this.broadcastCallEvent('join', nextSession);
}
}
leaveCall(callId: string, endForEveryone = false): void {
const session = this.sessionById(callId);
if (!session) {
return;
}
this.leaveJoinedSession(session, endForEveryone);
}
leaveCurrentJoinedCall(exceptCallId?: string): void {
for (const session of this.sessionsSignal()) {
if (session.callId === exceptCallId || !this.isCurrentUserJoined(session)) {
continue;
}
this.leaveJoinedSession(session);
}
}
private leaveJoinedSession(session: DirectCallSession, endForEveryone = false): void {
const action = endForEveryone ? 'end' : 'leave';
const nextSession = this.markCurrentUserLeft(session, endForEveryone);
this.audio.stop(AppSound.Call);
this.broadcastCallEvent(action, nextSession);
this.stopLocalMedia(nextSession);
this.upsertSession(nextSession);
this.currentSession.set(null);
}
async inviteUser(callId: string, user: User): Promise<void> {
const session = this.sessionById(callId);
if (!session) {
return;
}
const participant = toDirectMessageParticipant(user);
const nextSession = this.createSession({
...session,
participantIds: this.uniqueIds([...session.participantIds, participant.userId]),
participants: [...Object.values(session.participants).map((entry) => entry.profile), participant],
status: session.status
});
const convertedSession = await this.convertToGroupConversationIfNeeded(this.preserveJoinedParticipants(session, nextSession));
this.upsertSession(convertedSession);
this.currentSession.set(convertedSession);
this.broadcastCallEvent('update', convertedSession, [participant.userId]);
this.sendCallEvent(participant.userId, 'ring', convertedSession);
}
remoteParticipantIds(session: DirectCallSession): string[] {
const meId = this.currentUserId();
return session.participantIds.filter((participantId) => participantId !== meId);
}
userForParticipant(participantId: string): User | null {
const known = this.users().find((user) => user.id === participantId || user.oderId === participantId || user.peerId === participantId);
if (known) {
return known;
}
const participant = this.currentSession()?.participants[participantId]?.profile;
return participant ? participantToUser(participant) : null;
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
const meId = this.currentUserId();
if (!meId || payload.sender.userId === meId) {
return;
}
if (!this.callPayloadIncludesParticipant(payload, meId)) {
return;
}
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
const incomingSession = this.createSession({
callId: payload.callId,
conversationId: payload.conversationId,
createdAt: payload.createdAt,
initiatorId: existing?.initiatorId ?? payload.sender.userId,
participantIds: this.uniqueIds([
...payload.participantIds,
meId,
payload.sender.userId
]),
participants,
status: this.resolveIncomingStatus(payload.action, existing?.status)
});
const preservedSession = existing ? this.preserveJoinedParticipants(existing, incomingSession) : incomingSession;
const session = this.applyIncomingParticipantState(preservedSession, payload);
this.upsertSession(session);
this.currentSession.set(this.currentSession()?.callId === session.callId ? session : this.currentSession());
this.markRemoteVoiceState(payload.sender.userId, session, payload.action === 'join');
if (payload.action === 'update') {
await this.ensureCallConversation(session);
return;
}
if (payload.action === 'ring') {
await this.ensureCallConversation(session);
if (this.shouldAlertIncomingCall(session)) {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
if (this.shouldAlertIncomingCall(session)) {
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
}
return;
}
this.audio.stop(AppSound.Call);
if (payload.action === 'end') {
if (this.currentSession()?.callId === payload.callId) {
this.stopLocalMedia(session);
this.currentSession.set(null);
}
}
}
private async startGroupCall(conversation: DirectMessageConversation): Promise<DirectCallSession> {
const me = this.requireCurrentUser();
const meParticipant = toDirectMessageParticipant(me);
const participantIds = this.uniqueIds([...conversation.participants, meParticipant.userId]);
const conversationParticipants = participantIds.map((participantId) => this.participantFromConversation(conversation, participantId));
const participants = this.uniqueParticipants([meParticipant, ...conversationParticipants]);
const activeSession = this.findLiveSessionForParticipants(participantIds, conversation.id);
if (activeSession) {
return await this.rejoinLiveSession(activeSession);
}
const existing = this.sessionById(conversation.id);
const session = existing && existing.status !== 'ended'
? existing
: this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
initiatorId: meParticipant.userId,
participantIds,
participants,
status: 'calling'
});
this.upsertSession(session);
this.currentSession.set(session);
await this.joinCall(session.callId, false);
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
await this.router.navigate(['/call', session.callId]);
return this.sessionById(session.callId) ?? session;
}
private async rejoinLiveSession(session: DirectCallSession): Promise<DirectCallSession> {
this.upsertSession(session);
this.currentSession.set(session);
if (!this.isCurrentUserJoined(session)) {
await this.joinCall(session.callId);
}
const nextSession = this.sessionById(session.callId) ?? session;
await this.router.navigate(['/call', nextSession.callId]);
return nextSession;
}
private leaveOtherJoinedCalls(callId: string): void {
this.leaveCurrentJoinedCall(callId);
}
private leaveCurrentVoiceTargetForCall(callId: string): void {
const user = this.currentUser();
const voiceState = user?.voiceState;
if (!voiceState?.isConnected || (voiceState.roomId === callId && voiceState.serverId === callId)) {
return;
}
const userId = user?.id;
const userKey = user ? this.userKey(user) : undefined;
this.voice.stopVoiceHeartbeat();
if (userKey) {
this.voiceActivity.untrackLocalMic(userKey);
}
this.voice.disableVoice();
this.playback.teardownAll();
this.voiceSession.endSession();
if (userId) {
this.store.dispatch(UsersActions.updateVoiceState({
userId,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
}));
}
this.voice.broadcastMessage({
type: 'voice-state',
oderId: userKey,
displayName: user?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: voiceState.roomId,
serverId: voiceState.serverId
}
});
}
private async ensureConversation(sender: DirectMessageParticipant): Promise<void> {
await this.directMessages.createConversation(participantToUser(sender));
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private participantFromConversation(conversation: DirectMessageConversation, participantId: string): DirectMessageParticipant {
const knownUser = this.userForParticipant(participantId);
const profile = conversation.participantProfiles[participantId];
if (knownUser) {
return toDirectMessageParticipant(knownUser);
}
return profile ?? {
userId: participantId,
username: participantId,
displayName: participantId
};
}
private resolveIncomingStatus(action: DirectCallEventPayload['action'], currentStatus?: DirectCallSession['status']): DirectCallSession['status'] {
if (action === 'ring') {
return currentStatus === 'connected' ? 'connected' : 'ringing';
}
if (action === 'join') {
return 'connected';
}
if (action === 'end') {
return 'ended';
}
if (action === 'update') {
return currentStatus ?? 'ringing';
}
if (action === 'leave') {
return currentStatus === 'ringing' ? 'ringing' : 'connected';
}
return currentStatus ?? 'ringing';
}
private applyIncomingParticipantState(session: DirectCallSession, payload: DirectCallEventPayload): DirectCallSession {
if (payload.action === 'ring' || payload.action === 'join') {
return this.markParticipantJoined(session, payload.sender.userId, true, payload.action === 'join' ? 'connected' : session.status);
}
if (payload.action === 'leave') {
const nextSession = this.markParticipantJoined(session, payload.sender.userId, false, session.status);
return this.hasConnectedParticipant(nextSession)
? nextSession
: {
...nextSession,
status: 'ended'
};
}
if (payload.action === 'end') {
return {
...session,
status: 'ended'
};
}
return session;
}
private sendCallEvent(recipientId: string, action: DirectCallEventPayload['action'], session: DirectCallSession): void {
const me = this.requireCurrentUser();
this.delivery.sendCallEvent(recipientId, {
type: 'direct-call',
directCall: {
action,
callId: session.callId,
conversationId: session.conversationId,
createdAt: session.createdAt,
sender: toDirectMessageParticipant(me),
participantIds: session.participantIds,
participants: Object.values(session.participants).map((participant) => participant.profile)
}
});
}
private broadcastCallEvent(action: DirectCallEventPayload['action'], session: DirectCallSession, excludedParticipantIds: string[] = []): void {
const excluded = new Set(excludedParticipantIds);
for (const participantId of this.remoteParticipantIds(session)) {
if (excluded.has(participantId)) {
continue;
}
this.sendCallEvent(participantId, action, session);
}
}
private async convertToGroupConversationIfNeeded(session: DirectCallSession): Promise<DirectCallSession> {
if (session.participantIds.length <= 2) {
return session;
}
const conversation = await this.directMessages.createGroupConversation(
Object.values(session.participants).map((participant) => participant.profile),
this.groupConversationTitle(session),
session.conversationId.startsWith('dm-group-') ? session.conversationId : undefined
);
return {
...session,
conversationId: conversation.id
};
}
private preserveJoinedParticipants(previousSession: DirectCallSession, nextSession: DirectCallSession): DirectCallSession {
return {
...nextSession,
participants: Object.fromEntries(Object.values(nextSession.participants).map((participant) => [
participant.userId,
{
...participant,
joined: previousSession.participants[participant.userId]?.joined ?? participant.joined
}
]))
};
}
private async ensureCallConversation(session: DirectCallSession): Promise<void> {
if (session.participantIds.length > 2) {
await this.directMessages.createGroupConversation(
Object.values(session.participants).map((participant) => participant.profile),
this.groupConversationTitle(session),
session.conversationId
);
return;
}
const sender = Object.values(session.participants)
.map((participant) => participant.profile)
.find((participant) => participant.userId !== this.currentUserId());
if (sender) {
await this.ensureConversation(sender);
}
}
private callParticipantsFromPayload(payload: DirectCallEventPayload): DirectMessageParticipant[] {
return this.uniqueParticipants([
payload.sender,
toDirectMessageParticipant(this.requireCurrentUser()),
...(payload.participants ?? []),
...payload.participantIds
.map((participantId) => this.userForParticipant(participantId))
.filter((user): user is User => !!user)
.map((user) => toDirectMessageParticipant(user))
]);
}
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
return payload.participantIds.includes(participantId)
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
}
private groupConversationTitle(session: DirectCallSession): string {
const names = Object.values(session.participants)
.map((participant) => participant.profile.displayName || participant.profile.username || participant.userId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
private createSession(input: {
callId: string;
conversationId: string;
createdAt: number;
initiatorId: string;
participantIds: string[];
participants: DirectMessageParticipant[];
status: DirectCallSession['status'];
}): DirectCallSession {
const participants = Object.fromEntries(this.uniqueParticipants(input.participants).map((participant) => [
participant.userId,
{
userId: participant.userId,
profile: participant,
joined: input.status === 'connected' && participant.userId === this.currentUserId()
}
]));
return {
callId: input.callId,
conversationId: input.conversationId,
createdAt: input.createdAt,
initiatorId: input.initiatorId,
participantIds: this.uniqueIds(input.participantIds),
participants,
status: input.status
};
}
private markParticipantJoined(
session: DirectCallSession,
participantId: string,
joined: boolean,
status: DirectCallSession['status']
): DirectCallSession {
const participant = session.participants[participantId];
return {
...session,
status,
participants: {
...session.participants,
...(participant
? {
[participantId]: {
...participant,
joined
}
}
: {})
}
};
}
private upsertSession(session: DirectCallSession): void {
this.sessionsSignal.update((sessions) => [...sessions.filter((entry) => entry.callId !== session.callId), session]);
}
private markCurrentUserLeft(session: DirectCallSession, endForEveryone: boolean): DirectCallSession {
const meId = this.currentUserId();
const locallyLeftSession = meId
? this.markParticipantJoined(session, meId, false, session.status)
: session;
return {
...locallyLeftSession,
status: endForEveryone || !this.hasConnectedParticipant(locallyLeftSession) ? 'ended' as const : 'connected' as const
};
}
private findLiveSessionForParticipants(participantIds: string[], conversationId?: string | null): DirectCallSession | null {
const normalizedParticipantIds = this.uniqueIds(participantIds).sort();
return this.visibleActiveSessions().find((session) => {
if (conversationId && (session.callId === conversationId || session.conversationId === conversationId)) {
return true;
}
const sessionParticipantIds = this.uniqueIds(session.participantIds).sort();
return sessionParticipantIds.length === normalizedParticipantIds.length
&& sessionParticipantIds.every((participantId, index) => participantId === normalizedParticipantIds[index]);
}) ?? null;
}
private isCurrentUserJoined(session: DirectCallSession): boolean {
const meId = this.currentUserId();
return !!meId && !!session.participants[meId]?.joined;
}
private stopLocalMedia(session: DirectCallSession): void {
const meId = this.currentUserId();
if (meId) {
this.voiceActivity.untrackLocalMic(meId);
}
this.voice.stopVoiceHeartbeat();
this.voice.disableVoice();
this.playback.teardownAll();
this.updateLocalVoiceState(session, false);
}
private updateLocalVoiceState(session: DirectCallSession, connected: boolean): void {
const user = this.currentUser();
if (!user?.id) {
return;
}
this.store.dispatch(UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: connected,
isMuted: connected ? this.voice.isMuted() : false,
isDeafened: connected ? this.voice.isDeafened() : false,
roomId: connected ? session.callId : undefined,
serverId: connected ? session.callId : undefined
}
}));
}
private markRemoteVoiceState(userId: string, session: DirectCallSession, connected: boolean): void {
this.store.dispatch(UsersActions.updateVoiceState({
userId,
voiceState: {
isConnected: connected,
isMuted: false,
isDeafened: false,
roomId: connected ? session.callId : undefined,
serverId: connected ? session.callId : undefined
}
}));
}
private shouldAlertIncomingCall(session: DirectCallSession): boolean {
return session.status !== 'connected' && !this.isDoNotDisturb();
}
private isDoNotDisturb(): boolean {
return this.currentUser()?.status === 'busy';
}
private async showIncomingNotification(displayName: string, callId: string): Promise<void> {
if (typeof Notification === 'undefined') {
return;
}
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
return;
}
const notification = new Notification('Incoming call', {
body: `${displayName} is calling you`
});
notification.onclick = () => {
window.focus();
void this.router.navigate(['/call', callId]);
};
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
private uniqueIds(ids: string[]): string[] {
return ids.filter((id, index) => !!id && ids.indexOf(id) === index);
}
private userKey(user: User): string {
return user.oderId || user.id;
}
private currentUserId(): string | null {
const user = this.currentUser();
return user ? this.userKey(user) : null;
}
private requireCurrentUser(): User {
const user = this.currentUser();
if (!user) {
throw new Error('Cannot use calls without a current user.');
}
return user;
}
}

View File

@@ -0,0 +1,37 @@
import type { DirectMessageParticipant, User } from '../../../../shared-kernel';
export type DirectCallStatus = 'calling' | 'ringing' | 'connected' | 'ended';
export interface DirectCallParticipant {
userId: string;
profile: DirectMessageParticipant;
joined: boolean;
}
export interface DirectCallSession {
callId: string;
conversationId: string;
createdAt: number;
initiatorId: string;
participantIds: string[];
participants: Record<string, DirectCallParticipant>;
status: DirectCallStatus;
}
export function participantToUser(participant: DirectMessageParticipant): User {
return {
id: participant.userId,
oderId: participant.userId,
username: participant.username,
displayName: participant.displayName,
description: participant.description,
avatarUrl: participant.avatarUrl,
avatarHash: participant.avatarHash,
avatarMime: participant.avatarMime,
avatarUpdatedAt: participant.avatarUpdatedAt,
profileUpdatedAt: participant.profileUpdatedAt,
status: 'online',
role: 'member',
joinedAt: Date.now()
};
}

View File

@@ -0,0 +1,73 @@
@if (session()) {
<div class="fixed inset-0 z-[120] bg-black/60 backdrop-blur-sm"></div>
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
<section
class="pointer-events-auto w-full max-w-sm rounded-lg border border-border bg-card shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="incoming-call-title"
>
<div class="flex flex-col items-center px-6 pb-6 pt-7 text-center">
<div class="relative">
@if (caller(); as callerUser) {
<app-user-avatar
[avatarUrl]="callerUser.avatarUrl"
[name]="callerUser.displayName || callerUser.username"
[showStatusBadge]="true"
[status]="callerUser.status"
size="xl"
/>
} @else {
<div class="grid h-16 w-16 place-items-center rounded-full bg-secondary text-xl font-semibold text-secondary-foreground">
{{ callerName().charAt(0).toUpperCase() }}
</div>
}
<div class="absolute -bottom-1 -right-1 grid h-7 w-7 place-items-center rounded-full border border-card bg-green-600 text-white shadow-lg">
<ng-icon
name="lucidePhone"
class="h-3.5 w-3.5"
/>
</div>
</div>
<p class="mt-5 text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Incoming call</p>
<h2
id="incoming-call-title"
class="mt-2 text-xl font-semibold text-foreground"
>
{{ callerName() }} is calling
</h2>
<p class="mt-1 text-sm text-muted-foreground">{{ callKindLabel() }}</p>
<div class="mt-6 grid w-full grid-cols-2 gap-3">
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-border bg-secondary px-4 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
(click)="decline()"
>
<ng-icon
name="lucidePhoneOff"
class="h-4 w-4"
/>
Decline
</button>
<button
type="button"
class="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-green-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-green-500 disabled:cursor-not-allowed disabled:opacity-70"
[disabled]="answering()"
(click)="answer()"
>
<ng-icon
name="lucidePhone"
class="h-4 w-4"
/>
Answer
</button>
</div>
</div>
</section>
</div>
}

View File

@@ -0,0 +1,109 @@
import {
Component,
HostListener,
computed,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneOff } from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../../shared';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { DirectCallService } from '../../application/services/direct-call.service';
import { DirectCallSession, participantToUser } from '../../domain/models/direct-call.model';
@Component({
selector: 'app-incoming-call-modal',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucidePhone,
lucidePhoneOff
})
],
templateUrl: './incoming-call-modal.component.html'
})
export class IncomingCallModalComponent {
readonly calls = inject(DirectCallService);
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly session = this.calls.incomingCall;
readonly answering = signal(false);
readonly caller = computed(() => {
const session = this.session();
if (!session) {
return null;
}
const callerId = this.callerIdFor(session);
const participant = callerId ? session.participants[callerId]?.profile : null;
return (callerId ? this.calls.userForParticipant(callerId) : null)
?? (participant ? participantToUser(participant) : null);
});
readonly callerName = computed(() => this.caller()?.displayName || 'Someone');
readonly callKindLabel = computed(() => {
const participantCount = this.session()?.participantIds.length ?? 0;
return participantCount > 2 ? `${participantCount} person call` : 'Direct call';
});
@HostListener('document:keydown.escape')
onEscape(): void {
this.decline();
}
async answer(): Promise<void> {
const session = this.session();
if (!session || this.answering()) {
return;
}
this.answering.set(true);
try {
await this.calls.answerIncomingCall(session.callId);
} finally {
this.answering.set(false);
}
}
decline(): void {
const session = this.session();
if (!session) {
return;
}
this.calls.declineIncomingCall(session.callId);
}
private callerIdFor(session: DirectCallSession): string | null {
const currentUserId = this.currentUserKey();
if (session.initiatorId && session.initiatorId !== currentUserId) {
return session.initiatorId;
}
return session.participantIds.find((participantId) => participantId !== currentUserId) ?? null;
}
private currentUserKey(): string | null {
const user = this.currentUser();
return user ? this.userKey(user) : null;
}
private userKey(user: User): string {
return user.oderId || user.id;
}
}

View File

@@ -0,0 +1,3 @@
export * from './application/services/direct-call.service';
export * from './domain/models/direct-call.model';
export * from './feature/incoming-call-modal/incoming-call-modal.component';

View File

@@ -1,6 +1,8 @@
# Direct Message Domain # Direct Message Domain
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel. Direct messages provide local, offline-safe one-to-one and small-group messaging over the existing WebRTC data channel, with a signaling relay fallback when no peer data channel is available but a route to the recipient is known.
The same `PeerDeliveryService` also exposes direct-call events for the `direct-call` domain so private calls can ring through either an open peer data channel or a known signaling route without adding a second recipient lookup path.
## Structure ## Structure
@@ -15,10 +17,13 @@ direct-message/
## Flow ## Flow
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`. 1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to the recipient's current peer id. 2. `PeerDeliveryService` tries to send a `direct-message` P2P event to every other participant's current peer id.
3. If the peer is connected, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`. 3. If no data channel is connected, `PeerDeliveryService` tries each participant's known signaling route before leaving the message queued.
4. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back. 4. If either transport sends, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
5. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event. 5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history.
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`. Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
@@ -28,6 +33,14 @@ The DM view reuses the chat domain's shared message list, composer, overlays, ma
Message edits, deletions, and reaction changes are stored locally and mirrored to the peer with `direct-message-mutation` events. Delivery state remains direct-message-owned and is exposed separately from the visible shared chat row UI. Message edits, deletions, and reaction changes are stored locally and mirrored to the peer with `direct-message-mutation` events. Delivery state remains direct-message-owned and is exposed separately from the visible shared chat row UI.
When a private call grows beyond two participants, the direct-call domain creates a new empty `group` conversation and points the call chat panel at it. The previous one-to-one conversation remains untouched, so private history is not copied into the group chat. Group conversations reuse the same composer, message list, attachment, GIF, markdown, link-embed, typing, mutation, and sync paths as one-to-one DMs; delivery simply fans out to every participant except the sender.
The DM header and conversation list can start calls from both one-to-one and group conversations. Group calls reuse the group conversation id as the call id and send the same ring notification to every other participant.
Typing state is DM-owned as well. The composer emits `direct-message-typing` events, and the chat view renders the active peer names with a short TTL so the embedded private-call chat has the same typing feedback as a standalone PM.
When a conversation opens, a peer reconnects, or network service is restored, the selected conversation requests a bounded `direct-message-sync` snapshot from the peer. Incoming snapshots merge the newest messages by id instead of replacing local history, which lets clients backfill older PMs when their local stores drift.
## GIFs ## GIFs
The DM composer reuses the chat domain's KLIPY integration. Availability and GIF search go through the configured signal server API, and selected GIFs are sent as markdown image messages so the same proxy-fallback image rendering path is used in DMs and server chat. The DM composer reuses the chat domain's KLIPY integration. Availability and GIF search go through the configured signal server API, and selected GIFs are sent as markdown image messages so the same proxy-fallback image rendering path is used in DMs and server chat.

View File

@@ -1,7 +1,11 @@
import { import {
advanceDirectMessageStatus, advanceDirectMessageStatus,
createDirectConversation, createDirectConversation,
createGroupConversation,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
getDirectConversationId, getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation, updateMessageStatusInConversation,
upsertDirectMessage upsertDirectMessage
} from '../../domain/logic/direct-message.logic'; } from '../../domain/logic/direct-message.logic';
@@ -17,6 +21,11 @@ const bob: DirectMessageParticipant = {
username: 'bob', username: 'bob',
displayName: 'Bob' displayName: 'Bob'
}; };
const charlie: DirectMessageParticipant = {
userId: 'charlie',
username: 'charlie',
displayName: 'Charlie'
};
describe('DirectMessageService domain flow', () => { describe('DirectMessageService domain flow', () => {
it('should create conversation', () => { it('should create conversation', () => {
@@ -44,20 +53,85 @@ describe('DirectMessageService domain flow', () => {
expect(updatedConversation.messages[0].status).toBe('QUEUED'); expect(updatedConversation.messages[0].status).toBe('QUEUED');
}); });
it('should create empty group conversation without direct-message history', () => {
const directConversation = upsertDirectMessage(createDirectConversation(alice, bob, 10), createMessage('message-1', 'SENT'), false);
const groupConversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
], 30, 'Alice, Bob, Charlie');
expect(isGroupDirectConversation(groupConversation)).toBe(true);
expect(groupConversation.id).toBe('dm-group-test');
expect(groupConversation.title).toBe('Alice, Bob, Charlie');
expect(groupConversation.participants).toEqual([
'alice',
'bob',
'charlie'
]);
expect(groupConversation.messages).toEqual([]);
expect(directConversation.messages).toHaveLength(1);
});
it('should preserve group message recipient metadata', () => {
const conversation = createGroupConversation('dm-group-test', [
alice,
bob,
charlie
], 10);
const recipientIds = ['bob', 'charlie'];
const message = createMessage('message-1', 'QUEUED', conversation.id, recipientIds);
const updatedConversation = upsertDirectMessage(conversation, message, false);
expect(updatedConversation.messages[0].recipientId).toBe('bob');
expect(updatedConversation.messages[0].recipientIds).toEqual(recipientIds);
});
it('should update status correctly', () => { it('should update status correctly', () => {
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT'); expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED'); expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED'); expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED');
expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED'); expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED');
}); });
it('recognises only declared direct-message recipients and participants', () => {
const payload = {
message: createMessage('message-1', 'SENT', 'dm-group-test', ['bob']),
participants: [alice, bob],
sender: alice
};
expect(directMessageEventIncludesUser(payload, 'bob')).toBe(true);
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false);
});
it('recognises only declared sync participants', () => {
const payload = {
conversationId: 'dm-group-test',
messages: [],
participants: [alice, bob],
sender: alice,
syncedAt: 30
};
expect(directMessageSyncIncludesUser(payload, 'alice')).toBe(true);
expect(directMessageSyncIncludesUser(payload, 'charlie')).toBe(false);
});
}); });
function createMessage(id: string, status: DirectMessage['status']): DirectMessage { function createMessage(
id: string,
status: DirectMessage['status'],
conversationId = getDirectConversationId('alice', 'bob'),
recipientIds = ['bob']
): DirectMessage {
return { return {
id, id,
conversationId: getDirectConversationId('alice', 'bob'), conversationId,
senderId: 'alice', senderId: 'alice',
recipientId: 'bob', recipientId: recipientIds[0],
recipientIds,
content: 'Hello', content: 'Hello',
timestamp: 20, timestamp: 20,
status status

View File

@@ -12,10 +12,16 @@ import { v4 as uuidv4 } from 'uuid';
import { DirectMessageRepository } from '../../infrastructure/direct-message.repository'; import { DirectMessageRepository } from '../../infrastructure/direct-message.repository';
import { OfflineMessageQueueService } from './offline-message-queue.service'; import { OfflineMessageQueueService } from './offline-message-queue.service';
import { PeerDeliveryService } from './peer-delivery.service'; import { PeerDeliveryService } from './peer-delivery.service';
import { AttachmentFacade } from '../../../attachment';
import { import {
advanceDirectMessageStatus, advanceDirectMessageStatus,
createDirectConversation, createDirectConversation,
createGroupConversation,
directMessageConversationIncludesUser,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
getDirectConversationId, getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation, updateMessageStatusInConversation,
upsertDirectMessage upsertDirectMessage
} from '../../domain/logic/direct-message.logic'; } from '../../domain/logic/direct-message.logic';
@@ -24,8 +30,12 @@ import {
DirectMessageConversation, DirectMessageConversation,
DirectMessageEventPayload, DirectMessageEventPayload,
DirectMessageMutationEventPayload, DirectMessageMutationEventPayload,
DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageSyncRequestEventPayload,
DirectMessageStatus, DirectMessageStatus,
DirectMessageStatusEventPayload, DirectMessageStatusEventPayload,
DirectMessageTypingEventPayload,
toDirectMessageParticipant toDirectMessageParticipant
} from '../../domain/models/direct-message.model'; } from '../../domain/models/direct-message.model';
import type { import type {
@@ -35,16 +45,32 @@ import type {
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
const DIRECT_MESSAGE_SYNC_LIMIT = 1000;
const DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS = 5000;
const DIRECT_MESSAGE_TYPING_TTL_MS = 3000;
const DIRECT_MESSAGE_TYPING_PURGE_MS = 1000;
const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:';
interface DirectMessageTypingEntry {
conversationId: string;
userId: string;
displayName: string;
expiresAt: number;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DirectMessageService { export class DirectMessageService {
private readonly repository = inject(DirectMessageRepository); private readonly repository = inject(DirectMessageRepository);
private readonly offlineQueue = inject(OfflineMessageQueueService); private readonly offlineQueue = inject(OfflineMessageQueueService);
private readonly delivery = inject(PeerDeliveryService); private readonly delivery = inject(PeerDeliveryService);
private readonly attachments = inject(AttachmentFacade);
private readonly store = inject(Store); private readonly store = inject(Store);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly currentUser = this.store.selectSignal(selectCurrentUser); private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]); private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
private readonly selectedConversationIdSignal = signal<string | null>(null); private readonly selectedConversationIdSignal = signal<string | null>(null);
private readonly typingEntriesSignal = signal<DirectMessageTypingEntry[]>([]);
private readonly lastSyncRequestAt = new Map<string, number>();
private loadedOwnerId: string | null = null; private loadedOwnerId: string | null = null;
readonly conversations = computed(() => [...this.conversationsSignal()].sort( readonly conversations = computed(() => [...this.conversationsSignal()].sort(
@@ -62,6 +88,7 @@ export class DirectMessageService {
(total, conversation) => total + conversation.unreadCount, (total, conversation) => total + conversation.unreadCount,
0 0
)); ));
readonly typingEntries = this.typingEntriesSignal.asReadonly();
constructor() { constructor() {
effect(() => { effect(() => {
@@ -76,11 +103,15 @@ export class DirectMessageService {
this.delivery.peerConnected$.subscribe(() => { this.delivery.peerConnected$.subscribe(() => {
void this.retryPending(); void this.retryPending();
void this.requestOpenConversationSync();
}); });
this.delivery.networkRestored$.subscribe(() => { this.delivery.networkRestored$.subscribe(() => {
void this.retryPending(); void this.retryPending();
void this.requestOpenConversationSync();
}); });
window.setInterval(() => this.purgeExpiredTypingEntries(), DIRECT_MESSAGE_TYPING_PURGE_MS);
} }
async createConversation(user: User): Promise<DirectMessageConversation> { async createConversation(user: User): Promise<DirectMessageConversation> {
@@ -106,12 +137,47 @@ export class DirectMessageService {
return conversation; return conversation;
} }
async createGroupConversation(
participants: DirectMessageParticipant[],
title?: string,
conversationId = `dm-group-${uuidv4()}`
): Promise<DirectMessageConversation> {
const currentUser = this.requireCurrentUser();
const ownerId = this.getCurrentUserIdOrThrow();
const currentParticipant = toDirectMessageParticipant(currentUser);
const allParticipants = this.uniqueParticipants([currentParticipant, ...participants]);
await this.loadForOwner(ownerId);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
if (existingConversation) {
const mergedConversation = this.mergeConversationParticipants({
...existingConversation,
kind: 'group',
title: existingConversation.title || title
}, allParticipants);
await this.persistConversation(ownerId, mergedConversation);
this.selectedConversationIdSignal.set(mergedConversation.id);
return mergedConversation;
}
const conversation = createGroupConversation(conversationId, allParticipants, Date.now(), title);
await this.persistConversation(ownerId, conversation);
this.selectedConversationIdSignal.set(conversation.id);
return conversation;
}
async openConversation(conversationId: string): Promise<void> { async openConversation(conversationId: string): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
await this.loadForOwner(ownerId); await this.loadForOwner(ownerId);
this.selectedConversationIdSignal.set(conversationId); this.selectedConversationIdSignal.set(conversationId);
await this.markRead(conversationId); await this.markRead(conversationId);
this.requestConversationSync(conversationId);
} }
closeConversationView(conversationId?: string | null): void { closeConversationView(conversationId?: string | null): void {
@@ -152,10 +218,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId); const conversation = await this.requireConversation(ownerId, conversationId);
const senderId = currentUser.oderId || currentUser.id; const senderId = currentUser.oderId || currentUser.id;
const recipientId = conversation.participants.find((participantId) => participantId !== senderId); const recipientIds = this.recipientIdsFor(conversation, senderId);
const recipientId = recipientIds[0];
if (!recipientId) { if (!recipientId) {
throw new Error('Direct message conversation has no recipient.'); throw new Error('Direct message conversation has no recipients.');
} }
const message: DirectMessage = { const message: DirectMessage = {
@@ -163,6 +230,7 @@ export class DirectMessageService {
conversationId, conversationId,
senderId, senderId,
recipientId, recipientId,
recipientIds,
content: normalizedContent, content: normalizedContent,
timestamp: Date.now(), timestamp: Date.now(),
status: 'QUEUED', status: 'QUEUED',
@@ -172,7 +240,7 @@ export class DirectMessageService {
}; };
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false)); await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
await this.attemptDelivery(ownerId, message); await this.attemptDelivery(ownerId, message, conversation);
return message; return message;
} }
@@ -249,9 +317,8 @@ export class DirectMessageService {
requestPeerAvatarSync(conversationId: string): void { requestPeerAvatarSync(conversationId: string): void {
const currentUserId = this.getCurrentUserId(); const currentUserId = this.getCurrentUserId();
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId); const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
if (peerId) { for (const peerId of this.recipientIdsFor(conversation, currentUserId)) {
this.delivery.requestUserAvatar(peerId); this.delivery.requestUserAvatar(peerId);
} }
} }
@@ -321,13 +388,51 @@ export class DirectMessageService {
for (const messageId of pendingMessageIds) { for (const messageId of pendingMessageIds) {
const message = messages.find((entry) => entry.id === messageId); const message = messages.find((entry) => entry.id === messageId);
const conversation = message
? this.conversationsSignal().find((entry) => entry.id === message.conversationId)
: null;
if (message) { if (message && conversation) {
await this.attemptDelivery(ownerId, message); await this.attemptDelivery(ownerId, message, conversation);
} }
} }
} }
typingUsers(conversationId: string | null | undefined): string[] {
if (!conversationId) {
return [];
}
const now = Date.now();
return this.typingEntriesSignal()
.filter((entry) => entry.conversationId === conversationId && entry.expiresAt > now)
.map((entry) => entry.displayName);
}
sendTyping(conversationId: string, isTyping = true): void {
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const currentUser = this.currentUser();
const currentUserId = this.getCurrentUserId();
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
if (!conversation || !currentUser || recipientIds.length === 0) {
return;
}
for (const recipientId of recipientIds) {
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-typing',
directMessageTyping: {
conversationId,
sender: toDirectMessageParticipant(currentUser),
isTyping,
updatedAt: Date.now()
}
});
}
}
private async handlePeerEvent(event: ChatEvent): Promise<void> { private async handlePeerEvent(event: ChatEvent): Promise<void> {
if (event.type === 'direct-message' && event.directMessage) { if (event.type === 'direct-message' && event.directMessage) {
await this.handleIncomingMessage(event.directMessage); await this.handleIncomingMessage(event.directMessage);
@@ -341,18 +446,46 @@ export class DirectMessageService {
if (event.type === 'direct-message-mutation' && event.directMessageMutation) { if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
await this.handleIncomingMutation(event.directMessageMutation); await this.handleIncomingMutation(event.directMessageMutation);
return;
}
if (event.type === 'direct-message-typing' && event.directMessageTyping) {
this.handleIncomingTyping(event.directMessageTyping);
return;
}
if (event.type === 'direct-message-sync-request' && event.directMessageSyncRequest) {
await this.handleIncomingSyncRequest(event.directMessageSyncRequest);
return;
}
if (event.type === 'direct-message-sync' && event.directMessageSync) {
await this.handleIncomingSync(event.directMessageSync);
} }
} }
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> { private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) {
return;
}
const currentParticipant = toDirectMessageParticipant(currentUser); const currentParticipant = toDirectMessageParticipant(currentUser);
const sender = payload.sender; const sender = payload.sender;
const conversationId = payload.message.conversationId const conversationId = payload.message.conversationId
|| getDirectConversationId(currentParticipant.userId, sender.userId); || getDirectConversationId(currentParticipant.userId, sender.userId);
const participants = this.uniqueParticipants([
currentParticipant,
sender,
...(payload.participants ?? [])
]);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId) const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? createDirectConversation(currentParticipant, sender, payload.message.timestamp); ?? (payload.conversationKind === 'group' || participants.length > 2
? createGroupConversation(conversationId, participants, payload.message.timestamp, payload.conversationTitle)
: createDirectConversation(currentParticipant, sender, payload.message.timestamp));
const conversationWithParticipants = this.mergeConversationParticipants(existingConversation, participants);
const incomingMessage: DirectMessage = { const incomingMessage: DirectMessage = {
...payload.message, ...payload.message,
conversationId, conversationId,
@@ -360,7 +493,7 @@ export class DirectMessageService {
}; };
const shouldIncrementUnread = !this.isConversationVisible(conversationId); const shouldIncrementUnread = !this.isConversationVisible(conversationId);
await this.persistConversation(ownerId, upsertDirectMessage(existingConversation, incomingMessage, shouldIncrementUnread)); await this.persistConversation(ownerId, upsertDirectMessage(conversationWithParticipants, incomingMessage, shouldIncrementUnread));
this.sendStatusUpdate(incomingMessage.senderId, { this.sendStatusUpdate(incomingMessage.senderId, {
conversationId, conversationId,
messageId: incomingMessage.id, messageId: incomingMessage.id,
@@ -384,24 +517,140 @@ export class DirectMessageService {
private isConversationVisible(conversationId: string): boolean { private isConversationVisible(conversationId: string): boolean {
const currentUrl = this.router.url.split(/[?#]/, 1)[0]; const currentUrl = this.router.url.split(/[?#]/, 1)[0];
if (!currentUrl.startsWith('/dm/')) { if (!currentUrl.startsWith('/dm/') && !currentUrl.startsWith('/pm/')) {
if (currentUrl.startsWith('/call/')) {
return this.selectedConversationIdSignal() === conversationId;
}
return false; return false;
} }
const prefix = currentUrl.startsWith('/pm/') ? '/pm/' : '/dm/';
try { try {
return decodeURIComponent(currentUrl.slice('/dm/'.length)) === conversationId; return decodeURIComponent(currentUrl.slice(prefix.length)) === conversationId;
} catch { } catch {
return currentUrl.slice('/dm/'.length) === conversationId; return currentUrl.slice(prefix.length) === conversationId;
} }
} }
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> { private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, payload.conversationId); const conversation = await this.findConversation(ownerId, payload.conversationId);
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) {
return;
}
await this.persistConversation(ownerId, this.applyMutation(conversation, payload)); await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
} }
private handleIncomingTyping(payload: DirectMessageTypingEventPayload): void {
const currentUserId = this.getCurrentUserId();
if (!currentUserId || payload.sender.userId === currentUserId) {
return;
}
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
if (!conversation
|| !directMessageConversationIncludesUser(conversation, currentUserId)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return;
}
if (!payload.isTyping) {
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
));
return;
}
const nextEntry: DirectMessageTypingEntry = {
conversationId: payload.conversationId,
userId: payload.sender.userId,
displayName: payload.sender.displayName,
expiresAt: Date.now() + DIRECT_MESSAGE_TYPING_TTL_MS
};
this.typingEntriesSignal.update((entries) => [
...entries.filter((entry) =>
!(entry.conversationId === nextEntry.conversationId && entry.userId === nextEntry.userId)
),
nextEntry
]);
}
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const conversation = await this.findConversation(ownerId, payload.conversationId);
if (!conversation
|| payload.sender.userId === ownerId
|| !directMessageConversationIncludesUser(conversation, ownerId)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return;
}
this.delivery.sendViaWebRTC(payload.sender.userId, {
type: 'direct-message-sync',
directMessageSync: {
conversationId: conversation.id,
sender: toDirectMessageParticipant(currentUser),
participants: Object.values(conversation.participantProfiles),
conversationKind: this.conversationKind(conversation),
conversationTitle: conversation.title,
messages: conversation.messages.slice(-DIRECT_MESSAGE_SYNC_LIMIT),
syncedAt: Date.now()
}
});
}
private async handleIncomingSync(payload: DirectMessageSyncEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser);
if (payload.sender.userId === ownerId) {
return;
}
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
return;
}
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId)
?? (payload.conversationKind === 'group' || payload.participants.length > 2
? createGroupConversation(payload.conversationId, [currentParticipant, ...payload.participants], payload.syncedAt, payload.conversationTitle)
: createDirectConversation(currentParticipant, payload.sender, payload.syncedAt));
const participantProfiles = {
...existingConversation.participantProfiles,
...Object.fromEntries(payload.participants.map((participant) => [participant.userId, participant])),
[currentParticipant.userId]: currentParticipant
};
const syncBaseConversation: DirectMessageConversation = {
...existingConversation,
kind: payload.conversationKind ?? existingConversation.kind,
title: payload.conversationTitle ?? existingConversation.title,
participants: Object.keys(participantProfiles).sort(),
participantProfiles
};
const mergedConversation = payload.messages.reduce<DirectMessageConversation>(
(conversation, message) => upsertDirectMessage(conversation, message, false),
syncBaseConversation
);
await this.persistConversation(ownerId, mergedConversation);
if (this.selectedConversationIdSignal() === payload.conversationId) {
await this.markRead(payload.conversationId);
}
}
private async applyAndSendMutation( private async applyAndSendMutation(
conversationId: string, conversationId: string,
payload: DirectMessageMutationEventPayload payload: DirectMessageMutationEventPayload
@@ -409,11 +658,11 @@ export class DirectMessageService {
const ownerId = this.getCurrentUserIdOrThrow(); const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, conversationId); const conversation = await this.requireConversation(ownerId, conversationId);
const updatedConversation = this.applyMutation(conversation, payload); const updatedConversation = this.applyMutation(conversation, payload);
const recipientId = conversation.participants.find((participantId) => participantId !== ownerId); const recipientIds = this.recipientIdsFor(conversation, ownerId);
await this.persistConversation(ownerId, updatedConversation); await this.persistConversation(ownerId, updatedConversation);
if (recipientId) { for (const recipientId of recipientIds) {
this.delivery.sendViaWebRTC(recipientId, { this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-mutation', type: 'direct-message-mutation',
directMessageMutation: payload directMessageMutation: payload
@@ -474,23 +723,38 @@ export class DirectMessageService {
return { ...conversation, messages }; return { ...conversation, messages };
} }
private async attemptDelivery(ownerId: string, message: DirectMessage): Promise<void> { private async attemptDelivery(ownerId: string, message: DirectMessage, conversation: DirectMessageConversation): Promise<void> {
const currentUser = this.requireCurrentUser(); const currentUser = this.requireCurrentUser();
const sent = this.delivery.sendViaWebRTC(message.recipientId, { const recipientIds = this.recipientIdsFor(conversation, ownerId);
type: 'direct-message',
directMessage: {
message,
sender: toDirectMessageParticipant(currentUser)
}
});
if (!sent) { let sentCount = 0;
await this.offlineQueue.enqueue(ownerId, message.id);
return; for (const recipientId of recipientIds) {
if (this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message',
directMessage: {
message,
sender: toDirectMessageParticipant(currentUser),
participants: Object.values(conversation.participantProfiles),
conversationKind: this.conversationKind(conversation),
conversationTitle: conversation.title
}
})) {
sentCount += 1;
}
} }
await this.offlineQueue.markDelivered(ownerId, message.id); if (sentCount < recipientIds.length) {
await this.updateStatus(message.id, 'SENT'); await this.offlineQueue.enqueue(ownerId, message.id);
}
if (sentCount > 0) {
await this.updateStatus(message.id, 'SENT');
}
if (sentCount === recipientIds.length) {
await this.offlineQueue.markDelivered(ownerId, message.id);
}
} }
private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void { private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
@@ -500,6 +764,52 @@ export class DirectMessageService {
}); });
} }
private requestOpenConversationSync(): void {
const conversationId = this.selectedConversationIdSignal();
if (conversationId) {
this.requestConversationSync(conversationId);
}
}
private requestConversationSync(conversationId: string): void {
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
const currentUser = this.currentUser();
const currentUserId = this.getCurrentUserId();
const recipientIds = this.recipientIdsFor(conversation, currentUserId);
if (!conversation || !currentUser || recipientIds.length === 0) {
return;
}
const now = Date.now();
for (const recipientId of recipientIds) {
const syncKey = `${conversationId}:${recipientId}`;
if (now - (this.lastSyncRequestAt.get(syncKey) ?? 0) < DIRECT_MESSAGE_SYNC_REQUEST_COOLDOWN_MS) {
continue;
}
this.lastSyncRequestAt.set(syncKey, now);
this.delivery.sendViaWebRTC(recipientId, {
type: 'direct-message-sync-request',
directMessageSyncRequest: {
conversationId,
sender: toDirectMessageParticipant(currentUser),
requestedAt: Date.now()
}
});
}
}
private purgeExpiredTypingEntries(): void {
const now = Date.now();
this.typingEntriesSignal.update((entries) => entries.filter((entry) => entry.expiresAt > now));
}
private async loadForOwner(ownerId: string | null): Promise<void> { private async loadForOwner(ownerId: string | null): Promise<void> {
if (!ownerId) { if (!ownerId) {
this.loadedOwnerId = null; this.loadedOwnerId = null;
@@ -512,10 +822,14 @@ export class DirectMessageService {
} }
this.loadedOwnerId = ownerId; this.loadedOwnerId = ownerId;
this.conversationsSignal.set(await this.repository.loadConversations(ownerId)); const conversations = await this.repository.loadConversations(ownerId);
conversations.forEach((conversation) => this.rememberConversationAttachmentStorage(conversation));
this.conversationsSignal.set(conversations);
} }
private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> { private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
this.rememberConversationAttachmentStorage(conversation);
await this.repository.saveConversation(ownerId, conversation); await this.repository.saveConversation(ownerId, conversation);
this.conversationsSignal.update((conversations) => { this.conversationsSignal.update((conversations) => {
const nextConversations = conversations.filter((entry) => entry.id !== conversation.id); const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);
@@ -525,11 +839,57 @@ export class DirectMessageService {
}); });
} }
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> { private rememberConversationAttachmentStorage(conversation: DirectMessageConversation): void {
await this.loadForOwner(ownerId); const storageContainer = `${DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX}${conversation.id}`;
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId) for (const message of conversation.messages) {
?? await this.repository.getConversation(ownerId, conversationId); this.attachments.rememberMessageRoom(message.id, storageContainer);
}
}
private mergeConversationParticipants(
conversation: DirectMessageConversation,
participants: DirectMessageParticipant[]
): DirectMessageConversation {
const participantProfiles = {
...conversation.participantProfiles,
...Object.fromEntries(participants.map((participant) => [participant.userId, participant]))
};
return {
...conversation,
participants: Object.keys(participantProfiles).sort(),
participantProfiles
};
}
private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] {
if (!conversation || !currentUserId) {
return [];
}
return conversation.participants.filter((participantId) => participantId !== currentUserId);
}
private conversationKind(conversation: DirectMessageConversation): 'direct' | 'group' {
return isGroupDirectConversation(conversation) ? 'group' : 'direct';
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (!participant.userId || seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
const conversation = await this.findConversation(ownerId, conversationId);
if (!conversation) { if (!conversation) {
throw new Error('Direct message conversation not found.'); throw new Error('Direct message conversation not found.');
@@ -538,6 +898,13 @@ export class DirectMessageService {
return conversation; return conversation;
} }
private async findConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
await this.loadForOwner(ownerId);
return this.conversationsSignal().find((entry) => entry.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
}
private requireCurrentUser(): User { private requireCurrentUser(): User {
const currentUser = this.currentUser(); const currentUser = this.currentUser();

View File

@@ -0,0 +1,131 @@
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { ChatEvent, User } from '../../../../shared-kernel';
import { PeerDeliveryService } from './peer-delivery.service';
describe('PeerDeliveryService', () => {
it('relays direct messages through signaling when no data channel is connected', () => {
const context = createServiceContext({ connectedPeers: [], routedPeers: ['bob'] });
const event: ChatEvent = {
type: 'direct-message',
directMessage: {
message: {
id: 'message-1',
conversationId: 'dm-alice-bob',
senderId: 'alice',
recipientId: 'bob',
content: 'hello',
timestamp: 1,
status: 'QUEUED'
},
sender: {
userId: 'alice',
username: 'alice',
displayName: 'Alice'
}
}
};
expect(context.service.sendViaWebRTC('bob', event)).toBe(true);
expect(context.realtime.sendToPeer).not.toHaveBeenCalled();
expect(context.realtime.sendRawMessage).toHaveBeenCalledWith({
...event,
targetUserId: 'bob'
});
});
it('keeps messages queued when neither P2P nor signaling can reach the recipient', () => {
const context = createServiceContext({ connectedPeers: [], routedPeers: [] });
expect(context.service.sendViaWebRTC('bob', { type: 'direct-message' })).toBe(false);
expect(context.realtime.sendRawMessage).not.toHaveBeenCalled();
});
it('emits direct messages received over signaling', () => {
const context = createServiceContext({ connectedPeers: [] });
const received: ChatEvent[] = [];
context.service.directMessageEvents$.subscribe((event) => received.push(event));
context.signalingMessages.next({ type: 'direct-message' } as ChatEvent);
expect(received).toEqual([{ type: 'direct-message' }]);
});
});
interface ServiceContextOptions {
connectedPeers: string[];
routedPeers?: string[];
}
interface ServiceContext {
service: PeerDeliveryService;
signalingMessages: Subject<ChatEvent>;
realtime: {
getConnectedPeers: ReturnType<typeof vi.fn>;
hasSignalingRouteForPeer: ReturnType<typeof vi.fn>;
sendRawMessage: ReturnType<typeof vi.fn>;
sendToPeer: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const users = signal<User[]>([createUser('alice', 'Alice'), createUser('bob', 'Bob')]);
const incomingMessages = new Subject<ChatEvent>();
const signalingMessages = new Subject<ChatEvent>();
const peerConnected = new Subject<string>();
const realtime = {
onMessageReceived: incomingMessages.asObservable(),
onSignalingMessage: signalingMessages.asObservable(),
onPeerConnected: peerConnected.asObservable(),
getConnectedPeers: vi.fn(() => options.connectedPeers),
hasSignalingRouteForPeer: vi.fn((peerId: string) => (options.routedPeers ?? []).includes(peerId)),
sendRawMessage: vi.fn(),
sendToPeer: vi.fn()
};
const store = {
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectAllUsers) {
return users;
}
throw new Error('Unexpected selector requested by PeerDeliveryService test.');
})
};
const injector = Injector.create({
providers: [
{
provide: RealtimeSessionFacade,
useValue: realtime
},
{
provide: Store,
useValue: store
}
]
});
return {
service: runInInjectionContext(injector, () => new PeerDeliveryService()),
signalingMessages,
realtime
};
}
function createUser(id: string, displayName: string): User {
return {
id,
oderId: id,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}

View File

@@ -4,6 +4,7 @@ import { Store } from '@ngrx/store';
import { import {
Subject, Subject,
filter, filter,
merge,
type Observable type Observable
} from 'rxjs'; } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
@@ -17,8 +18,23 @@ export class PeerDeliveryService {
private readonly users = this.store.selectSignal(selectAllUsers); private readonly users = this.store.selectSignal(selectAllUsers);
private readonly networkRestoredSubject = new Subject<void>(); private readonly networkRestoredSubject = new Subject<void>();
readonly directMessageEvents$: Observable<ChatEvent> = this.webrtc.onMessageReceived.pipe( readonly directMessageEvents$: Observable<ChatEvent> = merge(
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation') this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
).pipe(
filter((event) => event.type === 'direct-message'
|| event.type === 'direct-message-status'
|| event.type === 'direct-message-mutation'
|| event.type === 'direct-message-typing'
|| event.type === 'direct-message-sync-request'
|| event.type === 'direct-message-sync')
);
readonly directCallEvents$: Observable<ChatEvent> = merge(
this.webrtc.onMessageReceived,
this.webrtc.onSignalingMessage as Observable<ChatEvent>
).pipe(
filter((event) => event.type === 'direct-call')
); );
readonly peerConnected$ = this.webrtc.onPeerConnected; readonly peerConnected$ = this.webrtc.onPeerConnected;
@@ -35,12 +51,14 @@ export class PeerDeliveryService {
const peerId = this.resolvePeerId(recipientId); const peerId = this.resolvePeerId(recipientId);
if (!peerId) { let sent = false;
return false;
if (peerId) {
this.webrtc.sendToPeer(peerId, event);
sent = true;
} }
this.webrtc.sendToPeer(peerId, event); return this.sendViaSignaling(recipientId, event) || sent;
return true;
} }
handleAck(recipientId: string, event: ChatEvent): boolean { handleAck(recipientId: string, event: ChatEvent): boolean {
@@ -54,6 +72,10 @@ export class PeerDeliveryService {
}); });
} }
sendCallEvent(recipientId: string, event: ChatEvent): boolean {
return this.sendViaWebRTC(recipientId, event);
}
syncOnReconnect(onReconnect: () => void): void { syncOnReconnect(onReconnect: () => void): void {
this.peerConnected$.subscribe(() => onReconnect()); this.peerConnected$.subscribe(() => onReconnect());
} }
@@ -77,6 +99,56 @@ export class PeerDeliveryService {
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null; return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
} }
private sendViaSignaling(recipientId: string, event: ChatEvent): boolean {
if (
event.type !== 'direct-message'
&& event.type !== 'direct-message-status'
&& event.type !== 'direct-message-mutation'
&& event.type !== 'direct-message-typing'
&& event.type !== 'direct-message-sync-request'
&& event.type !== 'direct-message-sync'
&& event.type !== 'direct-call'
) {
return false;
}
const targetPeerId = this.resolveSignalingPeerId(recipientId);
if (!targetPeerId) {
return false;
}
try {
this.webrtc.sendRawMessage({
...event,
targetUserId: targetPeerId
});
return true;
} catch {
return false;
}
}
private resolveSignalingPeerId(recipientId: string): string | null {
return this.resolveCandidateIds(recipientId).find((candidate) => this.webrtc.hasSignalingRouteForPeer(candidate)) ?? null;
}
private resolveCandidateIds(recipientId: string): string[] {
const user = this.users().find((candidate: User) =>
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId
);
return [
recipientId,
user?.oderId,
user?.peerId,
user?.id
].filter((candidate, index, candidates): candidate is string =>
!!candidate && candidates.indexOf(candidate) === index
);
}
private isOfflineOverrideEnabled(): boolean { private isOfflineOverrideEnabled(): boolean {
return typeof window !== 'undefined' return typeof window !== 'undefined'
&& !!(window as Window & { metoyouDmNetworkOffline?: boolean }).metoyouDmNetworkOffline; && !!(window as Window & { metoyouDmNetworkOffline?: boolean }).metoyouDmNetworkOffline;

View File

@@ -1,7 +1,9 @@
import type { import type {
DirectMessage, DirectMessage,
DirectMessageConversation, DirectMessageConversation,
DirectMessageEventPayload,
DirectMessageParticipant, DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageStatus DirectMessageStatus
} from '../models/direct-message.model'; } from '../models/direct-message.model';
@@ -37,6 +39,7 @@ export function createDirectConversation(
return { return {
id: getDirectConversationId(currentUser.userId, peer.userId), id: getDirectConversationId(currentUser.userId, peer.userId),
kind: 'direct',
participants, participants,
participantProfiles: { participantProfiles: {
[currentUser.userId]: currentUser, [currentUser.userId]: currentUser,
@@ -48,6 +51,52 @@ export function createDirectConversation(
}; };
} }
export function createGroupConversation(
conversationId: string,
participants: DirectMessageParticipant[],
now: number,
title?: string
): DirectMessageConversation {
const uniqueParticipants = uniqueDirectMessageParticipants(participants);
const participantIds = uniqueParticipants.map((participant) => participant.userId).sort();
return {
id: conversationId,
kind: 'group',
title: title || buildGroupConversationTitle(uniqueParticipants),
participants: participantIds,
participantProfiles: Object.fromEntries(uniqueParticipants.map((participant) => [participant.userId, participant])),
messages: [],
lastMessageAt: now,
unreadCount: 0
};
}
export function isGroupDirectConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
export function directMessageConversationIncludesUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userId: string
): boolean {
return conversation.participants.includes(userId) || !!conversation.participantProfiles[userId];
}
export function directMessageEventIncludesUser(
payload: DirectMessageEventPayload,
userId: string
): boolean {
return collectDirectMessageEventParticipantIds(payload).has(userId);
}
export function directMessageSyncIncludesUser(
payload: DirectMessageSyncEventPayload,
userId: string
): boolean {
return payload.participants.some((participant) => participant.userId === userId);
}
export function upsertDirectMessage( export function upsertDirectMessage(
conversation: DirectMessageConversation, conversation: DirectMessageConversation,
message: DirectMessage, message: DirectMessage,
@@ -89,3 +138,50 @@ export function updateMessageStatusInConversation(
return { ...conversation, messages }; return { ...conversation, messages };
} }
function uniqueDirectMessageParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
const seen = new Set<string>();
return participants.filter((participant) => {
if (!participant.userId || seen.has(participant.userId)) {
return false;
}
seen.add(participant.userId);
return true;
});
}
function collectDirectMessageEventParticipantIds(payload: DirectMessageEventPayload): Set<string> {
const participantIds = new Set<string>();
if (payload.message.senderId) {
participantIds.add(payload.message.senderId);
}
if (payload.message.recipientId) {
participantIds.add(payload.message.recipientId);
}
for (const recipientId of payload.message.recipientIds ?? []) {
participantIds.add(recipientId);
}
for (const participant of payload.participants ?? []) {
if (participant.userId) {
participantIds.add(participant.userId);
}
}
return participantIds;
}
function buildGroupConversationTitle(participants: DirectMessageParticipant[]): string {
const names = participants.map((participant) => participant.displayName || participant.username || participant.userId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}

View File

@@ -1,17 +1,24 @@
import type { User } from '../../../../shared-kernel'; import type { User } from '../../../../shared-kernel';
import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel'; import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel';
export type DirectMessageConversationKind = 'direct' | 'group';
export type { export type {
DirectMessage, DirectMessage,
DirectMessageEventPayload, DirectMessageEventPayload,
DirectMessageMutationEventPayload, DirectMessageMutationEventPayload,
DirectMessageParticipant, DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageSyncRequestEventPayload,
DirectMessageStatus, DirectMessageStatus,
DirectMessageStatusEventPayload DirectMessageStatusEventPayload,
DirectMessageTypingEventPayload
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
export interface DirectMessageConversation { export interface DirectMessageConversation {
id: string; id: string;
kind?: DirectMessageConversationKind;
title?: string;
participants: string[]; participants: string[];
participantProfiles: Record<string, DirectMessageParticipant>; participantProfiles: Record<string, DirectMessageParticipant>;
messages: DirectMessage[]; messages: DirectMessage[];

View File

@@ -6,17 +6,54 @@
appThemeNode="dmChatHeader" appThemeNode="dmChatHeader"
class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4" class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4"
> >
<app-user-avatar @if (peerUser()) {
[name]="peerName()" <button
[avatarUrl]="peerUser()?.avatarUrl" type="button"
[status]="peerUser()?.status" class="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 pr-2 text-left transition-colors hover:bg-secondary/60 focus:outline-none focus:ring-2 focus:ring-primary/50"
[showStatusBadge]="true" [attr.aria-label]="'Open profile for ' + peerName()"
size="md" [title]="'Open profile for ' + peerName()"
/> (click)="openHeaderProfileCard($event)"
<div class="min-w-0"> >
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1> <app-user-avatar
<p class="text-xs text-muted-foreground">Direct Message</p> [name]="peerName()"
</div> [avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">Direct Message</p>
</div>
</button>
} @else {
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerUser()?.avatarUrl"
[status]="peerUser()?.status"
[showStatusBadge]="true"
size="md"
/>
<div class="min-w-0 flex-1">
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
</div>
}
@if (showCallButton() && conversation()) {
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
[disabled]="!canCallConversation()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
(click)="callConversation()"
>
<ng-icon
[name]="peerCallIcon()"
class="h-4 w-4"
/>
</button>
}
</header> </header>
@if (conversation()) { @if (conversation()) {
@@ -58,6 +95,15 @@
appThemeNode="chatComposerBar" appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md" class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
> >
@if (typingUsers().length > 0) {
<div
data-testid="dm-typing-indicator"
class="px-4 pb-1 text-xs text-muted-foreground"
>
{{ typingUsers().join(', ') }} {{ typingUsers().length === 1 ? 'is' : 'are' }} typing...
</div>
}
<app-chat-message-composer <app-chat-message-composer
[replyTo]="replyTo()" [replyTo]="replyTo()"
[showKlipyGifPicker]="showGifPicker()" [showKlipyGifPicker]="showGifPicker()"
@@ -65,6 +111,7 @@
[klipySignalSource]="null" [klipySignalSource]="null"
[textareaTestId]="'dm-input'" [textareaTestId]="'dm-input'"
(messageSubmitted)="handleMessageSubmitted($event)" (messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()" (replyCleared)="clearReply()"
(heightChanged)="composerBottomPadding.set($event + 20)" (heightChanged)="composerBottomPadding.set($event + 20)"
(klipyGifPickerToggleRequested)="toggleGifPicker()" (klipyGifPickerToggleRequested)="toggleGifPicker()"
@@ -72,29 +119,40 @@
</div> </div>
@if (showGifPicker()) { @if (showGifPicker()) {
<div @if (isMobile()) {
class="fixed inset-0 z-[89]" <app-bottom-sheet (dismissed)="closeGifPicker()">
tabindex="0" <div appThemeNode="chatGifPickerSurface">
role="button" <app-klipy-gif-picker
aria-label="Close GIF picker" (gifSelected)="handleGifSelected($event)"
(click)="closeGifPicker()" (closed)="closeGifPicker()"
(keydown.enter)="closeGifPicker()" />
(keydown.space)="closeGifPicker()" </div>
></div> </app-bottom-sheet>
} @else {
<div class="pointer-events-none fixed inset-0 z-[90]">
<div <div
appThemeNode="chatGifPickerSurface" class="fixed inset-0 z-[89]"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]" tabindex="0"
[style.bottom.px]="composerBottomPadding() + 8" role="button"
[style.right.px]="gifPickerAnchorRight()" aria-label="Close GIF picker"
> (click)="closeGifPicker()"
<app-klipy-gif-picker (keydown.enter)="closeGifPicker()"
(gifSelected)="handleGifSelected($event)" (keydown.space)="closeGifPicker()"
(closed)="closeGifPicker()" ></div>
/>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="gifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleGifSelected($event)"
(closed)="closeGifPicker()"
/>
</div>
</div> </div>
</div> }
} }
<app-chat-message-overlays <app-chat-message-overlays

View File

@@ -5,6 +5,7 @@ import {
effect, effect,
HostListener, HostListener,
inject, inject,
input,
signal, signal,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
@@ -14,11 +15,19 @@ import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { UserAvatarComponent } from '../../../../shared'; import { ViewportService } from '../../../../core/platform';
import {
BottomSheetComponent,
ProfileCardService,
UserAvatarComponent
} from '../../../../shared';
import { DirectCallService } from '../../../direct-call';
import { Attachment, AttachmentFacade } from '../../../attachment'; import { Attachment, AttachmentFacade } from '../../../attachment';
import { ThemeNodeDirective } from '../../../theme'; import { ThemeNodeDirective } from '../../../theme';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
import { import {
ChatMessageComposerSubmitEvent, ChatMessageComposerSubmitEvent,
ChatMessageComposerComponent, ChatMessageComposerComponent,
@@ -57,9 +66,12 @@ interface DmStatusLabel {
ChatMessageListComponent, ChatMessageListComponent,
ChatMessageOverlaysComponent, ChatMessageOverlaysComponent,
KlipyGifPickerComponent, KlipyGifPickerComponent,
BottomSheetComponent,
NgIcon,
ThemeNodeDirective, ThemeNodeDirective,
UserAvatarComponent UserAvatarComponent
], ],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })],
templateUrl: './dm-chat.component.html', templateUrl: './dm-chat.component.html',
host: { host: {
class: 'block h-full' class: 'block h-full'
@@ -74,10 +86,18 @@ export class DmChatComponent {
private readonly attachments = inject(AttachmentFacade); private readonly attachments = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService); private readonly klipy = inject(KlipyService);
private readonly linkMetadata = inject(LinkMetadataService); private readonly linkMetadata = inject(LinkMetadataService);
private readonly profileCard = inject(ProfileCardService);
private readonly viewport = inject(ViewportService);
private readonly metadataRequestKeys = new Set<string>();
private openedConversationId: string | null = null;
readonly isMobile = this.viewport.isMobile;
readonly directCalls = inject(DirectCallService);
readonly directMessages = inject(DirectMessageService); readonly directMessages = inject(DirectMessageService);
readonly currentUser = this.store.selectSignal(selectCurrentUser); readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly allUsers = this.store.selectSignal(selectAllUsers); readonly allUsers = this.store.selectSignal(selectAllUsers);
readonly showGifPicker = signal(false); readonly showGifPicker = signal(false);
readonly conversationId = input<string | null>(null);
readonly showCallButton = input(true);
readonly composerBottomPadding = signal(140); readonly composerBottomPadding = signal(140);
readonly gifPickerAnchorRight = signal(16); readonly gifPickerAnchorRight = signal(16);
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({}); readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
@@ -87,15 +107,26 @@ export class DmChatComponent {
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), { readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId') initialValue: this.route.snapshot.paramMap.get('conversationId')
}); });
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || ''); readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
readonly conversation = this.directMessages.selectedConversation; readonly conversation = this.directMessages.selectedConversation;
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null)); readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none'); readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
readonly typingUsers = computed(() => {
void this.directMessages.typingEntries();
return this.directMessages.typingUsers(this.conversation()?.id);
});
readonly peerUser = computed(() => { readonly peerUser = computed(() => {
const conversation = this.conversation(); const conversation = this.conversation();
return conversation ? this.peerUserFor(conversation) : null; return conversation ? this.peerUserFor(conversation) : null;
}); });
readonly isGroupConversation = computed(() => {
const conversation = this.conversation();
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
});
readonly participantUsers = computed<User[]>(() => { readonly participantUsers = computed<User[]>(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const knownUsers = this.allUsers(); const knownUsers = this.allUsers();
@@ -173,22 +204,57 @@ export class DmChatComponent {
readonly peerName = computed(() => { readonly peerName = computed(() => {
const conversation = this.conversation(); const conversation = this.conversation();
const currentUserId = this.currentUserId(); const currentUserId = this.currentUserId();
if (conversation && this.isGroupConversation()) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId); const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message'; return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
}); });
readonly peerCallIcon = computed(() => {
const conversation = this.conversation();
if (conversation && this.isGroupConversation()) {
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
}
const peer = this.peerUser();
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
});
readonly canCallConversation = computed(() => {
const conversation = this.conversation();
if (!conversation) {
return false;
}
if (this.isGroupConversation()) {
return conversation.participants.some((participantId) => participantId !== this.currentUserId());
}
return !!this.peerUser();
});
constructor() { constructor() {
effect(() => { effect(() => {
const conversationId = this.routeConversationId(); const conversationId = this.effectiveConversationId();
if (conversationId) { if (!conversationId) {
this.openedConversationId = null;
return;
}
if (conversationId !== this.openedConversationId) {
this.openedConversationId = conversationId;
void this.directMessages.openConversation(conversationId); void this.directMessages.openConversation(conversationId);
} }
}); });
effect(() => { effect(() => {
void this.routeConversationId(); void this.effectiveConversationId();
void this.klipy.refreshAvailability(null); void this.klipy.refreshAvailability(null);
}); });
@@ -226,11 +292,39 @@ export class DmChatComponent {
this.replyTo.set(null); this.replyTo.set(null);
if (event.pendingFiles.length > 0) { if (event.pendingFiles.length > 0) {
this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`);
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined); this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
} }
}); });
} }
handleTypingStarted(): void {
const conversationId = this.conversation()?.id;
if (conversationId) {
this.directMessages.sendTyping(conversationId, true);
}
}
async callConversation(): Promise<void> {
const conversation = this.conversation();
if (conversation && this.canCallConversation()) {
await this.directCalls.startConversationCall(conversation);
}
}
openHeaderProfileCard(event: MouseEvent): void {
const user = this.peerUser();
if (!user) {
return;
}
event.stopPropagation();
this.profileCard.open(event.currentTarget as HTMLElement, user, { editable: false });
}
setReplyTo(message: ChatMessageReplyEvent): void { setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message); this.replyTo.set(message);
} }
@@ -325,6 +419,20 @@ export class DmChatComponent {
const electronApi = this.electronBridge.getApi(); const electronApi = this.electronBridge.getApi();
if (electronApi) { if (electronApi) {
const diskPath = this.getAttachmentDiskPath(attachment);
if (diskPath && electronApi.saveExistingFileAs) {
try {
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
if (result.saved || result.cancelled) {
return;
}
} catch {
/* fall back to blob/browser download */
}
}
const blob = await this.getAttachmentBlob(attachment); const blob = await this.getAttachmentBlob(attachment);
if (blob) { if (blob) {
@@ -391,12 +499,16 @@ export class DmChatComponent {
continue; continue;
} }
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url)); const urls = this.linkMetadata.extractUrls(message.content)
.filter((url) => !hasDedicatedChatEmbed(url))
.filter((url) => !this.metadataRequestKeys.has(this.metadataRequestKey(message.id, url)));
if (urls.length === 0) { if (urls.length === 0) {
continue; continue;
} }
urls.forEach((url) => this.metadataRequestKeys.add(this.metadataRequestKey(message.id, url)));
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed); const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
if (metadata.length === 0) { if (metadata.length === 0) {
@@ -410,11 +522,19 @@ export class DmChatComponent {
} }
} }
private metadataRequestKey(messageId: string, url: string): string {
return `${messageId}:${url}`;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> { private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl) { if (!attachment.objectUrl) {
return null; return null;
} }
if (attachment.objectUrl.startsWith('file:')) {
return null;
}
try { try {
const response = await fetch(attachment.objectUrl); const response = await fetch(attachment.objectUrl);
@@ -424,6 +544,10 @@ export class DmChatComponent {
} }
} }
private getAttachmentDiskPath(attachment: Attachment): string | null {
return attachment.savedPath || attachment.filePath || null;
}
private blobToBase64(blob: Blob): Promise<string> { private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -445,6 +569,10 @@ export class DmChatComponent {
} }
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null { private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
if (conversation.kind === 'group' || conversation.participants.length > 2) {
return null;
}
const currentUserId = this.currentUserId(); const currentUserId = this.currentUserId();
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId); const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
@@ -454,4 +582,16 @@ export class DmChatComponent {
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null; return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
} }
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {
const names = conversation.participants
.filter((participantId) => participantId !== this.currentUserId())
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
} }

View File

@@ -3,7 +3,7 @@
<div class="group/server relative flex w-full justify-center"> <div class="group/server relative flex w-full justify-center">
<button <button
type="button" type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground" class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground md:h-10 md:w-10"
title="Direct Messages" title="Direct Messages"
aria-label="Direct Messages" aria-label="Direct Messages"
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'" [ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
@@ -12,7 +12,7 @@
> >
<ng-icon <ng-icon
name="lucideMessageCircle" name="lucideMessageCircle"
class="h-4 w-4" class="h-[18px] w-[18px] md:h-4 md:w-4"
/> />
@if (directMessages.totalUnreadCount() > 0) { @if (directMessages.totalUnreadCount() > 0) {
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span> <span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
@@ -24,14 +24,16 @@
<div class="group/server relative flex w-full justify-center"> <div class="group/server relative flex w-full justify-center">
<button <button
type="button" type="button"
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card" class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10"
[class.dm-rail-slide-in]="!item.isExiting" [class.dm-rail-slide-in]="!item.isExiting"
[class.dm-rail-slide-out]="item.isExiting" [class.dm-rail-slide-out]="item.isExiting"
[class.pointer-events-none]="item.isExiting" [class.pointer-events-none]="item.isExiting"
[ngClass]="isSelectedItem(item) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'" [ngClass]="isSelectedItem(item) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[attr.data-testid]="'dm-rail-item-' + item.id"
[title]="item.label" [title]="item.label"
[attr.aria-current]="isSelectedItem(item) ? 'page' : null" [attr.aria-current]="isSelectedItem(item) ? 'page' : null"
(click)="openItem(item)" (click)="openItem(item)"
(contextmenu)="openContextMenu($event, item)"
> >
<div class="h-full w-full overflow-hidden rounded-[inherit]"> <div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (item.avatarUrl) { @if (item.avatarUrl) {
@@ -58,7 +60,7 @@
class="absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full bg-secondary text-muted-foreground shadow-sm ring-2 ring-card" class="absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full bg-secondary text-muted-foreground shadow-sm ring-2 ring-card"
> >
<ng-icon <ng-icon
name="lucideUser" [name]="iconFor(item)"
class="h-2.5 w-2.5" class="h-2.5 w-2.5"
/> />
</span> </span>
@@ -72,3 +74,24 @@
</div> </div>
} }
</div> </div>
@if (contextMenu(); as menu) {
<app-context-menu
[x]="menu.x"
[y]="menu.y"
width="w-44"
(closed)="closeContextMenu()"
>
<button
type="button"
class="context-menu-item-icon-danger"
(click)="forgetContextItem()"
>
<ng-icon
[name]="forgetContextIcon(menu.item)"
class="h-4 w-4"
/>
{{ forgetContextLabel(menu.item) }}
</button>
</app-context-menu>
}

View File

@@ -12,8 +12,15 @@ import { CommonModule } from '@angular/common';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle, lucideUser } from '@ng-icons/lucide'; import {
lucideLogOut,
lucideMessageCircle,
lucideTrash2,
lucideUser,
lucideUsers
} from '@ng-icons/lucide';
import { filter, map } from 'rxjs'; import { filter, map } from 'rxjs';
import { ContextMenuComponent } from '../../../../shared';
import { DirectMessageService } from '../../application/services/direct-message.service'; import { DirectMessageService } from '../../application/services/direct-message.service';
import { FriendService } from '../../application/services/friend.service'; import { FriendService } from '../../application/services/friend.service';
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
@@ -30,13 +37,23 @@ interface DmRailItem {
unreadCount: number; unreadCount: number;
} }
interface DmRailContextMenuState {
x: number;
y: number;
item: DmRailItem;
}
const EXIT_ANIMATION_MS = 160; const EXIT_ANIMATION_MS = 160;
@Component({ @Component({
selector: 'app-dm-rail', selector: 'app-dm-rail',
standalone: true, standalone: true,
imports: [CommonModule, NgIcon], imports: [
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })], CommonModule,
ContextMenuComponent,
NgIcon
],
viewProviders: [provideIcons({ lucideLogOut, lucideMessageCircle, lucideTrash2, lucideUser, lucideUsers })],
templateUrl: './dm-rail.component.html', templateUrl: './dm-rail.component.html',
styleUrl: './dm-rail.component.scss' styleUrl: './dm-rail.component.scss'
}) })
@@ -60,11 +77,29 @@ export class DmRailComponent implements OnDestroy {
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId() this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
)); ));
readonly railItems = signal<DmRailItem[]>([]); readonly railItems = signal<DmRailItem[]>([]);
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
readonly unreadRailItems = computed<DmRailItem[]>(() => { readonly unreadRailItems = computed<DmRailItem[]>(() => {
const currentUserId = this.currentUserId(); const currentUserId = this.currentUserId();
const items = new Map<string, DmRailItem>(); const items = new Map<string, DmRailItem>();
for (const conversation of this.directMessages.conversations()) { for (const conversation of this.directMessages.conversations()) {
if (conversation.unreadCount === 0) {
continue;
}
if (this.isGroupConversation(conversation)) {
items.set(conversation.id, {
id: conversation.id,
label: this.titleFor(conversation),
conversation,
isExiting: false,
user: null,
unreadCount: conversation.unreadCount
});
continue;
}
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId); const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
if (!peerId) { if (!peerId) {
@@ -103,7 +138,7 @@ export class DmRailComponent implements OnDestroy {
}); });
} }
return Array.from(items.values()).filter((item) => item.unreadCount > 0); return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
}); });
readonly isOnDirectMessages = toSignal( readonly isOnDirectMessages = toSignal(
this.router.events.pipe( this.router.events.pipe(
@@ -140,6 +175,8 @@ export class DmRailComponent implements OnDestroy {
} }
async openItem(item: DmRailItem): Promise<void> { async openItem(item: DmRailItem): Promise<void> {
this.closeContextMenu();
if (item.conversation) { if (item.conversation) {
await this.openConversation(item.conversation); await this.openConversation(item.conversation);
return; return;
@@ -155,6 +192,10 @@ export class DmRailComponent implements OnDestroy {
} }
titleFor(conversation: DirectMessageConversation): string { titleFor(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId()); const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM'; return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
@@ -184,6 +225,51 @@ export class DmRailComponent implements OnDestroy {
return !!item.conversation && this.isSelectedConversation(item.conversation); return !!item.conversation && this.isSelectedConversation(item.conversation);
} }
iconFor(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideUsers' : 'lucideUser';
}
openContextMenu(event: MouseEvent, item: DmRailItem): void {
if (!item.conversation) {
return;
}
event.preventDefault();
event.stopPropagation();
this.contextMenu.set({
x: event.clientX,
y: event.clientY,
item
});
}
closeContextMenu(): void {
this.contextMenu.set(null);
}
async forgetContextItem(): Promise<void> {
const item = this.contextMenu()?.item;
if (!item?.conversation) {
return;
}
await this.directMessages.forgetConversation(item.conversation.id);
this.closeContextMenu();
if (this.isSelectedConversation(item.conversation)) {
await this.router.navigate(['/dm']);
}
}
forgetContextLabel(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'Leave chat' : 'Forget chat';
}
forgetContextIcon(item: DmRailItem): string {
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideLogOut' : 'lucideTrash2';
}
formatUnreadCount(count: number): string { formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count); return count > 99 ? '99+' : String(count);
} }
@@ -227,4 +313,20 @@ export class DmRailComponent implements OnDestroy {
this.railItems.set(nextItems); this.railItems.set(nextItems);
} }
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private groupConversationTitle(conversation: DirectMessageConversation): string {
const names = conversation.participants
.filter((participantId) => participantId !== this.currentUserId())
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
} }

View File

@@ -0,0 +1,7 @@
<main
appThemeNode="dmChatPanel"
class="relative h-full min-h-0 w-full min-w-0 overflow-hidden bg-background"
[ngStyle]="chatPanelStyles()"
>
<app-dm-chat />
</main>

View File

@@ -0,0 +1,21 @@
import { Component, computed, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { DmChatComponent } from '../dm-chat/dm-chat.component';
@Component({
selector: 'app-dm-chat-panel',
standalone: true,
imports: [
CommonModule,
ThemeNodeDirective,
DmChatComponent
],
host: { class: 'contents' },
templateUrl: './dm-chat-panel.component.html'
})
export class DmChatPanelComponent {
private readonly theme = inject(ThemeService);
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
}

View File

@@ -0,0 +1,54 @@
<div
appThemeNode="dmConversationItem"
class="group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
[class.bg-primary/10]="isSelected()"
[class.text-foreground]="isSelected()"
[attr.aria-current]="isSelected() ? 'page' : null"
(click)="openConversation()"
(keydown.enter)="openConversation()"
(keydown.space)="openConversation()"
role="button"
tabindex="0"
>
<app-user-avatar
[name]="peerName()"
[avatarUrl]="peerAvatarUrl()"
size="sm"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-medium text-foreground">{{ peerName() }}</p>
@if (conversation().unreadCount > 0) {
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
{{ formatUnreadCount(conversation().unreadCount) }}
</span>
}
</div>
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview() }}</p>
</div>
<button
type="button"
class="invisible grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-emerald-500/10 hover:text-emerald-600 focus:visible focus:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100 disabled:group-focus-within:opacity-30 disabled:group-hover:opacity-30"
[disabled]="!canCall()"
[attr.aria-label]="'Call ' + peerName()"
[title]="'Call ' + peerName()"
(click)="callConversationPeer($event)"
>
<ng-icon
[name]="callIcon()"
class="h-3.5 w-3.5"
/>
</button>
<button
type="button"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
[attr.aria-label]="'Forget ' + peerName()"
[title]="'Forget ' + peerName()"
(click)="forgetConversation($event)"
>
<ng-icon
name="lucideTrash2"
class="h-3.5 w-3.5"
/>
</button>
</div>

View File

@@ -0,0 +1,219 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
input,
output
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucidePhone,
lucidePhoneCall,
lucideTrash2
} from '@ng-icons/lucide';
import { map } from 'rxjs';
import { UserAvatarComponent } from '../../../../shared';
import { ThemeNodeDirective } from '../../../theme';
import { AttachmentFacade } from '../../../attachment';
import { DirectCallService } from '../../../direct-call';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import type { Attachment } from '../../../attachment';
import type { User } from '../../../../shared-kernel';
import { DirectMessageService } from '../../application/services/direct-message.service';
@Component({
selector: 'app-dm-conversation-item',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall, lucideTrash2 })],
host: { class: 'block' },
templateUrl: './dm-conversation-item.component.html'
})
export class DmConversationItemComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly attachments = inject(AttachmentFacade);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
readonly conversation = input.required<DirectMessageConversation>();
readonly conversationOpened = output<string>();
readonly users = this.store.selectSignal(selectAllUsers);
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
initialValue: this.route.snapshot.paramMap.get('conversationId')
});
readonly isSelected = computed(() => this.routeConversationId() === this.conversation().id);
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
constructor() {
effect(() => {
const conversation = this.conversation();
const peer = this.peerUser(conversation, this.users());
if (!peer?.avatarUrl) {
this.directMessages.requestPeerAvatarSync(conversation.id);
}
});
}
openConversation(): void {
this.conversationOpened.emit(this.conversation().id);
void this.router.navigate(['/dm', this.conversation().id]);
}
async forgetConversation(event: Event): Promise<void> {
event.stopPropagation();
const conversation = this.conversation();
const conversations = this.directMessages.conversations();
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
await this.directMessages.forgetConversation(conversation.id);
if (this.routeConversationId() === conversation.id) {
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
}
}
async callConversationPeer(event: Event): Promise<void> {
event.stopPropagation();
await this.directCalls.startConversationCall(this.conversation());
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
private resolvePeerName(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return conversation.title || this.groupConversationTitle(conversation);
}
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
}
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
if (this.isGroupConversation(conversation)) {
return undefined;
}
const peerId = this.peerId(conversation);
const knownUser = this.peerUser(conversation);
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
}
private resolveLastMessagePreview(conversation: DirectMessageConversation): string {
const lastMessage = conversation.messages.at(-1);
if (!lastMessage) {
return 'No messages yet';
}
if (lastMessage.isDeleted) {
return 'Message deleted';
}
if (this.isKlipyGif(lastMessage.content)) {
return 'Sent a GIF';
}
this.attachments.updated();
const attachments = this.attachments.getForMessage(lastMessage.id);
if (attachments.length > 0) {
return this.attachmentPreview(attachments);
}
return lastMessage.content || 'Attachment';
}
private conversationCallIcon(conversation: DirectMessageConversation): string {
if (this.isGroupConversation(conversation)) {
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
}
const peer = this.peerUser(conversation);
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
}
private canCallConversation(conversation: DirectMessageConversation): boolean {
if (this.isGroupConversation(conversation)) {
return conversation.participants.some((participantId) => participantId !== this.directMessages.currentUserId());
}
return !!this.peerUser(conversation);
}
private peerId(conversation: DirectMessageConversation): string | undefined {
const currentUserId = this.directMessages.currentUserId();
return conversation.participants.find((participantId) => participantId !== currentUserId);
}
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
if (this.isGroupConversation(conversation)) {
return undefined;
}
const peerId = this.peerId(conversation);
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
}
private isGroupConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
private groupConversationTitle(conversation: DirectMessageConversation): string {
const currentUserId = this.directMessages.currentUserId();
const names = conversation.participants
.filter((participantId) => participantId !== currentUserId)
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
if (names.length <= 3) {
return names.join(', ');
}
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
}
private isKlipyGif(content: string): boolean {
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
}
private attachmentPreview(attachments: Attachment[]): string {
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
return 'Sent an image';
}
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
return 'Sent a video';
}
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
return 'Sent audio';
}
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
}
}

View File

@@ -0,0 +1,48 @@
<aside
appThemeNode="dmConversationsPanel"
class="flex min-h-0 w-full min-w-0 overflow-hidden border-r border-border bg-card"
[ngStyle]="listPanelStyles()"
>
<section class="flex h-full w-full min-w-0 flex-col">
<header
appThemeNode="dmConversationsHeader"
class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3"
>
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
<ng-icon
name="lucideMessageCircle"
class="h-4 w-4"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
</div>
</header>
<div
appThemeNode="dmConversationList"
class="min-h-0 flex-1 overflow-y-auto p-2"
>
@if (directMessages.conversations().length === 0) {
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
} @else {
<div class="space-y-1">
@for (conversation of directMessages.conversations(); track trackConversationId($index, conversation)) {
<app-dm-conversation-item
[conversation]="conversation"
(conversationOpened)="conversationSelected.emit($event)"
/>
}
</div>
}
</div>
<div
appThemeNode="dmVoiceControlsArea"
class="border-t border-border px-2 py-3"
>
<app-voice-controls />
</div>
</section>
</aside>

View File

@@ -0,0 +1,40 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject,
output
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideMessageCircle } from '@ng-icons/lucide';
import { ThemeNodeDirective, ThemeService } from '../../../theme';
import { VoiceControlsComponent } from '../../../voice-session';
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
import { DirectMessageService } from '../../application/services/direct-message.service';
import { DmConversationItemComponent } from './dm-conversation-item.component';
@Component({
selector: 'app-dm-conversations-panel',
standalone: true,
imports: [
CommonModule,
DmConversationItemComponent,
NgIcon,
ThemeNodeDirective,
VoiceControlsComponent
],
viewProviders: [provideIcons({ lucideMessageCircle })],
host: { class: 'contents' },
templateUrl: './dm-conversations-panel.component.html'
})
export class DmConversationsPanelComponent {
private readonly theme = inject(ThemeService);
readonly directMessages = inject(DirectMessageService);
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
readonly conversationSelected = output<string>();
trackConversationId(index: number, conversation: DirectMessageConversation): string {
return conversation.id;
}
}

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