[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

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

Refactor post composer to use signals

Grace Kind (Jul 8, 2026, 1:34 PM -0500) 8b936be4 91fbe05b

+318 -243
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.113", 3 + "version": "0.17.114", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+1 -1
src/index.html
··· 105 105 106 106 // Dev tools 107 107 if ( 108 - window.env.environment === "development" || 108 + (window.env.environment === "development" && !window.env.playwright) || 109 109 params.has("enable-debug") 110 110 ) { 111 111 enableErrorLogs();
+19 -16
src/js/components/detected-rich-text.js
··· 1 1 import { render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 - import { Signal, effect } from "/js/signals.js"; 3 + import { Signal, ReactiveStore, effect } from "/js/signals.js"; 4 4 import { richTextTemplate } from "/js/templates/richText.template.js"; 5 5 import { 6 6 getUnresolvedFacetsFromText, ··· 16 16 if (this.initialized) return; 17 17 this.initialized = true; 18 18 19 - this.$text = new Signal.State(this.getAttribute("text") ?? ""); 20 - this.$truncateUrls = new Signal.State(this.hasAttribute("truncate-urls")); 21 - this.$unresolvedFacets = new Signal.Computed(() => 22 - getUnresolvedFacetsFromText(this.$text.get()), 19 + this.state = new ReactiveStore("detected-rich-text"); 20 + this.state.$text = new Signal.State(this.getAttribute("text") ?? ""); 21 + this.state.$truncateUrls = new Signal.State( 22 + this.hasAttribute("truncate-urls"), 23 23 ); 24 - this.$resolvedFacets = new Signal.State(null); 24 + this.state.$unresolvedFacets = new Signal.Computed(() => 25 + getUnresolvedFacetsFromText(this.state.$text.get()), 26 + ); 27 + this.state.$resolvedFacets = new Signal.State(null); 25 28 26 29 // Resolve facets whenever unresolved facets change 27 30 this.disposeResolve = effect(() => { 28 31 if (!this.identityResolver) return; 29 - const unresolvedFacets = this.$unresolvedFacets.get(); 32 + const unresolvedFacets = this.state.$unresolvedFacets.get(); 30 33 resolveFacets(unresolvedFacets, this.identityResolver).then( 31 34 (resolvedFacets) => { 32 - if (unresolvedFacets !== this.$unresolvedFacets.get()) { 35 + if (unresolvedFacets !== this.state.$unresolvedFacets.get()) { 33 36 // If unresolved facets have changed since we started resolving, don't update 34 37 return; 35 38 } 36 - this.$resolvedFacets.set(resolvedFacets); 39 + this.state.$resolvedFacets.set(resolvedFacets); 37 40 }, 38 41 ); 39 42 }); 40 43 41 44 this.disposeRender = effect(() => { 42 - const text = this.$text.get(); 43 - const unresolvedFacets = this.$unresolvedFacets.get(); 44 - const resolvedFacets = this.$resolvedFacets.get(); 45 - const truncateUrls = this.$truncateUrls.get(); 45 + const text = this.state.$text.get(); 46 + const unresolvedFacets = this.state.$unresolvedFacets.get(); 47 + const resolvedFacets = this.state.$resolvedFacets.get(); 48 + const truncateUrls = this.state.$truncateUrls.get(); 46 49 let facets = resolvedFacets ?? unresolvedFacets; 47 50 if (!this.identityResolver) { 48 51 facets = facets.filter( ··· 58 61 if (!this.initialized || oldValue === newValue) return; 59 62 if (name === "text") { 60 63 const text = newValue ?? ""; 61 - this.$resolvedFacets.set(null); 62 - this.$text.set(text); 64 + this.state.$resolvedFacets.set(null); 65 + this.state.$text.set(text); 63 66 } else if (name === "truncate-urls") { 64 - this.$truncateUrls.set(newValue !== null); 67 + this.state.$truncateUrls.set(newValue !== null); 65 68 } 66 69 } 67 70
+26 -28
src/js/components/plugin-posts-feed.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 4 - import { Signal, effect } from "/js/signals.js"; 4 + import { Signal, ReactiveStore, effect } from "/js/signals.js"; 5 5 6 6 class PluginPostsFeed extends Component { 7 7 static get observedAttributes() { ··· 14 14 if (!this.postInteractionHandler) { 15 15 throw new Error("postInteractionHandler is required"); 16 16 } 17 - this.attribs = { 18 - uris: new Signal.State(this.parseUris()), 19 - emptyMessage: new Signal.State(this.getAttribute("empty-message")), 20 - }; 21 - this.state = { 22 - currentUser: this.dataLayer.derived.$currentUser, 23 - loaded: new Signal.State(false), 24 - posts: new Signal.Computed(() => { 25 - if (!this.state.loaded.get()) return null; 26 - const uris = this.attribs.uris.get(); 27 - if (!uris) return null; 28 - return uris 29 - .map((uri) => this.dataLayer.derived.$hydratedPosts.get(uri)) 30 - .filter(Boolean); 31 - }), 32 - error: new Signal.State(null), 33 - }; 17 + this.state = new ReactiveStore("plugin-posts-feed"); 18 + this.state.$uris = new Signal.State(this.parseUris()); 19 + this.state.$emptyMessage = new Signal.State( 20 + this.getAttribute("empty-message"), 21 + ); 22 + this.state.$loaded = new Signal.State(false); 23 + this.state.$posts = new Signal.Computed(() => { 24 + if (!this.state.$loaded.get()) return null; 25 + const uris = this.state.$uris.get(); 26 + if (!uris) return null; 27 + return uris 28 + .map((uri) => this.dataLayer.derived.$hydratedPosts.get(uri)) 29 + .filter(Boolean); 30 + }); 31 + this.state.$error = new Signal.State(null); 34 32 this._disposers = [ 35 33 effect(() => { 36 - const error = this.state.error.get(); 37 - const posts = this.state.posts.get(); 38 - const currentUser = this.state.currentUser.get(); 39 - const emptyMessage = this.attribs.emptyMessage.get(); 34 + const error = this.state.$error.get(); 35 + const posts = this.state.$posts.get(); 36 + const currentUser = this.dataLayer.derived.$currentUser.get(); 37 + const emptyMessage = this.state.$emptyMessage.get(); 40 38 if (error) { 41 39 render(html`<div class="posts-feed-error">${error}</div>`, this); 42 40 return; ··· 60 58 ); 61 59 }), 62 60 effect(() => { 63 - this.attribs.uris.get(); 61 + this.state.$uris.get(); 64 62 this.load(); 65 63 }), 66 64 ]; ··· 75 73 attributeChangedCallback() { 76 74 if (this.initialized) { 77 75 // TODO - smarter updates? 78 - this.attribs.uris.set(this.parseUris()); 79 - this.attribs.emptyMessage.set(this.getAttribute("empty-message")); 76 + this.state.$uris.set(this.parseUris()); 77 + this.state.$emptyMessage.set(this.getAttribute("empty-message")); 80 78 } 81 79 } 82 80 ··· 92 90 const uris = this.parseUris(); 93 91 const requestToken = Symbol(); 94 92 this._requestToken = requestToken; 95 - this.state.error.set(null); 93 + this.state.$error.set(null); 96 94 try { 97 95 await this.dataLayer.declarative.ensurePosts(uris); 98 96 if (this._requestToken !== requestToken) return; 99 - this.state.loaded.set(true); 97 + this.state.$loaded.set(true); 100 98 } catch (error) { 101 99 if (this._requestToken !== requestToken) return; 102 - this.state.error.set(error.message ?? String(error)); 100 + this.state.$error.set(error.message ?? String(error)); 103 101 } 104 102 } 105 103 }
+28 -29
src/js/components/plugin-profiles-list.js
··· 1 1 import { html, render } from "/js/lib/lit-html.js"; 2 2 import { Component } from "/js/components/component.js"; 3 3 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 4 - import { Signal, effect } from "/js/signals.js"; 4 + import { Signal, ReactiveStore, effect } from "/js/signals.js"; 5 5 6 6 class PluginProfilesList extends Component { 7 7 static get observedAttributes() { ··· 11 11 connectedCallback() { 12 12 if (this.initialized) return; 13 13 this.initialized = true; 14 - this.attribs = { 15 - dids: new Signal.State(this.parseDids()), 16 - emptyMessage: new Signal.State(this.getAttribute("empty-message")), 17 - }; 18 - this.state = { 19 - loaded: new Signal.State(false), 20 - profiles: new Signal.Computed(() => { 21 - if (!this.state.loaded.get()) return null; 22 - const dids = this.attribs.dids.get(); 23 - return dids 24 - .map((did) => this.dataLayer.derived.$hydratedProfiles.get(did)) 25 - .filter(Boolean); 26 - }), 27 - error: new Signal.State(null), 28 - }; 14 + this.state = new ReactiveStore("plugin-profiles-list"); 15 + this.state.$dids = new Signal.State(this.parseDids()); 16 + this.state.$emptyMessage = new Signal.State( 17 + this.getAttribute("empty-message"), 18 + ); 19 + this.state.$loaded = new Signal.State(false); 20 + this.state.$profiles = new Signal.Computed(() => { 21 + if (!this.state.$loaded.get()) return null; 22 + const dids = this.state.$dids.get(); 23 + return dids 24 + .map((did) => this.dataLayer.derived.$hydratedProfiles.get(did)) 25 + .filter(Boolean); 26 + }); 27 + this.state.$error = new Signal.State(null); 29 28 this._disposers = [ 30 29 effect(() => { 31 - const error = this.state.error.get(); 32 - const profiles = this.state.profiles.get(); 33 - const emptyMessage = this.attribs.emptyMessage.get(); 34 - const dids = this.attribs.dids.get(); 30 + const error = this.state.$error.get(); 31 + const profiles = this.state.$profiles.get(); 32 + const emptyMessage = this.state.$emptyMessage.get(); 33 + const dids = this.state.$dids.get(); 35 34 if (error) { 36 35 render(html`<div class="profile-list-error">${error}</div>`, this); 37 36 return; ··· 48 47 ); 49 48 }), 50 49 effect(() => { 51 - this.attribs.dids.get(); 50 + this.state.$dids.get(); 52 51 this.load(); 53 52 }), 54 53 ]; ··· 62 61 63 62 attributeChangedCallback() { 64 63 if (this.initialized) { 65 - this.attribs.dids.set(this.parseDids()); 66 - this.attribs.emptyMessage.set(this.getAttribute("empty-message")); 64 + this.state.$dids.set(this.parseDids()); 65 + this.state.$emptyMessage.set(this.getAttribute("empty-message")); 67 66 } 68 67 } 69 68 ··· 76 75 } 77 76 78 77 async load() { 79 - const dids = this.attribs.dids.get(); 78 + const dids = this.state.$dids.get(); 80 79 const requestToken = Symbol(); 81 80 this._requestToken = requestToken; 82 - this.state.error.set(null); 81 + this.state.$error.set(null); 83 82 if (dids.length === 0) { 84 - this.state.loaded.set(true); 83 + this.state.$loaded.set(true); 85 84 return; 86 85 } 87 - this.state.loaded.set(false); 86 + this.state.$loaded.set(false); 88 87 try { 89 88 await this.dataLayer.declarative.ensureDetailedProfiles(dids); 90 89 if (this._requestToken !== requestToken) return; 91 - this.state.loaded.set(true); 90 + this.state.$loaded.set(true); 92 91 } catch (error) { 93 92 if (this._requestToken !== requestToken) return; 94 - this.state.error.set(error.message ?? String(error)); 93 + this.state.$error.set(error.message ?? String(error)); 95 94 } 96 95 } 97 96 }
+174 -107
src/js/components/post-composer.js
··· 26 26 import { LINK_CARD_SERVICE_URL } from "/js/config.js"; 27 27 import { recordEmbedTemplate } from "/js/templates/postEmbed.template.js"; 28 28 import { parseRecordLink, resolveRecordFromLink } from "/js/embedHelpers.js"; 29 + import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; 29 30 import "/js/components/rich-text-input.js"; 30 31 import "/js/components/image-alt-text-dialog.js"; 31 32 import "/js/components/emoji-picker-dialog.js"; ··· 176 177 this.setAttribute("data-dialog-wrapper", ""); 177 178 this.scrollLock = new ScrollLock(this); 178 179 this.innerHTML = ""; 179 - this._postText = ""; 180 180 this.initialText = this.initialText ?? null; 181 181 this.initialCursor = this.initialCursor ?? null; 182 - this._isSending = false; 183 182 this._unresolvedFacets = []; 184 183 this._quotedRecordUrl = null; 185 - this.quotedRecord = this.quotedRecord ?? null; 186 184 this._externalLinkUrl = null; 187 - this._externalLinkEmbedData = null; 188 185 this._rejectedLinkEmbeds = new Set(); 189 - this._selectedImages = []; 190 - this._selectedVideo = null; 191 - this.render(); 186 + this._videoToken = null; 187 + this.state = new ReactiveStore("postComposer"); 188 + this.state.$postText = new Signal.State(""); 189 + this.state.$isSending = new Signal.State(false); 190 + this.state.$externalLinkEmbedData = new Signal.State(null); 191 + this.state.$selectedImages = new Signal.State([]); 192 + this.state.$selectedVideo = new Signal.State(null); 193 + this.state.$quotedRecord = new Signal.State( 194 + this._pendingQuotedRecord ?? null, 195 + ); 196 + this._pendingQuotedRecord = null; 197 + this._disposers = [ 198 + effect(() => { 199 + this.render(); 200 + }), 201 + ]; 192 202 this.initialized = true; 203 + } 204 + 205 + disconnectedCallback() { 206 + if (!this.initialized) return; 207 + this._disposers?.forEach((dispose) => dispose()); 208 + this._disposers = null; 209 + } 210 + 211 + get quotedRecord() { 212 + if (!this.state) return this._pendingQuotedRecord ?? null; 213 + return untrack(() => this.state.$quotedRecord.get()); 214 + } 215 + 216 + set quotedRecord(value) { 217 + if (!this.state) { 218 + this._pendingQuotedRecord = value; 219 + return; 220 + } 221 + this.state.$quotedRecord.set(value); 193 222 } 194 223 195 224 render() { 196 225 const promptText = this.replyTo ? "Write your reply" : "What's up?"; 197 - const currentCharCount = graphemeCount(this._postText); 226 + const isSending = this.state.$isSending.get(); 227 + const externalLinkEmbedData = this.state.$externalLinkEmbedData.get(); 228 + const selectedImages = this.state.$selectedImages.get(); 229 + const selectedVideo = this.state.$selectedVideo.get(); 230 + const quotedRecord = this.state.$quotedRecord.get(); 231 + const currentCharCount = graphemeCount(this.state.$postText.get()); 198 232 const charCountPercentage = Math.min( 199 233 Math.round((currentCharCount / 300) * 100), 200 234 100, 201 235 ); 202 236 const isAboveCharLimit = currentCharCount > 300; 203 237 const isVideoUploading = 204 - this._selectedVideo && 205 - (this._selectedVideo.status === "uploading" || 206 - this._selectedVideo.status === "processing"); 207 - const hasVideo = !!this._selectedVideo; 238 + selectedVideo && 239 + (selectedVideo.status === "uploading" || 240 + selectedVideo.status === "processing"); 241 + const hasVideo = !!selectedVideo; 208 242 render( 209 243 html` 210 244 <dialog ··· 224 258 @keydown=${(e) => { 225 259 if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { 226 260 e.preventDefault(); 227 - if ( 228 - !this._isSending && 229 - !isAboveCharLimit && 230 - !isVideoUploading && 231 - this._postText.length > 0 232 - ) { 261 + const postText = untrack(() => this.state.$postText.get()); 262 + if (postText.length > 0) { 233 263 this.send(); 234 264 } 235 265 } ··· 252 282 data-testid="composer-submit-button" 253 283 data-teststate=${this.replyTo ? "reply" : "post"} 254 284 @click=${() => this.send()} 255 - .disabled=${this._isSending || 256 - isAboveCharLimit || 257 - isVideoUploading} 285 + .disabled=${isSending || isAboveCharLimit || isVideoUploading} 258 286 > 259 - ${this._isSending 287 + ${isSending 260 288 ? html`Sending... <span>&nbsp;&nbsp;</span> 261 289 <div class="loading-spinner"></div>` 262 290 : html`<span>${this.replyTo ? "Reply" : "Post"}</span>`} ··· 284 312 ></rich-text-input> 285 313 </div> 286 314 </div> 287 - ${this._externalLinkEmbedData 315 + ${externalLinkEmbedData 288 316 ? externalLinkEmbedPreviewTemplate({ 289 - data: this._externalLinkEmbedData, 317 + data: externalLinkEmbedData, 290 318 onClose: () => { 291 319 this.handleExternalLinkEmbedPreviewClose(); 292 320 }, 293 321 }) 294 322 : ""} 295 - ${this._selectedImages.length > 0 323 + ${selectedImages.length > 0 296 324 ? imagePreviewTemplate({ 297 - images: this._selectedImages, 325 + images: selectedImages, 298 326 onRemove: (index) => this.handleRemoveImage(index), 299 327 onEditAltText: (index) => this.handleEditAltText(index), 300 328 }) 301 329 : ""} 302 - ${this._selectedVideo 330 + ${selectedVideo 303 331 ? videoPreviewTemplate({ 304 - video: this._selectedVideo, 332 + video: selectedVideo, 305 333 onRemove: () => this.handleRemoveVideo(), 306 334 onEditAltText: () => this.handleEditVideoAltText(), 307 335 }) 308 336 : ""} 309 - ${this.quotedRecord 337 + ${quotedRecord 310 338 ? html`<div class="post-composer-embed-preview"> 311 339 <button 312 340 class="embed-preview-close-button" ··· 318 346 </button> 319 347 <div inert> 320 348 ${recordEmbedTemplate({ 321 - record: this.quotedRecord, 349 + record: quotedRecord, 322 350 isAuthenticated: true, 323 351 })} 324 352 </div> ··· 341 369 <button 342 370 class="image-picker-button" 343 371 @click=${() => this.handleMediaButtonClick()} 344 - .disabled=${hasVideo || this._selectedImages.length >= 4} 372 + .disabled=${hasVideo || selectedImages.length >= 4} 345 373 > 346 374 ${imageIconTemplate()} 347 375 </button> ··· 384 412 ); 385 413 } 386 414 415 + isSendBlocked() { 416 + const isSending = untrack(() => this.state.$isSending.get()); 417 + const postText = untrack(() => this.state.$postText.get()); 418 + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); 419 + const isVideoUploading = 420 + !!selectedVideo && 421 + (selectedVideo.status === "uploading" || 422 + selectedVideo.status === "processing"); 423 + return isSending || graphemeCount(postText) > 300 || isVideoUploading; 424 + } 425 + 387 426 handleEmojiButtonClick(event) { 388 427 const dialog = this.querySelector("emoji-picker-dialog"); 389 428 if (!dialog) return; ··· 422 461 handleExternalLinkEmbedPreviewClose() { 423 462 this._rejectedLinkEmbeds.add(this._externalLinkUrl); 424 463 this._externalLinkUrl = null; 425 - this._externalLinkEmbedData = null; 426 - this.render(); 464 + this.state.$externalLinkEmbedData.set(null); 427 465 } 428 466 429 467 handleQuotedEmbedPreviewClose() { 430 468 this._quotedRecordUrl = null; 431 - this.quotedRecord = null; 432 - this.render(); 469 + this.state.$quotedRecord.set(null); 433 470 } 434 471 435 472 async loadQuotedRecordFromLink() { ··· 441 478 }); 442 479 // the embed may have been closed or replaced while the record was loading 443 480 if (this._quotedRecordUrl !== url) return; 444 - this.quotedRecord = record; 445 - this.render(); 481 + this.state.$quotedRecord.set(record); 446 482 } catch (error) { 447 483 console.error("Error loading record embed from link: ", error); 448 484 this._rejectedLinkEmbeds.add(url); ··· 484 520 } 485 521 486 522 if (videoFiles.length > 0) { 487 - if (this._selectedImages.length > 0) { 523 + const selectedImages = untrack(() => this.state.$selectedImages.get()); 524 + if (selectedImages.length > 0) { 488 525 showToast("Selecting multiple media types is not supported", { 489 526 style: "warning", 490 527 }); ··· 500 537 } 501 538 502 539 if (imageFiles.length > 0) { 503 - if (this._selectedVideo) { 540 + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); 541 + if (selectedVideo) { 504 542 showToast("Selecting multiple media types is not supported", { 505 543 style: "warning", 506 544 }); ··· 512 550 513 551 async addImageFiles(files) { 514 552 const maxImages = 4; 515 - const remainingSlots = maxImages - this._selectedImages.length; 553 + const currentImages = untrack(() => this.state.$selectedImages.get()); 554 + const remainingSlots = maxImages - currentImages.length; 516 555 517 556 if (files.length > remainingSlots) { 518 557 showToast("You can select up to 4 images in total", { style: "warning" }); 519 558 } 520 559 560 + const newImages = []; 521 561 for (let i = 0; i < Math.min(files.length, remainingSlots); i++) { 522 562 const file = files[i]; 523 563 const dataUrl = await readFileAsDataUrl(file); 524 - this._selectedImages.push({ 564 + newImages.push({ 525 565 file, 526 566 dataUrl, 527 567 }); 528 568 } 569 + const latestImages = untrack(() => this.state.$selectedImages.get()); 570 + const selectedImages = [...latestImages, ...newImages]; 571 + this.state.$selectedImages.set(selectedImages); 529 572 530 573 // Reject external link embed if images are added 531 - if (this._selectedImages.length > 0 && this._externalLinkUrl) { 574 + if (selectedImages.length > 0 && this._externalLinkUrl) { 532 575 this._rejectedLinkEmbeds.add(this._externalLinkUrl); 533 576 this._externalLinkUrl = null; 534 - this._externalLinkEmbedData = null; 577 + this.state.$externalLinkEmbedData.set(null); 535 578 } 536 - 537 - this.render(); 538 579 } 539 580 540 581 handleRemoveImage(index) { 541 - this._selectedImages.splice(index, 1); 542 - this.render(); 582 + const selectedImages = untrack(() => this.state.$selectedImages.get()); 583 + this.state.$selectedImages.set( 584 + selectedImages.filter((image, imageIndex) => imageIndex !== index), 585 + ); 543 586 } 544 587 545 588 handleEditAltText(index) { 546 - const image = this._selectedImages[index]; 589 + const selectedImages = untrack(() => this.state.$selectedImages.get()); 590 + const image = selectedImages[index]; 547 591 const dialog = document.createElement("image-alt-text-dialog"); 548 592 dialog.imageUrl = image.dataUrl; 549 593 dialog.value = image.alt || ""; 550 594 551 595 dialog.addEventListener("alt-text-saved", (e) => { 552 - this._selectedImages[index].alt = e.detail.altText; 553 - this.render(); 596 + const latestImages = untrack(() => this.state.$selectedImages.get()); 597 + this.state.$selectedImages.set( 598 + latestImages.map((selectedImage, imageIndex) => 599 + imageIndex === index 600 + ? { ...selectedImage, alt: e.detail.altText } 601 + : selectedImage, 602 + ), 603 + ); 554 604 dialog.remove(); 555 605 }); 556 606 ··· 584 634 showToast(msg, { style: "warning" }); 585 635 return; 586 636 } 587 - this._selectedVideo = { 637 + const token = Symbol(); 638 + this._videoToken = token; 639 + this.state.$selectedVideo.set({ 588 640 file, 589 641 previewUrl: URL.createObjectURL(file), 590 642 alt: "", ··· 594 646 jobId: null, 595 647 blob: null, 596 648 error: null, 597 - }; 598 - this.render(); 599 - this.uploadSelectedVideo(); 649 + }); 650 + this.uploadSelectedVideo(token); 600 651 } 601 652 602 - async uploadSelectedVideo() { 603 - const video = this._selectedVideo; 653 + // Applies a partial update to the selected video, unless it has been removed 654 + // or replaced since `token` was issued. 655 + patchSelectedVideo(token, patch) { 656 + if (this._videoToken !== token) return null; 657 + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); 658 + const video = { ...selectedVideo, ...patch }; 659 + this.state.$selectedVideo.set(video); 660 + return video; 661 + } 662 + 663 + async uploadSelectedVideo(token) { 664 + const video = untrack(() => this.state.$selectedVideo.get()); 604 665 if (!video) return; 605 666 try { 606 667 const uploader = new VideoUploader(this.dataLayer.api); 607 668 const blob = await uploader.upload(video.file, { 608 669 onJobStart: (job) => { 609 - if (this._selectedVideo !== video) return; 610 - this._selectedVideo.jobId = job.jobId; 611 - this._selectedVideo.status = "processing"; 612 - this.render(); 670 + this.patchSelectedVideo(token, { 671 + jobId: job.jobId, 672 + status: "processing", 673 + }); 613 674 }, 614 675 onProgress: (_state, progress) => { 615 - if (this._selectedVideo !== video) return; 616 - this._selectedVideo.progress = progress; 617 - this.render(); 676 + this.patchSelectedVideo(token, { progress }); 618 677 }, 619 678 }); 620 - if (this._selectedVideo !== video) return; 621 - this._selectedVideo.blob = blob; 622 - this._selectedVideo.status = "done"; 623 - this.render(); 679 + this.patchSelectedVideo(token, { blob, status: "done" }); 624 680 } catch (error) { 625 681 console.error("Video upload error: ", error); 626 - if (this._selectedVideo !== video) return; 627 - this._selectedVideo.status = "error"; 628 - this._selectedVideo.error = error.message || "Upload failed"; 629 - this.render(); 630 - showToast(this._selectedVideo.error, { style: "error" }); 682 + const failedVideo = this.patchSelectedVideo(token, { 683 + status: "error", 684 + error: error.message || "Upload failed", 685 + }); 686 + if (failedVideo) { 687 + showToast(failedVideo.error, { style: "error" }); 688 + } 631 689 } 632 690 } 633 691 634 692 handleRemoveVideo() { 635 - if (this._selectedVideo?.previewUrl) { 636 - URL.revokeObjectURL(this._selectedVideo.previewUrl); 693 + const video = untrack(() => this.state.$selectedVideo.get()); 694 + if (video?.previewUrl) { 695 + URL.revokeObjectURL(video.previewUrl); 637 696 } 638 - this._selectedVideo = null; 639 - this.render(); 697 + this._videoToken = null; 698 + this.state.$selectedVideo.set(null); 640 699 } 641 700 642 701 handleEditVideoAltText() { 643 - const video = this._selectedVideo; 702 + const video = untrack(() => this.state.$selectedVideo.get()); 644 703 if (!video) return; 704 + const token = this._videoToken; 645 705 const dialog = document.createElement("image-alt-text-dialog"); 646 706 dialog.value = video.alt || ""; 647 707 648 708 dialog.addEventListener("alt-text-saved", (e) => { 649 - if (this._selectedVideo === video) { 650 - this._selectedVideo.alt = e.detail.altText; 651 - this.render(); 652 - } 709 + this.patchSelectedVideo(token, { alt: e.detail.altText }); 653 710 dialog.remove(); 654 711 }); 655 712 ··· 663 720 664 721 handleInput(e) { 665 722 const previousFacets = this._unresolvedFacets; 666 - this._postText = e.detail.text; 723 + this.state.$postText.set(e.detail.text); 667 724 this._unresolvedFacets = e.detail.facets; 668 725 // If the facets *haven't* changed, and the latest change was a space or newline, check for possible link embeds 669 726 if ( ··· 709 766 } 710 767 } 711 768 } 712 - this.render(); 713 769 } 714 770 715 771 handlePaste(e) { ··· 744 800 async loadExternalLinkEmbedPreview() { 745 801 const url = this._externalLinkUrl; 746 802 // preliminary data 747 - this._externalLinkEmbedData = { 803 + this.state.$externalLinkEmbedData.set({ 748 804 url, 749 805 title: url, 750 806 description: "", 751 807 image: "", 752 - }; 753 - this.render(); 808 + }); 754 809 let res = null; 755 810 try { 756 811 res = await fetch(`${LINK_CARD_SERVICE_URL}/v1/extract?url=${url}`); ··· 760 815 } 761 816 if (res && res.ok) { 762 817 const data = await res.json(); 763 - // preview may have been closed while metadata was loading 764 - if (!this._externalLinkEmbedData) return; 818 + // preview may have been closed or replaced while metadata was loading 819 + const current = this.state.$externalLinkEmbedData.get(); 820 + if (!current || current.url !== url) return; 821 + const updated = { ...current }; 765 822 if (data.title) { 766 - this._externalLinkEmbedData.title = data.title; 823 + updated.title = data.title; 767 824 } 768 825 if (data.description) { 769 - this._externalLinkEmbedData.description = data.description; 826 + updated.description = data.description; 770 827 } 771 - this.render(); 828 + this.state.$externalLinkEmbedData.set(updated); 772 829 if (data.image) { 773 830 // only show image if it can be loaded 774 831 let imageRes = null; 775 832 try { 776 833 imageRes = await fetch(sanitizeUri(data.image)); 777 834 } catch (error) {} 778 - // preview may have been closed while the image was loading 779 - if (imageRes && imageRes.ok && this._externalLinkEmbedData) { 780 - this._externalLinkEmbedData.image = data.image; 781 - this.render(); 835 + // preview may have been closed or replaced while the image was loading 836 + const latest = this.state.$externalLinkEmbedData.get(); 837 + if (imageRes && imageRes.ok && latest && latest.url === url) { 838 + this.state.$externalLinkEmbedData.set({ 839 + ...latest, 840 + image: data.image, 841 + }); 782 842 } 783 843 } 784 844 } ··· 829 889 } 830 890 831 891 send() { 832 - this._isSending = true; 833 - this.render(); 892 + if (this.isSendBlocked()) return; 893 + this.state.$isSending.set(true); 834 894 const successCallback = () => { 835 895 this.close(); 836 896 }; 837 897 const errorCallback = () => { 838 - this._isSending = false; 898 + this.state.$isSending.set(false); 839 899 // todo: show error message 840 - this.render(); 841 900 }; 901 + const postText = untrack(() => this.state.$postText.get()); 902 + const external = untrack(() => this.state.$externalLinkEmbedData.get()); 903 + const quotedRecord = untrack(() => this.state.$quotedRecord.get()); 904 + const images = untrack(() => this.state.$selectedImages.get()); 905 + const video = untrack(() => this.state.$selectedVideo.get()); 842 906 this.dispatchEvent( 843 907 new CustomEvent("send-post", { 844 908 detail: { 845 - postText: this._postText, 846 - external: this._externalLinkEmbedData, 909 + postText, 910 + external, 847 911 replyTo: this.replyTo, 848 912 replyRoot: this.replyRoot, 849 - quotedRecord: this.quotedRecord, 850 - images: this._selectedImages, 851 - video: this._selectedVideo, 913 + quotedRecord, 914 + images, 915 + video, 852 916 successCallback, 853 917 errorCallback, 854 918 }, ··· 858 922 859 923 confirmClose() { 860 924 // Todo - check for other unsaved changes 925 + const postText = untrack(() => this.state.$postText.get()); 926 + const selectedImages = untrack(() => this.state.$selectedImages.get()); 927 + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); 861 928 if ( 862 - this._postText.length === 0 && 863 - this._selectedImages.length === 0 && 864 - !this._selectedVideo 929 + postText.length === 0 && 930 + selectedImages.length === 0 && 931 + !selectedVideo 865 932 ) { 866 933 return true; 867 934 }
+8 -7
src/js/components/rendered-markdown.js
··· 1 1 import { Component } from "/js/components/component.js"; 2 - import { Signal, effect } from "/js/signals.js"; 2 + import { Signal, ReactiveStore, effect } from "/js/signals.js"; 3 3 4 4 class RenderedMarkdown extends Component { 5 5 connectedCallback() { 6 6 if (this.initialized) return; 7 7 this.initialized = true; 8 - this.$dependencies = new Signal.State(null); 9 - this.$content = new Signal.State(this.getAttribute("content") || ""); 8 + this.state = new ReactiveStore("rendered-markdown"); 9 + this.state.$dependencies = new Signal.State(null); 10 + this.state.$content = new Signal.State(this.getAttribute("content") || ""); 10 11 // Fetch dependencies 11 12 Promise.all([ 12 13 import("/js/lib/dompurify.js"), 13 14 import("/js/lib/marked.js"), 14 15 ]).then(([{ default: DOMPurify }, { marked }]) => { 15 - this.$dependencies.set({ DOMPurify, marked }); 16 + this.state.$dependencies.set({ DOMPurify, marked }); 16 17 }); 17 18 this.dispose = effect(() => { 18 - const dependencies = this.$dependencies.get(); 19 + const dependencies = this.state.$dependencies.get(); 19 20 if (!dependencies) return; 20 21 const { DOMPurify, marked } = dependencies; 21 - const content = this.$content.get(); 22 + const content = this.state.$content.get(); 22 23 this.innerHTML = DOMPurify.sanitize(marked.parse(content)); 23 24 }); 24 25 // Treat links inside rendered markdown as external ··· 37 38 attributeChangedCallback(name, oldValue, newValue) { 38 39 if (!this.initialized || oldValue === newValue) return; 39 40 if (name === "content") { 40 - this.$content.set(newValue); 41 + this.state.$content.set(newValue); 41 42 } 42 43 } 43 44
+61 -54
tests/unit/specs/components/post-composer.test.js
··· 8 8 document.body.innerHTML = ""; 9 9 }); 10 10 11 + async function nextFrame() { 12 + // The render effect flushes on requestAnimationFrame (setTimeout(0) in the 13 + // test env), so one tick applies pending renders. 14 + await new Promise((resolve) => setTimeout(resolve, 0)); 15 + } 16 + 11 17 function connectElement(element) { 12 18 const container = document.createElement("div"); 13 19 container.className = "page-visible"; ··· 118 124 it("should start with empty post text", () => { 119 125 const element = createPostComposer(); 120 126 connectElement(element); 121 - assertEquals(element._postText, ""); 127 + assertEquals(element.state.$postText.get(), ""); 122 128 }); 123 129 124 130 it("should not be sending initially", () => { 125 131 const element = createPostComposer(); 126 132 connectElement(element); 127 - assertEquals(element._isSending, false); 133 + assertEquals(element.state.$isSending.get(), false); 128 134 }); 129 135 130 136 it("should have no selected images initially", () => { 131 137 const element = createPostComposer(); 132 138 connectElement(element); 133 - assertEquals(element._selectedImages.length, 0); 139 + assertEquals(element.state.$selectedImages.get().length, 0); 134 140 }); 135 141 }); 136 142 ··· 142 148 assertEquals(wordCount.textContent, "300"); 143 149 }); 144 150 145 - it("should add overflow class when over limit", () => { 151 + it("should add overflow class when over limit", async () => { 146 152 const element = createPostComposer(); 147 153 connectElement(element); 148 - element._postText = "x".repeat(301); 149 - element.render(); 154 + element.state.$postText.set("x".repeat(301)); 155 + await nextFrame(); 150 156 const wordCountContainer = element.querySelector(".word-count"); 151 157 assert(wordCountContainer.classList.contains("overflow")); 152 158 }); 153 159 154 - it("should disable post button when over limit", () => { 160 + it("should disable post button when over limit", async () => { 155 161 const element = createPostComposer(); 156 162 connectElement(element); 157 - element._postText = "x".repeat(301); 158 - element.render(); 163 + element.state.$postText.set("x".repeat(301)); 164 + await nextFrame(); 159 165 const postButton = element.querySelector(".rounded-button-primary"); 160 166 assert(postButton.disabled); 161 167 }); ··· 197 203 }); 198 204 199 205 t.describe("PostComposer - send method", (it) => { 200 - it("should set _isSending to true when send() is called", () => { 206 + it("should set isSending to true when send() is called", () => { 201 207 const element = createPostComposer(); 202 208 connectElement(element); 203 - element._postText = "Hello world"; 209 + element.state.$postText.set("Hello world"); 204 210 205 211 // Listen for the event but don't do anything 206 212 element.addEventListener("send-post", () => {}); 207 213 208 214 element.send(); 209 - assertEquals(element._isSending, true); 215 + assertEquals(element.state.$isSending.get(), true); 210 216 }); 211 217 212 218 it("should dispatch send-post event with post data", () => { 213 219 const element = createPostComposer(); 214 220 connectElement(element); 215 - element._postText = "Hello world"; 221 + element.state.$postText.set("Hello world"); 216 222 217 223 let receivedDetail = null; 218 224 element.addEventListener("send-post", (e) => { ··· 223 229 assertEquals(receivedDetail.postText, "Hello world"); 224 230 }); 225 231 226 - it("should show loading spinner when sending", () => { 232 + it("should show loading spinner when sending", async () => { 227 233 const element = createPostComposer(); 228 234 connectElement(element); 229 - element._isSending = true; 230 - element.render(); 235 + element.state.$isSending.set(true); 236 + await nextFrame(); 231 237 const spinner = element.querySelector(".loading-spinner"); 232 238 assert(spinner !== null); 233 239 }); 234 240 235 - it("should disable post button when sending", () => { 241 + it("should disable post button when sending", async () => { 236 242 const element = createPostComposer(); 237 243 connectElement(element); 238 - element._isSending = true; 239 - element.render(); 244 + element.state.$isSending.set(true); 245 + await nextFrame(); 240 246 const postButton = element.querySelector(".rounded-button-primary"); 241 247 assert(postButton.disabled); 242 248 }); ··· 246 252 it("should send post on Cmd+Enter", () => { 247 253 const element = createPostComposer(); 248 254 connectElement(element); 249 - element._postText = "Hello world"; 255 + element.state.$postText.set("Hello world"); 250 256 251 257 let receivedDetail = null; 252 258 element.addEventListener("send-post", (e) => { ··· 268 274 it("should send post on Ctrl+Enter", () => { 269 275 const element = createPostComposer(); 270 276 connectElement(element); 271 - element._postText = "Hello world"; 277 + element.state.$postText.set("Hello world"); 272 278 273 279 let fired = false; 274 280 element.addEventListener("send-post", () => { ··· 309 315 it("should not send on Cmd+Enter when over character limit", () => { 310 316 const element = createPostComposer(); 311 317 connectElement(element); 312 - element._postText = "x".repeat(301); 313 - element.render(); 318 + element.state.$postText.set("x".repeat(301)); 314 319 315 320 let fired = false; 316 321 element.addEventListener("send-post", () => { ··· 331 336 it("should not send on Cmd+Enter when already sending", () => { 332 337 const element = createPostComposer(); 333 338 connectElement(element); 334 - element._postText = "Hello world"; 335 - element._isSending = true; 336 - element.render(); 339 + element.state.$postText.set("Hello world"); 340 + element.state.$isSending.set(true); 337 341 338 342 let count = 0; 339 343 element.addEventListener("send-post", () => { ··· 354 358 it("should not send on plain Enter", () => { 355 359 const element = createPostComposer(); 356 360 connectElement(element); 357 - element._postText = "Hello world"; 361 + element.state.$postText.set("Hello world"); 358 362 359 363 let fired = false; 360 364 element.addEventListener("send-post", () => { ··· 379 383 assert(input.multiple); 380 384 }); 381 385 382 - it("should disable image button when 4 images are selected", () => { 386 + it("should disable image button when 4 images are selected", async () => { 383 387 const element = createPostComposer(); 384 388 connectElement(element); 385 - element._selectedImages = [ 389 + element.state.$selectedImages.set([ 386 390 { file: {}, dataUrl: "data:..." }, 387 391 { file: {}, dataUrl: "data:..." }, 388 392 { file: {}, dataUrl: "data:..." }, 389 393 { file: {}, dataUrl: "data:..." }, 390 - ]; 391 - element.render(); 394 + ]); 395 + await nextFrame(); 392 396 const imageButton = element.querySelector(".image-picker-button"); 393 397 assert(imageButton.disabled); 394 398 }); ··· 398 402 it("should return true when post text is empty", async () => { 399 403 const element = createPostComposer(); 400 404 connectElement(element); 401 - element._postText = ""; 405 + element.state.$postText.set(""); 402 406 const result = await element.confirmClose(); 403 407 assertEquals(result, true); 404 408 }); ··· 408 412 it("should not reinitialize when connectedCallback is called multiple times", () => { 409 413 const element = createPostComposer(); 410 414 connectElement(element); 411 - element._postText = "Test content"; 415 + element.state.$postText.set("Test content"); 412 416 413 417 element.connectedCallback(); 414 418 415 - assertEquals(element._postText, "Test content"); 419 + assertEquals(element.state.$postText.get(), "Test content"); 416 420 }); 417 421 }); 418 422 ··· 440 444 element.open(); 441 445 const richTextInput = element.querySelector("rich-text-input"); 442 446 assertEquals(richTextInput.text, "Hello from a plugin"); 443 - assertEquals(element._postText, "Hello from a plugin"); 447 + assertEquals(element.state.$postText.get(), "Hello from a plugin"); 444 448 }); 445 449 446 450 it("does not seed text when initialText is null", () => { ··· 449 453 element.open(); 450 454 const richTextInput = element.querySelector("rich-text-input"); 451 455 assertEquals(richTextInput.text, ""); 452 - assertEquals(element._postText, ""); 456 + assertEquals(element.state.$postText.get(), ""); 453 457 }); 454 458 455 459 it("calls setCursor on the rich-text-input when initialCursor is set", () => { ··· 532 536 const event = makePasteEvent([makeImageFile()]); 533 537 element.handlePaste(event); 534 538 await new Promise((resolve) => setTimeout(resolve, 10)); 535 - assertEquals(element._selectedImages.length, 1); 536 - assert(element._selectedImages[0].dataUrl.startsWith("data:image/png")); 539 + const selectedImages = element.state.$selectedImages.get(); 540 + assertEquals(selectedImages.length, 1); 541 + assert(selectedImages[0].dataUrl.startsWith("data:image/png")); 537 542 assert(event.defaultPrevented); 538 543 }); 539 544 540 545 it("adds multiple pasted images up to the 4-image cap", async () => { 541 546 const element = createPostComposer(); 542 547 connectElement(element); 543 - element._selectedImages = [ 548 + element.state.$selectedImages.set([ 544 549 { file: {}, dataUrl: "data:..." }, 545 550 { file: {}, dataUrl: "data:..." }, 546 551 { file: {}, dataUrl: "data:..." }, 547 - ]; 552 + ]); 548 553 const event = makePasteEvent([ 549 554 makeImageFile("a.png"), 550 555 makeImageFile("b.png"), ··· 552 557 ]); 553 558 element.handlePaste(event); 554 559 await new Promise((resolve) => setTimeout(resolve, 10)); 555 - assertEquals(element._selectedImages.length, 4); 560 + assertEquals(element.state.$selectedImages.get().length, 4); 556 561 }); 557 562 558 563 it("does not add pasted images when a video is already selected", async () => { 559 564 const element = createPostComposer(); 560 565 connectElement(element); 561 - element._selectedVideo = { file: {}, status: "done" }; 566 + element.state.$selectedVideo.set({ file: {}, status: "done" }); 562 567 const event = makePasteEvent([makeImageFile()]); 563 568 element.handlePaste(event); 564 569 await new Promise((resolve) => setTimeout(resolve, 10)); 565 - assertEquals(element._selectedImages.length, 0); 570 + assertEquals(element.state.$selectedImages.get().length, 0); 566 571 assert(event.defaultPrevented); 567 572 }); 568 573 ··· 573 578 const event = makePasteEvent([]); 574 579 element.handlePaste(event); 575 580 assert(!event.defaultPrevented); 576 - assertEquals(element._selectedImages.length, 0); 581 + assertEquals(element.state.$selectedImages.get().length, 0); 577 582 }); 578 583 }); 579 584 ··· 601 606 await new Promise((resolve) => requestAnimationFrame(resolve)); 602 607 assertEquals(element._externalLinkUrl, "https://example.com/article"); 603 608 assertEquals( 604 - element._externalLinkEmbedData.url, 609 + element.state.$externalLinkEmbedData.get().url, 605 610 "https://example.com/article", 606 611 ); 607 612 }); ··· 614 619 element.handlePaste(makePasteEvent([])); 615 620 await new Promise((resolve) => requestAnimationFrame(resolve)); 616 621 assertEquals(element._externalLinkUrl, null); 617 - assertEquals(element._externalLinkEmbedData, null); 622 + assertEquals(element.state.$externalLinkEmbedData.get(), null); 618 623 }); 619 624 620 625 it("does not replace an existing external link embed", async () => { ··· 730 735 element.handleInput({ 731 736 detail: { text: `check ${url} `, facets: [facet] }, 732 737 }); 733 - return new Promise((resolve) => setTimeout(resolve, 0)); 738 + // one tick for the record load to resolve, one for the render effect 739 + return new Promise((resolve) => setTimeout(resolve, 0)).then(nextFrame); 734 740 } 735 741 736 742 it("preserves quotedRecord set before connectedCallback and renders its preview", () => { ··· 927 933 it("clears the record embed when the preview is closed", async () => { 928 934 await inputLink("https://bsky.app/profile/creator1.test/feed/cool-feed"); 929 935 element.handleQuotedEmbedPreviewClose(); 936 + await nextFrame(); 930 937 assertEquals(element.quotedRecord, null); 931 938 assertEquals(element._quotedRecordUrl, null); 932 939 assertEquals(element.querySelector(".post-composer-embed-preview"), null); ··· 934 941 935 942 it("sends the record embed as quotedRecord", async () => { 936 943 await inputLink("https://bsky.app/profile/creator1.test/feed/cool-feed"); 937 - element._postText = "check this feed"; 944 + element.state.$postText.set("check this feed"); 938 945 let receivedDetail = null; 939 946 element.addEventListener("send-post", (e) => { 940 947 receivedDetail = e.detail; ··· 967 974 const element = createPostComposer(); 968 975 connectElement(element); 969 976 await element.addMediaFiles([makeImageFile()]); 970 - assertEquals(element._selectedImages.length, 1); 977 + assertEquals(element.state.$selectedImages.get().length, 1); 971 978 }); 972 979 973 980 it("rejects mixed image and video files", async () => { 974 981 const element = createPostComposer(); 975 982 connectElement(element); 976 983 await element.addMediaFiles([makeImageFile(), makeVideoFile()]); 977 - assertEquals(element._selectedImages.length, 0); 978 - assertEquals(element._selectedVideo, null); 984 + assertEquals(element.state.$selectedImages.get().length, 0); 985 + assertEquals(element.state.$selectedVideo.get(), null); 979 986 }); 980 987 981 988 it("rejects unsupported file types without adding anything", async () => { ··· 985 992 makeImageFile(), 986 993 { name: "note.txt", type: "text/plain" }, 987 994 ]); 988 - assertEquals(element._selectedImages.length, 0); 995 + assertEquals(element.state.$selectedImages.get().length, 0); 989 996 }); 990 997 991 998 it("returns early on empty input", async () => { 992 999 const element = createPostComposer(); 993 1000 connectElement(element); 994 1001 await element.addMediaFiles([]); 995 - assertEquals(element._selectedImages.length, 0); 996 - assertEquals(element._selectedVideo, null); 1002 + assertEquals(element.state.$selectedImages.get().length, 0); 1003 + assertEquals(element.state.$selectedVideo.get(), null); 997 1004 }); 998 1005 }); 999 1006