diff --git a/package.json b/package.json index 3abe721a..29486eea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.113", + "version": "0.17.114", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/index.html b/src/index.html index 27306289..c99fefe8 100644 --- a/src/index.html +++ b/src/index.html @@ -105,7 +105,7 @@ // Dev tools if ( - window.env.environment === "development" || + (window.env.environment === "development" && !window.env.playwright) || params.has("enable-debug") ) { enableErrorLogs(); diff --git a/src/js/components/detected-rich-text.js b/src/js/components/detected-rich-text.js index d2d38997..f481c360 100644 --- a/src/js/components/detected-rich-text.js +++ b/src/js/components/detected-rich-text.js @@ -1,6 +1,6 @@ import { render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { Signal, effect } from "/js/signals.js"; +import { Signal, ReactiveStore, effect } from "/js/signals.js"; import { richTextTemplate } from "/js/templates/richText.template.js"; import { getUnresolvedFacetsFromText, @@ -16,33 +16,36 @@ class DetectedRichText extends Component { if (this.initialized) return; this.initialized = true; - this.$text = new Signal.State(this.getAttribute("text") ?? ""); - this.$truncateUrls = new Signal.State(this.hasAttribute("truncate-urls")); - this.$unresolvedFacets = new Signal.Computed(() => - getUnresolvedFacetsFromText(this.$text.get()), + this.state = new ReactiveStore("detected-rich-text"); + this.state.$text = new Signal.State(this.getAttribute("text") ?? ""); + this.state.$truncateUrls = new Signal.State( + this.hasAttribute("truncate-urls"), ); - this.$resolvedFacets = new Signal.State(null); + this.state.$unresolvedFacets = new Signal.Computed(() => + getUnresolvedFacetsFromText(this.state.$text.get()), + ); + this.state.$resolvedFacets = new Signal.State(null); // Resolve facets whenever unresolved facets change this.disposeResolve = effect(() => { if (!this.identityResolver) return; - const unresolvedFacets = this.$unresolvedFacets.get(); + const unresolvedFacets = this.state.$unresolvedFacets.get(); resolveFacets(unresolvedFacets, this.identityResolver).then( (resolvedFacets) => { - if (unresolvedFacets !== this.$unresolvedFacets.get()) { + if (unresolvedFacets !== this.state.$unresolvedFacets.get()) { // If unresolved facets have changed since we started resolving, don't update return; } - this.$resolvedFacets.set(resolvedFacets); + this.state.$resolvedFacets.set(resolvedFacets); }, ); }); this.disposeRender = effect(() => { - const text = this.$text.get(); - const unresolvedFacets = this.$unresolvedFacets.get(); - const resolvedFacets = this.$resolvedFacets.get(); - const truncateUrls = this.$truncateUrls.get(); + const text = this.state.$text.get(); + const unresolvedFacets = this.state.$unresolvedFacets.get(); + const resolvedFacets = this.state.$resolvedFacets.get(); + const truncateUrls = this.state.$truncateUrls.get(); let facets = resolvedFacets ?? unresolvedFacets; if (!this.identityResolver) { facets = facets.filter( @@ -58,10 +61,10 @@ class DetectedRichText extends Component { if (!this.initialized || oldValue === newValue) return; if (name === "text") { const text = newValue ?? ""; - this.$resolvedFacets.set(null); - this.$text.set(text); + this.state.$resolvedFacets.set(null); + this.state.$text.set(text); } else if (name === "truncate-urls") { - this.$truncateUrls.set(newValue !== null); + this.state.$truncateUrls.set(newValue !== null); } } diff --git a/src/js/components/plugin-posts-feed.js b/src/js/components/plugin-posts-feed.js index 92ec6be3..b2fa40d0 100644 --- a/src/js/components/plugin-posts-feed.js +++ b/src/js/components/plugin-posts-feed.js @@ -1,7 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { postFeedTemplate } from "/js/templates/postFeed.template.js"; -import { Signal, effect } from "/js/signals.js"; +import { Signal, ReactiveStore, effect } from "/js/signals.js"; class PluginPostsFeed extends Component { static get observedAttributes() { @@ -14,29 +14,27 @@ class PluginPostsFeed extends Component { if (!this.postInteractionHandler) { throw new Error("postInteractionHandler is required"); } - this.attribs = { - uris: new Signal.State(this.parseUris()), - emptyMessage: new Signal.State(this.getAttribute("empty-message")), - }; - this.state = { - currentUser: this.dataLayer.derived.$currentUser, - loaded: new Signal.State(false), - posts: new Signal.Computed(() => { - if (!this.state.loaded.get()) return null; - const uris = this.attribs.uris.get(); - if (!uris) return null; - return uris - .map((uri) => this.dataLayer.derived.$hydratedPosts.get(uri)) - .filter(Boolean); - }), - error: new Signal.State(null), - }; + this.state = new ReactiveStore("plugin-posts-feed"); + this.state.$uris = new Signal.State(this.parseUris()); + this.state.$emptyMessage = new Signal.State( + this.getAttribute("empty-message"), + ); + this.state.$loaded = new Signal.State(false); + this.state.$posts = new Signal.Computed(() => { + if (!this.state.$loaded.get()) return null; + const uris = this.state.$uris.get(); + if (!uris) return null; + return uris + .map((uri) => this.dataLayer.derived.$hydratedPosts.get(uri)) + .filter(Boolean); + }); + this.state.$error = new Signal.State(null); this._disposers = [ effect(() => { - const error = this.state.error.get(); - const posts = this.state.posts.get(); - const currentUser = this.state.currentUser.get(); - const emptyMessage = this.attribs.emptyMessage.get(); + const error = this.state.$error.get(); + const posts = this.state.$posts.get(); + const currentUser = this.dataLayer.derived.$currentUser.get(); + const emptyMessage = this.state.$emptyMessage.get(); if (error) { render(html`
${error}
`, this); return; @@ -60,7 +58,7 @@ class PluginPostsFeed extends Component { ); }), effect(() => { - this.attribs.uris.get(); + this.state.$uris.get(); this.load(); }), ]; @@ -75,8 +73,8 @@ class PluginPostsFeed extends Component { attributeChangedCallback() { if (this.initialized) { // TODO - smarter updates? - this.attribs.uris.set(this.parseUris()); - this.attribs.emptyMessage.set(this.getAttribute("empty-message")); + this.state.$uris.set(this.parseUris()); + this.state.$emptyMessage.set(this.getAttribute("empty-message")); } } @@ -92,14 +90,14 @@ class PluginPostsFeed extends Component { const uris = this.parseUris(); const requestToken = Symbol(); this._requestToken = requestToken; - this.state.error.set(null); + this.state.$error.set(null); try { await this.dataLayer.declarative.ensurePosts(uris); if (this._requestToken !== requestToken) return; - this.state.loaded.set(true); + this.state.$loaded.set(true); } catch (error) { if (this._requestToken !== requestToken) return; - this.state.error.set(error.message ?? String(error)); + this.state.$error.set(error.message ?? String(error)); } } } diff --git a/src/js/components/plugin-profiles-list.js b/src/js/components/plugin-profiles-list.js index ffd6900d..9a05428a 100644 --- a/src/js/components/plugin-profiles-list.js +++ b/src/js/components/plugin-profiles-list.js @@ -1,7 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; -import { Signal, effect } from "/js/signals.js"; +import { Signal, ReactiveStore, effect } from "/js/signals.js"; class PluginProfilesList extends Component { static get observedAttributes() { @@ -11,27 +11,26 @@ class PluginProfilesList extends Component { connectedCallback() { if (this.initialized) return; this.initialized = true; - this.attribs = { - dids: new Signal.State(this.parseDids()), - emptyMessage: new Signal.State(this.getAttribute("empty-message")), - }; - this.state = { - loaded: new Signal.State(false), - profiles: new Signal.Computed(() => { - if (!this.state.loaded.get()) return null; - const dids = this.attribs.dids.get(); - return dids - .map((did) => this.dataLayer.derived.$hydratedProfiles.get(did)) - .filter(Boolean); - }), - error: new Signal.State(null), - }; + this.state = new ReactiveStore("plugin-profiles-list"); + this.state.$dids = new Signal.State(this.parseDids()); + this.state.$emptyMessage = new Signal.State( + this.getAttribute("empty-message"), + ); + this.state.$loaded = new Signal.State(false); + this.state.$profiles = new Signal.Computed(() => { + if (!this.state.$loaded.get()) return null; + const dids = this.state.$dids.get(); + return dids + .map((did) => this.dataLayer.derived.$hydratedProfiles.get(did)) + .filter(Boolean); + }); + this.state.$error = new Signal.State(null); this._disposers = [ effect(() => { - const error = this.state.error.get(); - const profiles = this.state.profiles.get(); - const emptyMessage = this.attribs.emptyMessage.get(); - const dids = this.attribs.dids.get(); + const error = this.state.$error.get(); + const profiles = this.state.$profiles.get(); + const emptyMessage = this.state.$emptyMessage.get(); + const dids = this.state.$dids.get(); if (error) { render(html`
${error}
`, this); return; @@ -48,7 +47,7 @@ class PluginProfilesList extends Component { ); }), effect(() => { - this.attribs.dids.get(); + this.state.$dids.get(); this.load(); }), ]; @@ -62,8 +61,8 @@ class PluginProfilesList extends Component { attributeChangedCallback() { if (this.initialized) { - this.attribs.dids.set(this.parseDids()); - this.attribs.emptyMessage.set(this.getAttribute("empty-message")); + this.state.$dids.set(this.parseDids()); + this.state.$emptyMessage.set(this.getAttribute("empty-message")); } } @@ -76,22 +75,22 @@ class PluginProfilesList extends Component { } async load() { - const dids = this.attribs.dids.get(); + const dids = this.state.$dids.get(); const requestToken = Symbol(); this._requestToken = requestToken; - this.state.error.set(null); + this.state.$error.set(null); if (dids.length === 0) { - this.state.loaded.set(true); + this.state.$loaded.set(true); return; } - this.state.loaded.set(false); + this.state.$loaded.set(false); try { await this.dataLayer.declarative.ensureDetailedProfiles(dids); if (this._requestToken !== requestToken) return; - this.state.loaded.set(true); + this.state.$loaded.set(true); } catch (error) { if (this._requestToken !== requestToken) return; - this.state.error.set(error.message ?? String(error)); + this.state.$error.set(error.message ?? String(error)); } } } diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index 4795ffa0..96168bfe 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -26,6 +26,7 @@ import { import { LINK_CARD_SERVICE_URL } from "/js/config.js"; import { recordEmbedTemplate } from "/js/templates/postEmbed.template.js"; import { parseRecordLink, resolveRecordFromLink } from "/js/embedHelpers.js"; +import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; import "/js/components/rich-text-input.js"; import "/js/components/image-alt-text-dialog.js"; import "/js/components/emoji-picker-dialog.js"; @@ -176,35 +177,68 @@ class PostComposer extends Component { this.setAttribute("data-dialog-wrapper", ""); this.scrollLock = new ScrollLock(this); this.innerHTML = ""; - this._postText = ""; this.initialText = this.initialText ?? null; this.initialCursor = this.initialCursor ?? null; - this._isSending = false; this._unresolvedFacets = []; this._quotedRecordUrl = null; - this.quotedRecord = this.quotedRecord ?? null; this._externalLinkUrl = null; - this._externalLinkEmbedData = null; this._rejectedLinkEmbeds = new Set(); - this._selectedImages = []; - this._selectedVideo = null; - this.render(); + this._videoToken = null; + this.state = new ReactiveStore("postComposer"); + this.state.$postText = new Signal.State(""); + this.state.$isSending = new Signal.State(false); + this.state.$externalLinkEmbedData = new Signal.State(null); + this.state.$selectedImages = new Signal.State([]); + this.state.$selectedVideo = new Signal.State(null); + this.state.$quotedRecord = new Signal.State( + this._pendingQuotedRecord ?? null, + ); + this._pendingQuotedRecord = null; + this._disposers = [ + effect(() => { + this.render(); + }), + ]; this.initialized = true; } + disconnectedCallback() { + if (!this.initialized) return; + this._disposers?.forEach((dispose) => dispose()); + this._disposers = null; + } + + get quotedRecord() { + if (!this.state) return this._pendingQuotedRecord ?? null; + return untrack(() => this.state.$quotedRecord.get()); + } + + set quotedRecord(value) { + if (!this.state) { + this._pendingQuotedRecord = value; + return; + } + this.state.$quotedRecord.set(value); + } + render() { const promptText = this.replyTo ? "Write your reply" : "What's up?"; - const currentCharCount = graphemeCount(this._postText); + const isSending = this.state.$isSending.get(); + const externalLinkEmbedData = this.state.$externalLinkEmbedData.get(); + const selectedImages = this.state.$selectedImages.get(); + const selectedVideo = this.state.$selectedVideo.get(); + const quotedRecord = this.state.$quotedRecord.get(); + const currentCharCount = graphemeCount(this.state.$postText.get()); const charCountPercentage = Math.min( Math.round((currentCharCount / 300) * 100), 100, ); const isAboveCharLimit = currentCharCount > 300; const isVideoUploading = - this._selectedVideo && - (this._selectedVideo.status === "uploading" || - this._selectedVideo.status === "processing"); - const hasVideo = !!this._selectedVideo; + selectedVideo && + (selectedVideo.status === "uploading" || + selectedVideo.status === "processing"); + const hasVideo = !!selectedVideo; render( html` { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); - if ( - !this._isSending && - !isAboveCharLimit && - !isVideoUploading && - this._postText.length > 0 - ) { + const postText = untrack(() => this.state.$postText.get()); + if (postText.length > 0) { this.send(); } } @@ -252,11 +282,9 @@ class PostComposer extends Component { data-testid="composer-submit-button" data-teststate=${this.replyTo ? "reply" : "post"} @click=${() => this.send()} - .disabled=${this._isSending || - isAboveCharLimit || - isVideoUploading} + .disabled=${isSending || isAboveCharLimit || isVideoUploading} > - ${this._isSending + ${isSending ? html`Sending...   
` : html`${this.replyTo ? "Reply" : "Post"}`} @@ -284,29 +312,29 @@ class PostComposer extends Component { > - ${this._externalLinkEmbedData + ${externalLinkEmbedData ? externalLinkEmbedPreviewTemplate({ - data: this._externalLinkEmbedData, + data: externalLinkEmbedData, onClose: () => { this.handleExternalLinkEmbedPreviewClose(); }, }) : ""} - ${this._selectedImages.length > 0 + ${selectedImages.length > 0 ? imagePreviewTemplate({ - images: this._selectedImages, + images: selectedImages, onRemove: (index) => this.handleRemoveImage(index), onEditAltText: (index) => this.handleEditAltText(index), }) : ""} - ${this._selectedVideo + ${selectedVideo ? videoPreviewTemplate({ - video: this._selectedVideo, + video: selectedVideo, onRemove: () => this.handleRemoveVideo(), onEditAltText: () => this.handleEditVideoAltText(), }) : ""} - ${this.quotedRecord + ${quotedRecord ? html`
@@ -384,6 +412,17 @@ class PostComposer extends Component { ); } + isSendBlocked() { + const isSending = untrack(() => this.state.$isSending.get()); + const postText = untrack(() => this.state.$postText.get()); + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); + const isVideoUploading = + !!selectedVideo && + (selectedVideo.status === "uploading" || + selectedVideo.status === "processing"); + return isSending || graphemeCount(postText) > 300 || isVideoUploading; + } + handleEmojiButtonClick(event) { const dialog = this.querySelector("emoji-picker-dialog"); if (!dialog) return; @@ -422,14 +461,12 @@ class PostComposer extends Component { handleExternalLinkEmbedPreviewClose() { this._rejectedLinkEmbeds.add(this._externalLinkUrl); this._externalLinkUrl = null; - this._externalLinkEmbedData = null; - this.render(); + this.state.$externalLinkEmbedData.set(null); } handleQuotedEmbedPreviewClose() { this._quotedRecordUrl = null; - this.quotedRecord = null; - this.render(); + this.state.$quotedRecord.set(null); } async loadQuotedRecordFromLink() { @@ -441,8 +478,7 @@ class PostComposer extends Component { }); // the embed may have been closed or replaced while the record was loading if (this._quotedRecordUrl !== url) return; - this.quotedRecord = record; - this.render(); + this.state.$quotedRecord.set(record); } catch (error) { console.error("Error loading record embed from link: ", error); this._rejectedLinkEmbeds.add(url); @@ -484,7 +520,8 @@ class PostComposer extends Component { } if (videoFiles.length > 0) { - if (this._selectedImages.length > 0) { + const selectedImages = untrack(() => this.state.$selectedImages.get()); + if (selectedImages.length > 0) { showToast("Selecting multiple media types is not supported", { style: "warning", }); @@ -500,7 +537,8 @@ class PostComposer extends Component { } if (imageFiles.length > 0) { - if (this._selectedVideo) { + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); + if (selectedVideo) { showToast("Selecting multiple media types is not supported", { style: "warning", }); @@ -512,45 +550,57 @@ class PostComposer extends Component { async addImageFiles(files) { const maxImages = 4; - const remainingSlots = maxImages - this._selectedImages.length; + const currentImages = untrack(() => this.state.$selectedImages.get()); + const remainingSlots = maxImages - currentImages.length; if (files.length > remainingSlots) { showToast("You can select up to 4 images in total", { style: "warning" }); } + const newImages = []; for (let i = 0; i < Math.min(files.length, remainingSlots); i++) { const file = files[i]; const dataUrl = await readFileAsDataUrl(file); - this._selectedImages.push({ + newImages.push({ file, dataUrl, }); } + const latestImages = untrack(() => this.state.$selectedImages.get()); + const selectedImages = [...latestImages, ...newImages]; + this.state.$selectedImages.set(selectedImages); // Reject external link embed if images are added - if (this._selectedImages.length > 0 && this._externalLinkUrl) { + if (selectedImages.length > 0 && this._externalLinkUrl) { this._rejectedLinkEmbeds.add(this._externalLinkUrl); this._externalLinkUrl = null; - this._externalLinkEmbedData = null; + this.state.$externalLinkEmbedData.set(null); } - - this.render(); } handleRemoveImage(index) { - this._selectedImages.splice(index, 1); - this.render(); + const selectedImages = untrack(() => this.state.$selectedImages.get()); + this.state.$selectedImages.set( + selectedImages.filter((image, imageIndex) => imageIndex !== index), + ); } handleEditAltText(index) { - const image = this._selectedImages[index]; + const selectedImages = untrack(() => this.state.$selectedImages.get()); + const image = selectedImages[index]; const dialog = document.createElement("image-alt-text-dialog"); dialog.imageUrl = image.dataUrl; dialog.value = image.alt || ""; dialog.addEventListener("alt-text-saved", (e) => { - this._selectedImages[index].alt = e.detail.altText; - this.render(); + const latestImages = untrack(() => this.state.$selectedImages.get()); + this.state.$selectedImages.set( + latestImages.map((selectedImage, imageIndex) => + imageIndex === index + ? { ...selectedImage, alt: e.detail.altText } + : selectedImage, + ), + ); dialog.remove(); }); @@ -584,7 +634,9 @@ class PostComposer extends Component { showToast(msg, { style: "warning" }); return; } - this._selectedVideo = { + const token = Symbol(); + this._videoToken = token; + this.state.$selectedVideo.set({ file, previewUrl: URL.createObjectURL(file), alt: "", @@ -594,62 +646,67 @@ class PostComposer extends Component { jobId: null, blob: null, error: null, - }; - this.render(); - this.uploadSelectedVideo(); + }); + this.uploadSelectedVideo(token); + } + + // Applies a partial update to the selected video, unless it has been removed + // or replaced since `token` was issued. + patchSelectedVideo(token, patch) { + if (this._videoToken !== token) return null; + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); + const video = { ...selectedVideo, ...patch }; + this.state.$selectedVideo.set(video); + return video; } - async uploadSelectedVideo() { - const video = this._selectedVideo; + async uploadSelectedVideo(token) { + const video = untrack(() => this.state.$selectedVideo.get()); if (!video) return; try { const uploader = new VideoUploader(this.dataLayer.api); const blob = await uploader.upload(video.file, { onJobStart: (job) => { - if (this._selectedVideo !== video) return; - this._selectedVideo.jobId = job.jobId; - this._selectedVideo.status = "processing"; - this.render(); + this.patchSelectedVideo(token, { + jobId: job.jobId, + status: "processing", + }); }, onProgress: (_state, progress) => { - if (this._selectedVideo !== video) return; - this._selectedVideo.progress = progress; - this.render(); + this.patchSelectedVideo(token, { progress }); }, }); - if (this._selectedVideo !== video) return; - this._selectedVideo.blob = blob; - this._selectedVideo.status = "done"; - this.render(); + this.patchSelectedVideo(token, { blob, status: "done" }); } catch (error) { console.error("Video upload error: ", error); - if (this._selectedVideo !== video) return; - this._selectedVideo.status = "error"; - this._selectedVideo.error = error.message || "Upload failed"; - this.render(); - showToast(this._selectedVideo.error, { style: "error" }); + const failedVideo = this.patchSelectedVideo(token, { + status: "error", + error: error.message || "Upload failed", + }); + if (failedVideo) { + showToast(failedVideo.error, { style: "error" }); + } } } handleRemoveVideo() { - if (this._selectedVideo?.previewUrl) { - URL.revokeObjectURL(this._selectedVideo.previewUrl); + const video = untrack(() => this.state.$selectedVideo.get()); + if (video?.previewUrl) { + URL.revokeObjectURL(video.previewUrl); } - this._selectedVideo = null; - this.render(); + this._videoToken = null; + this.state.$selectedVideo.set(null); } handleEditVideoAltText() { - const video = this._selectedVideo; + const video = untrack(() => this.state.$selectedVideo.get()); if (!video) return; + const token = this._videoToken; const dialog = document.createElement("image-alt-text-dialog"); dialog.value = video.alt || ""; dialog.addEventListener("alt-text-saved", (e) => { - if (this._selectedVideo === video) { - this._selectedVideo.alt = e.detail.altText; - this.render(); - } + this.patchSelectedVideo(token, { alt: e.detail.altText }); dialog.remove(); }); @@ -663,7 +720,7 @@ class PostComposer extends Component { handleInput(e) { const previousFacets = this._unresolvedFacets; - this._postText = e.detail.text; + this.state.$postText.set(e.detail.text); this._unresolvedFacets = e.detail.facets; // If the facets *haven't* changed, and the latest change was a space or newline, check for possible link embeds if ( @@ -709,7 +766,6 @@ class PostComposer extends Component { } } } - this.render(); } handlePaste(e) { @@ -744,13 +800,12 @@ class PostComposer extends Component { async loadExternalLinkEmbedPreview() { const url = this._externalLinkUrl; // preliminary data - this._externalLinkEmbedData = { + this.state.$externalLinkEmbedData.set({ url, title: url, description: "", image: "", - }; - this.render(); + }); let res = null; try { res = await fetch(`${LINK_CARD_SERVICE_URL}/v1/extract?url=${url}`); @@ -760,25 +815,30 @@ class PostComposer extends Component { } if (res && res.ok) { const data = await res.json(); - // preview may have been closed while metadata was loading - if (!this._externalLinkEmbedData) return; + // preview may have been closed or replaced while metadata was loading + const current = this.state.$externalLinkEmbedData.get(); + if (!current || current.url !== url) return; + const updated = { ...current }; if (data.title) { - this._externalLinkEmbedData.title = data.title; + updated.title = data.title; } if (data.description) { - this._externalLinkEmbedData.description = data.description; + updated.description = data.description; } - this.render(); + this.state.$externalLinkEmbedData.set(updated); if (data.image) { // only show image if it can be loaded let imageRes = null; try { imageRes = await fetch(sanitizeUri(data.image)); } catch (error) {} - // preview may have been closed while the image was loading - if (imageRes && imageRes.ok && this._externalLinkEmbedData) { - this._externalLinkEmbedData.image = data.image; - this.render(); + // preview may have been closed or replaced while the image was loading + const latest = this.state.$externalLinkEmbedData.get(); + if (imageRes && imageRes.ok && latest && latest.url === url) { + this.state.$externalLinkEmbedData.set({ + ...latest, + image: data.image, + }); } } } @@ -829,26 +889,30 @@ class PostComposer extends Component { } send() { - this._isSending = true; - this.render(); + if (this.isSendBlocked()) return; + this.state.$isSending.set(true); const successCallback = () => { this.close(); }; const errorCallback = () => { - this._isSending = false; + this.state.$isSending.set(false); // todo: show error message - this.render(); }; + const postText = untrack(() => this.state.$postText.get()); + const external = untrack(() => this.state.$externalLinkEmbedData.get()); + const quotedRecord = untrack(() => this.state.$quotedRecord.get()); + const images = untrack(() => this.state.$selectedImages.get()); + const video = untrack(() => this.state.$selectedVideo.get()); this.dispatchEvent( new CustomEvent("send-post", { detail: { - postText: this._postText, - external: this._externalLinkEmbedData, + postText, + external, replyTo: this.replyTo, replyRoot: this.replyRoot, - quotedRecord: this.quotedRecord, - images: this._selectedImages, - video: this._selectedVideo, + quotedRecord, + images, + video, successCallback, errorCallback, }, @@ -858,10 +922,13 @@ class PostComposer extends Component { confirmClose() { // Todo - check for other unsaved changes + const postText = untrack(() => this.state.$postText.get()); + const selectedImages = untrack(() => this.state.$selectedImages.get()); + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); if ( - this._postText.length === 0 && - this._selectedImages.length === 0 && - !this._selectedVideo + postText.length === 0 && + selectedImages.length === 0 && + !selectedVideo ) { return true; } diff --git a/src/js/components/rendered-markdown.js b/src/js/components/rendered-markdown.js index 0a46998d..de93e7d8 100644 --- a/src/js/components/rendered-markdown.js +++ b/src/js/components/rendered-markdown.js @@ -1,24 +1,25 @@ import { Component } from "/js/components/component.js"; -import { Signal, effect } from "/js/signals.js"; +import { Signal, ReactiveStore, effect } from "/js/signals.js"; class RenderedMarkdown extends Component { connectedCallback() { if (this.initialized) return; this.initialized = true; - this.$dependencies = new Signal.State(null); - this.$content = new Signal.State(this.getAttribute("content") || ""); + this.state = new ReactiveStore("rendered-markdown"); + this.state.$dependencies = new Signal.State(null); + this.state.$content = new Signal.State(this.getAttribute("content") || ""); // Fetch dependencies Promise.all([ import("/js/lib/dompurify.js"), import("/js/lib/marked.js"), ]).then(([{ default: DOMPurify }, { marked }]) => { - this.$dependencies.set({ DOMPurify, marked }); + this.state.$dependencies.set({ DOMPurify, marked }); }); this.dispose = effect(() => { - const dependencies = this.$dependencies.get(); + const dependencies = this.state.$dependencies.get(); if (!dependencies) return; const { DOMPurify, marked } = dependencies; - const content = this.$content.get(); + const content = this.state.$content.get(); this.innerHTML = DOMPurify.sanitize(marked.parse(content)); }); // Treat links inside rendered markdown as external @@ -37,7 +38,7 @@ class RenderedMarkdown extends Component { attributeChangedCallback(name, oldValue, newValue) { if (!this.initialized || oldValue === newValue) return; if (name === "content") { - this.$content.set(newValue); + this.state.$content.set(newValue); } } diff --git a/tests/unit/specs/components/post-composer.test.js b/tests/unit/specs/components/post-composer.test.js index 83f8464c..8e2c6a60 100644 --- a/tests/unit/specs/components/post-composer.test.js +++ b/tests/unit/specs/components/post-composer.test.js @@ -8,6 +8,12 @@ t.beforeEach(() => { document.body.innerHTML = ""; }); +async function nextFrame() { + // The render effect flushes on requestAnimationFrame (setTimeout(0) in the + // test env), so one tick applies pending renders. + await new Promise((resolve) => setTimeout(resolve, 0)); +} + function connectElement(element) { const container = document.createElement("div"); container.className = "page-visible"; @@ -118,19 +124,19 @@ t.describe("PostComposer - initial state", (it) => { it("should start with empty post text", () => { const element = createPostComposer(); connectElement(element); - assertEquals(element._postText, ""); + assertEquals(element.state.$postText.get(), ""); }); it("should not be sending initially", () => { const element = createPostComposer(); connectElement(element); - assertEquals(element._isSending, false); + assertEquals(element.state.$isSending.get(), false); }); it("should have no selected images initially", () => { const element = createPostComposer(); connectElement(element); - assertEquals(element._selectedImages.length, 0); + assertEquals(element.state.$selectedImages.get().length, 0); }); }); @@ -142,20 +148,20 @@ t.describe("PostComposer - character limit", (it) => { assertEquals(wordCount.textContent, "300"); }); - it("should add overflow class when over limit", () => { + it("should add overflow class when over limit", async () => { const element = createPostComposer(); connectElement(element); - element._postText = "x".repeat(301); - element.render(); + element.state.$postText.set("x".repeat(301)); + await nextFrame(); const wordCountContainer = element.querySelector(".word-count"); assert(wordCountContainer.classList.contains("overflow")); }); - it("should disable post button when over limit", () => { + it("should disable post button when over limit", async () => { const element = createPostComposer(); connectElement(element); - element._postText = "x".repeat(301); - element.render(); + element.state.$postText.set("x".repeat(301)); + await nextFrame(); const postButton = element.querySelector(".rounded-button-primary"); assert(postButton.disabled); }); @@ -197,22 +203,22 @@ t.describe("PostComposer - close method", (it) => { }); t.describe("PostComposer - send method", (it) => { - it("should set _isSending to true when send() is called", () => { + it("should set isSending to true when send() is called", () => { const element = createPostComposer(); connectElement(element); - element._postText = "Hello world"; + element.state.$postText.set("Hello world"); // Listen for the event but don't do anything element.addEventListener("send-post", () => {}); element.send(); - assertEquals(element._isSending, true); + assertEquals(element.state.$isSending.get(), true); }); it("should dispatch send-post event with post data", () => { const element = createPostComposer(); connectElement(element); - element._postText = "Hello world"; + element.state.$postText.set("Hello world"); let receivedDetail = null; element.addEventListener("send-post", (e) => { @@ -223,20 +229,20 @@ t.describe("PostComposer - send method", (it) => { assertEquals(receivedDetail.postText, "Hello world"); }); - it("should show loading spinner when sending", () => { + it("should show loading spinner when sending", async () => { const element = createPostComposer(); connectElement(element); - element._isSending = true; - element.render(); + element.state.$isSending.set(true); + await nextFrame(); const spinner = element.querySelector(".loading-spinner"); assert(spinner !== null); }); - it("should disable post button when sending", () => { + it("should disable post button when sending", async () => { const element = createPostComposer(); connectElement(element); - element._isSending = true; - element.render(); + element.state.$isSending.set(true); + await nextFrame(); const postButton = element.querySelector(".rounded-button-primary"); assert(postButton.disabled); }); @@ -246,7 +252,7 @@ t.describe("PostComposer - keyboard shortcuts", (it) => { it("should send post on Cmd+Enter", () => { const element = createPostComposer(); connectElement(element); - element._postText = "Hello world"; + element.state.$postText.set("Hello world"); let receivedDetail = null; element.addEventListener("send-post", (e) => { @@ -268,7 +274,7 @@ t.describe("PostComposer - keyboard shortcuts", (it) => { it("should send post on Ctrl+Enter", () => { const element = createPostComposer(); connectElement(element); - element._postText = "Hello world"; + element.state.$postText.set("Hello world"); let fired = false; element.addEventListener("send-post", () => { @@ -309,8 +315,7 @@ t.describe("PostComposer - keyboard shortcuts", (it) => { it("should not send on Cmd+Enter when over character limit", () => { const element = createPostComposer(); connectElement(element); - element._postText = "x".repeat(301); - element.render(); + element.state.$postText.set("x".repeat(301)); let fired = false; element.addEventListener("send-post", () => { @@ -331,9 +336,8 @@ t.describe("PostComposer - keyboard shortcuts", (it) => { it("should not send on Cmd+Enter when already sending", () => { const element = createPostComposer(); connectElement(element); - element._postText = "Hello world"; - element._isSending = true; - element.render(); + element.state.$postText.set("Hello world"); + element.state.$isSending.set(true); let count = 0; element.addEventListener("send-post", () => { @@ -354,7 +358,7 @@ t.describe("PostComposer - keyboard shortcuts", (it) => { it("should not send on plain Enter", () => { const element = createPostComposer(); connectElement(element); - element._postText = "Hello world"; + element.state.$postText.set("Hello world"); let fired = false; element.addEventListener("send-post", () => { @@ -379,16 +383,16 @@ t.describe("PostComposer - image selection", (it) => { assert(input.multiple); }); - it("should disable image button when 4 images are selected", () => { + it("should disable image button when 4 images are selected", async () => { const element = createPostComposer(); connectElement(element); - element._selectedImages = [ + element.state.$selectedImages.set([ { file: {}, dataUrl: "data:..." }, { file: {}, dataUrl: "data:..." }, { file: {}, dataUrl: "data:..." }, { file: {}, dataUrl: "data:..." }, - ]; - element.render(); + ]); + await nextFrame(); const imageButton = element.querySelector(".image-picker-button"); assert(imageButton.disabled); }); @@ -398,7 +402,7 @@ t.describe("PostComposer - confirmClose", (it) => { it("should return true when post text is empty", async () => { const element = createPostComposer(); connectElement(element); - element._postText = ""; + element.state.$postText.set(""); const result = await element.confirmClose(); assertEquals(result, true); }); @@ -408,11 +412,11 @@ t.describe("PostComposer - reinitialization protection", (it) => { it("should not reinitialize when connectedCallback is called multiple times", () => { const element = createPostComposer(); connectElement(element); - element._postText = "Test content"; + element.state.$postText.set("Test content"); element.connectedCallback(); - assertEquals(element._postText, "Test content"); + assertEquals(element.state.$postText.get(), "Test content"); }); }); @@ -440,7 +444,7 @@ t.describe("PostComposer - initial text/cursor", (it) => { element.open(); const richTextInput = element.querySelector("rich-text-input"); assertEquals(richTextInput.text, "Hello from a plugin"); - assertEquals(element._postText, "Hello from a plugin"); + assertEquals(element.state.$postText.get(), "Hello from a plugin"); }); it("does not seed text when initialText is null", () => { @@ -449,7 +453,7 @@ t.describe("PostComposer - initial text/cursor", (it) => { element.open(); const richTextInput = element.querySelector("rich-text-input"); assertEquals(richTextInput.text, ""); - assertEquals(element._postText, ""); + assertEquals(element.state.$postText.get(), ""); }); it("calls setCursor on the rich-text-input when initialCursor is set", () => { @@ -532,19 +536,20 @@ t.describe("PostComposer - paste media", (it) => { const event = makePasteEvent([makeImageFile()]); element.handlePaste(event); await new Promise((resolve) => setTimeout(resolve, 10)); - assertEquals(element._selectedImages.length, 1); - assert(element._selectedImages[0].dataUrl.startsWith("data:image/png")); + const selectedImages = element.state.$selectedImages.get(); + assertEquals(selectedImages.length, 1); + assert(selectedImages[0].dataUrl.startsWith("data:image/png")); assert(event.defaultPrevented); }); it("adds multiple pasted images up to the 4-image cap", async () => { const element = createPostComposer(); connectElement(element); - element._selectedImages = [ + element.state.$selectedImages.set([ { file: {}, dataUrl: "data:..." }, { file: {}, dataUrl: "data:..." }, { file: {}, dataUrl: "data:..." }, - ]; + ]); const event = makePasteEvent([ makeImageFile("a.png"), makeImageFile("b.png"), @@ -552,17 +557,17 @@ t.describe("PostComposer - paste media", (it) => { ]); element.handlePaste(event); await new Promise((resolve) => setTimeout(resolve, 10)); - assertEquals(element._selectedImages.length, 4); + assertEquals(element.state.$selectedImages.get().length, 4); }); it("does not add pasted images when a video is already selected", async () => { const element = createPostComposer(); connectElement(element); - element._selectedVideo = { file: {}, status: "done" }; + element.state.$selectedVideo.set({ file: {}, status: "done" }); const event = makePasteEvent([makeImageFile()]); element.handlePaste(event); await new Promise((resolve) => setTimeout(resolve, 10)); - assertEquals(element._selectedImages.length, 0); + assertEquals(element.state.$selectedImages.get().length, 0); assert(event.defaultPrevented); }); @@ -573,7 +578,7 @@ t.describe("PostComposer - paste media", (it) => { const event = makePasteEvent([]); element.handlePaste(event); assert(!event.defaultPrevented); - assertEquals(element._selectedImages.length, 0); + assertEquals(element.state.$selectedImages.get().length, 0); }); }); @@ -601,7 +606,7 @@ t.describe("PostComposer - paste links", (it, { beforeEach, afterEach }) => { await new Promise((resolve) => requestAnimationFrame(resolve)); assertEquals(element._externalLinkUrl, "https://example.com/article"); assertEquals( - element._externalLinkEmbedData.url, + element.state.$externalLinkEmbedData.get().url, "https://example.com/article", ); }); @@ -614,7 +619,7 @@ t.describe("PostComposer - paste links", (it, { beforeEach, afterEach }) => { element.handlePaste(makePasteEvent([])); await new Promise((resolve) => requestAnimationFrame(resolve)); assertEquals(element._externalLinkUrl, null); - assertEquals(element._externalLinkEmbedData, null); + assertEquals(element.state.$externalLinkEmbedData.get(), null); }); it("does not replace an existing external link embed", async () => { @@ -730,7 +735,8 @@ t.describe( element.handleInput({ detail: { text: `check ${url} `, facets: [facet] }, }); - return new Promise((resolve) => setTimeout(resolve, 0)); + // one tick for the record load to resolve, one for the render effect + return new Promise((resolve) => setTimeout(resolve, 0)).then(nextFrame); } it("preserves quotedRecord set before connectedCallback and renders its preview", () => { @@ -927,6 +933,7 @@ t.describe( it("clears the record embed when the preview is closed", async () => { await inputLink("https://bsky.app/profile/creator1.test/feed/cool-feed"); element.handleQuotedEmbedPreviewClose(); + await nextFrame(); assertEquals(element.quotedRecord, null); assertEquals(element._quotedRecordUrl, null); assertEquals(element.querySelector(".post-composer-embed-preview"), null); @@ -934,7 +941,7 @@ t.describe( it("sends the record embed as quotedRecord", async () => { await inputLink("https://bsky.app/profile/creator1.test/feed/cool-feed"); - element._postText = "check this feed"; + element.state.$postText.set("check this feed"); let receivedDetail = null; element.addEventListener("send-post", (e) => { receivedDetail = e.detail; @@ -967,15 +974,15 @@ t.describe("PostComposer - addMediaFiles", (it) => { const element = createPostComposer(); connectElement(element); await element.addMediaFiles([makeImageFile()]); - assertEquals(element._selectedImages.length, 1); + assertEquals(element.state.$selectedImages.get().length, 1); }); it("rejects mixed image and video files", async () => { const element = createPostComposer(); connectElement(element); await element.addMediaFiles([makeImageFile(), makeVideoFile()]); - assertEquals(element._selectedImages.length, 0); - assertEquals(element._selectedVideo, null); + assertEquals(element.state.$selectedImages.get().length, 0); + assertEquals(element.state.$selectedVideo.get(), null); }); it("rejects unsupported file types without adding anything", async () => { @@ -985,15 +992,15 @@ t.describe("PostComposer - addMediaFiles", (it) => { makeImageFile(), { name: "note.txt", type: "text/plain" }, ]); - assertEquals(element._selectedImages.length, 0); + assertEquals(element.state.$selectedImages.get().length, 0); }); it("returns early on empty input", async () => { const element = createPostComposer(); connectElement(element); await element.addMediaFiles([]); - assertEquals(element._selectedImages.length, 0); - assertEquals(element._selectedVideo, null); + assertEquals(element.state.$selectedImages.get().length, 0); + assertEquals(element.state.$selectedVideo.get(), null); }); });