Skip to content
2 changes: 2 additions & 0 deletions src/extensions/staticAppExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { colemakKeybindingsPlugin } from '@/plugins/colemak-keybindings'
import { extractTypePlugin } from '@/plugins/extract-type'
import { birthdayPlugin } from '@/plugins/birthday'
import { characterCounterPlugin } from '@/plugins/character-counter'
import { presencePlugin } from '@/plugins/presence'
import type { AppExtension } from '@/facets/facet.js'

export const staticAppExtensions = ({repo}: {repo: Repo}): AppExtension[] => [
Expand Down Expand Up @@ -136,6 +137,7 @@ export const staticAppExtensions = ({repo}: {repo: Repo}): AppExtension[] => [
agentRuntimePlugin,
roamImportPlugin({repo}),
birthdayPlugin,
presencePlugin,
// appIntentsPlugin's bootstrap effect resolves the layout-session
// block via getUIStateBlock + getLayoutSessionBlock and then
// dispatches any PWA-shortcut / share-target / note-taker intent
Expand Down
65 changes: 65 additions & 0 deletions src/plugins/presence/PresencePublisher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Publishes the LOCAL user's selection / focus / editor caret to the
* presence channel. Mounted once per panel via `panelMountsFacet` (so it
* gets the panel's UI-state `block`), but only the ACTIVE panel publishes —
* one client tracks one selection at a time, and the active panel is the one
* the user is actually driving. Renders nothing.
*/
import { useEffect } from 'react'
import type { Block } from '@/data/block.js'
import { useHandle } from '@/hooks/block.js'
import { useIsActivePanel } from '@/data/globalState.js'
import {
editorSelection,
focusedBlockLocationFromProperties,
isEditingProp,
selectionStateProp,
} from '@/data/properties.js'
import { presenceClient } from './presenceClient.js'
import type { LocalPresence } from './types.js'

const buildLocal = (properties: Record<string, unknown> | undefined): LocalPresence => {
const selRaw = properties?.[selectionStateProp.name]
const selection = selRaw === undefined
? selectionStateProp.defaultValue
: selectionStateProp.codec.decode(selRaw)

const focusedBlockId = focusedBlockLocationFromProperties(properties)?.blockId ?? null

const editingRaw = properties?.[isEditingProp.name]
const editing = editingRaw === undefined
? isEditingProp.defaultValue
: isEditingProp.codec.decode(editingRaw)

const edRaw = properties?.[editorSelection.name]
const ed = edRaw === undefined ? undefined : editorSelection.codec.decode(edRaw)
// Only surface the caret while the user is actually editing the focused
// block. Gating on edit mode (not just focus) clears the caret on
// Escape/blur — otherwise `editorSelection` lingers and peers see a ghost
// caret until focus moves to another block.
const editor = editing && ed && ed.blockId === focusedBlockId
? { blockId: ed.blockId, start: ed.start ?? null, end: ed.end ?? ed.start ?? null }
: null

return {
selectedBlockIds: selection.selectedBlockIds,
anchorBlockId: selection.anchorBlockId,
focusedBlockId,
editor,
}
}

export function PresencePublisher({ block }: { block: Block }) {
const active = useIsActivePanel(block)
// `useHandle` structurally dedups the selected value, so `local`'s identity
// is stable until the published state actually changes — the effect below
// doesn't re-fire on unrelated UI-state churn.
const local = useHandle(block, { selector: doc => buildLocal(doc?.properties) })

useEffect(() => {
if (!active) return
presenceClient.updateLocal(local)
}, [active, local])

return null
}
117 changes: 117 additions & 0 deletions src/plugins/presence/RemoteCursorsOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Fixed, click-through layer that paints remote peers' mouse cursors.
* Mounted once at app root via `appMountsFacet`.
*
* Each cursor is anchored to a block id + fractional offset (see
* `presenceClient.handlePointerMove`), so we resolve it against the live DOM
* every paint: this makes the cursor land in the same logical spot across
* different scroll positions / window widths, and naturally hides cursors
* whose block is scrolled out of view. We re-paint on the cursor stream and
* on scroll/resize (rAF-coalesced).
*/
import { useEffect, useReducer } from 'react'
import { createPortal } from 'react-dom'
import { useRemoteCursors } from './hooks.js'
import type { RemoteCursor } from './types.js'

const resolveAnchor = (cursor: RemoteCursor): HTMLElement | null => {
if (!cursor.blockId) return null
// Match the publish side: only block shells (`.tm-block`), not the
// block-refs / property rows that also carry `data-block-id`.
const matches = document.querySelectorAll<HTMLElement>(
`.tm-block[data-block-id="${CSS.escape(cursor.blockId)}"]`,
)
if (matches.length === 0) return null
if (cursor.renderScopeId) {
for (const el of matches) {
if (el.dataset.renderScopeId === cursor.renderScopeId) return el
}
// Scoped cursor but this client has no copy in that render scope (the
// peer is in an embed / backlink / other pane we don't show). Don't fall
// back to an unrelated copy of the same block — hide it.
return null
}
return matches[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide scoped cursors when the scope is absent

Even after shell scoping, a cursor that carries a renderScopeId falls back to the first rendered copy of that block when this client does not have the peer's scope. In split panes, embeds, or backlink surfaces the same block can exist in multiple render scopes, so a peer moving in an embed can appear over an unrelated outline copy on another client; return null when a scoped cursor has no matching scope, and only use the fallback for unscoped payloads.

Useful? React with 👍 / 👎.

}

function RemoteCursorView({ cursor }: { cursor: RemoteCursor }) {
const anchor = resolveAnchor(cursor)
if (!anchor) return null
const rect = anchor.getBoundingClientRect()
const x = rect.left + cursor.nx * rect.width
const y = rect.top + cursor.ny * rect.height
if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) return null

return (
<div
style={{
position: 'absolute',
left: 0,
top: 0,
transform: `translate(${x}px, ${y}px)`,
willChange: 'transform',
}}
>
<svg width="16" height="22" viewBox="0 0 16 22" fill="none" style={{ display: 'block' }}>
<path
d="M1 1L1 16.5L5 12.5L7.5 18.5L10 17.5L7.5 11.5L13 11.5L1 1Z"
fill={cursor.color}
stroke="white"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
<span
style={{
position: 'absolute',
left: 12,
top: 12,
padding: '1px 6px',
borderRadius: 6,
background: cursor.color,
color: 'white',
fontSize: 11,
lineHeight: '16px',
fontWeight: 500,
whiteSpace: 'nowrap',
boxShadow: '0 1px 2px rgba(0,0,0,0.25)',
}}
>
{cursor.name}
</span>
</div>
)
}

export function RemoteCursorsOverlay() {
const cursors = useRemoteCursors()
const [, repaint] = useReducer((n: number) => n + 1, 0)

// Cursor coordinates are derived from live DOM rects, so they drift on any
// scroll/resize even when no new cursor packet arrives — repaint then too.
useEffect(() => {
let raf = 0
const schedule = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(repaint)
}
window.addEventListener('scroll', schedule, true)
window.addEventListener('resize', schedule)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('scroll', schedule, true)
window.removeEventListener('resize', schedule)
}
}, [])

if (typeof document === 'undefined' || cursors.length === 0) return null

return createPortal(
<div style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 60 }}>
{cursors.map(cursor => (
<RemoteCursorView key={cursor.clientId} cursor={cursor} />
))}
</div>,
document.body,
)
}
48 changes: 48 additions & 0 deletions src/plugins/presence/RemoteSelectionShellDecorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Paints a coloured ring around any block a remote peer has selected /
* focused / is editing. Contributed to `blockShellDecoratorsFacet`, so it
* composes with the local focus highlight rather than replacing it.
*
* The ring colour is per-peer and dynamic, which Tailwind classes can't
* express, so we set `box-shadow` directly on the shell node via `shellRef`.
* Stacked `inset` rings (2px, 4px, …) show multiple peers on one block. This
* is a property React never sets on the shell, so the write survives
* re-renders; the effect clears it on change/unmount.
*/
import { useLayoutEffect } from 'react'
import type {
BlockShellDecoratorContribution,
BlockShellDecoratorProps,
} from '@/extensions/blockInteraction.js'
import { useRemoteSelectionColorKey } from './hooks.js'

