Files
peerspeak/docs/contacts-plan.md
T
molluskandClaude Opus 4.8 0af2af2dd9 docs: scope contract for contacts + room-invite notifications (W7)
Bank the W7 investigation as a scope contract. Key finding: pixelpass's
friends/control/identity code ports near-verbatim (same iroh 1.0.0-rc.0),
but PeerSpeak's endpoint is room-scoped (built in Join, torn down on Leave)
with a fresh identity each launch — so the real work is a new always-on
control-plane endpoint + persistent identity, not the friends list. Phased
plan (0 identity / 1 control plane / 2 store+handshake / 3 drawer UI /
4 security+field-test), ~4 sessions, with 4 open decisions that block build
(discovery-vs-privacy being the big one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:27:56 -04:00

7.8 KiB
Raw Blame History

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<Inbound>. ControlMsg variants Hello/FriendRequest/FriendAccept/FriendDecline/ShareCode.
src/common/friends.rs ~330 near-verbatimFriendStore, 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, ~12 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<Inbound> into the core→UI event flow (new UiEvent variants + CoreCommands 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 · SmallMedium

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

MediumHigh, ~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).