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.

Redirect back to doc

+20 -15
+7 -2
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 } from "./y-pds.js"; 12 + import { DOC_COLLECTION, YPdsProvider } from "./y-pds.js"; 13 13 import "actor-typeahead"; 14 14 15 15 configure(); ··· 39 39 this.did.value = session.info.sub; 40 40 41 41 const params = new URLSearchParams(location.search); 42 - let uri = params.get("id") ?? ""; 42 + let uri = params.get("id") ?? localStorage.getItem("ypds:pending-id") ?? ""; 43 + localStorage.removeItem("ypds:pending-id"); 43 44 if (!uri) { 44 45 uri = `at://${this.did.value}/${DOC_COLLECTION}/${crypto.randomUUID()}`; 46 + } 47 + if (!params.has("id") || params.get("id") !== uri) { 45 48 params.set("id", uri); 46 49 history.replaceState(null, "", "?" + params); 47 50 } ··· 68 71 const data = new FormData(e.currentTarget); 69 72 const identifier = data.get("handle"); 70 73 if (typeof identifier !== "string") throw new Error("invalid handle"); 74 + const id = new URLSearchParams(location.search).get("id"); 75 + if (id) localStorage.setItem("ypds:pending-id", id); 71 76 const authUrl = await createAuthorizationUrl({ 72 77 target: { type: "account", identifier }, 73 78 scope,
+13 -13
y-pds.js
··· 13 13 return new Client({ handler: simpleFetchHandler({ service: pds }) }); 14 14 } 15 15 16 - const COLLECTION = "com.jakelazaroff.ypds.update"; 17 - const DOC_COLLECTION = "com.jakelazaroff.ypds.doc"; 18 - const CURSOR_COLLECTION = "com.jakelazaroff.ypds.awareness"; 16 + export const DOC_COLLECTION = "com.jakelazaroff.ypds.doc"; 17 + export const UPDATE_COLLECTION = "com.jakelazaroff.ypds.update"; 18 + export const AWARENESS_COLLECTION = "com.jakelazaroff.ypds.awareness"; 19 19 20 20 const encode = (/** @type {Uint8Array} */ update) => btoa(String.fromCharCode(...update)); 21 21 const decode = (/** @type {string} */ b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0)); ··· 85 85 86 86 do { 87 87 const res = await rpc.get("com.atproto.repo.listRecords", { 88 - params: { repo, collection: COLLECTION, limit: 100, cursor }, 88 + params: { repo, collection: UPDATE_COLLECTION, limit: 100, cursor }, 89 89 }); 90 90 records.push(...res.data.records); 91 91 cursor = res.data.cursor; ··· 101 101 params: {}, 102 102 input: { 103 103 repo: this.did, 104 - collection: COLLECTION, 104 + collection: UPDATE_COLLECTION, 105 105 record: { 106 - $type: COLLECTION, 106 + $type: UPDATE_COLLECTION, 107 107 docId: this.#rkey, 108 108 update: encode(update), 109 109 createdAt: new Date().toISOString(), ··· 126 126 params: {}, 127 127 input: { 128 128 repo: this.did, 129 - collection: CURSOR_COLLECTION, 129 + collection: AWARENESS_COLLECTION, 130 130 rkey: this.#rkey, 131 131 record: { 132 - $type: CURSOR_COLLECTION, 132 + $type: AWARENESS_COLLECTION, 133 133 docId: this.#rkey, 134 134 awareness: encode(update), 135 135 createdAt: new Date().toISOString(), ··· 187 187 188 188 #subscribe() { 189 189 const url = new URL(this.#jetstream); 190 - url.searchParams.append("wantedCollections", COLLECTION); 191 - url.searchParams.append("wantedCollections", CURSOR_COLLECTION); 190 + url.searchParams.append("wantedCollections", UPDATE_COLLECTION); 191 + url.searchParams.append("wantedCollections", AWARENESS_COLLECTION); 192 192 url.searchParams.append("wantedCollections", DOC_COLLECTION); 193 193 for (const repo of this.#repos) url.searchParams.append("wantedDids", repo); 194 194 ··· 205 205 206 206 if (!this.#repos.has(event.did)) return; 207 207 208 - if (event.commit.collection === COLLECTION) { 208 + if (event.commit.collection === UPDATE_COLLECTION) { 209 209 if (event.commit.operation !== "create") return; 210 210 if (event.commit.record.docId !== this.#rkey) return; 211 211 212 212 // verify: re-fetch the record and confirm the CID matches 213 213 const rpc = event.did === this.did ? this.rpc : await clientForDid(event.did); 214 214 const res = await rpc.get("com.atproto.repo.getRecord", { 215 - params: { repo: event.did, collection: COLLECTION, rkey: event.commit.rkey }, 215 + params: { repo: event.did, collection: UPDATE_COLLECTION, rkey: event.commit.rkey }, 216 216 }); 217 217 if (!res.ok || res.data.cid !== event.commit.cid) return; 218 218 219 219 Y.applyUpdate(this.#ydoc, decode(event.commit.record.update), this); 220 - } else if (event.commit.collection === CURSOR_COLLECTION) { 220 + } else if (event.commit.collection === AWARENESS_COLLECTION) { 221 221 if (event.did === this.did) return; 222 222 if (event.commit.record.docId !== this.#rkey) return; 223 223 applyAwarenessUpdate(this.awareness, decode(event.commit.record.awareness), "remote");