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