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 share modal

+130 -46
+94 -41
app.js
··· 134 134 class Editor extends Component { 135 135 editorRef = createRef(); 136 136 shareDialogRef = createRef(); 137 - provider = null; 137 + provider = signal(null); 138 138 view = null; 139 139 140 140 async componentDidMount() { ··· 143 143 const ydoc = new Y.Doc(); 144 144 const yxml = ydoc.getXmlFragment("prosemirror"); 145 145 146 - this.provider = new YPdsProvider({ 146 + this.provider.value = new YPdsProvider({ 147 147 ydoc, 148 148 client: client(session), 149 149 atUri: this.props.atUri, ··· 157 157 const shareItem = new MenuItem({ 158 158 title: "Share document", 159 159 select: () => isOwner, 160 - run: () => this.shareDialogRef.current.open(this.provider), 160 + run: () => void 0, 161 161 render: () => { 162 162 const btn = document.createElement("button"); 163 163 btn.textContent = "Share"; 164 - btn.className = "button share-menu-button"; 164 + btn.className = "share-menu-button"; 165 + btn.commandForElement = this.shareDialogRef.current.dialogRef.current; 166 + btn.command = "show-modal"; 165 167 return btn; 166 168 }, 167 169 }); ··· 185 187 ...exampleSetup({ schema, history: false, menuContent }), 186 188 ySyncPlugin(yxml), 187 189 yUndoPlugin(), 188 - yCursorPlugin(this.provider.awareness, { 190 + yCursorPlugin(this.provider.value.awareness, { 189 191 cursorBuilder(user) { 190 192 const el = document.createElement("span"); 191 193 el.className = "collab-cursor"; ··· 198 200 }); 199 201 200 202 this.view = new EditorView(this.editorRef.current, { state }); 201 - await this.provider.load(); 203 + await this.provider.value.load(); 202 204 203 205 const color = colorForDid(this.props.did); 204 206 let name = this.props.did; ··· 211 213 name = profile.displayName || profile.handle || this.props.did; 212 214 } 213 215 } catch {} 214 - this.provider.awareness.setLocalState({ user: { name, color } }); 216 + this.provider.value.awareness.setLocalState({ user: { name, color } }); 215 217 } 216 218 217 219 componentWillUnmount() { 218 - this.provider?.destroy(); 220 + this.provider.value?.destroy(); 219 221 this.view?.destroy(); 220 222 } 221 223 222 224 render() { 223 225 return html` 224 226 <div id="editor" ref=${this.editorRef}></div> 225 - <${ShareDialog} ref=${this.shareDialogRef} /> 227 + <${ShareDialog} 228 + ref=${this.shareDialogRef} 229 + atUri=${this.props.atUri} 230 + provider=${this.provider.value} 231 + /> 226 232 `; 227 233 } 228 234 } ··· 230 236 class ShareDialog extends Component { 231 237 dialogRef = createRef(); 232 238 editors = signal([]); 233 - provider = null; 234 239 235 - async open(provider) { 236 - this.provider = provider; 237 - const dids = provider.getMembers(); 238 - this.editors.value = await Promise.all( 239 - dids.map(async did => ({ did, profile: await fetchProfile(did) })), 240 - ); 241 - this.dialogRef.current.showModal(); 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 + }); 242 249 } 243 250 244 251 async addMember(e) { ··· 247 254 const identifier = e.currentTarget.did.value.trim(); 248 255 if (!identifier) return; 249 256 const newDid = await resolve(identifier); 250 - const dids = this.provider.getMembers(); 257 + const dids = this.props.provider.getMembers(); 251 258 if (dids.includes(newDid)) return; 252 259 const updated = [...dids, newDid]; 253 - await this.provider.setMembers(updated); 260 + await this.props.provider.setMembers(updated); 254 261 this.editors.value = [ 255 262 ...this.editors.value, 256 263 { did: newDid, profile: await fetchProfile(newDid) }, ··· 260 267 261 268 async removeMember(did) { 262 269 const updated = this.editors.value.filter(m => m.did !== did).map(m => m.did); 263 - await this.provider.setMembers(updated); 270 + await this.props.provider.setMembers(updated); 264 271 this.editors.value = this.editors.value.filter(m => m.did !== did); 265 272 } 266 273 267 274 render() { 268 275 return html` 269 - <dialog ref=${this.dialogRef} closedby="any"> 276 + <dialog id="share" ref=${this.dialogRef} closedby="any"> 270 277 <header> 271 278 <h2>Share</h2> 272 279 <form method="dialog"> ··· 286 293 </button> 287 294 </form> 288 295 </header> 289 - <ul id="editors"> 290 - ${this.editors.value.map( 291 - ({ did, profile }) => html` 292 - <li key=${did}> 293 - ${profile.avatar && html`<img class="avatar" src=${profile.avatar} alt="" />`} 294 - <span class="member-info"> 295 - ${profile.displayName && html`<strong>${profile.displayName}</strong>`} 296 - <small>@${profile.handle}</small> 297 - </span> 298 - <button type="button" onClick=${() => this.removeMember(did)}>Remove</button> 299 - </li> 300 - `, 301 - )} 302 - </ul> 303 - <form id="add-member" onSubmit=${e => this.addMember(e)}> 304 - <actor-typeahead> 305 - <input name="did" placeholder="example.bsky.social" autocomplete="off" /> 306 - </actor-typeahead> 307 - <button type="submit">Add</button> 308 - </form> 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> 309 362 </dialog> 310 363 `; 311 364 }
+36 -5
style.css
··· 55 55 } 56 56 57 57 label { 58 + display: block; 59 + flex: 1; 60 + width: 100%; 58 61 font-size: 0.75rem; 59 62 } 60 63 ··· 177 180 border-bottom: 1px solid #dddddd; 178 181 } 179 182 180 - h2 { 181 - font-size: 1rem; 182 - } 183 - 184 183 > * { 185 184 padding: 12px; 186 185 } ··· 203 202 } 204 203 205 204 #editors { 206 - list-style: none; 205 + ul { 206 + list-style: none; 207 + } 207 208 208 209 &:empty { 209 210 display: none; ··· 246 247 } 247 248 } 248 249 250 + .copy-link { 251 + display: flex; 252 + align-items: center; 253 + background: #f5f5f5; 254 + border-radius: 4px; 255 + 256 + input { 257 + all: unset; 258 + flex: 1; 259 + color: #666; 260 + padding: 0.4rem 0.5rem; 261 + min-width: 0; 262 + overflow: hidden; 263 + text-overflow: ellipsis; 264 + white-space: nowrap; 265 + pointer-events: none; 266 + } 267 + 268 + .icon-button { 269 + flex-shrink: 0; 270 + } 271 + } 272 + 273 + .share-footer { 274 + display: flex; 275 + flex-direction: column; 276 + gap: 12px; 277 + } 278 + 249 279 #add-member { 250 280 display: flex; 251 281 gap: 0.5rem; 282 + align-items: flex-end; 252 283 } 253 284 254 285 .collab-cursor {