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

Configure Feed

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

ypds / app.js
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}