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

Configure Feed

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

Improve styles a bit

+70 -28
+4
.prettierrc.json
··· 1 + { 2 + "arrowParens": "avoid", 3 + "printWidth": 100 4 + }
+27 -13
app.js
··· 9 9 import { EditorView } from "prosemirror-view"; 10 10 import { exampleSetup } from "prosemirror-example-setup"; 11 11 import { configure, client, resolve, scope } from "./oauth.js"; 12 - import { YPdsProvider, newDocUri } from "./y-pds.js"; 12 + import { YPdsProvider } from "./y-pds.js"; 13 13 import "actor-typeahead"; 14 14 15 15 configure(); 16 16 17 17 export class App extends Component { 18 - did = signal(undefined); 19 - atUri = signal(null); 18 + loading = signal(false); 19 + did = signal(""); 20 + atUri = signal(""); 20 21 21 22 async componentDidMount() { 22 23 try { 23 - const storedDid = localStorage.getItem("ypds:did"); 24 - const session = await getSession(storedDid, { allowStale: false }); 24 + const did = localStorage.getItem("ypds:did"); 25 + if (!did) return; 26 + 27 + this.loading.value = true; 28 + 29 + const session = await getSession(did, { allowStale: false }); 25 30 this.did.value = session.info.sub; 31 + 26 32 const params = new URLSearchParams(location.search); 27 - if (!params.has("id")) { 28 - params.set("id", newDocUri(this.did.value)); 33 + let uri = params.get("id") ?? ""; 34 + if (!uri) { 35 + uri = `at://${this.did.value}/${DOC_COLLECTION}/${crypto.randomUUID()}`; 36 + params.set("id", uri); 29 37 history.replaceState(null, "", "?" + params); 30 38 } 31 - this.atUri.value = params.get("id"); 32 - } catch { 33 - this.did.value = null; 39 + 40 + this.atUri.value = uri; 41 + } catch (e) { 42 + console.error(e); 43 + this.did.value = ""; 44 + } finally { 45 + this.loading.value = false; 34 46 } 35 47 } 36 48 37 49 render() { 38 - if (this.did.value === undefined) return html`<p>loading…</p>`; 50 + if (this.loading.value) return html`<p>loading…</p>`; 39 51 if (!this.did.value) return html`<${Login} />`; 40 - if (!this.atUri.value) return null; 41 52 return html`<${Editor} did=${this.did.value} atUri=${this.atUri.value} />`; 42 53 } 43 54 } ··· 63 74 <input name="handle" /> 64 75 </actor-typeahead> 65 76 <button>log in</button> 77 + <p>Log in with your <a href="https://internethandle.org">Internet handle</a>.</p> 66 78 </form> 67 79 `; 68 80 } ··· 113 125 render() { 114 126 return html` 115 127 <div id="editor" ref=${this.editorRef}></div> 116 - <button id="share" onClick=${() => this.shareDialogRef.current.open(this.provider)}>Share</button> 128 + <button id="share" onClick=${() => this.shareDialogRef.current.open(this.provider)}> 129 + Share 130 + </button> 117 131 <${ShareDialog} ref=${this.shareDialogRef} /> 118 132 `; 119 133 }
+33 -4
style.css
··· 9 9 body { 10 10 height: 100%; 11 11 } 12 + 13 + html { 14 + font-family: 15 + system-ui, 16 + -apple-system, 17 + BlinkMacSystemFont, 18 + "Segoe UI", 19 + Roboto, 20 + Oxygen, 21 + Ubuntu, 22 + Cantarell, 23 + "Open Sans", 24 + "Helvetica Neue", 25 + sans-serif; 26 + } 27 + 12 28 body { 13 29 display: flex; 14 30 flex-direction: column; 15 31 } 32 + 16 33 #app { 17 34 flex: 1; 18 35 display: flex; 19 36 flex-direction: column; 20 37 } 38 + 21 39 #editor { 22 40 flex: 1; 23 41 overflow-y: auto; 24 42 padding: 0; 25 - font-family: Georgia, serif; 43 + font-family: ui-serif, Georgia, serif; 26 44 font-size: 1rem; 27 45 line-height: 1.6; 28 46 } 47 + 29 48 .ProseMirror { 30 49 min-height: 100%; 31 50 outline: none; 32 - } 33 - .ProseMirror p { 34 - margin-bottom: 1em; 51 + padding: 1rem; 52 + margin-trim: block; 53 + 54 + > :first-child, 55 + > :first-child p { 56 + margin-top: 0; 57 + } 58 + 59 + > :last-child, 60 + > :last-child p { 61 + margin-bottom: 0; 62 + } 35 63 } 64 + 36 65 .ProseMirror-menubar-wrapper { 37 66 height: 100%; 38 67 }
+6 -11
y-pds.js
··· 7 7 const url = did.startsWith("did:web:") 8 8 ? `https://${did.slice("did:web:".length)}/.well-known/did.json` 9 9 : `https://plc.directory/${did}`; 10 - const doc = await fetch(url).then(r => r.json()); 11 - const pds = doc.service?.find(s => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 10 + const doc = await fetch(url).then((r) => r.json()); 11 + const pds = doc.service?.find((s) => s.type === "AtprotoPersonalDataServer")?.serviceEndpoint; 12 12 if (!pds) throw new Error(`no PDS found for ${did}`); 13 13 return new Client({ handler: simpleFetchHandler({ service: pds }) }); 14 14 } ··· 18 18 const CURSOR_COLLECTION = "com.jakelazaroff.ypds.awareness"; 19 19 20 20 const encode = (/** @type {Uint8Array} */ update) => btoa(String.fromCharCode(...update)); 21 - const decode = (/** @type {string} */ b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); 22 - 23 - /** @param {string} did */ 24 - export function newDocUri(did) { 25 - return `at://${did}/${DOC_COLLECTION}/${crypto.randomUUID()}`; 26 - } 21 + const decode = (/** @type {string} */ b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); 27 22 28 23 /** @param {string} did */ 29 24 function colorForDid(did) { ··· 68 63 const doc = await this.#ensureDocument(); 69 64 const repos = [this.#ownerDid, ...doc.members]; 70 65 71 - const perRepo = await Promise.all(repos.map(repo => this.#fetchUpdates(repo))); 66 + const perRepo = await Promise.all(repos.map((repo) => this.#fetchUpdates(repo))); 72 67 const updates = perRepo 73 68 .flat() 74 69 .sort((a, b) => a.value.createdAt.localeCompare(b.value.createdAt)); ··· 111 106 cursor = res.data.cursor; 112 107 } while (cursor); 113 108 114 - return records.filter(r => r.value.docId === this.#rkey); 109 + return records.filter((r) => r.value.docId === this.#rkey); 115 110 } 116 111 117 112 /** @param {Uint8Array} update @param {unknown} origin */ ··· 209 204 for (const repo of repos) url.searchParams.append("wantedDids", repo); 210 205 211 206 this.#ws = new WebSocket(url); 212 - this.#ws.onmessage = async e => { 207 + this.#ws.onmessage = async (e) => { 213 208 const event = JSON.parse(e.data); 214 209 if (event.kind !== "commit") return; 215 210 if (!repos.has(event.did)) return;