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.

Single-page apps are good actually

+378 -303
+177
app.js
··· 1 + import { Component, createRef } from "preact"; 2 + import { html } from "htm/preact"; 3 + import { signal } from "@preact/signals"; 4 + import { getSession, createAuthorizationUrl } from "@atcute/oauth-browser-client"; 5 + import * as Y from "yjs"; 6 + import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from "y-prosemirror"; 7 + import { schema } from "prosemirror-schema-basic"; 8 + import { EditorState } from "prosemirror-state"; 9 + import { EditorView } from "prosemirror-view"; 10 + import { exampleSetup } from "prosemirror-example-setup"; 11 + import { configure, client, resolve, scope } from "./oauth.js"; 12 + import { YPdsProvider, newDocUri } from "./y-pds.js"; 13 + import "actor-typeahead"; 14 + 15 + configure(); 16 + 17 + export class App extends Component { 18 + did = signal(undefined); 19 + atUri = signal(null); 20 + 21 + async componentDidMount() { 22 + try { 23 + const storedDid = localStorage.getItem("ypds:did"); 24 + const session = await getSession(storedDid, { allowStale: false }); 25 + this.did.value = session.info.sub; 26 + const params = new URLSearchParams(location.search); 27 + if (!params.has("id")) { 28 + params.set("id", newDocUri(this.did.value)); 29 + history.replaceState(null, "", "?" + params); 30 + } 31 + this.atUri.value = params.get("id"); 32 + } catch { 33 + this.did.value = null; 34 + } 35 + } 36 + 37 + render() { 38 + if (this.did.value === undefined) return html`<p>loading…</p>`; 39 + if (!this.did.value) return html`<${Login} />`; 40 + if (!this.atUri.value) return null; 41 + return html`<${Editor} did=${this.did.value} atUri=${this.atUri.value} />`; 42 + } 43 + } 44 + 45 + class Login extends Component { 46 + async onSubmit(e) { 47 + e.preventDefault(); 48 + const data = new FormData(e.currentTarget); 49 + const identifier = data.get("handle"); 50 + if (typeof identifier !== "string") throw new Error("invalid handle"); 51 + const authUrl = await createAuthorizationUrl({ 52 + target: { type: "account", identifier }, 53 + scope, 54 + }); 55 + await new Promise(r => setTimeout(r, 100)); 56 + window.location.assign(authUrl); 57 + } 58 + 59 + render() { 60 + return html` 61 + <form onSubmit=${e => this.onSubmit(e)}> 62 + <actor-typeahead> 63 + <input name="handle" /> 64 + </actor-typeahead> 65 + <button>log in</button> 66 + </form> 67 + `; 68 + } 69 + } 70 + 71 + class Editor extends Component { 72 + editorRef = createRef(); 73 + shareDialogRef = createRef(); 74 + provider = null; 75 + view = null; 76 + 77 + async componentDidMount() { 78 + const session = await getSession(this.props.did, { allowStale: false }); 79 + const rpc = client(session); 80 + 81 + const ydoc = new Y.Doc(); 82 + const yxml = ydoc.getXmlFragment("prosemirror"); 83 + 84 + this.provider = new YPdsProvider(ydoc, this.props.atUri, { rpc, did: this.props.did }); 85 + 86 + const state = EditorState.create({ 87 + schema, 88 + plugins: [ 89 + ...exampleSetup({ schema, history: false }), 90 + ySyncPlugin(yxml), 91 + yUndoPlugin(), 92 + yCursorPlugin(this.provider.awareness, { 93 + cursorBuilder(user) { 94 + const el = document.createElement("span"); 95 + el.className = "collab-cursor"; 96 + el.style.setProperty("--color", user.color); 97 + el.dataset.name = user.name ?? ""; 98 + return el; 99 + }, 100 + }), 101 + ], 102 + }); 103 + 104 + this.view = new EditorView(this.editorRef.current, { state }); 105 + await this.provider.load(); 106 + } 107 + 108 + componentWillUnmount() { 109 + this.provider?.destroy(); 110 + this.view?.destroy(); 111 + } 112 + 113 + render() { 114 + return html` 115 + <div id="editor" ref=${this.editorRef}></div> 116 + <button id="share" onClick=${() => this.shareDialogRef.current.open(this.provider)}>Share</button> 117 + <${ShareDialog} ref=${this.shareDialogRef} /> 118 + `; 119 + } 120 + } 121 + 122 + class ShareDialog extends Component { 123 + dialogRef = createRef(); 124 + members = signal([]); 125 + provider = null; 126 + 127 + async open(provider) { 128 + this.provider = provider; 129 + const doc = await provider.getDocument(); 130 + this.members.value = doc.members; 131 + this.dialogRef.current.showModal(); 132 + } 133 + 134 + async addMember(e) { 135 + e.preventDefault(); 136 + const identifier = e.currentTarget.did.value.trim(); 137 + if (!identifier) return; 138 + const newDid = await resolve(identifier); 139 + const doc = await this.provider.getDocument(); 140 + if (doc.members.includes(newDid)) return; 141 + const updated = [...doc.members, newDid]; 142 + await this.provider.updateMembers(updated); 143 + this.members.value = updated; 144 + e.currentTarget.reset(); 145 + } 146 + 147 + async removeMember(memberDid) { 148 + const updated = this.members.value.filter(m => m !== memberDid); 149 + await this.provider.updateMembers(updated); 150 + this.members.value = updated; 151 + } 152 + 153 + render() { 154 + return html` 155 + <dialog ref=${this.dialogRef}> 156 + <h2>Share</h2> 157 + <ul id="members"> 158 + ${this.members.value.map( 159 + m => html` 160 + <li key=${m}> 161 + <span>${m}</span> 162 + <button type="button" onClick=${() => this.removeMember(m)}>Remove</button> 163 + </li> 164 + `, 165 + )} 166 + </ul> 167 + <form id="add-member" onSubmit=${e => this.addMember(e)}> 168 + <actor-typeahead> 169 + <input name="did" placeholder="Search for a user…" autocomplete="off" /> 170 + </actor-typeahead> 171 + <button type="submit">Add</button> 172 + </form> 173 + <button onClick=${() => this.dialogRef.current.close()}>Close</button> 174 + </dialog> 175 + `; 176 + } 177 + }
+1 -1
callback.html
··· 19 19 finalizeAuthorization(params) 20 20 .then((agent) => { 21 21 localStorage.setItem("ypds:did", agent.session.info.sub); 22 - window.location.assign("/doc.html?id=" + crypto.randomUUID()); 22 + window.location.assign("/"); 23 23 }) 24 24 .catch((err) => console.error(err)); 25 25 </script>
+3 -3
client-metadata.json
··· 1 1 { 2 - "client_id": "https://25bf-66-108-106-210.ngrok-free.app/client-metadata.json", 3 - "client_uri": "https://25bf-66-108-106-210.ngrok-free.app", 4 - "redirect_uris": ["https://25bf-66-108-106-210.ngrok-free.app/callback.html"], 2 + "client_id": "https://4da7-66-108-106-210.ngrok-free.app/client-metadata.json", 3 + "client_uri": "https://4da7-66-108-106-210.ngrok-free.app", 4 + "redirect_uris": ["https://4da7-66-108-106-210.ngrok-free.app/callback.html"], 5 5 "application_type": "native", 6 6 "client_name": "atrtc demo", 7 7 "dpop_bound_access_tokens": true,
-268
doc.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <title>yjs via pds</title> 5 - <meta charset="utf8" /> 6 - <style> 7 - * { 8 - box-sizing: border-box; 9 - margin: 0; 10 - padding: 0; 11 - } 12 - html, 13 - body { 14 - height: 100%; 15 - } 16 - body { 17 - display: flex; 18 - flex-direction: column; 19 - } 20 - #editor { 21 - flex: 1; 22 - overflow-y: auto; 23 - padding: 2rem; 24 - font-family: Georgia, serif; 25 - font-size: 1rem; 26 - line-height: 1.6; 27 - } 28 - .ProseMirror { 29 - min-height: 100%; 30 - outline: none; 31 - } 32 - .ProseMirror p { 33 - margin-bottom: 1em; 34 - } 35 - </style> 36 - <script type="importmap"> 37 - { 38 - "imports": { 39 - "@atcute/client": "https://esm.sh/@atcute/client", 40 - "@atcute/identity-resolver": "https://esm.sh/@atcute/identity-resolver", 41 - "@atcute/oauth-browser-client": "https://esm.sh/@atcute/oauth-browser-client@3.0.0", 42 - "prosemirror-state": "https://esm.sh/prosemirror-state", 43 - "prosemirror-view": "https://esm.sh/prosemirror-view", 44 - "prosemirror-model": "https://esm.sh/prosemirror-model", 45 - "prosemirror-schema-basic": "https://esm.sh/prosemirror-schema-basic", 46 - "prosemirror-example-setup": "https://esm.sh/prosemirror-example-setup", 47 - "yjs": "https://esm.sh/yjs", 48 - "y-prosemirror": "https://esm.sh/y-prosemirror", 49 - "y-protocols/awareness": "https://esm.sh/y-protocols/awareness", 50 - "actor-typeahead": "https://esm.sh/actor-typeahead" 51 - } 52 - } 53 - </script> 54 - <link rel="stylesheet" href="https://esm.sh/prosemirror-view/style/prosemirror.css" /> 55 - <link rel="stylesheet" href="https://esm.sh/prosemirror-menu/style/menu.css" /> 56 - <link rel="stylesheet" href="https://esm.sh/prosemirror-example-setup/style/style.css" /> 57 - <link rel="stylesheet" href="https://esm.sh/prosemirror-gapcursor/style/gapcursor.css" /> 58 - <script type="module"> 59 - import { schema } from "prosemirror-schema-basic"; 60 - import { EditorState } from "prosemirror-state"; 61 - import { EditorView } from "prosemirror-view"; 62 - import { exampleSetup } from "prosemirror-example-setup"; 63 - import * as Y from "yjs"; 64 - import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from "y-prosemirror"; 65 - import { getSession } from "@atcute/oauth-browser-client"; 66 - import { configure, client, resolve } from "./oauth.js"; 67 - import { YPdsProvider, newDocUri } from "./y-pds.js"; 68 - import "actor-typeahead"; 69 - 70 - configure(); 71 - 72 - const did = localStorage.getItem("ypds:did"); 73 - const session = await getSession(did, { allowStale: false }); 74 - const rpc = client(session); 75 - 76 - const params = new URLSearchParams(location.search); 77 - if (!params.has("id")) { 78 - params.set("id", newDocUri(did)); 79 - history.replaceState(null, "", "?" + params); 80 - } 81 - const atUri = params.get("id"); 82 - 83 - const ydoc = new Y.Doc(); 84 - const yxml = ydoc.getXmlFragment("prosemirror"); 85 - 86 - // provider must be created before the editor so we can pass its awareness to yCursorPlugin 87 - const provider = new YPdsProvider(ydoc, atUri, { rpc, did }); 88 - 89 - const state = EditorState.create({ 90 - schema, 91 - plugins: [ 92 - ...exampleSetup({ schema, history: false }), 93 - ySyncPlugin(yxml), 94 - yUndoPlugin(), 95 - yCursorPlugin(provider.awareness, { 96 - cursorBuilder(user) { 97 - const el = document.createElement("span"); 98 - el.className = "collab-cursor"; 99 - el.style.setProperty("--color", user.color); 100 - el.dataset.name = user.name ?? ""; 101 - return el; 102 - }, 103 - }), 104 - ], 105 - }); 106 - 107 - const view = new EditorView(document.querySelector("#editor"), { state }); 108 - 109 - await provider.load(); 110 - 111 - const shareBtn = document.querySelector("#share"); 112 - const shareDialog = document.querySelector("#share-dialog"); 113 - const membersList = document.querySelector("#members"); 114 - const addMemberForm = document.querySelector("#add-member"); 115 - 116 - shareBtn.addEventListener("click", async () => { 117 - const doc = await provider.getDocument(); 118 - renderMembers(doc.members); 119 - shareDialog.showModal(); 120 - }); 121 - 122 - function renderMembers(members) { 123 - membersList.innerHTML = ""; 124 - for (const memberDid of members) { 125 - const li = document.createElement("li"); 126 - const span = document.createElement("span"); 127 - span.textContent = memberDid; 128 - const removeBtn = document.createElement("button"); 129 - removeBtn.type = "button"; 130 - removeBtn.textContent = "Remove"; 131 - removeBtn.addEventListener("click", async () => { 132 - const updated = members.filter(m => m !== memberDid); 133 - await provider.updateMembers(updated); 134 - renderMembers(updated); 135 - }); 136 - li.append(span, removeBtn); 137 - membersList.append(li); 138 - } 139 - } 140 - 141 - addMemberForm.addEventListener("submit", async e => { 142 - e.preventDefault(); 143 - const identifier = addMemberForm.did.value.trim(); 144 - if (!identifier) return; 145 - const newDid = await resolve(identifier); 146 - const doc = await provider.getDocument(); 147 - if (doc.members.includes(newDid)) return; 148 - const updated = [...doc.members, newDid]; 149 - await provider.updateMembers(updated); 150 - renderMembers(updated); 151 - addMemberForm.reset(); 152 - }); 153 - 154 - document.querySelector("#close-dialog").addEventListener("click", () => { 155 - shareDialog.close(); 156 - }); 157 - </script> 158 - <style> 159 - #editor { 160 - padding: 0; 161 - } 162 - 163 - .ProseMirror-menubar-wrapper { 164 - height: 100%; 165 - } 166 - 167 - #share { 168 - position: fixed; 169 - top: 0.6rem; 170 - right: 0.75rem; 171 - z-index: 10; 172 - } 173 - 174 - #share-dialog { 175 - min-width: 320px; 176 - padding: 1.5rem; 177 - border: 1px solid #ccc; 178 - border-radius: 8px; 179 - } 180 - 181 - #share-dialog h2 { 182 - margin-bottom: 1rem; 183 - font-size: 1rem; 184 - } 185 - 186 - #members { 187 - list-style: none; 188 - margin-bottom: 1rem; 189 - } 190 - 191 - #members li { 192 - display: flex; 193 - align-items: center; 194 - gap: 0.5rem; 195 - padding: 0.25rem 0; 196 - } 197 - 198 - #members li span { 199 - flex: 1; 200 - font-size: 0.875rem; 201 - font-family: monospace; 202 - overflow: hidden; 203 - text-overflow: ellipsis; 204 - } 205 - 206 - #add-member { 207 - display: flex; 208 - gap: 0.5rem; 209 - margin-bottom: 1rem; 210 - } 211 - 212 - #add-member input { 213 - flex: 1; 214 - font: inherit; 215 - font-size: 0.875rem; 216 - padding: 0.25rem 0.5rem; 217 - border: 1px solid #ccc; 218 - border-radius: 4px; 219 - } 220 - 221 - #share-dialog button { 222 - font: inherit; 223 - font-size: 0.875rem; 224 - padding: 0.25rem 0.75rem; 225 - border: 1px solid #ccc; 226 - border-radius: 4px; 227 - cursor: pointer; 228 - } 229 - 230 - .collab-cursor { 231 - position: relative; 232 - border-left: 2px solid var(--color); 233 - margin-left: -1px; 234 - pointer-events: none; 235 - } 236 - 237 - .collab-cursor::after { 238 - content: attr(data-name); 239 - position: absolute; 240 - top: -1.4em; 241 - left: -2px; 242 - background: var(--color); 243 - color: white; 244 - font-size: 0.7rem; 245 - font-family: sans-serif; 246 - padding: 0 4px; 247 - border-radius: 3px 3px 3px 0; 248 - white-space: nowrap; 249 - pointer-events: none; 250 - } 251 - </style> 252 - </head> 253 - <body> 254 - <div id="editor"></div> 255 - <button id="share">Share</button> 256 - <dialog id="share-dialog"> 257 - <h2>Share</h2> 258 - <ul id="members"></ul> 259 - <form id="add-member"> 260 - <actor-typeahead> 261 - <input name="did" placeholder="Search for a user…" autocomplete="off" /> 262 - </actor-typeahead> 263 - <button type="submit">Add</button> 264 - </form> 265 - <button id="close-dialog">Close</button> 266 - </dialog> 267 - </body> 268 - </html>
+20 -31
index.html
··· 6 6 <script type="importmap"> 7 7 { 8 8 "imports": { 9 + "preact": "https://esm.sh/preact", 10 + "preact/hooks": "https://esm.sh/preact/hooks", 11 + "htm/preact": "https://esm.sh/htm/preact?external=preact", 12 + "@preact/signals": "https://esm.sh/@preact/signals?external=preact", 9 13 "@atcute/client": "https://esm.sh/@atcute/client", 10 14 "@atcute/identity-resolver": "https://esm.sh/@atcute/identity-resolver", 11 15 "@atcute/oauth-browser-client": "https://esm.sh/@atcute/oauth-browser-client@3.0.0", 16 + "prosemirror-state": "https://esm.sh/prosemirror-state", 17 + "prosemirror-view": "https://esm.sh/prosemirror-view", 18 + "prosemirror-model": "https://esm.sh/prosemirror-model", 19 + "prosemirror-schema-basic": "https://esm.sh/prosemirror-schema-basic", 20 + "prosemirror-example-setup": "https://esm.sh/prosemirror-example-setup", 21 + "yjs": "https://esm.sh/yjs", 22 + "y-prosemirror": "https://esm.sh/y-prosemirror", 23 + "y-protocols/awareness": "https://esm.sh/y-protocols/awareness", 12 24 "actor-typeahead": "https://esm.sh/actor-typeahead" 13 25 } 14 26 } 15 27 </script> 28 + <link rel="stylesheet" href="/style.css" /> 29 + </head> 30 + <body> 31 + <div id="app"></div> 16 32 <script type="module"> 17 - import "actor-typeahead"; 18 - import { createAuthorizationUrl } from "@atcute/oauth-browser-client"; 19 - 20 - import { configure, scope } from "./oauth.js"; 21 - 22 - configure(); 23 - 24 - document.querySelector("form").onsubmit = async e => { 25 - e.preventDefault(); 26 - const data = new FormData(e.currentTarget); 27 - const identifier = data.get("handle"); 28 - if (typeof identifier !== "string") throw new Error("invalid handle"); 29 - 30 - const authUrl = await createAuthorizationUrl({ 31 - target: { type: "account", identifier }, 32 - scope, 33 - }); 34 - 35 - await new Promise(r => setTimeout(r, 100)); 36 - 37 - window.location.assign(authUrl); 38 - }; 33 + import { render } from "preact"; 34 + import { html } from "htm/preact"; 35 + import { App } from "./app.js"; 36 + render(html`<${App} />`, document.getElementById("app")); 39 37 </script> 40 - <style></style> 41 - </head> 42 - <body> 43 - <form> 44 - <actor-typeahead> 45 - <input name="handle" /> 46 - </actor-typeahead> 47 - <button>log in</button> 48 - </form> 49 38 </body> 50 39 </html>
+65
reset.css
··· 1 + @layer reset { 2 + *, 3 + *::before, 4 + *::after { 5 + box-sizing: border-box; 6 + } 7 + 8 + * { 9 + margin: 0; 10 + padding: 0; 11 + } 12 + 13 + :root { 14 + interpolate-size: allow-keywords; 15 + } 16 + 17 + body { 18 + line-height: 1.5; 19 + } 20 + 21 + :where(img, picture, video, canvas, svg) { 22 + display: block; 23 + max-inline-size: 100%; 24 + } 25 + 26 + :where(input, button, textarea, select) { 27 + font: inherit; 28 + letter-spacing: inherit; 29 + word-spacing: inherit; 30 + color: currentColor; 31 + } 32 + 33 + :where(p, h1, h2, h3, h4, h5, h6) { 34 + overflow-wrap: break-word; 35 + } 36 + 37 + :where(ol, ul) { 38 + list-style: none; 39 + } 40 + 41 + :not([class]) { 42 + &:where(h1, h2, h3, h4, h5, h6) { 43 + margin-block: 0.75em; 44 + margin-trim: block; 45 + line-height: 1.25; 46 + text-wrap: balance; 47 + letter-spacing: -0.05ch; 48 + } 49 + 50 + &:where(p, ol, ul) { 51 + margin-block: 1em; 52 + margin-trim: block; 53 + } 54 + 55 + &:where(ol, ul) { 56 + padding-inline-start: 1.5em; 57 + list-style: revert; 58 + } 59 + 60 + &:where(li) { 61 + margin-block: 0.5em; 62 + margin-trim: block; 63 + } 64 + } 65 + }
+112
style.css
··· 1 + @import url("/reset.css"); 2 + 3 + @import url("https://esm.sh/prosemirror-view/style/prosemirror.css"); 4 + @import url("https://esm.sh/prosemirror-menu/style/menu.css"); 5 + @import url("https://esm.sh/prosemirror-example-setup/style/style.css"); 6 + @import url("https://esm.sh/prosemirror-gapcursor/style/gapcursor.css"); 7 + 8 + html, 9 + body { 10 + height: 100%; 11 + } 12 + body { 13 + display: flex; 14 + flex-direction: column; 15 + } 16 + #app { 17 + flex: 1; 18 + display: flex; 19 + flex-direction: column; 20 + } 21 + #editor { 22 + flex: 1; 23 + overflow-y: auto; 24 + padding: 0; 25 + font-family: Georgia, serif; 26 + font-size: 1rem; 27 + line-height: 1.6; 28 + } 29 + .ProseMirror { 30 + min-height: 100%; 31 + outline: none; 32 + } 33 + .ProseMirror p { 34 + margin-bottom: 1em; 35 + } 36 + .ProseMirror-menubar-wrapper { 37 + height: 100%; 38 + } 39 + #share { 40 + position: fixed; 41 + top: 0.6rem; 42 + right: 0.75rem; 43 + z-index: 10; 44 + } 45 + #share-dialog { 46 + min-width: 320px; 47 + padding: 1.5rem; 48 + border: 1px solid #ccc; 49 + border-radius: 8px; 50 + } 51 + #share-dialog h2 { 52 + margin-bottom: 1rem; 53 + font-size: 1rem; 54 + } 55 + #members { 56 + list-style: none; 57 + margin-bottom: 1rem; 58 + } 59 + #members li { 60 + display: flex; 61 + align-items: center; 62 + gap: 0.5rem; 63 + padding: 0.25rem 0; 64 + } 65 + #members li span { 66 + flex: 1; 67 + font-size: 0.875rem; 68 + font-family: monospace; 69 + overflow: hidden; 70 + text-overflow: ellipsis; 71 + } 72 + #add-member { 73 + display: flex; 74 + gap: 0.5rem; 75 + margin-bottom: 1rem; 76 + } 77 + #add-member input { 78 + flex: 1; 79 + font: inherit; 80 + font-size: 0.875rem; 81 + padding: 0.25rem 0.5rem; 82 + border: 1px solid #ccc; 83 + border-radius: 4px; 84 + } 85 + #share-dialog button { 86 + font: inherit; 87 + font-size: 0.875rem; 88 + padding: 0.25rem 0.75rem; 89 + border: 1px solid #ccc; 90 + border-radius: 4px; 91 + cursor: pointer; 92 + } 93 + .collab-cursor { 94 + position: relative; 95 + border-left: 2px solid var(--color); 96 + margin-left: -1px; 97 + pointer-events: none; 98 + } 99 + .collab-cursor::after { 100 + content: attr(data-name); 101 + position: absolute; 102 + top: -1.4em; 103 + left: -2px; 104 + background: var(--color); 105 + color: white; 106 + font-size: 0.7rem; 107 + font-family: sans-serif; 108 + padding: 0 4px; 109 + border-radius: 3px 3px 3px 0; 110 + white-space: nowrap; 111 + pointer-events: none; 112 + }