a proof of concept realtime collaborative text editor using atproto as a sync server
jake.tngl.io/y-pds/
1import { Component, createRef } from "preact";
2import { html } from "htm/preact";
3import { signal } from "@preact/signals";
4import { getSession, logIn, resolveDID } from "./atsw.js";
5import metadata from "./client-metadata.json" with { type: "json" };
6import * as Y from "yjs";
7import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from "y-prosemirror";
8import { schema } from "prosemirror-schema-basic";
9import { EditorState } from "prosemirror-state";
10import { EditorView } from "prosemirror-view";
11import { exampleSetup, buildMenuItems } from "prosemirror-example-setup";
12import { MenuItem, joinUpItem, liftItem, undoItem, redoItem } from "prosemirror-menu";
13import { DOC_COLLECTION, YPdsProvider } from "./y-pds.js";
14import "actor-typeahead";
15
16/** @param {string} did */
17async function fetchProfile(did) {
18 try {
19 const res = await fetch(
20 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
21 );
22 if (res.ok) {
23 const p = await res.json();
24 return {
25 displayName: p.displayName || null,
26 handle: p.handle || did,
27 avatar: p.avatar || null,
28 };
29 }
30 } catch {}
31 return { displayName: null, handle: did, avatar: null };
32}
33
34/** @param {string} did */
35function colorForDid(did) {
36 let hash = 0;
37 for (let i = 0; i < did.length; i++) {
38 hash = (hash * 31 + did.charCodeAt(i)) >>> 0;
39 }
40 return `hsl(${hash % 360}, 70%, 45%)`;
41}
42
43export class App extends Component {
44 loading = signal(false);
45 did = signal("");
46 atUri = signal("");
47
48 async componentDidMount() {
49 try {
50 const did = localStorage.getItem("ypds:did");
51 if (!did) return;
52
53 this.loading.value = true;
54
55 const session = await getSession(did);
56 if (!session) {
57 this.did.value = "";
58 return;
59 }
60 this.did.value = session.did;
61
62 const params = new URLSearchParams(location.search);
63 let uri = params.get("id") ?? localStorage.getItem("ypds:pending-id") ?? "";
64 localStorage.removeItem("ypds:pending-id");
65 if (!uri) {
66 uri = `at://${this.did.value}/${DOC_COLLECTION}/${crypto.randomUUID()}`;
67 }
68 if (!params.has("id") || params.get("id") !== uri) {
69 params.set("id", uri);
70 history.replaceState(null, "", "?" + params);
71 }
72
73 this.atUri.value = uri;
74 } catch (e) {
75 console.error(e);
76 this.did.value = "";
77 } finally {
78 this.loading.value = false;
79 }
80 }
81
82 render() {
83 if (this.loading.value) return html`<${Loading} />`;
84 if (!this.did.value) return html`<${Login} />`;
85 return html`<${Editor} did=${this.did.value} atUri=${this.atUri.value} />`;
86 }
87}
88
89class Loading extends Component {
90 render() {
91 return html`<div class="loading"><p>loading…</p></div>`;
92 }
93}
94
95class Login extends Component {
96 async onSubmit(e) {
97 e.preventDefault();
98 const data = new FormData(e.currentTarget);
99 const identifier = data.get("handle");
100 if (typeof identifier !== "string") throw new Error("invalid handle");
101 const id = new URLSearchParams(location.search).get("id");
102 if (id) localStorage.setItem("ypds:pending-id", id);
103 const did = await resolveDID(identifier);
104 localStorage.setItem("ypds:did", did);
105 await logIn(
106 {
107 clientId: metadata.client_id,
108 redirectUri: metadata.redirect_uris[0],
109 scope: metadata.scope,
110 },
111 identifier,
112 );
113 }
114
115 render() {
116 return html`
117 <div class="login">
118 <h1>Yjs via PDS</h1>
119 <p>
120 A proof-of-concept collaborative text editor,<br />built with${" "}
121 <a href="https://yjs.dev">Yjs</a> on top of <a href="https://atproto.com">Atproto</a>.
122 </p>
123 <form class="login-form" onSubmit=${e => this.onSubmit(e)}>
124 <label>
125 <span>Handle</span>
126 <actor-typeahead>
127 <input name="handle" placeholder="example.bsky.social" />
128 </actor-typeahead>
129 </label>
130 <button>Log in</button>
131 </form>
132 <p class="login-blurb">
133 Log in with your <a href="https://internethandle.org">Internet handle</a>.
134 </p>
135 </div>
136 `;
137 }
138}
139
140class Editor extends Component {
141 editorRef = createRef();
142 shareDialogRef = createRef();
143 provider = signal(null);
144 canEdit = signal(true);
145 view = null;
146
147 async componentDidMount() {
148 const session = await getSession(this.props.did);
149
150 const ydoc = new Y.Doc();
151 const yxml = ydoc.getXmlFragment("prosemirror");
152
153 this.provider.value = new YPdsProvider({
154 ydoc,
155 pds: session.pds,
156 atUri: this.props.atUri,
157 did: this.props.did,
158 });
159
160 const ownerDid = this.props.atUri.slice("at://".length).split("/")[0];
161 const isOwner = ownerDid === this.props.did;
162
163 const menuItems = buildMenuItems(schema);
164 const shareItem = new MenuItem({
165 title: "Share document",
166 select: () => isOwner,
167 run: () => void 0,
168 render: () => {
169 const btn = document.createElement("button");
170 btn.textContent = "Share";
171 btn.className = "share-menu-button";
172 btn.commandForElement = this.shareDialogRef.current.dialogRef.current;
173 btn.command = "show-modal";
174 return btn;
175 },
176 });
177 const menuContent = [
178 ...menuItems.inlineMenu,
179 [menuItems.typeMenu],
180 [undoItem, redoItem],
181 [
182 menuItems.wrapBulletList,
183 menuItems.wrapOrderedList,
184 menuItems.wrapBlockQuote,
185 joinUpItem,
186 liftItem,
187 ].filter(Boolean),
188 [shareItem],
189 ];
190
191 const state = EditorState.create({
192 schema,
193 plugins: [
194 ...exampleSetup({ schema, history: false, menuContent }),
195 ySyncPlugin(yxml),
196 yUndoPlugin(),
197 yCursorPlugin(this.provider.value.awareness, {
198 cursorBuilder(user) {
199 const el = document.createElement("span");
200 el.className = "collab-cursor";
201 el.style.setProperty("--color", user.color);
202 el.dataset.name = user.name ?? "";
203 return el;
204 },
205 }),
206 ],
207 });
208
209 this.view = new EditorView(this.editorRef.current, { state });
210
211 this.provider.value.onMembersChange = editors => {
212 const canEdit = isOwner || editors.includes(this.props.did);
213 this.canEdit.value = canEdit;
214 this.view?.setProps({ editable: () => canEdit });
215 };
216
217 await this.provider.value.load();
218
219 const color = colorForDid(this.props.did);
220 let name = this.props.did;
221 try {
222 const res = await fetch(
223 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(this.props.did)}`,
224 );
225 if (res.ok) {
226 const profile = await res.json();
227 name = profile.displayName || profile.handle || this.props.did;
228 }
229 } catch {}
230 this.provider.value.awareness.setLocalState({ user: { name, color } });
231 }
232
233 componentWillUnmount() {
234 this.provider.value?.destroy();
235 this.view?.destroy();
236 }
237
238 render() {
239 return html`
240 <div id="editor" ref=${this.editorRef}></div>
241 ${!this.canEdit.value &&
242 html`<div class="readonly-banner">
243 You're viewing this document in read-only mode. Ask the owner to click the "Share" button to
244 add you as an editor.
245 </div>`}
246 <${ShareDialog}
247 ref=${this.shareDialogRef}
248 atUri=${this.props.atUri}
249 provider=${this.provider.value}
250 />
251 `;
252 }
253}
254
255class ShareDialog extends Component {
256 dialogRef = createRef();
257 editors = signal([]);
258
259 componentDidMount() {
260 this.dialogRef.current.addEventListener("toggle", async e => {
261 if (e.newState === "open" && this.props.provider) {
262 const dids = this.props.provider.getMembers();
263 this.editors.value = await Promise.all(
264 dids.map(async did => ({ did, profile: await fetchProfile(did) })),
265 );
266 }
267 });
268 }
269
270 async addMember(e) {
271 const form = e.currentTarget;
272 e.preventDefault();
273 const identifier = e.currentTarget.did.value.trim();
274 if (!identifier) return;
275 const newDid = await resolveDID(identifier);
276 const dids = this.props.provider.getMembers();
277 if (dids.includes(newDid)) return;
278 const updated = [...dids, newDid];
279 await this.props.provider.setMembers(updated);
280 this.editors.value = [
281 ...this.editors.value,
282 { did: newDid, profile: await fetchProfile(newDid) },
283 ];
284 form.reset();
285 }
286
287 async removeMember(did) {
288 const updated = this.editors.value.filter(m => m.did !== did).map(m => m.did);
289 await this.props.provider.setMembers(updated);
290 this.editors.value = this.editors.value.filter(m => m.did !== did);
291 }
292
293 render() {
294 return html`
295 <dialog id="share" ref=${this.dialogRef} closedby="any">
296 <header>
297 <h2>Share</h2>
298 <form method="dialog">
299 <button class="icon-button" aria-label="Close">
300 <svg
301 width="16"
302 height="16"
303 viewBox="0 0 16 16"
304 fill="none"
305 stroke="currentColor"
306 stroke-width="2"
307 stroke-linecap="round"
308 >
309 <line x1="4" y1="4" x2="12" y2="12" />
310 <line x1="12" y1="4" x2="4" y2="12" />
311 </svg>
312 </button>
313 </form>
314 </header>
315
316 ${this.editors.value.length
317 ? html`
318 <div>
319 <h3>Editors</h3>
320 <ul id="editors">
321 ${this.editors.value.map(
322 ({ did, profile }) => html`
323 <li key=${did}>
324 ${profile.avatar &&
325 html`<img class="avatar" src=${profile.avatar} alt="" />`}
326 <span class="member-info">
327 ${profile.displayName && html`<strong>${profile.displayName}</strong>`}
328 <small>@${profile.handle}</small>
329 </span>
330 <button type="button" onClick=${() => this.removeMember(did)}>
331 Remove
332 </button>
333 </li>
334 `,
335 )}
336 </ul>
337 </div>
338 `
339 : null}
340 <div class="share-footer">
341 <p>
342 Enter another user's Internet Handle and send them this link to let them collaborate
343 with you:
344 </p>
345
346 <div class="copy-link">
347 <input readonly value=${location.href} tabindex="-1" />
348 <button
349 type="button"
350 class="icon-button"
351 aria-label="Copy link"
352 onClick=${() => navigator.clipboard.writeText(location.href)}
353 >
354 <svg
355 width="16"
356 height="16"
357 viewBox="0 0 16 16"
358 fill="none"
359 stroke="currentColor"
360 stroke-width="2"
361 stroke-linecap="round"
362 stroke-linejoin="round"
363 >
364 <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
365 <path
366 d="M10.5 5.5V3.5a1.5 1.5 0 0 0-1.5-1.5H3.5A1.5 1.5 0 0 0 2 3.5V9a1.5 1.5 0 0 0 1.5 1.5h2"
367 />
368 </svg>
369 </button>
370 </div>
371 <form id="add-member" onSubmit=${e => this.addMember(e)}>
372 <label>
373 <span>Handle</span>
374 <actor-typeahead>
375 <input name="did" placeholder="example.bsky.social" autocomplete="off" />
376 </actor-typeahead>
377 </label>
378 <button type="submit">Add</button>
379 </form>
380 </div>
381 </dialog>
382 `;
383 }
384}