dorfteich/apps/web/src/editor/use-presence.ts
Claude Opus 4.8 63fe6af6b0
All checks were successful
CD / Build and push images (push) Successful in 2m54s
CI / Lint, typecheck, test (push) Successful in 1m58s
CI / Auth e2e pack (push) Successful in 2m10s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 11s
Add remote cursors and a presence strip (#37)
Seeing other participants live (ADR 0003/0004, realtime-collaboration.md
§Awareness):

- The collaboration-caret extension renders remote carets and selections
  with a name flag and a per-user colour. Colours come from a small,
  hand-picked palette hashed by user id (FNV-1a), so they are stable across
  sessions; a unit test asserts each palette colour clears WCAG AA contrast
  (4.5:1) against the white label text.
- A presence strip at the top of the page shows an avatar (initials) per
  connected participant, deduplicated by user id, with an overflow count.
  Read-only participants appear in the strip (with a marker) but broadcast
  no caret — the caret render suppresses read-only users — so the same
  awareness feed drives both cursors and presence. Own identity (id +
  display name) comes from the auth context into the awareness `user` field.
- Presence updates on every awareness change, so a disconnect drops the
  participant within seconds.

The collab e2e pack gains a test: two browsers see each other in the
presence strip, one participant's named caret appears in the other's editor,
and disconnecting removes them. Validated locally against the full stack.
de + en strings and cursor/presence styles added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-08 18:22:41 +02:00

44 lines
1.4 KiB
TypeScript

import type { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useState } from 'react';
import type { CaretUser } from './collaboration-caret';
export type Participant = CaretUser;
/**
* The current participants on a page, derived from the collab awareness states
* (issue #37). Deduplicated by user id (a user with two tabs is one avatar) and
* updated whenever awareness changes, so a disconnect drops the participant
* within seconds.
*/
export function usePresence(provider: HocuspocusProvider | null): Participant[] {
const [participants, setParticipants] = useState<Participant[]>([]);
useEffect(() => {
const awareness = provider?.awareness;
if (!awareness) {
setParticipants([]);
return;
}
const update = (): void => {
const byUser = new Map<string, Participant>();
for (const state of awareness.getStates().values()) {
const user = (state as { user?: Partial<CaretUser> }).user;
if (!user?.userId || byUser.has(user.userId)) continue;
byUser.set(user.userId, {
userId: user.userId,
name: user.name ?? '',
color: user.color ?? '#495057',
readOnly: Boolean(user.readOnly),
});
}
setParticipants([...byUser.values()]);
};
update();
awareness.on('change', update);
return () => awareness.off('change', update);
}, [provider]);
return participants;
}