Skip to content

feat(presence): board-level awareness via Yjs (#43)#50

Merged
Musiker15 merged 1 commit into
mainfrom
feat/presence-awareness
May 25, 2026
Merged

feat(presence): board-level awareness via Yjs (#43)#50
Musiker15 merged 1 commit into
mainfrom
feat/presence-awareness

Conversation

@Musiker15
Copy link
Copy Markdown
Member

Summary

Adds a Yjs-Awareness presence layer on top of the existing E2EE WebSocket relay so users on the same board can see each other (closes #43, except live cursors — see scope note).

  • Avatar stack (max 5 + "+N") top-right of the board header — who else is on this board right now
  • Per-card viewer dots in the kanban view when another user has a card drawer open
  • Deterministic colour per userId (HSL hash) — stable across reloads, same on every peer

Zero-knowledge posture

  • Awareness payload is {userId, color, viewing?} only — no email, no card title, no description bytes
  • Updates are XChaCha20-Poly1305-sealed under the BoardKey (AAD board:<id>:awareness) before they hit the relay; the server sees opaque base64
  • color is a deterministic HSL hash of userId so peers can derive it themselves; we send it anyway to save a round-trip per user observed
  • Yjs Awareness auto-expires stale states after ~30s. We refresh every 10s so it never trips for a live peer; on destroy we explicitly broadcast a null state so peers see us leave immediately

Transport

src/server/ws/index.ts gains a second room kind:

Room Mechanism Late-joiner sync Use
card:<id> Y.Doc request-state from one peer (Yjs merges) card description CRDT
board:<id> Y.Awareness request-state from all peers (per-clientID, no merge) presence

Workspace-member authorization for board rooms (authorizeBoardJoin). Card-room semantics are unchanged.

Files

File Why
src/lib/realtime/awareness.ts BoardPresenceProvider — mirrors CardWsProvider's lifecycle but encodes Yjs awareness updates instead of Y.Doc updates
src/lib/realtime/user-color.ts Pure HSL hash, no client-only deps, unit-testable
src/hooks/use-board-presence.ts React hook → stable peer list + setViewing callback
src/server/ws/index.ts New parseBoardId + authorizeBoardJoin; branch in handleJoin for the all-peers seed
src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx Mounts hook, mirrors openCardId into awareness, builds viewersByCard, renders <PresenceStack> in header
src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx Takes viewersByCard, renders viewer dot stack next to the Open button
tests/unit/user-color.test.ts 6 cases — determinism, HSL shape, negative-mod normalisation, empty-input

Test plan

  • pnpm typecheck clean
  • pnpm lint clean
  • pnpm test --run — 113 tests pass (107 prior + 6 new)
  • pnpm build — production build succeeds
  • Manual: two browser sessions on the same board → both see each other in the avatar stack
  • Manual: one closes the tab → disappears from the stack within ~30s (or immediately if clean disconnect)
  • Manual: user A opens a card in the drawer → user B sees the coloured dots on that card in the kanban view
  • Manual: reload — same colour for the same user

Scope note: live cursors

The other half of issue #43 (live cursors inside the description editor) is deliberately out of scope. The description is currently a plain <textarea> synced via Y.Text observation, not a CRDT-backed editor like TipTap or CodeMirror. Cursor rendering needs an editor that exposes cursor positions as observable state — that's a separate change (likely a new ADR + an editor swap).

🤖 Generated with Claude Code

…43)

Adds a "who else is here" layer to boards using Yjs Awareness on top
of the existing encrypted WebSocket relay. No new transport, no new
infrastructure - just a second room kind alongside the existing
card:<id> rooms.

What the user sees:
- Avatar stack (max 5 + "+N") top-right of the board header showing
  who else is currently on this board
- Small coloured dots on each card in the kanban view when other
  users have the card drawer open ("Lisa-sees-this-card" indicator)
- Colours are deterministic HSL hashes of userId - stable across
  reloads and the same on every peer

Zero-knowledge posture (CLAUDE.md s3.1):
- Awareness payload is {userId, color, viewing?} - no email, no
  card title, no description text
- Payload bytes are XChaCha20-Poly1305-sealed under the BoardKey
  with AAD `board:<id>:awareness` before they hit the wire; the
  relay only sees opaque base64
- Yjs's Awareness auto-expires stale states after ~30s
  (`outdatedTimeout`); we refresh every 10s so it never trips for
  a live peer, and on destroy we explicitly send a null state so
  peers see us leave immediately

Transport changes:
- src/server/ws/index.ts gains parseBoardId + authorizeBoardJoin
  (workspace-member check) and a branch in handleJoin: for `board:`
  rooms the joiner gets request-state from *every* existing peer
  because Awareness is per-clientID and doesn't merge (cards stay
  one-peer because Y.Doc merges deterministically)

New code:
- src/lib/realtime/awareness.ts - BoardPresenceProvider, mirrors the
  shape of CardWsProvider but binds awareness updates instead of
  Y.Doc updates
- src/lib/realtime/user-color.ts - pure HSL hash (no client-only deps
  so it can be unit-tested without bootstrapping libsodium)
- src/hooks/use-board-presence.ts - React hook returning a stable
  peer list + setViewing callback
- tests/unit/user-color.test.ts - 6 cases (determinism, HSL shape,
  negative-mod normalisation, empty-input safety)

Wiring:
- board-client.tsx mounts the hook once boardKey is derived, mirrors
  openCardId into the awareness viewing field, and builds a
  cardId -> peers map for the kanban view
- kanban-view.tsx takes viewersByCard, renders a small dot stack
  next to the Open button on each card

Scope note: live cursors inside the description editor (the other
half of issue #43) are deliberately not in this PR. The description
is currently a plain <textarea> synced via Y.Text observation, not
a CRDT-backed editor like TipTap. Cursor rendering needs an editor
that exposes cursor positions as observable state - separate task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Musiker15 <info@musiker15.de>
@Musiker15 Musiker15 merged commit 68937f0 into main May 25, 2026
8 checks passed
@Musiker15 Musiker15 deleted the feat/presence-awareness branch May 25, 2026 14:49
Musiker15 added a commit that referenced this pull request May 28, 2026
ADR-first for the last v0.2.0-beta-deferred feature. Live cursors need
a character-level CRDT-aware editor (the current textarea full-string-
replaces into Y.Text, which isn't a real binding and has nowhere to
carry a remote caret).

Recommends CodeMirror 6 + y-codemirror.next behind a
FEATURE_LIVE_CURSORS flag:
- keeps the Markdown-source storage model (no change to encryption /
  export / render paths — unlike a ProseMirror/TipTap rich-text model),
- official Yjs binding renders remote carets/selections from Awareness,
- reuses PR #50's per-user colour + PII-free Awareness payload,
- content + cursor metadata stay E2EE through the existing relay
  (ADR 0003 preserved),
- lazy-imported so the perf budget is untouched until opted in.

Status: proposed. Implementation is a follow-up PR gated on this
merging. Adds the ADR index row in CLAUDE.md §14.

Signed-off-by: Moritz Kohm <moritz.kohm@gmail.com>
Signed-off-by: Musiker15 <info@musiker15.de>
Musiker15 added a commit that referenced this pull request May 30, 2026
Add three foreign-format converters in src/lib/import/foreign.ts, each
producing the canonical ExportedBoardV1 shape so the existing
zero-knowledge importBoard() pipeline re-encrypts the result under a fresh
BoardKey:

- fromGithubProjects: `gh project item-list <n> --owner <o> --format json`
  (or a bare items array). Status field -> columns, labels + assignees
  mapped, content body/url folded into the description.
- fromJira: Jira CSV. Handles Jira's repeated `Labels` columns (collects
  all indices, not just the first), plus Summary/Status/Issue key/Due
  date/Start date/Assignee.
- fromWekan: Wekan board JSON. Lists -> columns (archived dropped, sorted),
  named label colors -> hex, checklists + items + comments + members.

The workspace import widget now offers all six formats (MSKanban, Trello,
GitHub Projects, Jira, Wekan, CSV) with the right file-type accept filter.

Tests: 7 new cases in tests/unit/foreign-import.test.ts covering column
derivation, label/assignee mapping, multi-Labels Jira columns, Wekan
checklist/comment nesting, and the error paths.

Docs: CLAUDE.md (gap list, §5.8, header), CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PR 4: Presence + Live-Cursors via Yjs awareness

1 participant