feat(presence): board-level awareness via Yjs (#43)#50
Merged
Conversation
…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>
2 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
userId(HSL hash) — stable across reloads, same on every peerZero-knowledge posture
{userId, color, viewing?}only — no email, no card title, no description bytesboard:<id>:awareness) before they hit the relay; the server sees opaque base64coloris a deterministic HSL hash ofuserIdso peers can derive it themselves; we send it anyway to save a round-trip per user observedAwarenessauto-expires stale states after ~30s. We refresh every 10s so it never trips for a live peer; on destroy we explicitly broadcast anullstate so peers see us leave immediatelyTransport
src/server/ws/index.tsgains a second room kind:card:<id>board:<id>Workspace-member authorization for board rooms (
authorizeBoardJoin). Card-room semantics are unchanged.Files
src/lib/realtime/awareness.tsBoardPresenceProvider— mirrorsCardWsProvider's lifecycle but encodes Yjs awareness updates instead of Y.Doc updatessrc/lib/realtime/user-color.tssrc/hooks/use-board-presence.tssetViewingcallbacksrc/server/ws/index.tsparseBoardId+authorizeBoardJoin; branch inhandleJoinfor the all-peers seedsrc/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsxopenCardIdinto awareness, buildsviewersByCard, renders<PresenceStack>in headersrc/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsxviewersByCard, renders viewer dot stack next to the Open buttontests/unit/user-color.test.tsTest plan
pnpm typecheckcleanpnpm lintcleanpnpm test --run— 113 tests pass (107 prior + 6 new)pnpm build— production build succeedsScope 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