export function RemoteSelectionShellDecorator({
resolveContext,
shellRef,
state,
children,
}: BlockShellDecoratorProps) {
const colorKey = useRemoteSelectionColorKey(resolveContext.block.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve render scope for remote focus

When a peer focuses or edits a block that is also visible in another render scope (split pane, embed, or backlink), the app's own focus state is scoped by focusedBlockLocation.renderScopeId, but presence reduces it to a block id and this hook asks only for resolveContext.block.id. As a result every rendered copy of that block gets the remote focus/edit ring, so other users see activity on the wrong surface; include the render scope in the published presence and filter against this shell's scope before treating focus/editor as occupying the block.

Useful? React with 👍 / 👎.


useLayoutEffect(() => {
const el = shellRef.current
if (!el) return
if (!colorKey) {
el.style.boxShadow = ''
return
}
el.style.boxShadow = colorKey
.split(',')
.map((color, i) => `inset 0 0 0 ${2 * (i + 1)}px ${color}`)
.join(', ')
return () => {
const node = shellRef.current
if (node) node.style.boxShadow = ''
}
}, [colorKey, shellRef])

return <>{children(state)}</>
}

export const remoteSelectionShellDecorator: BlockShellDecoratorContribution = () =>
RemoteSelectionShellDecorator
22 changes: 22 additions & 0 deletions src/plugins/presence/colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Deterministic per-user colour. Same `userId` → same hue on every client,
* so a peer's selection ring / cursor / caret are all the same colour
* without any colour-assignment coordination over the wire.
*/

const hashString = (value: string): number => {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}

/** A saturated, mid-lightness HSL colour keyed off the user id. The fixed
* saturation/lightness keep every assigned colour legible against both the
* light and dark block backgrounds. */
export const colorForUser = (userId: string): string => {
const hue = hashString(userId) % 360
return `hsl(${hue} 70% 50%)`
}
23 changes: 23 additions & 0 deletions src/plugins/presence/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** React bindings over the presence store. */
import { useSyncExternalStore } from 'react'
import { presenceClient } from './presenceClient.js'
import type { RemoteCursor } from './types.js'

const EMPTY_CURSORS: readonly RemoteCursor[] = []

/** The `,`-joined peer colours occupying `blockId` (or `''`). Primitive, so
* only blocks whose occupancy changed re-render. */
export const useRemoteSelectionColorKey = (blockId: string): string =>
useSyncExternalStore(
presenceClient.subscribePresence,
() => presenceClient.selectionColorKey(blockId),
() => '',
)

/** All remote mouse cursors (already excludes self). */
export const useRemoteCursors = (): readonly RemoteCursor[] =>
useSyncExternalStore(
presenceClient.subscribeCursors,
presenceClient.getCursors,
() => EMPTY_CURSORS,
)
56 changes: 56 additions & 0 deletions src/plugins/presence/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Live-presence spike — shows other people in a shared workspace in real
* time: the blocks they have selected/focused (coloured rings), their mouse
* cursor, and their editor caret. Runs over Supabase Realtime (Presence for
* identity + selection + caret, Broadcast for the cursor) — entirely beside
* the kernel/PowerSync document path, so none of it is ever persisted.
*
* Seams used:
* - appEffectsFacet → owns the Realtime channel + cursor broadcast
* - panelMountsFacet → publishes the local user's selection/caret
* - blockShellDecoratorsFacet → remote-selection rings
* - appMountsFacet → the mouse-cursor overlay
* - codeMirrorExtensionsFacet → remote editor carets
*
* SPIKE NOTE: defaults OFF — opt in per workspace via Extensions settings.
* The channel is still PUBLIC, so enabling it globally must wait on Realtime
* Authorization (private channel + RLS on `realtime.messages`); until then an
* on-by-default public channel would let anyone with a workspace URL (ids
* appear in URL hashes) observe or forge presence. See the security/privacy
* caveats in `presenceClient.ts` (public channel → private + RLS; e2ee
* metadata leak).
*/
import { appEffectsFacet, appMountsFacet, panelMountsFacet } from '@/extensions/core.js'
import { blockShellDecoratorsFacet } from '@/extensions/blockInteraction.js'
import { codeMirrorExtensionsFacet } from '@/editor/codeMirrorExtensions.js'
import type { AppExtension } from '@/facets/facet.js'
import { systemToggle } from '@/facets/togglable.js'
import { presenceAppEffect } from './presenceEffect.js'
import { PresencePublisher } from './PresencePublisher.js'
import { RemoteCursorsOverlay } from './RemoteCursorsOverlay.js'
import { remoteSelectionShellDecorator } from './RemoteSelectionShellDecorator.js'
import { remoteCaretsCodeMirrorExtensions } from './remoteCaretsExtension.js'

const SOURCE = 'presence'

export const presencePlugin: AppExtension = systemToggle({
id: 'system:presence',
name: 'Live presence',
description:
"Shows other people in the workspace — their selected blocks, mouse cursors, and editor carets — over Supabase Realtime.",
// Off by default: the channel is public for now, so this stays opt-in until
// it's private + RLS-gated (see the module header).
defaultEnabled: false,
}).of([
appEffectsFacet.of(presenceAppEffect, { source: SOURCE }),
panelMountsFacet.of(
{ id: 'presence.publisher', component: PresencePublisher },
{ source: SOURCE },
),
blockShellDecoratorsFacet.of(remoteSelectionShellDecorator, { source: SOURCE }),
appMountsFacet.of(
{ id: 'presence.cursors', component: RemoteCursorsOverlay },
{ source: SOURCE },
),
codeMirrorExtensionsFacet.of(remoteCaretsCodeMirrorExtensions, { source: SOURCE }),
])
Loading
Loading