import { useState, useEffect, useMemo } from "react";
import * as Y from "yjs";
import { Awareness } from "y-protocols/awareness";
import * as base64 from "base64-js";
import { useEntity, useReplicache } from "src/replicache";
import { useYjsRealtime, YjsRealtimeConnection } from "src/yjsRealtime";
import { useIdentityData } from "components/IdentityProvider";
import { remoteCursorPlugin } from "./remoteCursorPlugin";
import { RemoteCursors } from "./RemoteCursors";
import { SCHEMA_VERSION } from "./schema";
import {
markClientStale,
stampDocSchemaVersion,
updateSchemaVersion,
useStaleClient,
} from "./schemaVersion";
// Everything a collaboratively-edited text entity needs for live multiplayer,
// in one place so the moving parts can't drift apart between the text block
// and footnote call sites:
// - the synced yjs value to bind ySyncPlugin to (`yText`, from
// useYJSValue below, which owns the doc and its replicache/realtime
// sync),
// - the ProseMirror plugin that publishes the local selection to awareness
// and decorates remote selections (`cursorPlugin`), and
// - the overlay that draws remote carets outside the contenteditable
// (`overlay`).
// The plugin is memoized on the (stable) awareness so it's built once, and
// the overlay element carries the same awareness, so a caller only has to
// drop `cursorPlugin` into its plugin list and render `overlay`.
export function useCollabText(entityID: string) {
let { yText, awareness } = useYJSValue(entityID);
let cursorPlugin = useMemo(() => remoteCursorPlugin(awareness), [awareness]);
let overlay = ;
return { yText, awareness, cursorPlugin, overlay };
}
// The yjs document behind a collaboratively-edited text entity, wired to its
// two sync paths:
// - replicache holds the source of truth (the `block/text` fact is the full
// encoded doc state): applied here on load and on pokes, and written back
// (debounced) when local edits change the fragment;
// - the realtime channel carries live incremental updates and cursor
// (awareness) state between connected peers.
// Both inbound paths are gated on the doc's schema version (see
// ./schemaVersion): content from a newer schema is never applied to a doc an
// editor binds to. The channel side of that gate lives in yjsRealtime; the
// replicache side is here.
export function useYJSValue(entityID: string) {
const [ydoc] = useState(() => new Y.Doc());
const docStateFromReplicache = useEntity(entityID, "block/text");
let rep = useReplicache();
let realtime = useYjsRealtime();
let { identity } = useIdentityData();
const [yText] = useState(() => ydoc.getXmlFragment("prosemirror"));
const [awareness] = useState(() => new Awareness(ydoc));
// Content written by a newer schema must never reach the editor's doc (see
// ./schemaVersion) — leave it unapplied and go stale; the read-only
// fallback renders the fact directly.
const factValue = docStateFromReplicache?.data.value;
const factTooNew = useMemo(
() =>
!!factValue &&
updateSchemaVersion(base64.toByteArray(factValue)) > SCHEMA_VERSION,
[factValue],
);
useEffect(() => {
if (factTooNew) markClientStale();
}, [factTooNew]);
if (docStateFromReplicache && !factTooNew) {
const update = base64.toByteArray(docStateFromReplicache.data.value);
Y.applyUpdate(ydoc, update);
}
let userName =
identity?.bsky_profiles?.record?.displayName ||
identity?.bsky_profiles?.handle ||
"Anonymous";
useEffect(() => {
// a stable hue offset per client; each peer derives the actual cursor
// color from their own theme's accent (see collabCursor.ts)
awareness.setLocalStateField("user", {
name: userName,
hue: (ydoc.clientID % 8) * 45,
});
}, [awareness, userName, ydoc]);
// Broadcast local edits and cursor positions to connected peers, and apply
// theirs as they arrive. Cursors only ever travel over the channel; doc
// updates also flow through replicache below.
useEffect(() => {
if (!realtime) return;
return realtime.register(entityID, ydoc, awareness);
}, [realtime, entityID, ydoc, awareness]);
useEffect(() => {
return () => {
awareness.destroy();
};
}, [awareness]);
useEffect(() => {
if (!rep.rep) return;
let timeout = null as null | number;
const updateReplicache = async () => {
// The debounced flush can fire after going stale unmounts the editor;
// writing then could clobber newer content already in the fact.
if (useStaleClient.getState().stale) return;
stampDocSchemaVersion(ydoc);
const update = Y.encodeStateAsUpdate(ydoc);
await rep.rep?.mutate.assertFact({
//These undos are handled above in the Prosemirror context
ignoreUndo: true,
entity: entityID,
attribute: "block/text",
data: {
value: base64.fromByteArray(update),
type: "text",
},
});
};
const f = async (events: Y.YEvent[], transaction: Y.Transaction) => {
// Transactions with no origin come from replicache itself, and ones
// originating from the realtime channel are persisted by the peer that
// authored them — only local edits should be written back.
if (!transaction.origin) return;
if (transaction.origin instanceof YjsRealtimeConnection) return;
if (timeout) clearTimeout(timeout);
timeout = window.setTimeout(async () => {
updateReplicache();
}, 300);
};
yText.observeDeep(f);
return () => {
yText.unobserveDeep(f);
};
}, [yText, entityID, rep, ydoc]);
return { yText, awareness };
}