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.

Multi-account support

+277 -20
+135 -14
app.js
··· 1 1 import { Component, createRef } from "preact"; 2 2 import { html } from "htm/preact"; 3 3 import { signal } from "@preact/signals"; 4 - import { getSession, logIn, logOut, resolveDID } from "./atsw.js"; 4 + import { getSession, listSessions, logIn, logOut, resolveDID } from "./atsw.js"; 5 5 import metadata from "./client-metadata.json" with { type: "json" }; 6 6 import * as Y from "yjs"; 7 7 import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from "y-prosemirror"; ··· 126 126 127 127 async componentDidMount() { 128 128 try { 129 - const did = localStorage.getItem("ypds:did"); 130 - if (!did) return; 129 + let did = sessionStorage.getItem("ypds:did"); 130 + if (!did) { 131 + const sessions = await listSessions(); 132 + if (!sessions.length) return; 133 + did = sessions[0].did; 134 + sessionStorage.setItem("ypds:did", did); 135 + } 131 136 132 137 this.loading.value = true; 133 138 ··· 139 144 this.did.value = session.did; 140 145 141 146 const params = new URLSearchParams(location.search); 142 - let uri = params.get("id") ?? localStorage.getItem("ypds:pending-id") ?? ""; 143 - localStorage.removeItem("ypds:pending-id"); 147 + let uri = params.get("id") ?? sessionStorage.getItem("ypds:pending-id") ?? ""; 148 + sessionStorage.removeItem("ypds:pending-id"); 144 149 if (!uri) { 145 150 uri = `at://${this.did.value}/${DOC_COLLECTION}/${genTid()}`; 146 151 } ··· 178 183 const identifier = data.get("handle"); 179 184 if (typeof identifier !== "string") throw new Error("invalid handle"); 180 185 const id = new URLSearchParams(location.search).get("id"); 181 - if (id) localStorage.setItem("ypds:pending-id", id); 186 + if (id) sessionStorage.setItem("ypds:pending-id", id); 182 187 const did = await resolveDID(identifier); 183 - localStorage.setItem("ypds:did", did); 188 + sessionStorage.setItem("ypds:did", did); 184 189 await logIn( 185 190 { 186 191 clientId: metadata.client_id, ··· 219 224 class Editor extends Component { 220 225 editorRef = createRef(); 221 226 shareDialogRef = createRef(); 227 + accountsDialogRef = createRef(); 222 228 provider = signal(null); 223 229 canEdit = signal(true); 224 230 showDevtools = signal(false); ··· 269 275 }); 270 276 wrap.appendChild(dbBtn); 271 277 272 - const logOutBtn = document.createElement("button"); 273 - logOutBtn.textContent = "Log out"; 274 - logOutBtn.addEventListener("click", async () => { 275 - await logOut(this.props.did); 276 - localStorage.removeItem("ypds:did"); 277 - location.reload(); 278 + const accountBtn = document.createElement("button"); 279 + accountBtn.textContent = this.props.did; 280 + accountBtn.addEventListener("click", () => { 281 + this.accountsDialogRef.current.open(); 278 282 }); 279 - wrap.appendChild(logOutBtn); 283 + wrap.appendChild(accountBtn); 284 + this._accountBtn = accountBtn; 280 285 281 286 return wrap; 282 287 }, ··· 332 337 if (res.ok) { 333 338 const profile = await res.json(); 334 339 name = profile.displayName || profile.handle || this.props.did; 340 + if (this._accountBtn && profile.handle) this._accountBtn.textContent = `@${profile.handle}`; 335 341 } 336 342 } catch {} 337 343 this.provider.value.awareness.setLocalState({ user: { name, color } }); ··· 373 379 You're viewing this document in read-only mode. Ask the owner to click the "Share" button to 374 380 add you as an editor. 375 381 </div>`} 382 + <${AccountsPopover} 383 + ref=${this.accountsDialogRef} 384 + did=${this.props.did} 385 + /> 376 386 <${ShareDialog} 377 387 ref=${this.shareDialogRef} 378 388 atUri=${this.props.atUri} ··· 714 724 ${tab === "updates" ? this.renderTimeline() 715 725 : tab === "awareness" ? this.renderAwareness() 716 726 : this.renderDoc()} 727 + </div> 728 + </div> 729 + `; 730 + } 731 + } 732 + 733 + class AccountsPopover extends Component { 734 + dialogRef = createRef(); 735 + sessions = signal([]); 736 + profiles = signal({}); 737 + 738 + async open() { 739 + const sessions = await listSessions(); 740 + this.sessions.value = sessions; 741 + const map = { ...this.profiles.value }; 742 + await Promise.all( 743 + sessions.filter(s => !map[s.did]).map(async s => { 744 + map[s.did] = await fetchProfile(s.did); 745 + }), 746 + ); 747 + this.profiles.value = map; 748 + this.dialogRef.current.showPopover(); 749 + } 750 + 751 + handleSwitch(did) { 752 + sessionStorage.setItem("ypds:did", did); 753 + location.reload(); 754 + } 755 + 756 + async handleLogOut(did) { 757 + await logOut(did); 758 + if (did === this.props.did) { 759 + sessionStorage.removeItem("ypds:did"); 760 + location.reload(); 761 + return; 762 + } 763 + this.sessions.value = this.sessions.value.filter(s => s.did !== did); 764 + } 765 + 766 + async handleAddAccount(e) { 767 + e.preventDefault(); 768 + const data = new FormData(e.currentTarget); 769 + const identifier = data.get("handle"); 770 + if (typeof identifier !== "string" || !identifier.trim()) return; 771 + const id = new URLSearchParams(location.search).get("id"); 772 + if (id) sessionStorage.setItem("ypds:pending-id", id); 773 + await logIn( 774 + { clientId: metadata.client_id, redirectUri: metadata.redirect_uris[0], scope: metadata.scope }, 775 + identifier.trim(), 776 + ); 777 + } 778 + 779 + render() { 780 + const activeDid = this.props.did; 781 + const sessions = [...this.sessions.value].sort((a, b) => { 782 + if (a.did === activeDid) return -1; 783 + if (b.did === activeDid) return 1; 784 + return 0; 785 + }); 786 + const profiles = this.profiles.value; 787 + 788 + return html` 789 + <div ref=${this.dialogRef} popover class="accounts-popover"> 790 + <header> 791 + <h2>Accounts</h2> 792 + <button 793 + class="icon-button" 794 + aria-label="Close" 795 + onClick=${() => this.dialogRef.current.hidePopover()} 796 + > 797 + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 798 + <line x1="4" y1="4" x2="12" y2="12" /> 799 + <line x1="12" y1="4" x2="4" y2="12" /> 800 + </svg> 801 + </button> 802 + </header> 803 + <ul class="accounts-list"> 804 + ${sessions.map(session => { 805 + const profile = profiles[session.did] ?? {}; 806 + const isActive = session.did === activeDid; 807 + return html` 808 + <li key=${session.did} class=${isActive ? "active" : ""}> 809 + <button 810 + class="account-row" 811 + onClick=${() => { if (!isActive) this.handleSwitch(session.did); }} 812 + > 813 + ${profile.avatar 814 + ? html`<img class="avatar" width="32" height="32" src=${profile.avatar} alt="" />` 815 + : html`<div class="avatar avatar-placeholder"></div>`} 816 + <span class="member-info"> 817 + ${profile.displayName && html`<strong>${profile.displayName}</strong>`} 818 + <small>@${profile.handle || session.did}</small> 819 + </span> 820 + </button> 821 + <button type="button" onClick=${() => this.handleLogOut(session.did)}> 822 + Log out 823 + </button> 824 + </li> 825 + `; 826 + })} 827 + </ul> 828 + <div class="accounts-footer"> 829 + <form onSubmit=${e => this.handleAddAccount(e)}> 830 + <label> 831 + <span>Add another account</span> 832 + <actor-typeahead> 833 + <input name="handle" placeholder="example.bsky.social" autocomplete="off" /> 834 + </actor-typeahead> 835 + </label> 836 + <button type="submit">Log in</button> 837 + </form> 717 838 </div> 718 839 </div> 719 840 `;
+142 -6
style.css
··· 34 34 flex: 1; 35 35 } 36 36 37 - button, 38 - .ProseMirror-menuitem .share-menu-button { 37 + button { 39 38 padding: 0.4rem 0.75rem; 40 39 border: 1px solid #ccc; 41 40 border-radius: 4px; ··· 297 296 align-items: flex-end; 298 297 } 299 298 299 + .accounts-popover { 300 + position: fixed; 301 + inset: auto; 302 + top: 2.5rem; 303 + right: 0.5rem; 304 + width: 280px; 305 + padding: 0; 306 + margin: 0; 307 + border: 1px solid #ddd; 308 + border-radius: 10px; 309 + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); 310 + background: #fff; 311 + 312 + &:popover-open { 313 + animation: popover-in 0.1s ease; 314 + } 315 + 316 + header { 317 + display: flex; 318 + justify-content: space-between; 319 + align-items: center; 320 + padding: 10px 12px; 321 + border-bottom: 1px solid #ddd; 322 + } 323 + 324 + h2 { 325 + font-size: 0.875rem; 326 + font-weight: 600; 327 + margin: 0; 328 + } 329 + } 330 + 331 + @keyframes popover-in { 332 + from { 333 + opacity: 0; 334 + transform: translateY(-4px); 335 + } 336 + to { 337 + opacity: 1; 338 + transform: translateY(0); 339 + } 340 + } 341 + 342 + .accounts-list { 343 + list-style: none; 344 + margin: 0; 345 + padding: 8px 0; 346 + display: flex; 347 + flex-direction: column; 348 + 349 + > li { 350 + display: flex; 351 + align-items: center; 352 + gap: 4px; 353 + padding: 0 8px; 354 + 355 + &:not(.active):hover { 356 + background: #f0f0f0; 357 + } 358 + 359 + &.active { 360 + background: #e8f0fe; 361 + 362 + .account-row { 363 + cursor: default; 364 + } 365 + } 366 + } 367 + } 368 + 369 + .account-row { 370 + all: unset; 371 + flex: 1; 372 + display: flex; 373 + align-items: center; 374 + gap: 0.5rem; 375 + padding: 0.4rem 0; 376 + cursor: pointer; 377 + min-width: 0; 378 + 379 + &:hover { 380 + background: transparent; 381 + } 382 + 383 + .avatar { 384 + border-radius: 50%; 385 + flex-shrink: 0; 386 + } 387 + 388 + .avatar-placeholder { 389 + width: 32px; 390 + height: 32px; 391 + border-radius: 50%; 392 + background: #e0e0e0; 393 + flex-shrink: 0; 394 + } 395 + 396 + .member-info { 397 + flex: 1; 398 + display: flex; 399 + flex-direction: column; 400 + min-width: 0; 401 + overflow: hidden; 402 + 403 + strong { 404 + font-size: 0.875rem; 405 + text-overflow: ellipsis; 406 + overflow: hidden; 407 + white-space: nowrap; 408 + } 409 + 410 + small { 411 + font-size: 0.75rem; 412 + color: #888; 413 + text-overflow: ellipsis; 414 + overflow: hidden; 415 + white-space: nowrap; 416 + } 417 + } 418 + } 419 + 420 + .accounts-footer { 421 + border-top: 1px solid #ddd; 422 + padding: 10px 12px; 423 + 424 + form { 425 + display: flex; 426 + gap: 0.5rem; 427 + align-items: flex-end; 428 + } 429 + } 430 + 300 431 .readonly-banner { 301 432 position: fixed; 302 433 bottom: 1rem; ··· 353 484 flex-direction: column; 354 485 border-top: 1px solid #ddd; 355 486 background: #fff; 356 - font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, 357 - "DejaVu Sans Mono", monospace; 487 + font-family: 488 + ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace; 358 489 font-size: 12px; 359 490 } 360 491 ··· 443 574 border-bottom: 2px solid transparent; 444 575 white-space: nowrap; 445 576 446 - &:hover { color: #333; } 447 - &.active { color: #1a73e8; border-bottom-color: #1a73e8; } 577 + &:hover { 578 + color: #333; 579 + } 580 + &.active { 581 + color: #1a73e8; 582 + border-bottom-color: #1a73e8; 583 + } 448 584 } 449 585 } 450 586