a proof of concept realtime collaborative text editor using atproto as a sync server
jake.tngl.io/y-pds/
11 kB
365 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 view = null;
139
140 async componentDidMount() {
141 const session = await getSession(this.props.did, { allowStale: false });
142
143 const ydoc = new Y.Doc();
144 const yxml = ydoc.getXmlFragment("prosemirror");
145
146 this.provider.value = new YPdsProvider({
147 ydoc,
148 client: client(session),
149 atUri: this.props.atUri,
150 did: this.props.did,
151 });
152
153 const ownerDid = this.props.atUri.slice("at://".length).split("/")[0];
154 const isOwner = ownerDid === this.props.did;
155
156 const menuItems = buildMenuItems(schema);
157 const shareItem = new MenuItem({
158 title: "Share document",
159 select: () => isOwner,
160 run: () => void 0,
161 render: () => {
162 const btn = document.createElement("button");
163 btn.textContent = "Share";
164 btn.className = "share-menu-button";
165 btn.commandForElement = this.shareDialogRef.current.dialogRef.current;
166 btn.command = "show-modal";
167 return btn;
168 },
169 });
170 const menuContent = [
171 ...menuItems.inlineMenu,
172 [menuItems.typeMenu],
173 [undoItem, redoItem],
174 [
175 menuItems.wrapBulletList,
176 menuItems.wrapOrderedList,
177 menuItems.wrapBlockQuote,
178 joinUpItem,
179 liftItem,
180 ].filter(Boolean),
181 [shareItem],
182 ];
183
184 const state = EditorState.create({
185 schema,
186 plugins: [
187 ...exampleSetup({ schema, history: false, menuContent }),
188 ySyncPlugin(yxml),
189 yUndoPlugin(),
190 yCursorPlugin(this.provider.value.awareness, {
191 cursorBuilder(user) {
192 const el = document.createElement("span");
193 el.className = "collab-cursor";
194 el.style.setProperty("--color", user.color);
195 el.dataset.name = user.name ?? "";
196 return el;
197 },
198 }),
199 ],
200 });
201
202 this.view = new EditorView(this.editorRef.current, { state });
203 await this.provider.value.load();
204
205 const color = colorForDid(this.props.did);
206 let name = this.props.did;
207 try {
208 const res = await fetch(
209 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(this.props.did)}`,
210 );
211 if (res.ok) {
212 const profile = await res.json();
213 name = profile.displayName || profile.handle || this.props.did;
214 }
215 } catch {}
216 this.provider.value.awareness.setLocalState({ user: { name, color } });
217 }
218
219 componentWillUnmount() {
220 this.provider.value?.destroy();
221 this.view?.destroy();
222 }
223
224 render() {
225 return html`
226 <div id="editor" ref=${this.editorRef}></div>
227 <${ShareDialog}
228 ref=${this.shareDialogRef}
229 atUri=${this.props.atUri}
230 provider=${this.provider.value}
231 />
232 `;
233 }
234}
235
236class ShareDialog extends Component {
237 dialogRef = createRef();
238 editors = signal([]);
239
240 componentDidMount() {
241 this.dialogRef.current.addEventListener("toggle", async e => {
242 if (e.newState === "open" && this.props.provider) {
243 const dids = this.props.provider.getMembers();
244 this.editors.value = await Promise.all(
245 dids.map(async did => ({ did, profile: await fetchProfile(did) })),
246 );
247 }
248 });
249 }
250
251 async addMember(e) {
252 const form = e.currentTarget;
253 e.preventDefault();
254 const identifier = e.currentTarget.did.value.trim();
255 if (!identifier) return;
256 const newDid = await resolve(identifier);
257 const dids = this.props.provider.getMembers();
258 if (dids.includes(newDid)) return;
259 const updated = [...dids, newDid];
260 await this.props.provider.setMembers(updated);
261 this.editors.value = [
262 ...this.editors.value,
263 { did: newDid, profile: await fetchProfile(newDid) },
264 ];
265 form.reset();
266 }
267
268 async removeMember(did) {
269 const updated = this.editors.value.filter(m => m.did !== did).map(m => m.did);
270 await this.props.provider.setMembers(updated);
271 this.editors.value = this.editors.value.filter(m => m.did !== did);
272 }
273
274 render() {
275 return html`
276 <dialog id="share" ref=${this.dialogRef} closedby="any">
277 <header>
278 <h2>Share</h2>
279 <form method="dialog">
280 <button class="icon-button" aria-label="Close">
281 <svg
282 width="16"
283 height="16"
284 viewBox="0 0 16 16"
285 fill="none"
286 stroke="currentColor"
287 stroke-width="2"
288 stroke-linecap="round"
289 >
290 <line x1="4" y1="4" x2="12" y2="12" />
291 <line x1="12" y1="4" x2="4" y2="12" />
292 </svg>
293 </button>
294 </form>
295 </header>
296
297 ${this.editors.value.length
298 ? html`
299 <div>
300 <h3>Editors</h3>
301 <ul id="editors">
302 ${this.editors.value.map(
303 ({ did, profile }) => html`
304 <li key=${did}>
305 ${profile.avatar &&
306 html`<img class="avatar" src=${profile.avatar} alt="" />`}
307 <span class="member-info">
308 ${profile.displayName && html`<strong>${profile.displayName}</strong>`}
309 <small>@${profile.handle}</small>
310 </span>
311 <button type="button" onClick=${() => this.removeMember(did)}>
312 Remove
313 </button>
314 </li>
315 `,
316 )}
317 </ul>
318 </div>
319 `
320 : null}
321 <div class="share-footer">
322 <p>
323 Enter another user's Internet Handle and send them this link to let them collaborate
324 with you:
325 </p>
326
327 <div class="copy-link">
328 <input readonly value=${location.href} tabindex="-1" />
329 <button
330 type="button"
331 class="icon-button"
332 aria-label="Copy link"
333 onClick=${() => navigator.clipboard.writeText(location.href)}
334 >
335 <svg
336 width="16"
337 height="16"
338 viewBox="0 0 16 16"
339 fill="none"
340 stroke="currentColor"
341 stroke-width="2"
342 stroke-linecap="round"
343 stroke-linejoin="round"
344 >
345 <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
346 <path
347 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"
348 />
349 </svg>
350 </button>
351 </div>
352 <form id="add-member" onSubmit=${e => this.addMember(e)}>
353 <label>
354 <span>Handle</span>
355 <actor-typeahead>
356 <input name="did" placeholder="example.bsky.social" autocomplete="off" />
357 </actor-typeahead>
358 </label>
359 <button type="submit">Add</button>
360 </form>
361 </div>
362 </dialog>
363 `;
364 }
365}