# Contacts list + room-invite notifications (W7) — plan / scope contract **Status:** SCOPED, not started (assessment 2026-06-14 by the senior). This is the scope contract; update it as phases land. Source: wishlist item **W7** in `~/Documents/handoff-docs/Gemini/peerspeak/wishlist-handoff.md`. ## Goal Save known peers and make joining their rooms frictionless: - A **contacts list** — save known peers (by stable id + name) so you don't re-exchange tickets every time. - A **notification drawer** where incoming **room invites** appear out-of-room. - Each invite carries the room **ticket in a button**; clicking it **auto-fills the join-room text box** (one-click join — never copy/paste a ticket). ## The decisive architectural finding (read this first) The wishlist says "port pixelpass's friends-list/notification system." The *protocol* code ports nearly verbatim — **both projects are on the exact same `iroh = "1.0.0-rc.0"`** — but the *integration* is genuinely new architecture for PeerSpeak, because the two apps have opposite networking lifecycles: - **pixelpass** runs a **persistent identity** + an **always-on control-plane endpoint** (bound at GUI start, online the whole time the app is open), separate from any video session. The friends system rides that. - **PeerSpeak** has **no endpoint at all when not in a call**: `core/mod.rs:500` (the `Join` handler) builds the endpoint and tears it down on `Leave`, and `core/mod.rs:421` mints a **fresh random `SecretKey::generate()` every launch**, so a peer's `EndpointId` changes each run and nothing is listening while idle. W7 fundamentally needs to reach a contact who is **not in a room** — which PeerSpeak currently cannot do at all. **That gap, not the friends list, is the real work.** Porting the proven protocol/store is ~60% of the effort (low risk); the new 40% is the always-on control plane (Phase 1) and the all-new drawer/ contacts UI (Phase 3). ## Reusable prior art (pixelpass — `~/git/butter/pixelpass/`) Field-verified + merged (`04bc0a8` + audit `6d0bf99`/`cfc4800`). Same stack, same iroh version → API-compatible. | pixelpass file | lines | reuse for PeerSpeak | | --- | --- | --- | | `src/common/identity.rs` | ~140 | **near-verbatim** — persistent ed25519 key, `load_or_create()`, atomic 0600 write. | | `src/common/control.rs` | ~260 | **protocol reusable as-is** — one-message-per-connection JSON over a bi-stream, **authenticated sender via `conn.remote_id()`** (not spoofable), one-byte ACK = delivery+parse signal, `serve()` accept loop → `mpsc::Receiver`. `ControlMsg` variants `Hello`/`FriendRequest`/`FriendAccept`/`FriendDecline`/`ShareCode`. | | `src/common/friends.rs` | ~330 | **near-verbatim** — `FriendStore`, mutual-consent `FriendState` (`PendingOutgoing`/`PendingIncoming`/`Accepted`), atomic write, keyed by stable `EndpointId`. | | `src/common/alpn.rs` | — | `CONTROL_ALPN = b"pixelpass/ctrl/0"` pattern → mint `b"peerspeak/ctrl/0"`. | | `src/gui/presence.rs` | ~299 | **reference, not copy** — service orchestration + online/presence indicators; PeerSpeak's iced UI differs, so adapt. | ## Locked design decisions *(none yet — see "Open decisions" below; the user must answer the four before build)* ## Adaptations from pixelpass's model - pixelpass's `ControlMsg::ShareCode { name, ticket }` (push a video share-code to a friend) maps directly onto PeerSpeak's need: a **room invite** carrying a `PeerSpeakTicket` (`src/network/mod.rs:81`). Likely rename to `RoomInvite`. - Store contacts/identity as **JSON** (PeerSpeak already uses `serde_json` everywhere; pixelpass uses `toml`) to avoid adding the `toml` dependency — see Open decision #3. ## Phases ### Phase 0 — Persistent identity · Small (~1 session) Port `identity.rs` → `~/.config/peerspeak/identity.key` (0600). Swap `core/mod.rs:421` `SecretKey::generate()` for `load_or_create()`. Unit-testable (hex round-trip tests come with it). - **Side effect:** the gossip-signing key (security S2) becomes stable across launches → minor cross-room linkability. Note it (Open decision #2). ### Phase 1 — Always-on control plane · Large (the crux, ~1–2 sessions) Stand up a **second, long-lived endpoint** on `peerspeak/ctrl/0`, online whenever the app runs, owned by the core loop **outside** the `Join`/`Leave` session lifecycle (today *all* networking lives inside `ActiveSession`). - Port `control.rs` (protocol as-is). Wire its inbound `mpsc::Receiver` into the core→UI event flow (new `UiEvent` variants + `CoreCommand`s for send-request / accept / decline / invite). - **Discovery:** the control endpoint likely needs **`presets::N0` (n0 DNS discovery)** to be reachable by bare id while idle, even if the call posture stays `RelayNoDiscovery`. This is Open decision #1. - This is net-new long-lived networking; the bulk of W7's risk lives here. ### Phase 2 — Contacts store + friend handshake · Medium (~1 session) Port `friends.rs` (as JSON). Wire `FriendRequest`/`Accept`/`Decline` through the control plane and core. Persist `~/.config/peerspeak/contacts.json`. Pure store = unit-testable. Implement the `RoomInvite` send/receive path (carry a `PeerSpeakTicket`). ### Phase 3 — UI: contacts list + notification drawer + one-click join · Medium (~1 session) All-new iced UI (PeerSpeak has **no drawer/notification panel today** — the only "drawer" is the chat layout): - **Contacts view** — add by id, see online/pending status, accept/decline. - **Notification drawer** — top-right popup, **reuse the layout-switcher popup pattern** (`SelectRoomLayout` flow) in the always-visible top-right cluster; incoming invites listed with a one-click-join button. - **One-click join** — the button routes the carried ticket into the join-room text-input state (validate, then pre-fill; do **not** auto-join). - Online/presence indicators driven by `ControlMsg::Hello` refreshes. - Cross-ref **W4 avatars**: show a contact's avatar (ties into `PeerState.avatar`). ### Phase 4 — Security review + 2-machine field test · Small–Medium New untrusted surface: - **Unsolicited control messages from arbitrary ids** = friend-request spam / DoS vector → need a cap / rate-limit (Open decision #4). - **Invite-carried tickets** are untrusted even from a contact → validate defensively before pre-fill; never auto-join. - Sender identity is **authenticated** (`remote_id()`) — inherited from pixelpass's design, good. - `cargo audit` (no new deps expected if we store as JSON, not TOML). - 2-machine field test on dopedart: add-contact both ways, request/accept, send a room invite out-of-room, one-click join. ## Open decisions (the user must answer before build) 1. **Discovery vs privacy (the big one).** Reaching an idle contact by stable id needs **n0 DNS discovery**, which conflicts with the privacy-minded `RelayNoDiscovery` default ([[user-security-preferences]] / telemetry stance). Proposed: run **only the control plane** on n0 discovery; keep the user's chosen posture for the call itself. Accept? 2. **Stable identity → stable gossip-signing key** across rooms (minor linkability). Acceptable? 3. **Dependency:** store contacts/identity as **JSON** (no new dep) rather than pixelpass's TOML. Confirm (default: JSON). 4. **Spam control:** cap / rate-limit unsolicited friend requests from the start? ## Effort summary **Medium–High, ~4 focused sessions.** ~60% proven low-risk port (same iroh version); ~40% new design — the always-on control endpoint (Phase 1) and the all-new drawer/contacts UI (Phase 3). ## Cross-references - Wishlist W7 (the request, with pixelpass-port pointer): `wishlist-handoff.md`. - Notification system precedent (chimes): `src/notify.rs` + W6 (per-sound toggles, `7e75ae3`). - Top-right popup pattern to reuse: the layout switcher (`SelectRoomLayout`). - Ticket type to carry in invites: `PeerSpeakTicket` (`src/network/mod.rs:81`).