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
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}