a proof of concept realtime collaborative text editor using atproto as a sync server jake.tngl.io/y-pds/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 384 lines 12 kB view raw
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}