[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.

Add drafts

Grace Kind (Jul 18, 2026, 1:44 AM -0500) 2cafac42 14d53a2b

+3347 -99
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.199", 3 + "version": "0.18.0", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+203 -1
src/css/style.css
··· 94 94 --text-link-color: var(--highlight-color); 95 95 --like-color: var(--pink); 96 96 --error-color: var(--red-muted); 97 + --error-subtle-color: color-mix(in srgb, var(--error-color) 12%, transparent); 98 + --error-subtle-hover-color: color-mix( 99 + in srgb, 100 + var(--error-color) 20%, 101 + transparent 102 + ); 97 103 --warning-color: var(--yellow); 98 104 99 105 /* border colors */ ··· 5905 5911 } 5906 5912 5907 5913 .post-composer { 5908 - border: none; 5914 + border: var(--hair) solid var(--post-border-color); 5915 + border-bottom: none; 5909 5916 border-radius: var(--sheet-border-radius) var(--sheet-border-radius) 0 0; 5910 5917 background-color: light-dark(var(--white), var(--mid-gray)); 5911 5918 color: var(--text-color); ··· 5939 5946 bottom: auto; 5940 5947 height: auto; 5941 5948 max-height: calc(100vh - 64px); 5949 + border-bottom: var(--hair) solid var(--post-border-color); 5942 5950 border-radius: var(--dialog-border-radius); 5943 5951 box-shadow: none; 5944 5952 animation: none; ··· 6035 6043 bottom: auto; 6036 6044 height: auto; 6037 6045 max-height: 80vh; 6046 + border-bottom: var(--hair) solid var(--post-border-color); 6038 6047 border-radius: var(--dialog-border-radius); 6039 6048 box-shadow: none; 6040 6049 animation: none; ··· 6792 6801 color: var(--off-white); 6793 6802 } 6794 6803 6804 + .modal-dialog-button.danger-subtle-button { 6805 + background-color: var(--error-subtle-color); 6806 + color: var(--error-color); 6807 + } 6808 + 6809 + @media (hover: hover) { 6810 + .modal-dialog-button.danger-subtle-button:hover { 6811 + background-color: var(--error-subtle-hover-color); 6812 + } 6813 + } 6814 + 6795 6815 @media (hover: hover) { 6796 6816 .modal-dialog-button.primary-button:hover { 6797 6817 background-color: var(--primary-button-hover-color); ··· 7658 7678 @media (min-width: 800px) { 7659 7679 .account-switcher-dialog { 7660 7680 max-width: 410px; 7681 + } 7682 + } 7683 + 7684 + .modal-dialog-buttons-stacked { 7685 + flex-direction: column; 7686 + } 7687 + 7688 + @media (max-width: 799px) { 7689 + .drafts-dialog { 7690 + top: 32px; 7691 + } 7692 + } 7693 + 7694 + @media (min-width: 800px) { 7695 + .drafts-dialog { 7696 + max-width: 500px; 7697 + } 7698 + } 7699 + 7700 + .drafts-dialog::before { 7701 + display: none; 7702 + } 7703 + 7704 + .drafts-dialog-content { 7705 + padding: 16px; 7706 + display: flex; 7707 + flex-direction: column; 7708 + min-height: 0; 7709 + } 7710 + 7711 + .drafts-dialog-header { 7712 + position: relative; 7713 + display: flex; 7714 + align-items: center; 7715 + justify-content: center; 7716 + margin-bottom: 12px; 7717 + } 7718 + 7719 + .drafts-dialog-title { 7720 + font-size: 18px; 7721 + font-weight: 600; 7722 + margin: 0; 7723 + } 7724 + 7725 + .drafts-dialog-back { 7726 + position: absolute; 7727 + left: 0; 7728 + background: none; 7729 + border: none; 7730 + font-size: 15px; 7731 + cursor: pointer; 7732 + padding: 8px 12px; 7733 + transition: color 0.2s; 7734 + border-radius: var(--pill-button-border-radius); 7735 + color: var(--highlight-color); 7736 + } 7737 + 7738 + .drafts-dialog-list { 7739 + overflow-y: auto; 7740 + min-height: 120px; 7741 + max-height: 60vh; 7742 + } 7743 + 7744 + .drafts-dialog-message { 7745 + display: flex; 7746 + justify-content: center; 7747 + padding: 32px 0; 7748 + color: var(--text-color-muted); 7749 + font-size: 15px; 7750 + } 7751 + 7752 + .draft-item { 7753 + display: flex; 7754 + align-items: flex-start; 7755 + gap: 8px; 7756 + padding: 12px; 7757 + border: var(--hair) solid var(--post-border-color); 7758 + border-radius: 12px; 7759 + cursor: pointer; 7760 + } 7761 + 7762 + .draft-item:not(:first-child) { 7763 + margin-top: 8px; 7764 + } 7765 + 7766 + @media (hover: hover) { 7767 + .draft-item:hover { 7768 + background-color: var(--secondary-background-color); 7769 + } 7770 + } 7771 + 7772 + .draft-item-main { 7773 + flex: 1; 7774 + min-width: 0; 7775 + } 7776 + 7777 + .draft-item-text { 7778 + font-size: 15px; 7779 + line-height: 1.4; 7780 + white-space: pre-wrap; 7781 + word-break: break-word; 7782 + display: -webkit-box; 7783 + -webkit-line-clamp: 8; 7784 + -webkit-box-orient: vertical; 7785 + overflow: hidden; 7786 + } 7787 + 7788 + .draft-item-media { 7789 + display: flex; 7790 + gap: 6px; 7791 + margin-top: 8px; 7792 + } 7793 + 7794 + .draft-item-thumb { 7795 + width: 56px; 7796 + height: 56px; 7797 + object-fit: cover; 7798 + border-radius: 8px; 7799 + border: var(--hair) solid var(--post-border-color); 7800 + } 7801 + 7802 + .draft-item-video-placeholder { 7803 + display: flex; 7804 + align-items: center; 7805 + justify-content: center; 7806 + color: var(--text-color-muted); 7807 + background-color: var(--secondary-background-color); 7808 + } 7809 + 7810 + .draft-item-footer { 7811 + display: flex; 7812 + align-items: center; 7813 + flex-wrap: wrap; 7814 + gap: 6px; 7815 + margin-top: 8px; 7816 + } 7817 + 7818 + .draft-item-tag { 7819 + font-size: 12px; 7820 + padding: 2px 8px; 7821 + border-radius: 999px; 7822 + background-color: var(--secondary-background-color); 7823 + color: var(--text-color-muted); 7824 + } 7825 + 7826 + .draft-item-tag-warning { 7827 + background-color: color-mix(in srgb, var(--error-color) 15%, transparent); 7828 + color: var(--error-color); 7829 + } 7830 + 7831 + .draft-item-side { 7832 + display: flex; 7833 + flex-direction: column; 7834 + align-items: flex-end; 7835 + justify-content: space-between; 7836 + align-self: stretch; 7837 + gap: 6px; 7838 + } 7839 + 7840 + .draft-item-timestamp { 7841 + font-size: 12px; 7842 + color: var(--text-color-muted); 7843 + margin-bottom: 4px; 7844 + } 7845 + 7846 + .draft-item-delete { 7847 + background: none; 7848 + border: none; 7849 + color: var(--text-color-muted); 7850 + cursor: pointer; 7851 + border-radius: var(--icon-button-border-radius); 7852 + transition: background-color 0.2s; 7853 + } 7854 + 7855 + .draft-item-delete .icon { 7856 + width: 18px; 7857 + height: 18px; 7858 + } 7859 + 7860 + @media (hover: hover) { 7861 + .draft-item-delete:hover { 7862 + background-color: var(--back-button-hover-color); 7661 7863 } 7662 7864 } 7663 7865
+3
src/index.html
··· 149 149 import listDetailView from "/js/views/listDetail.view.js"; 150 150 import bookmarksView from "/js/views/bookmarks.view.js"; 151 151 import { DataLayer } from "/js/dataLayer/dataLayer.js"; 152 + import { DraftMediaStore } from "/js/drafts.js"; 152 153 import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; 153 154 import { IdentityResolver } from "/js/atproto.js"; 154 155 import { Router } from "/js/router.js"; ··· 207 208 window.disablePlugin = pluginService.disablePlugin.bind(pluginService); 208 209 209 210 const identityResolver = new IdentityResolver(); 211 + const draftMediaStore = new DraftMediaStore(); 210 212 const dataLayer = new DataLayer( 211 213 api, 212 214 pluginService, 213 215 preferencesProvider, 214 216 identityResolver, 217 + draftMediaStore, 215 218 ); 216 219 pluginService.setDataLayer(dataLayer); 217 220 // put dataLayer on window for easy access in dev tools
+95 -1
src/js/api.js
··· 204 204 return res.data; 205 205 } 206 206 207 + async getDrafts({ cursor = "", limit } = {}) { 208 + const query = {}; 209 + if (cursor) { 210 + query.cursor = cursor; 211 + } 212 + if (limit) { 213 + query.limit = limit; 214 + } 215 + const res = await this.request("app.bsky.draft.getDrafts", { 216 + query, 217 + headers: { 218 + "atproto-proxy": this.bskyAppViewServiceDid, 219 + }, 220 + }); 221 + return res.data; 222 + } 223 + 224 + async createDraft(draft) { 225 + const res = await this.request("app.bsky.draft.createDraft", { 226 + method: "POST", 227 + body: { draft }, 228 + headers: { 229 + "atproto-proxy": this.bskyAppViewServiceDid, 230 + }, 231 + }); 232 + return res.data; 233 + } 234 + 235 + async updateDraft(id, draft) { 236 + const res = await this.request("app.bsky.draft.updateDraft", { 237 + method: "POST", 238 + body: { draft: { id, draft } }, 239 + headers: { 240 + "atproto-proxy": this.bskyAppViewServiceDid, 241 + }, 242 + parseJson: false, 243 + }); 244 + return res.data; 245 + } 246 + 247 + async deleteDraft(id) { 248 + const res = await this.request("app.bsky.draft.deleteDraft", { 249 + method: "POST", 250 + body: { id }, 251 + headers: { 252 + "atproto-proxy": this.bskyAppViewServiceDid, 253 + }, 254 + parseJson: false, 255 + }); 256 + return res.data; 257 + } 258 + 207 259 async createFollowRecord(profile) { 208 260 const res = await this.request(`com.atproto.repo.createRecord`, { 209 261 method: "POST", ··· 1163 1215 return res.data; 1164 1216 } 1165 1217 1166 - async createPost({ text, facets, embed, reply, langs }) { 1218 + async createPost({ text, facets, embed, reply, langs, labels = null }) { 1167 1219 const record = { 1168 1220 text, 1169 1221 facets, ··· 1176 1228 if (reply) { 1177 1229 record.reply = reply; 1178 1230 } 1231 + if (labels) { 1232 + record.labels = labels; 1233 + } 1179 1234 const res = await this.request("com.atproto.repo.createRecord", { 1180 1235 method: "POST", 1181 1236 body: { 1182 1237 repo: this.session.did, 1183 1238 collection: "app.bsky.feed.post", 1184 1239 record, 1240 + }, 1241 + }); 1242 + return res.data; 1243 + } 1244 + 1245 + // Threadgate and postgate records use the same rkey as the post they gate 1246 + async createThreadgate(postUri, allow) { 1247 + const { rkey } = parseUri(postUri); 1248 + const res = await this.request("com.atproto.repo.createRecord", { 1249 + method: "POST", 1250 + body: { 1251 + repo: this.session.did, 1252 + collection: "app.bsky.feed.threadgate", 1253 + rkey, 1254 + record: { 1255 + $type: "app.bsky.feed.threadgate", 1256 + post: postUri, 1257 + allow, 1258 + createdAt: getCurrentTimestamp(), 1259 + }, 1260 + }, 1261 + }); 1262 + return res.data; 1263 + } 1264 + 1265 + async createPostgate(postUri, embeddingRules) { 1266 + const { rkey } = parseUri(postUri); 1267 + const res = await this.request("com.atproto.repo.createRecord", { 1268 + method: "POST", 1269 + body: { 1270 + repo: this.session.did, 1271 + collection: "app.bsky.feed.postgate", 1272 + rkey, 1273 + record: { 1274 + $type: "app.bsky.feed.postgate", 1275 + post: postUri, 1276 + embeddingRules, 1277 + createdAt: getCurrentTimestamp(), 1278 + }, 1185 1279 }, 1186 1280 }); 1187 1281 return res.data;
+352
src/js/components/drafts-dialog.js
··· 1 + import { html, render } from "/js/lib/lit-html.js"; 2 + import { Component } from "/js/components/component.js"; 3 + import { ScrollLock } from "/js/scrollLock.js"; 4 + import { displayRelativeTime, enableDragToDismiss } from "/js/utils.js"; 5 + import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; 6 + import { confirmModal } from "/js/modals/confirm.modal.js"; 7 + import { showToast } from "/js/toasts.js"; 8 + import { trashCanIconTemplate } from "/js/templates/icons/trashCanIcon.template.js"; 9 + import { playIconTemplate } from "/js/templates/icons/playIcon.template.js"; 10 + import { 11 + getLocalRefsFromDraft, 12 + getImagesFromDraftPost, 13 + } from "/js/dataHelpers.js"; 14 + import { getDraftDeviceId } from "/js/drafts.js"; 15 + import "/js/components/infinite-scroll-container.js"; 16 + 17 + // GIFs are serialized by bsky as external URLs with ww/hh query params on 18 + // known GIF proxy hostnames - no local bytes involved. 19 + const GIF_HOSTNAMES = ["media.tenor.com", "static.klipy.com"]; 20 + 21 + function parseGifFromUrl(url) { 22 + let parsed = null; 23 + try { 24 + parsed = new URL(url); 25 + } catch (error) { 26 + return null; 27 + } 28 + if (!GIF_HOSTNAMES.includes(parsed.hostname)) { 29 + return null; 30 + } 31 + const width = Number(parsed.searchParams.get("ww")); 32 + const height = Number(parsed.searchParams.get("hh")); 33 + if (!width || !height) { 34 + return null; 35 + } 36 + return { 37 + url, 38 + width, 39 + height, 40 + alt: parsed.searchParams.get("alt") ?? "", 41 + }; 42 + } 43 + 44 + function draftTagTemplate(name, label, { warning = false } = {}) { 45 + return html`<span 46 + class="draft-item-tag ${warning ? "draft-item-tag-warning" : ""}" 47 + data-testid="draft-item-tag-${name}" 48 + >${label}</span 49 + >`; 50 + } 51 + 52 + function draftMediaTemplate({ images, gif, video }) { 53 + const thumbs = []; 54 + for (const image of images) { 55 + if (image.previewUrl) { 56 + thumbs.push( 57 + html`<img 58 + class="draft-item-thumb" 59 + src=${image.previewUrl} 60 + alt=${image.alt ?? ""} 61 + />`, 62 + ); 63 + } 64 + } 65 + if (gif) { 66 + thumbs.push( 67 + html`<img class="draft-item-thumb" src=${gif.url} alt=${gif.alt} />`, 68 + ); 69 + } 70 + if (video?.exists) { 71 + thumbs.push( 72 + html`<div class="draft-item-thumb draft-item-video-placeholder"> 73 + ${playIconTemplate()} 74 + </div>`, 75 + ); 76 + } 77 + if (thumbs.length === 0) { 78 + return ""; 79 + } 80 + return html`<div class="draft-item-media" data-testid="draft-item-media"> 81 + ${thumbs} 82 + </div>`; 83 + } 84 + 85 + function draftItemTemplate({ draftView, onSelect, onDelete }) { 86 + const posts = draftView.posts; 87 + const firstPost = posts[0] ?? {}; 88 + const images = getImagesFromDraftPost(firstPost); 89 + const video = firstPost.embedVideos?.[0] ?? null; 90 + const gif = parseGifFromUrl(firstPost.embedExternals?.[0]?.uri ?? ""); 91 + const isOriginatingDevice = draftView.draft.deviceId === getDraftDeviceId(); 92 + const hasMissingMedia = posts.some( 93 + (draftPost) => 94 + getImagesFromDraftPost(draftPost).some((image) => !image.exists) || 95 + draftPost.embedVideos?.[0]?.exists === false, 96 + ); 97 + const hasQuotes = posts.some( 98 + (draftPost) => (draftPost.embedRecords?.length ?? 0) > 0, 99 + ); 100 + const replyCount = posts.length - 1; 101 + const showForeignMediaTag = !isOriginatingDevice && hasMissingMedia; 102 + const showMissingMediaTag = isOriginatingDevice && hasMissingMedia; 103 + return html` 104 + <div 105 + class="draft-item" 106 + data-testid="draft-item" 107 + role="button" 108 + tabindex="0" 109 + @click=${() => onSelect(draftView)} 110 + @keydown=${(e) => { 111 + if (e.key === "Enter") { 112 + onSelect(draftView); 113 + } 114 + }} 115 + > 116 + <div class="draft-item-main"> 117 + <div class="draft-item-timestamp"> 118 + ${displayRelativeTime(draftView.updatedAt)} 119 + </div> 120 + ${firstPost.text 121 + ? html`<div class="draft-item-text">${firstPost.text}</div>` 122 + : ""} 123 + ${isOriginatingDevice ? draftMediaTemplate({ images, gif, video }) : ""} 124 + <div class="draft-item-footer"> 125 + ${showForeignMediaTag 126 + ? draftTagTemplate( 127 + "foreign-media", 128 + "Media stored on another device", 129 + ) 130 + : ""} 131 + ${showMissingMediaTag 132 + ? draftTagTemplate("missing-media", "Missing media", { 133 + warning: true, 134 + }) 135 + : ""} 136 + ${hasQuotes ? draftTagTemplate("quote", "Quote post") : ""} 137 + ${replyCount > 0 138 + ? draftTagTemplate( 139 + "thread", 140 + replyCount === 1 ? "1 more post" : `${replyCount} more posts`, 141 + ) 142 + : ""} 143 + </div> 144 + </div> 145 + <div class="draft-item-side"> 146 + <button 147 + class="draft-item-delete" 148 + data-testid="draft-item-delete" 149 + aria-label="Delete draft" 150 + @click=${(e) => { 151 + e.stopPropagation(); 152 + onDelete(draftView); 153 + }} 154 + > 155 + ${trashCanIconTemplate()} 156 + </button> 157 + </div> 158 + </div> 159 + `; 160 + } 161 + 162 + class DraftsDialog extends Component { 163 + connectedCallback() { 164 + if (this.initialized) { 165 + return; 166 + } 167 + this.setAttribute("data-dialog-wrapper", ""); 168 + this.scrollLock = new ScrollLock(this); 169 + this.state = new ReactiveStore("drafts-dialog"); 170 + this.state.$loadError = new Signal.State(false); 171 + this.state.$isLoadingMore = new Signal.State(false); 172 + this.innerHTML = ""; 173 + this._disposeEffect = effect(() => { 174 + this.render(); 175 + }); 176 + this._loadInitial(); 177 + this.initialized = true; 178 + } 179 + 180 + disconnectedCallback() { 181 + this._disposeEffect?.(); 182 + this._disposeEffect = null; 183 + } 184 + 185 + async _loadInitial() { 186 + if (untrack(() => this.dataLayer.derived.$hydratedDrafts.get()) !== null) { 187 + return; 188 + } 189 + try { 190 + await this.dataLayer.requests.loadDrafts({ reload: true }); 191 + } catch (error) { 192 + console.error("Failed to load drafts", error); 193 + this.state.$loadError.set(true); 194 + } 195 + } 196 + 197 + async _loadMore(resume) { 198 + if (untrack(() => this.state.$isLoadingMore.get())) { 199 + resume(); 200 + return; 201 + } 202 + this.state.$isLoadingMore.set(true); 203 + try { 204 + await this.dataLayer.requests.loadDrafts(); 205 + } catch (error) { 206 + console.error("Failed to load more drafts", error); 207 + } finally { 208 + this.state.$isLoadingMore.set(false); 209 + resume(); 210 + } 211 + } 212 + 213 + async _onSelect(draftView) { 214 + if ((draftView.draft.posts ?? []).length > 1) { 215 + showToast("This draft is a thread and can't be edited here yet", { 216 + style: "warning", 217 + }); 218 + return; 219 + } 220 + // Close first, then restore (close-then-restore ordering) 221 + this.close(); 222 + this.dispatchEvent( 223 + new CustomEvent("draft-selected", { detail: { draftView } }), 224 + ); 225 + } 226 + 227 + async _onDelete(draftView) { 228 + const confirmed = await confirmModal( 229 + "This draft will be permanently deleted.", 230 + { 231 + title: "Discard draft?", 232 + confirmButtonStyle: "danger", 233 + confirmButtonText: "Delete", 234 + }, 235 + ); 236 + if (!confirmed) return; 237 + try { 238 + await this.dataLayer.mutations.deleteDraft({ 239 + draftId: draftView.id, 240 + localRefs: getLocalRefsFromDraft(draftView.draft), 241 + }); 242 + this.dispatchEvent( 243 + new CustomEvent("draft-deleted", { 244 + detail: { draftId: draftView.id }, 245 + }), 246 + ); 247 + } catch (error) { 248 + console.error("Failed to delete draft", error); 249 + showToast("Failed to delete draft", { style: "error" }); 250 + } 251 + } 252 + 253 + render() { 254 + const data = this.dataLayer?.derived.$hydratedDrafts.get() ?? null; 255 + const draftViews = data?.drafts ?? null; 256 + const cursor = data?.cursor ?? null; 257 + const loadError = this.state.$loadError.get(); 258 + const isLoadingMore = this.state.$isLoadingMore.get(); 259 + render( 260 + html` 261 + <dialog 262 + class="bottom-sheet drafts-dialog" 263 + data-testid="drafts-dialog" 264 + @click=${(event) => { 265 + if (event.target.tagName === "DIALOG") { 266 + this.close(); 267 + } 268 + }} 269 + @cancel=${(event) => { 270 + event.preventDefault(); 271 + this.close(); 272 + }} 273 + > 274 + <div class="drafts-dialog-content"> 275 + <div class="drafts-dialog-header"> 276 + <button 277 + class="drafts-dialog-back" 278 + data-testid="drafts-dialog-back" 279 + @click=${() => this.close()} 280 + > 281 + Back 282 + </button> 283 + <h2 class="drafts-dialog-title" data-testid="modal-title"> 284 + Drafts 285 + </h2> 286 + </div> 287 + <div class="drafts-dialog-list" data-testid="drafts-dialog-list"> 288 + ${loadError 289 + ? html`<div 290 + class="drafts-dialog-message" 291 + data-testid="error-state" 292 + > 293 + Failed to load drafts 294 + </div>` 295 + : draftViews === null 296 + ? html`<div class="drafts-dialog-message"> 297 + <div class="loading-spinner"></div> 298 + </div>` 299 + : draftViews.length === 0 300 + ? html`<div 301 + class="drafts-dialog-message" 302 + data-testid="empty-state" 303 + > 304 + No drafts yet 305 + </div>` 306 + : html`<infinite-scroll-container 307 + ?disabled=${cursor === null} 308 + @load-more=${(e) => this._loadMore(e.detail.resume)} 309 + > 310 + ${draftViews.map((draftView) => 311 + draftItemTemplate({ 312 + draftView, 313 + onSelect: (selected) => this._onSelect(selected), 314 + onDelete: (selected) => this._onDelete(selected), 315 + }), 316 + )} 317 + ${isLoadingMore 318 + ? html`<div class="drafts-dialog-message"> 319 + <div class="loading-spinner"></div> 320 + </div>` 321 + : ""} 322 + </infinite-scroll-container>`} 323 + </div> 324 + </div> 325 + </dialog> 326 + `, 327 + this, 328 + ); 329 + } 330 + 331 + open() { 332 + this.scrollLock.lock(); 333 + const dialog = this.querySelector("dialog"); 334 + dialog.showModal(); 335 + enableDragToDismiss(dialog, { 336 + onClose: () => this.close(), 337 + scrollContainer: this.querySelector(".drafts-dialog-list"), 338 + ignoreTouchTarget: (element) => element.closest("button") !== null, 339 + }); 340 + } 341 + 342 + close() { 343 + this.scrollLock.unlock(); 344 + const dialog = this.querySelector("dialog"); 345 + if (dialog?.open) { 346 + dialog.close(); 347 + } 348 + this.dispatchEvent(new CustomEvent("dialog-closed")); 349 + } 350 + } 351 + 352 + DraftsDialog.register();
+454 -25
src/js/components/post-composer.js
··· 30 30 import { recordEmbedTemplate } from "/js/templates/postEmbed.template.js"; 31 31 import { parseRecordLink, resolveRecordFromLink } from "/js/embedHelpers.js"; 32 32 import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; 33 + import { choiceModal } from "/js/modals/choice.modal.js"; 34 + import { 35 + parseUri, 36 + createEmbedFromPost, 37 + getLocalRefsFromDraft, 38 + getImagesFromDraftPost, 39 + } from "/js/dataHelpers.js"; 40 + import { 41 + DraftMediaStore, 42 + buildDraftFromComposerSnapshot, 43 + getDraftDeviceId, 44 + } from "/js/drafts.js"; 45 + import { ApiError } from "/js/api.js"; 33 46 import "/js/components/rich-text-input.js"; 34 47 import "/js/components/image-alt-text-dialog.js"; 35 48 import "/js/components/emoji-picker-dialog.js"; 49 + import "/js/components/drafts-dialog.js"; 50 + 51 + const MAX_DRAFT_GRAPHEME_LENGTH = 1000; 52 + 53 + function isDraftTextSavable(text) { 54 + return graphemeCount(text) <= MAX_DRAFT_GRAPHEME_LENGTH; 55 + } 56 + 57 + function isDraftLimitError(error) { 58 + return error instanceof ApiError && error.data?.error === "DraftLimitReached"; 59 + } 36 60 37 61 function replyToTemplate({ post }) { 38 62 return html` ··· 191 215 this._externalLinkUrl = null; 192 216 this._rejectedLinkEmbeds = new Set(); 193 217 this._videoToken = null; 218 + this._draftId = null; 219 + this._isDirty = false; 220 + this._originalLocalRefs = null; 221 + // Pass through unsupported fields on drafts 222 + this._draftPassthrough = null; 223 + this._draftVideoCaptions = null; 224 + this._draftUnrestoredMedia = null; 194 225 this.state = new ReactiveStore("postComposer"); 195 226 this.state.$postText = new Signal.State(""); 196 227 this.state.$isSending = new Signal.State(false); 228 + this.state.$isSavingDraft = new Signal.State(false); 197 229 this.state.$externalLinkEmbedData = new Signal.State(null); 198 230 this.state.$selectedImages = new Signal.State([]); 199 231 this.state.$selectedVideo = new Signal.State(null); ··· 231 263 render() { 232 264 const promptText = this.replyTo ? "Write your reply" : "What's up?"; 233 265 const isSending = this.state.$isSending.get(); 266 + const isSavingDraft = this.state.$isSavingDraft.get(); 234 267 const externalLinkEmbedData = this.state.$externalLinkEmbedData.get(); 235 268 const selectedImages = this.state.$selectedImages.get(); 236 269 const selectedVideo = this.state.$selectedVideo.get(); ··· 284 317 > 285 318 Cancel 286 319 </button> 320 + ${!this.replyTo 321 + ? html`<button 322 + class="post-composer-drafts-button" 323 + data-testid="composer-drafts-button" 324 + .disabled=${isSavingDraft} 325 + @click=${() => this.handleDraftsButtonClick()} 326 + > 327 + Drafts 328 + </button>` 329 + : ""} 287 330 <button 288 331 class="rounded-button rounded-button-primary" 289 332 data-testid="composer-submit-button" ··· 455 498 this._rejectedLinkEmbeds.add(this._externalLinkUrl); 456 499 this._externalLinkUrl = null; 457 500 this.state.$externalLinkEmbedData.set(null); 501 + this._isDirty = true; 458 502 } 459 503 460 504 handleQuotedEmbedPreviewClose() { 461 505 this._quotedRecordUrl = null; 462 506 this.state.$quotedRecord.set(null); 507 + this._isDirty = true; 463 508 } 464 509 465 510 async loadQuotedRecordFromLink() { ··· 525 570 style: "warning", 526 571 }); 527 572 } 573 + this._isDirty = true; 528 574 await this.processVideoFile(videoFiles[0]); 529 575 return; 530 576 } ··· 562 608 const latestImages = untrack(() => this.state.$selectedImages.get()); 563 609 const selectedImages = [...latestImages, ...newImages]; 564 610 this.state.$selectedImages.set(selectedImages); 611 + this._isDirty = true; 565 612 566 613 // Reject external link embed if images are added 567 614 if (selectedImages.length > 0 && this._externalLinkUrl) { ··· 576 623 this.state.$selectedImages.set( 577 624 selectedImages.filter((image, imageIndex) => imageIndex !== index), 578 625 ); 626 + this._isDirty = true; 579 627 } 580 628 581 629 handleEditAltText(index) { ··· 594 642 : selectedImage, 595 643 ), 596 644 ); 645 + this._isDirty = true; 597 646 dialog.remove(); 598 647 }); 599 648 ··· 629 678 } 630 679 const token = Symbol(); 631 680 this._videoToken = token; 681 + this._draftVideoCaptions = null; 632 682 this.state.$selectedVideo.set({ 633 683 file, 634 684 previewUrl: URL.createObjectURL(file), ··· 689 739 } 690 740 this._videoToken = null; 691 741 this.state.$selectedVideo.set(null); 742 + this._draftVideoCaptions = null; 743 + this._isDirty = true; 692 744 } 693 745 694 746 handleEditVideoAltText() { ··· 700 752 701 753 dialog.addEventListener("alt-text-saved", (e) => { 702 754 this.patchSelectedVideo(token, { alt: e.detail.altText }); 755 + this._isDirty = true; 703 756 dialog.remove(); 704 757 }); 705 758 ··· 712 765 } 713 766 714 767 handleInput(e) { 768 + this._isDirty = true; 715 769 const previousFacets = this._unresolvedFacets; 716 770 this.state.$postText.set(e.detail.text); 717 771 this._unresolvedFacets = e.detail.facets; ··· 891 945 this.state.$isSending.set(false); 892 946 // todo: show error message 893 947 }; 894 - const postText = untrack(() => this.state.$postText.get()); 895 - const external = untrack(() => this.state.$externalLinkEmbedData.get()); 896 - const quotedRecord = untrack(() => this.state.$quotedRecord.get()); 897 - const images = untrack(() => this.state.$selectedImages.get()); 898 - const video = untrack(() => this.state.$selectedVideo.get()); 899 948 this.dispatchEvent( 900 949 new CustomEvent("send-post", { 901 950 detail: { 902 - postText, 903 - external, 904 - replyTo: this.replyTo, 905 - replyRoot: this.replyRoot, 906 - quotedRecord, 907 - images, 908 - video, 951 + post: { 952 + ...this.readComposerState(), 953 + replyTo: this.replyTo, 954 + replyRoot: this.replyRoot, 955 + labels: this._draftPassthrough?.labels ?? null, 956 + threadgateAllow: this._draftPassthrough?.threadgateAllow ?? null, 957 + postgateEmbeddingRules: 958 + this._draftPassthrough?.postgateEmbeddingRules ?? null, 959 + }, 960 + // Publishing a saved/restored draft consumes it 961 + draft: this._draftId 962 + ? { 963 + draftId: this._draftId, 964 + localRefs: [...this._originalLocalRefs], 965 + } 966 + : null, 909 967 successCallback, 910 968 errorCallback, 911 969 }, ··· 913 971 ); 914 972 } 915 973 916 - confirmClose() { 917 - // Todo - check for other unsaved changes 974 + readComposerState() { 975 + return untrack(() => ({ 976 + postText: this.state.$postText.get(), 977 + images: this.state.$selectedImages.get(), 978 + video: this.state.$selectedVideo.get(), 979 + external: this.state.$externalLinkEmbedData.get(), 980 + quotedRecord: this.state.$quotedRecord.get(), 981 + })); 982 + } 983 + 984 + hasContent() { 985 + const { postText, images, video, external, quotedRecord } = 986 + this.readComposerState(); 987 + return ( 988 + postText.length > 0 || 989 + images.length > 0 || 990 + video !== null || 991 + external !== null || 992 + quotedRecord !== null 993 + ); 994 + } 995 + 996 + buildDraftSnapshot() { 997 + const composerState = this.readComposerState(); 998 + return { 999 + ...composerState, 1000 + video: composerState.video 1001 + ? { 1002 + file: composerState.video.file, 1003 + alt: composerState.video.alt, 1004 + captions: this._draftVideoCaptions, 1005 + } 1006 + : null, 1007 + labels: this._draftPassthrough?.labels ?? null, 1008 + threadgateAllow: this._draftPassthrough?.threadgateAllow ?? null, 1009 + postgateEmbeddingRules: 1010 + this._draftPassthrough?.postgateEmbeddingRules ?? null, 1011 + unrestoredImages: this._draftUnrestoredMedia?.images ?? null, 1012 + unrestoredVideo: this._draftUnrestoredMedia?.video ?? null, 1013 + }; 1014 + } 1015 + 1016 + async saveDraft() { 918 1017 const postText = untrack(() => this.state.$postText.get()); 919 - const selectedImages = untrack(() => this.state.$selectedImages.get()); 920 - const selectedVideo = untrack(() => this.state.$selectedVideo.get()); 921 - if ( 922 - postText.length === 0 && 923 - selectedImages.length === 0 && 924 - !selectedVideo 925 - ) { 1018 + if (!isDraftTextSavable(postText)) { 1019 + showToast( 1020 + `You can only save drafts up to ${MAX_DRAFT_GRAPHEME_LENGTH} characters`, 1021 + { style: "warning" }, 1022 + ); 1023 + return false; 1024 + } 1025 + this.state.$isSavingDraft.set(true); 1026 + const snapshot = this.buildDraftSnapshot(); 1027 + // Clear dirty so any edits made during save set it again 1028 + this._isDirty = false; 1029 + try { 1030 + const { draft, media } = buildDraftFromComposerSnapshot(snapshot); 1031 + const localRefs = getLocalRefsFromDraft(draft); 1032 + let id = this._draftId; 1033 + if (!id) { 1034 + id = await this.dataLayer.mutations.createDraft({ draft, media }); 1035 + } else { 1036 + // prune outdated refs along with update 1037 + const refsToPrune = [...this._originalLocalRefs].filter( 1038 + (key) => !localRefs.includes(key), 1039 + ); 1040 + await this.dataLayer.mutations.updateDraft({ 1041 + draftId: id, 1042 + draft, 1043 + media, 1044 + pruneLocalRefs: refsToPrune, 1045 + }); 1046 + } 1047 + // Add new image keys back onto composer state so they can be reused. 1048 + const draftImages = draft.posts[0].embedGallery?.items ?? []; 1049 + const currentImages = untrack(() => this.state.$selectedImages.get()); 1050 + this.state.$selectedImages.set( 1051 + currentImages.map((image) => { 1052 + if (image.localRefPath) return image; 1053 + const snapshotIndex = snapshot.images.indexOf(image); 1054 + if (snapshotIndex === -1) return image; 1055 + return { 1056 + ...image, 1057 + localRefPath: draftImages[snapshotIndex].localRef.path, 1058 + }; 1059 + }), 1060 + ); 1061 + this.markSaved(id, localRefs); 926 1062 return true; 1063 + } catch (error) { 1064 + this._isDirty = true; 1065 + console.error("Failed to save draft", error); 1066 + showToast( 1067 + isDraftLimitError(error) 1068 + ? "You've reached the maximum number of drafts" 1069 + : "Failed to save draft", 1070 + { style: "error" }, 1071 + ); 1072 + return false; 1073 + } finally { 1074 + this.state.$isSavingDraft.set(false); 927 1075 } 928 - return confirmModal("Are you sure you'd like to discard this draft?", { 929 - title: "Discard draft?", 930 - confirmButtonStyle: "danger", 931 - confirmButtonText: "Discard", 1076 + } 1077 + 1078 + markSaved(draftId, localRefs) { 1079 + this._draftId = draftId; 1080 + this._originalLocalRefs = new Set(localRefs); 1081 + } 1082 + 1083 + clearComposer() { 1084 + const richTextInput = this.querySelector("rich-text-input"); 1085 + richTextInput?.setText(""); 1086 + this.state.$postText.set(""); 1087 + this._unresolvedFacets = []; 1088 + this._rejectedLinkEmbeds = new Set(); 1089 + this.state.$selectedImages.set([]); 1090 + this.handleRemoveVideo(); 1091 + this._externalLinkUrl = null; 1092 + this.state.$externalLinkEmbedData.set(null); 1093 + this._quotedRecordUrl = null; 1094 + this.state.$quotedRecord.set(null); 1095 + this._draftId = null; 1096 + this._originalLocalRefs = null; 1097 + this._draftPassthrough = null; 1098 + this._draftVideoCaptions = null; 1099 + this._draftUnrestoredMedia = null; 1100 + this._isDirty = false; 1101 + } 1102 + 1103 + async handleDraftsButtonClick() { 1104 + if (untrack(() => this.state.$isSavingDraft.get())) return; 1105 + if (!this.hasContent() || (this._draftId && !this._isDirty)) { 1106 + this.openDraftsDialog(); 1107 + return; 1108 + } 1109 + const postText = untrack(() => this.state.$postText.get()); 1110 + if (!isDraftTextSavable(postText)) { 1111 + const discard = await confirmModal( 1112 + `You can only save drafts up to ${MAX_DRAFT_GRAPHEME_LENGTH} characters. Your post will be discarded.`, 1113 + { 1114 + title: "Discard post?", 1115 + confirmButtonStyle: "danger", 1116 + confirmButtonText: "Discard", 1117 + }, 1118 + ); 1119 + if (!discard) return; 1120 + this.clearComposer(); 1121 + this.openDraftsDialog(); 1122 + return; 1123 + } 1124 + const choice = await this.promptSaveChoice({ forDraftsList: true }); 1125 + if (choice === "save") { 1126 + if (await this.saveDraft()) { 1127 + this.openDraftsDialog(); 1128 + } 1129 + } else if (choice === "discard") { 1130 + this.clearComposer(); 1131 + this.openDraftsDialog(); 1132 + } 1133 + } 1134 + 1135 + promptSaveChoice({ forDraftsList = false } = {}) { 1136 + const isEditingDraft = this._draftId !== null; 1137 + let message; 1138 + if (forDraftsList) { 1139 + message = isEditingDraft 1140 + ? "You have unsaved changes. Would you like to save them before viewing your drafts?" 1141 + : "Would you like to save this post as a draft before viewing your drafts?"; 1142 + } else { 1143 + message = isEditingDraft 1144 + ? "You have unsaved changes to this draft. Would you like to save them?" 1145 + : "Would you like to save this post as a draft?"; 1146 + } 1147 + return choiceModal(message, { 1148 + title: isEditingDraft ? "Save changes?" : "Save draft?", 1149 + choices: [ 1150 + { 1151 + value: "save", 1152 + label: isEditingDraft ? "Save changes" : "Save draft", 1153 + style: "primary", 1154 + }, 1155 + { value: "discard", label: "Discard", style: "danger-subtle" }, 1156 + { value: "keep", label: "Keep editing", style: "cancel" }, 1157 + ], 932 1158 }); 1159 + } 1160 + 1161 + openDraftsDialog() { 1162 + const dialog = document.createElement("drafts-dialog"); 1163 + dialog.dataLayer = this.dataLayer; 1164 + dialog.addEventListener("draft-selected", (e) => { 1165 + this.restoreFromDraft(e.detail.draftView); 1166 + }); 1167 + dialog.addEventListener("draft-deleted", (e) => { 1168 + this.handleDraftDeleted(e.detail.draftId); 1169 + }); 1170 + dialog.addEventListener("dialog-closed", () => { 1171 + dialog.remove(); 1172 + }); 1173 + document.body.appendChild(dialog); 1174 + dialog.open(); 1175 + } 1176 + 1177 + // The server silently ignores updates to a deleted draft id, so once the 1178 + // loaded draft is deleted from the list, treat the content as a new 1179 + // unsaved post 1180 + handleDraftDeleted(draftId) { 1181 + if (draftId === null || draftId !== this._draftId) return; 1182 + this._draftId = null; 1183 + this._originalLocalRefs = null; 1184 + this._draftUnrestoredMedia = null; 1185 + this._isDirty = true; 1186 + } 1187 + 1188 + async restoreFromDraft(draftView) { 1189 + const draft = draftView.draft; 1190 + const draftPost = draft.posts?.[0] ?? { text: "" }; 1191 + const isOriginatingDevice = draft.deviceId === getDraftDeviceId(); 1192 + this.clearComposer(); 1193 + const richTextInput = this.querySelector("rich-text-input"); 1194 + richTextInput?.setText(draftPost.text ?? ""); 1195 + this._draftPassthrough = { 1196 + labels: draftPost.labels ?? null, 1197 + threadgateAllow: draft.threadgateAllow ?? null, 1198 + postgateEmbeddingRules: draft.postgateEmbeddingRules ?? null, 1199 + }; 1200 + this._draftId = draftView.id; 1201 + this._originalLocalRefs = new Set(getLocalRefsFromDraft(draft)); 1202 + this._isDirty = false; 1203 + const unrestoredImages = []; 1204 + if (isOriginatingDevice) { 1205 + const restoredImages = []; 1206 + for (const item of getImagesFromDraftPost(draftPost)) { 1207 + try { 1208 + const blob = await this.dataLayer.draftMediaStore.readBlob( 1209 + item.localRef.path, 1210 + ); 1211 + if (!blob) { 1212 + unrestoredImages.push(item); 1213 + continue; 1214 + } 1215 + const file = new File([blob], "draft-image", { 1216 + type: blob.type || "image/jpeg", 1217 + }); 1218 + const image = { 1219 + file, 1220 + dataUrl: await readFileAsDataUrl(blob), 1221 + localRefPath: item.localRef.path, 1222 + }; 1223 + if (item.alt) { 1224 + image.alt = item.alt; 1225 + } 1226 + restoredImages.push(image); 1227 + } catch (error) { 1228 + console.warn("Failed to restore draft image", error); 1229 + unrestoredImages.push(item); 1230 + } 1231 + } 1232 + if (restoredImages.length > 0) { 1233 + this.state.$selectedImages.set(restoredImages); 1234 + } 1235 + } else { 1236 + unrestoredImages.push(...getImagesFromDraftPost(draftPost)); 1237 + } 1238 + const externalUri = draftPost.embedExternals?.[0]?.uri ?? null; 1239 + if (externalUri) { 1240 + this._externalLinkUrl = externalUri; 1241 + this.loadExternalLinkEmbedPreview(); 1242 + } 1243 + const quoteRef = draftPost.embedRecords?.[0]?.record ?? null; 1244 + if (quoteRef) { 1245 + await this.restoreQuotedRecord(quoteRef); 1246 + } 1247 + const videoEmbed = draftPost.embedVideos?.[0] ?? null; 1248 + let videoRestored = false; 1249 + if (videoEmbed?.localRef?.path && isOriginatingDevice) { 1250 + videoRestored = await this.restoreDraftVideo(videoEmbed); 1251 + } 1252 + const unrestoredVideo = videoEmbed && !videoRestored ? videoEmbed : null; 1253 + this._draftUnrestoredMedia = 1254 + unrestoredImages.length > 0 || unrestoredVideo 1255 + ? { images: unrestoredImages, video: unrestoredVideo } 1256 + : null; 1257 + } 1258 + 1259 + async restoreQuotedRecord(recordRef) { 1260 + try { 1261 + const { collection } = parseUri(recordRef.uri); 1262 + let record = null; 1263 + if (collection === "app.bsky.feed.generator") { 1264 + const view = await this.dataLayer.declarative.ensureFeedGenerator( 1265 + recordRef.uri, 1266 + ); 1267 + record = { ...view, $type: "app.bsky.feed.defs#generatorView" }; 1268 + } else if (collection === "app.bsky.graph.list") { 1269 + const view = await this.dataLayer.declarative.ensureList(recordRef.uri); 1270 + record = { ...view, $type: "app.bsky.graph.defs#listView" }; 1271 + } else if (collection === "app.bsky.graph.starterpack") { 1272 + const view = await this.dataLayer.declarative.ensureStarterPack( 1273 + recordRef.uri, 1274 + ); 1275 + record = { ...view, $type: "app.bsky.graph.defs#starterPackViewBasic" }; 1276 + } else { 1277 + const post = await this.dataLayer.declarative.ensurePost(recordRef.uri); 1278 + record = createEmbedFromPost(post); 1279 + } 1280 + this.state.$quotedRecord.set(record); 1281 + } catch (error) { 1282 + console.warn("Failed to restore draft quote", error); 1283 + showToast("Couldn't load this draft's quoted record", { 1284 + style: "warning", 1285 + }); 1286 + } 1287 + } 1288 + 1289 + async restoreDraftVideo(videoEmbed) { 1290 + try { 1291 + const path = videoEmbed.localRef.path; 1292 + const blob = await this.dataLayer.draftMediaStore.readBlob(path); 1293 + if (!blob) { 1294 + showToast("This draft's video is not available on this device", { 1295 + style: "warning", 1296 + }); 1297 + return false; 1298 + } 1299 + const extension = DraftMediaStore.parseVideoExtension(path); 1300 + const file = new File([blob], `draft-video.${extension}`, { 1301 + type: blob.type || DraftMediaStore.parseVideoMimeType(path), 1302 + }); 1303 + await this.processVideoFile(file); 1304 + if (!untrack(() => this.state.$selectedVideo.get())) { 1305 + return false; 1306 + } 1307 + if (videoEmbed.alt) { 1308 + this.patchSelectedVideo(this._videoToken, { alt: videoEmbed.alt }); 1309 + } 1310 + this._draftVideoCaptions = videoEmbed.captions ?? null; 1311 + return true; 1312 + } catch (error) { 1313 + console.warn("Failed to restore draft video", error); 1314 + showToast("Couldn't restore this draft's video", { style: "warning" }); 1315 + return false; 1316 + } 1317 + } 1318 + 1319 + async confirmClose() { 1320 + if (this.replyTo) { 1321 + const postText = untrack(() => this.state.$postText.get()); 1322 + const selectedImages = untrack(() => this.state.$selectedImages.get()); 1323 + const selectedVideo = untrack(() => this.state.$selectedVideo.get()); 1324 + if ( 1325 + postText.length === 0 && 1326 + selectedImages.length === 0 && 1327 + !selectedVideo 1328 + ) { 1329 + return true; 1330 + } 1331 + return confirmModal("Are you sure you'd like to discard this reply?", { 1332 + title: "Discard reply?", 1333 + confirmButtonStyle: "danger", 1334 + confirmButtonText: "Discard", 1335 + }); 1336 + } 1337 + if (!this.hasContent()) { 1338 + return true; 1339 + } 1340 + if (this._draftId && !this._isDirty) { 1341 + return true; 1342 + } 1343 + const postText = untrack(() => this.state.$postText.get()); 1344 + if (!isDraftTextSavable(postText)) { 1345 + return confirmModal( 1346 + `You can only save drafts up to ${MAX_DRAFT_GRAPHEME_LENGTH} characters. Your post will be discarded.`, 1347 + { 1348 + title: "Discard post?", 1349 + confirmButtonStyle: "danger", 1350 + confirmButtonText: "Discard", 1351 + }, 1352 + ); 1353 + } 1354 + const choice = await this.promptSaveChoice(); 1355 + if (choice === "discard") { 1356 + return true; 1357 + } 1358 + if (choice === "save") { 1359 + return this.saveDraft(); 1360 + } 1361 + return false; 933 1362 } 934 1363 } 935 1364
+20
src/js/dataHelpers.js
··· 1056 1056 } 1057 1057 return unique(posts, { by: "uri" }); 1058 1058 } 1059 + 1060 + export function getImagesFromDraftPost(draftPost) { 1061 + const items = draftPost.embedGallery?.items ?? draftPost.embedImages ?? []; 1062 + return items.filter((item) => item.localRef?.path); 1063 + } 1064 + 1065 + export function getLocalRefsFromDraft(draft) { 1066 + const refs = []; 1067 + for (const draftPost of draft.posts ?? []) { 1068 + for (const image of getImagesFromDraftPost(draftPost)) { 1069 + refs.push(image.localRef.path); 1070 + } 1071 + for (const video of draftPost.embedVideos ?? []) { 1072 + if (video.localRef?.path) { 1073 + refs.push(video.localRef.path); 1074 + } 1075 + } 1076 + } 1077 + return refs; 1078 + }
+11 -1
src/js/dataLayer/dataLayer.js
··· 6 6 import { Derived } from "/js/dataLayer/derived.js"; 7 7 8 8 export class DataLayer { 9 - constructor(api, pluginService, preferencesProvider, identityResolver) { 9 + constructor( 10 + api, 11 + pluginService, 12 + preferencesProvider, 13 + identityResolver, 14 + draftMediaStore, 15 + ) { 10 16 this.api = api; 11 17 this.pluginService = pluginService; 12 18 this.identityResolver = identityResolver; 19 + this.draftMediaStore = draftMediaStore; 13 20 this.isAuthenticated = api.isAuthenticated; 14 21 this.dataStore = new DataStore(); 15 22 this.patchStore = new PatchStore(this.dataStore); ··· 19 26 this.dataStore, 20 27 this.preferencesProvider, 21 28 this.pluginService, 29 + this.draftMediaStore, 22 30 ); 23 31 this.pluginService?.on("feedFiltersRefresh", async ({ feedURI }) => { 24 32 const feedURIs = feedURI ··· 40 48 this.patchStore, 41 49 this.preferencesProvider, 42 50 this.identityResolver, 51 + this.draftMediaStore, 43 52 ); 44 53 this.derived = new Derived( 45 54 this.dataStore, ··· 47 56 this.preferencesProvider, 48 57 this.pluginService, 49 58 this.isAuthenticated, 59 + this.draftMediaStore, 50 60 ); 51 61 this.declarative = new Declarative(this.derived, this.requests); 52 62 this.subscribers = [];
+1
src/js/dataLayer/dataStore.js
··· 17 17 this.$mentionNotifications = new Signal.State(null); 18 18 this.$pinnedItems = new Signal.State(null); 19 19 this.$bookmarks = new Signal.State(null); 20 + this.$drafts = new Signal.State(null); 20 21 this.$convoList = new Signal.State(null); 21 22 this.$convoRequestList = new Signal.State(null); 22 23 this.$blockedProfiles = new Signal.State(null);
+59
src/js/dataLayer/derived.js
··· 111 111 preferencesProvider, 112 112 pluginService, 113 113 isAuthenticated, 114 + draftMediaStore, 114 115 ) { 115 116 super("derived"); 116 117 this.dataStore = dataStore; ··· 118 119 this.preferencesProvider = preferencesProvider; 119 120 this.pluginService = pluginService; 120 121 this.isAuthenticated = isAuthenticated; 122 + this.draftMediaStore = draftMediaStore; 121 123 this.$showLessInteractions = new Signal.Computed(() => 122 124 this.dataStore.$showLessInteractions.get(), 123 125 ); ··· 448 450 cursor: data.cursor, 449 451 }); 450 452 }); 453 + this.$hydratedDrafts = new Signal.Computed(() => { 454 + const data = this.dataStore.$drafts.get(); 455 + if (!data) { 456 + return null; 457 + } 458 + const media = this.draftMediaStore.$media.get(); 459 + return { 460 + drafts: data.drafts.map((draftView) => ({ 461 + ...draftView, 462 + posts: (draftView.draft.posts ?? []).map((draftPost) => 463 + this.hydrateDraftPost(draftPost, media), 464 + ), 465 + })), 466 + cursor: data.cursor, 467 + }; 468 + }); 451 469 this.$labelerSettings = new ComputedMap((labelerDid) => { 452 470 const preferences = this.$preferences.get(); 453 471 if (!preferences) return null; ··· 614 632 this.$mentionNotificationCursor = new Signal.Computed( 615 633 () => this.dataStore.$mentionNotifications.get()?.cursor ?? null, 616 634 ); 635 + } 636 + 637 + hydrateDraftImageItem(item, media) { 638 + if (!item.localRef?.path) { 639 + return item; 640 + } 641 + const entry = media[item.localRef.path]; 642 + return { 643 + ...item, 644 + exists: entry != null, 645 + previewUrl: entry?.url ?? null, 646 + }; 647 + } 648 + 649 + // Decorates a draft post's media embeds with local state: `exists` on 650 + // images and videos, plus `previewUrl` on images 651 + hydrateDraftPost(draftPost, media) { 652 + const hydrated = { ...draftPost }; 653 + if (draftPost.embedGallery) { 654 + hydrated.embedGallery = { 655 + ...draftPost.embedGallery, 656 + items: (draftPost.embedGallery.items ?? []).map((item) => 657 + this.hydrateDraftImageItem(item, media), 658 + ), 659 + }; 660 + } 661 + if (draftPost.embedImages) { 662 + hydrated.embedImages = draftPost.embedImages.map((item) => 663 + this.hydrateDraftImageItem(item, media), 664 + ); 665 + } 666 + if (draftPost.embedVideos) { 667 + hydrated.embedVideos = draftPost.embedVideos.map((videoEmbed) => { 668 + if (!videoEmbed.localRef?.path) { 669 + return videoEmbed; 670 + } 671 + const entry = media[videoEmbed.localRef.path]; 672 + return { ...videoEmbed, exists: entry != null }; 673 + }); 674 + } 675 + return hydrated; 617 676 } 618 677 619 678 attachJoinLinkPreview(item) {
+64 -1
src/js/dataLayer/mutations.js
··· 4 4 addFeedItemToFeed, 5 5 pinPostInFeed, 6 6 unpinPostInFeed, 7 + valueForPinnedItem, 7 8 } from "/js/dataHelpers.js"; 8 9 import { getCurrentTimestamp } from "/js/utils.js"; 9 10 import { PostCreator } from "/js/postCreator.js"; 10 11 import { untrack } from "/js/signals.js"; 11 - import { valueForPinnedItem } from "/js/dataHelpers.js"; 12 12 13 13 // Handles mutations to the data, making optimistic updates if needed. 14 14 export class Mutations { ··· 18 18 patchStore, 19 19 preferencesProvider, 20 20 identityResolver, 21 + draftMediaStore, 21 22 ) { 22 23 this.api = api; 23 24 this.dataStore = dataStore; 24 25 this.patchStore = patchStore; 25 26 this.preferencesProvider = preferencesProvider; 27 + this.draftMediaStore = draftMediaStore; 26 28 this.postCreator = new PostCreator(api, identityResolver); 27 29 } 28 30 ··· 988 990 quotedRecord, 989 991 images, 990 992 video, 993 + labels, 994 + threadgateAllow, 995 + postgateEmbeddingRules, 991 996 }) { 992 997 const { uri, cid, post } = await this.postCreator.createPost({ 993 998 postText, ··· 997 1002 quotedRecord, 998 1003 images, 999 1004 video, 1005 + labels, 1006 + threadgateAllow, 1007 + postgateEmbeddingRules, 1000 1008 }); 1001 1009 // Update local data with new post, if present 1002 1010 if (post) { ··· 1236 1244 throw error; 1237 1245 } finally { 1238 1246 this.patchStore.removeMessagePatch(messageId, patchId); 1247 + } 1248 + } 1249 + 1250 + async createDraft({ draft, media }) { 1251 + const res = await this.api.createDraft(draft); 1252 + await this._saveDraftMedia(media); 1253 + this._invalidateCachedDrafts(); 1254 + return res.id; 1255 + } 1256 + 1257 + async updateDraft({ draftId, draft, media, pruneLocalRefs }) { 1258 + await this.api.updateDraft(draftId, draft); 1259 + await this._saveDraftMedia(media); 1260 + await this._deleteDraftMedia(pruneLocalRefs); 1261 + this._invalidateCachedDrafts(); 1262 + } 1263 + 1264 + async _saveDraftMedia(media) { 1265 + const storedMedia = this.draftMediaStore.$media.get(); 1266 + for (const { path, source } of media) { 1267 + if (storedMedia[path]) continue; 1268 + try { 1269 + await this.draftMediaStore.save(path, source); 1270 + } catch (error) { 1271 + console.error("Failed to save draft media locally", error); 1272 + } 1273 + } 1274 + } 1275 + 1276 + async _deleteDraftMedia(localRefs) { 1277 + for (const key of localRefs) { 1278 + try { 1279 + await this.draftMediaStore.delete(key); 1280 + } catch (error) { 1281 + console.error("Failed to delete draft media", error); 1282 + } 1283 + } 1284 + } 1285 + 1286 + // Delete the cached drafts list so the next dialog open refetches it 1287 + _invalidateCachedDrafts() { 1288 + if (untrack(() => this.dataStore.$drafts.get()) !== null) { 1289 + this.dataStore.$drafts.set(null); 1290 + } 1291 + } 1292 + 1293 + async deleteDraft({ draftId, localRefs }) { 1294 + await this.api.deleteDraft(draftId); 1295 + await this._deleteDraftMedia(localRefs); 1296 + const data = untrack(() => this.dataStore.$drafts.get()); 1297 + if (data) { 1298 + this.dataStore.$drafts.set({ 1299 + ...data, 1300 + drafts: data.drafts.filter((draftView) => draftView.id !== draftId), 1301 + }); 1239 1302 } 1240 1303 } 1241 1304
+18
src/js/dataLayer/requests.js
··· 17 17 getPostsFromFeed, 18 18 } from "/js/dataHelpers.js"; 19 19 import { Constellation } from "/js/constellation.js"; 20 + import { getLocalRefsFromDraft } from "/js/dataHelpers.js"; 20 21 import { unique } from "/js/utils.js"; 21 22 import { SignalMap, ComputedMap, ReactiveStore } from "/js/signals.js"; 22 23 import { ApiError } from "/js/api.js"; ··· 167 168 dataStore, 168 169 preferencesProvider, 169 170 pluginService, 171 + draftMediaStore, 170 172 { constellation } = {}, 171 173 ) { 172 174 this.api = api; 173 175 this.pluginService = pluginService; 174 176 this.dataStore = dataStore; 175 177 this.preferencesProvider = preferencesProvider; 178 + this.draftMediaStore = draftMediaStore; 176 179 this.constellation = constellation ?? new Constellation(); 177 180 this.statusStore = new StatusStore(); 178 181 // Enable status tracking ··· 237 240 this.loadKnownFollowers, 238 241 (profileDid) => "loadKnownFollowers-" + profileDid, 239 242 ); 243 + this.enableStatus(this.loadDrafts, "loadDrafts"); 240 244 this.enableStatus(this.loadBlockedProfiles, "loadBlockedProfiles"); 241 245 this.enableStatus(this.loadMutedProfiles, "loadMutedProfiles"); 242 246 } ··· 1309 1313 requestCursor: cursor, 1310 1314 overwrite: reload, 1311 1315 }); 1316 + } 1317 + 1318 + async loadDrafts({ reload = false } = {}) { 1319 + const cursor = reload ? "" : readCollectionCursor(this.dataStore.$drafts); 1320 + const res = await this.api.getDrafts({ cursor }); 1321 + writePageToCollection(this.dataStore.$drafts, "drafts", res, { 1322 + requestCursor: cursor, 1323 + overwrite: reload, 1324 + }); 1325 + // Load media refs 1326 + const localRefs = res.drafts.flatMap((draftView) => 1327 + getLocalRefsFromDraft(draftView.draft), 1328 + ); 1329 + await this.draftMediaStore.load(localRefs); 1312 1330 } 1313 1331 1314 1332 async loadProfileFollowers(profileDid, { cursor } = {}) {
+284
src/js/drafts.js
··· 1 + import { Signal, untrack } from "/js/signals.js"; 2 + 3 + const DEVICE_ID_STORAGE_KEY = "improDraftDeviceId"; 4 + 5 + export function getDraftDeviceId() { 6 + let deviceId = localStorage.getItem(DEVICE_ID_STORAGE_KEY); 7 + if (!deviceId) { 8 + deviceId = crypto.randomUUID(); 9 + localStorage.setItem(DEVICE_ID_STORAGE_KEY, deviceId); 10 + } 11 + return deviceId; 12 + } 13 + 14 + const DB_NAME = "impro-draft-media"; 15 + 16 + class KVIndexedDB { 17 + constructor(dbName, storeName) { 18 + this._dbName = dbName; 19 + this._storeName = storeName; 20 + this._dbPromise = null; 21 + } 22 + 23 + async _open() { 24 + if (this._dbPromise) return this._dbPromise; 25 + this._dbPromise = new Promise((resolve, reject) => { 26 + const request = indexedDB.open(this._dbName, 1); 27 + request.onupgradeneeded = () => { 28 + request.result.createObjectStore(this._storeName); 29 + }; 30 + request.onsuccess = () => resolve(request.result); 31 + request.onerror = () => reject(request.error); 32 + }); 33 + return this._dbPromise; 34 + } 35 + 36 + async _request(mode, callback) { 37 + const db = await this._open(); 38 + return new Promise((resolve, reject) => { 39 + const transaction = db.transaction(this._storeName, mode); 40 + const request = callback(transaction.objectStore(this._storeName)); 41 + request.onsuccess = () => resolve(request.result); 42 + request.onerror = () => reject(request.error); 43 + }); 44 + } 45 + 46 + async get(key) { 47 + return this._request("readonly", (store) => store.get(key)); 48 + } 49 + 50 + async has(key) { 51 + return this._request("readonly", (store) => store.getKey(key)).then( 52 + (foundKey) => foundKey !== undefined, 53 + ); 54 + } 55 + 56 + async put(key, value) { 57 + return this._request("readwrite", (store) => store.put(value, key)); 58 + } 59 + 60 + async delete(key) { 61 + return this._request("readwrite", (store) => store.delete(key)); 62 + } 63 + } 64 + 65 + // Stores blobs in indexeddb 66 + class IDBBlobStore { 67 + constructor(dbName, storeName = "blobs") { 68 + this._db = new KVIndexedDB(dbName, storeName); 69 + } 70 + 71 + async put(path, blob) { 72 + await this._db.put(path, { 73 + blob, 74 + createdAt: new Date().toISOString(), 75 + }); 76 + } 77 + 78 + async getBlob(path) { 79 + const record = await this._db.get(path); 80 + return record?.blob ?? null; 81 + } 82 + 83 + async has(path) { 84 + return this._db.has(path); 85 + } 86 + 87 + async delete(path) { 88 + return this._db.delete(path); 89 + } 90 + } 91 + 92 + const VIDEO_MIME_TO_EXT = { 93 + "video/mp4": "mp4", 94 + "video/mpeg": "mpeg", 95 + "video/webm": "webm", 96 + "video/quicktime": "mov", 97 + "image/gif": "gif", 98 + }; 99 + 100 + function isVideoPath(path) { 101 + return path.startsWith("video:"); 102 + } 103 + 104 + async function sourceToBlob(source) { 105 + if (source instanceof Blob) { 106 + return source; 107 + } 108 + if (typeof source === "string") { 109 + const res = await fetch(source); 110 + return res.blob(); 111 + } 112 + throw new Error("Unsupported media source"); 113 + } 114 + 115 + export class DraftMediaStore { 116 + static createImageLocalRef() { 117 + return `image:${crypto.randomUUID()}`; 118 + } 119 + 120 + // Video keys encode the mime type: `video:${mimeType}:${id}.${ext}`. They 121 + // are never reused - every re-save mints a new key 122 + static createVideoLocalRef(mimeType) { 123 + const mime = VIDEO_MIME_TO_EXT[mimeType] ? mimeType : "video/mp4"; 124 + const ext = VIDEO_MIME_TO_EXT[mime]; 125 + return `video:${mime}:${crypto.randomUUID()}.${ext}`; 126 + } 127 + 128 + // Legacy keys (`video:${id}`) predate the mime segment and default to mp4. 129 + static parseVideoMimeType(path) { 130 + const segments = path.split(":"); 131 + if (segments.length >= 3 && VIDEO_MIME_TO_EXT[segments[1]]) { 132 + return segments[1]; 133 + } 134 + return "video/mp4"; 135 + } 136 + 137 + static parseVideoExtension(path) { 138 + return VIDEO_MIME_TO_EXT[this.parseVideoMimeType(path)]; 139 + } 140 + 141 + constructor(dbName = DB_NAME) { 142 + this._blobStore = new IDBBlobStore(dbName); 143 + this.$media = new Signal.State({}); // path -> { url } | null 144 + } 145 + 146 + // Merges entries into $media, revoking object URLs that get replaced or 147 + // removed in the process 148 + _patchMedia(entries) { 149 + const current = untrack(() => this.$media.get()); 150 + for (const [path, entry] of Object.entries(entries)) { 151 + const previousUrl = current[path]?.url; 152 + if (previousUrl && previousUrl !== entry?.url) { 153 + URL.revokeObjectURL(previousUrl); 154 + } 155 + } 156 + this.$media.set({ ...current, ...entries }); 157 + } 158 + 159 + // Loads paths that aren't yet in $media 160 + async load(paths) { 161 + const known = untrack(() => this.$media.get()); 162 + const newPaths = [...new Set(paths)].filter((path) => !(path in known)); 163 + const entries = {}; 164 + for (const path of newPaths) { 165 + entries[path] = await this.loadPath(path); 166 + } 167 + this._patchMedia(entries); 168 + } 169 + 170 + async loadPath(path) { 171 + if (!(await this._blobStore.has(path))) { 172 + return null; 173 + } 174 + if (isVideoPath(path)) { 175 + return { url: null }; 176 + } 177 + const blob = await this._blobStore.getBlob(path); 178 + return { url: URL.createObjectURL(blob) }; 179 + } 180 + 181 + async save(path, source) { 182 + const blob = await sourceToBlob(source); 183 + await this._blobStore.put(path, blob); 184 + this._patchMedia({ 185 + [path]: isVideoPath(path) 186 + ? { url: null } 187 + : { url: URL.createObjectURL(blob) }, 188 + }); 189 + } 190 + 191 + async readBlob(path) { 192 + return this._blobStore.getBlob(path); 193 + } 194 + 195 + async delete(path) { 196 + await this._blobStore.delete(path); 197 + this._patchMedia({ [path]: null }); 198 + } 199 + } 200 + 201 + export function buildDraftFromComposerSnapshot(snapshot) { 202 + const media = []; 203 + const draftPost = { 204 + $type: "app.bsky.draft.defs#draftPost", 205 + text: snapshot.postText, 206 + }; 207 + if (snapshot.labels) { 208 + draftPost.labels = snapshot.labels; 209 + } 210 + if (snapshot.images?.length > 0) { 211 + draftPost.embedGallery = { 212 + $type: "app.bsky.draft.defs#draftEmbedGallery", 213 + items: snapshot.images.map((image) => { 214 + const path = 215 + image.localRefPath ?? DraftMediaStore.createImageLocalRef(); 216 + media.push({ path, source: image.file }); 217 + const item = { 218 + $type: "app.bsky.draft.defs#draftEmbedImage", 219 + localRef: { $type: "app.bsky.draft.defs#draftEmbedLocalRef", path }, 220 + }; 221 + if (image.alt) { 222 + item.alt = image.alt; 223 + } 224 + return item; 225 + }), 226 + }; 227 + } else if (snapshot.unrestoredImages?.length > 0) { 228 + draftPost.embedGallery = { 229 + $type: "app.bsky.draft.defs#draftEmbedGallery", 230 + items: snapshot.unrestoredImages, 231 + }; 232 + } 233 + if (snapshot.video) { 234 + const path = DraftMediaStore.createVideoLocalRef( 235 + snapshot.video.file?.type || "video/mp4", 236 + ); 237 + media.push({ path, source: snapshot.video.file }); 238 + const videoEmbed = { 239 + $type: "app.bsky.draft.defs#draftEmbedVideo", 240 + localRef: { $type: "app.bsky.draft.defs#draftEmbedLocalRef", path }, 241 + }; 242 + if (snapshot.video.alt) { 243 + videoEmbed.alt = snapshot.video.alt; 244 + } 245 + if (snapshot.video.captions?.length > 0) { 246 + videoEmbed.captions = snapshot.video.captions; 247 + } 248 + draftPost.embedVideos = [videoEmbed]; 249 + } else if (snapshot.unrestoredVideo) { 250 + draftPost.embedVideos = [snapshot.unrestoredVideo]; 251 + } 252 + if (snapshot.external) { 253 + draftPost.embedExternals = [ 254 + { 255 + $type: "app.bsky.draft.defs#draftEmbedExternal", 256 + uri: snapshot.external.url, 257 + }, 258 + ]; 259 + } 260 + if (snapshot.quotedRecord) { 261 + draftPost.embedRecords = [ 262 + { 263 + $type: "app.bsky.draft.defs#draftEmbedRecord", 264 + record: { 265 + uri: snapshot.quotedRecord.uri, 266 + cid: snapshot.quotedRecord.cid, 267 + }, 268 + }, 269 + ]; 270 + } 271 + const draft = { 272 + $type: "app.bsky.draft.defs#draft", 273 + deviceId: getDraftDeviceId(), 274 + deviceName: "Web", // match social-app 275 + posts: [draftPost], 276 + }; 277 + if (snapshot.threadgateAllow) { 278 + draft.threadgateAllow = snapshot.threadgateAllow; 279 + } 280 + if (snapshot.postgateEmbeddingRules?.length > 0) { 281 + draft.postgateEmbeddingRules = snapshot.postgateEmbeddingRules; 282 + } 283 + return { draft, media }; 284 + }
+57
src/js/modals/choice.modal.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { Modal } from "/js/modals/modal.js"; 3 + 4 + class ChoiceModal extends Modal { 5 + get className() { 6 + return "bottom-sheet text-modal confirm-modal choice-modal compact"; 7 + } 8 + 9 + get attributes() { 10 + return { "data-testid": "choice-modal" }; 11 + } 12 + 13 + render({ dismiss, props: { message, title, choices } }) { 14 + return html` 15 + <div class="modal-dialog-content"> 16 + ${title 17 + ? html`<h2 class="modal-dialog-title" data-testid="modal-title"> 18 + ${title} 19 + </h2>` 20 + : null} 21 + <p class="modal-dialog-message" data-testid="modal-message"> 22 + ${message} 23 + </p> 24 + <div class="modal-dialog-buttons modal-dialog-buttons-stacked"> 25 + ${choices.map( 26 + (choice) => html` 27 + <button 28 + class="modal-dialog-button ${choice.style ?? "primary"}-button" 29 + data-testid="modal-choice-${choice.value}" 30 + @click=${() => dismiss(choice.value)} 31 + > 32 + ${choice.label} 33 + </button> 34 + `, 35 + )} 36 + </div> 37 + </div> 38 + `; 39 + } 40 + } 41 + 42 + // Presents a stacked list of choices; resolves with the chosen value, or null 43 + // when dismissed without choosing (backdrop click, escape, swipe). 44 + export async function choiceModal(message, options = {}) { 45 + return new Promise((resolveOuter) => { 46 + let resolved = false; 47 + const resolveOnce = (value) => { 48 + if (resolved) return; 49 + resolved = true; 50 + resolveOuter(value); 51 + }; 52 + globalThis.__testChoice?.(resolveOnce); 53 + ChoiceModal.open({ message, ...options }).then((value) => 54 + resolveOnce(value ?? null), 55 + ); 56 + }); 57 + }
+21 -58
src/js/postComposerService.js
··· 50 50 this.currentPostComposer.initialCursor = composerInit.cursor; 51 51 } 52 52 this.currentPostComposer.addEventListener("send-post", async (e) => { 53 - const { 54 - postText, 55 - external, 56 - replyTo, 57 - replyRoot, 58 - quotedRecord, 59 - images, 60 - video, 61 - successCallback, 62 - errorCallback, 63 - } = e.detail; 53 + const { post, draft, successCallback, errorCallback } = e.detail; 64 54 try { 65 - const result = await this.onSend({ 66 - postText, 67 - external, 68 - replyTo, 69 - replyRoot, 70 - quotedRecord, 71 - images, 72 - video, 73 - }); 74 - successCallback(result); 75 - resolve(result); 55 + const res = await this.dataLayer.mutations.createPost(post); 56 + showToast( 57 + html`<div class="toast-with-link"> 58 + ${post.replyTo ? "Your reply was sent" : "Your post was sent"}<a 59 + href="${linkToPostFromUri(res.uri)}" 60 + >View</a 61 + > 62 + </div>`, 63 + { style: "success" }, 64 + ); 65 + // Publishing a saved/restored draft consumes it 66 + if (draft) { 67 + this.dataLayer.mutations.deleteDraft(draft).catch((error) => { 68 + console.error("Failed to clean up published draft", error); 69 + }); 70 + } 71 + successCallback(res); 72 + resolve(res); 76 73 } catch (error) { 74 + console.error(error); 75 + showToast("Failed to send post", { style: "error" }); 77 76 errorCallback(error); 78 77 reject(error); 79 78 } ··· 88 87 document.body.appendChild(this.currentPostComposer); 89 88 this.currentPostComposer.open(); 90 89 }); 91 - } 92 - 93 - async onSend({ 94 - postText, 95 - external, 96 - replyTo, 97 - replyRoot, 98 - quotedRecord, 99 - images, 100 - video, 101 - }) { 102 - try { 103 - const res = await this.dataLayer.mutations.createPost({ 104 - postText, 105 - external, 106 - replyTo, 107 - replyRoot, 108 - quotedRecord, 109 - images, 110 - video, 111 - }); 112 - showToast( 113 - html`<div class="toast-with-link"> 114 - ${replyTo ? "Your reply was sent" : "Your post was sent"}<a 115 - href="${linkToPostFromUri(res.uri)}" 116 - >View</a 117 - > 118 - </div>`, 119 - { style: "success" }, 120 - ); 121 - return res; 122 - } catch (error) { 123 - console.error(error); 124 - showToast("Failed to send post", { style: "error" }); 125 - throw error; 126 - } 127 90 } 128 91 }
+20
src/js/postCreator.js
··· 31 31 quotedRecord, 32 32 images, 33 33 video, 34 + labels = null, 35 + threadgateAllow = null, 36 + postgateEmbeddingRules = null, 34 37 }) { 35 38 const trimmedText = trimPostText(postText); 36 39 const unresolvedFacets = getUnresolvedFacetsFromText(trimmedText); ··· 88 91 embed, 89 92 reply, 90 93 langs: getPostLangs(), 94 + labels, 91 95 }); 96 + 97 + if (threadgateAllow) { 98 + try { 99 + await this.api.createThreadgate(res.uri, threadgateAllow); 100 + } catch (error) { 101 + console.error("Failed to create threadgate", error); 102 + } 103 + } 104 + 105 + if (postgateEmbeddingRules?.length > 0) { 106 + try { 107 + await this.api.createPostgate(res.uri, postgateEmbeddingRules); 108 + } catch (error) { 109 + console.error("Failed to create postgate", error); 110 + } 111 + } 92 112 93 113 // Attempt to get full post from the app view, return null on failure 94 114 const maxRetries = 5;
+4
src/oauthScopes.js
··· 96 96 "rpc:app.bsky.video.getJobStatus", 97 97 "rpc:app.bsky.video.getUploadLimits", 98 98 "rpc:app.bsky.video.uploadVideo", 99 + "rpc:app.bsky.draft.getDrafts", 100 + "rpc:app.bsky.draft.createDraft", 101 + "rpc:app.bsky.draft.updateDraft", 102 + "rpc:app.bsky.draft.deleteDraft", 99 103 ]; 100 104 101 105 const BSKY_OAUTH_REPO_SCOPES = [
+66
tests/e2e/mockServer.js
··· 18 18 this.joinLinkJoinedConvos = new Map(); 19 19 this.failJoinLinkCodes = new Set(); 20 20 this.createRecordCounter = 0; 21 + this.drafts = []; 22 + this.draftCounter = 0; 23 + this.deletedDraftIds = []; 24 + this.draftLimitReached = false; 21 25 this.interactionPayloads = []; 22 26 this.blobCounter = 0; 23 27 this.messageCounter = 0; ··· 1196 1200 this.bookmarks[idx].viewer.bookmarked = false; 1197 1201 this.bookmarks.splice(idx, 1); 1198 1202 } 1203 + return route.fulfill({ 1204 + status: 200, 1205 + contentType: "application/json", 1206 + body: "{}", 1207 + }); 1208 + }); 1209 + 1210 + await page.route("**/xrpc/app.bsky.draft.getDrafts*", (route) => 1211 + route.fulfill({ 1212 + status: 200, 1213 + contentType: "application/json", 1214 + body: JSON.stringify({ drafts: this.drafts }), 1215 + }), 1216 + ); 1217 + 1218 + await page.route("**/xrpc/app.bsky.draft.createDraft*", (route) => { 1219 + if (this.draftLimitReached) { 1220 + return route.fulfill({ 1221 + status: 400, 1222 + contentType: "application/json", 1223 + body: JSON.stringify({ 1224 + error: "DraftLimitReached", 1225 + message: "Draft limit reached", 1226 + }), 1227 + }); 1228 + } 1229 + const body = route.request().postDataJSON(); 1230 + const id = `draft-${++this.draftCounter}`; 1231 + const now = new Date().toISOString(); 1232 + this.drafts.unshift({ 1233 + id, 1234 + createdAt: now, 1235 + updatedAt: now, 1236 + draft: body.draft, 1237 + }); 1238 + return route.fulfill({ 1239 + status: 200, 1240 + contentType: "application/json", 1241 + body: JSON.stringify({ id }), 1242 + }); 1243 + }); 1244 + 1245 + await page.route("**/xrpc/app.bsky.draft.updateDraft*", (route) => { 1246 + const body = route.request().postDataJSON(); 1247 + const existing = this.drafts.find( 1248 + (draftView) => draftView.id === body.draft.id, 1249 + ); 1250 + if (existing) { 1251 + existing.draft = body.draft.draft; 1252 + existing.updatedAt = new Date().toISOString(); 1253 + } 1254 + return route.fulfill({ 1255 + status: 200, 1256 + contentType: "application/json", 1257 + body: "{}", 1258 + }); 1259 + }); 1260 + 1261 + await page.route("**/xrpc/app.bsky.draft.deleteDraft*", (route) => { 1262 + const body = route.request().postDataJSON(); 1263 + this.deletedDraftIds.push(body.id); 1264 + this.drafts = this.drafts.filter((draftView) => draftView.id !== body.id); 1199 1265 return route.fulfill({ 1200 1266 status: 200, 1201 1267 contentType: "application/json",
+228
tests/e2e/specs/flows/drafts.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + 5 + async function openComposer(page) { 6 + const homeView = page.locator("#home-view"); 7 + await expect(homeView).toBeVisible({ timeout: 10000 }); 8 + await page.locator('[data-testid="sidebar-compose-button"]').click(); 9 + const composer = page.locator("post-composer .post-composer"); 10 + await expect(composer).toBeVisible({ timeout: 10000 }); 11 + return composer; 12 + } 13 + 14 + test.describe("Post drafts flow", () => { 15 + test("save on cancel, restore from the drafts list, publish cleans up", async ({ 16 + page, 17 + }) => { 18 + const mockServer = new MockServer(); 19 + await mockServer.setup(page); 20 + 21 + await login(page); 22 + await page.goto("/"); 23 + 24 + // Compose some text, then cancel 25 + let composer = await openComposer(page); 26 + const draftText = "A thought worth keeping for later"; 27 + await composer.locator(".rich-text-input").click(); 28 + await composer.locator(".rich-text-input").type(draftText); 29 + await composer.locator(".post-composer-cancel-button").click(); 30 + 31 + // The tri-state prompt offers saving as a draft 32 + const choiceModal = page.locator('[data-testid="choice-modal"]'); 33 + await expect(choiceModal).toBeVisible({ timeout: 10000 }); 34 + await choiceModal.locator('[data-testid="modal-choice-save"]').click(); 35 + await expect(composer).not.toBeVisible({ timeout: 10000 }); 36 + 37 + // Reopen the composer and pick the draft from the list 38 + composer = await openComposer(page); 39 + await composer.locator('[data-testid="composer-drafts-button"]').click(); 40 + const draftsDialog = page.locator('[data-testid="drafts-dialog"]'); 41 + await expect(draftsDialog).toBeVisible({ timeout: 10000 }); 42 + const draftItem = draftsDialog.locator('[data-testid="draft-item"]'); 43 + await expect(draftItem).toHaveCount(1, { timeout: 10000 }); 44 + await expect(draftItem).toContainText(draftText); 45 + await draftItem.click(); 46 + await expect(draftsDialog).not.toBeVisible({ timeout: 10000 }); 47 + 48 + // The draft's text is restored into the composer 49 + await expect(composer.locator(".rich-text-input")).toContainText( 50 + draftText, 51 + { timeout: 10000 }, 52 + ); 53 + 54 + // A clean restored draft closes without a prompt 55 + await composer.locator(".post-composer-cancel-button").click(); 56 + await expect(composer).not.toBeVisible({ timeout: 10000 }); 57 + await expect(page.locator('[data-testid="choice-modal"]')).toHaveCount(0); 58 + 59 + // Restore again and publish; the draft is consumed 60 + composer = await openComposer(page); 61 + await composer.locator('[data-testid="composer-drafts-button"]').click(); 62 + await expect( 63 + page.locator('[data-testid="drafts-dialog"] [data-testid="draft-item"]'), 64 + ).toHaveCount(1, { timeout: 10000 }); 65 + await page 66 + .locator('[data-testid="drafts-dialog"] [data-testid="draft-item"]') 67 + .click(); 68 + await expect(composer.locator(".rich-text-input")).toContainText( 69 + draftText, 70 + { timeout: 10000 }, 71 + ); 72 + await composer.locator('[data-testid="composer-submit-button"]').click(); 73 + await expect(composer).not.toBeVisible({ timeout: 10000 }); 74 + 75 + // The published draft is deleted - the drafts list is empty now 76 + composer = await openComposer(page); 77 + await composer.locator('[data-testid="composer-drafts-button"]').click(); 78 + await expect( 79 + page.locator('[data-testid="drafts-dialog"] [data-testid="empty-state"]'), 80 + ).toBeVisible({ timeout: 10000 }); 81 + }); 82 + 83 + test("delete a draft from the list with confirmation", async ({ page }) => { 84 + const mockServer = new MockServer(); 85 + mockServer.drafts.push({ 86 + id: "draft-preexisting", 87 + createdAt: "2026-07-01T00:00:00.000Z", 88 + updatedAt: "2026-07-01T00:00:00.000Z", 89 + draft: { 90 + $type: "app.bsky.draft.defs#draft", 91 + deviceId: "another-device", 92 + deviceName: "Web", 93 + posts: [ 94 + { $type: "app.bsky.draft.defs#draftPost", text: "Delete me please" }, 95 + ], 96 + }, 97 + }); 98 + await mockServer.setup(page); 99 + 100 + await login(page); 101 + await page.goto("/"); 102 + 103 + const composer = await openComposer(page); 104 + await composer.locator('[data-testid="composer-drafts-button"]').click(); 105 + const draftsDialog = page.locator('[data-testid="drafts-dialog"]'); 106 + const draftItem = draftsDialog.locator('[data-testid="draft-item"]'); 107 + await expect(draftItem).toHaveCount(1, { timeout: 10000 }); 108 + 109 + await draftItem.locator('[data-testid="draft-item-delete"]').click(); 110 + const confirmModal = page.locator('[data-testid="confirm-modal"]'); 111 + await expect(confirmModal).toBeVisible({ timeout: 10000 }); 112 + await confirmModal.locator('[data-testid="modal-confirm-button"]').click(); 113 + 114 + await expect( 115 + draftsDialog.locator('[data-testid="empty-state"]'), 116 + ).toBeVisible({ timeout: 10000 }); 117 + }); 118 + 119 + test("video draft round-trips through the local byte store and re-uploads", async ({ 120 + page, 121 + }) => { 122 + const mockServer = new MockServer(); 123 + await mockServer.setup(page); 124 + 125 + // Stub video metadata loading - the fake buffer isn't a playable video, so 126 + // the real <video> element would fire `error` instead of `loadedmetadata` 127 + await page.addInitScript(() => { 128 + const proto = HTMLMediaElement.prototype; 129 + const desc = Object.getOwnPropertyDescriptor(proto, "src"); 130 + const origSet = desc.set; 131 + Object.defineProperty(proto, "src", { 132 + configurable: true, 133 + get: desc.get, 134 + set(val) { 135 + origSet.call(this, val); 136 + queueMicrotask(() => { 137 + if (this.tagName !== "VIDEO") return; 138 + Object.defineProperty(this, "videoWidth", { 139 + value: 1280, 140 + configurable: true, 141 + }); 142 + Object.defineProperty(this, "videoHeight", { 143 + value: 720, 144 + configurable: true, 145 + }); 146 + Object.defineProperty(this, "duration", { 147 + value: 5, 148 + configurable: true, 149 + }); 150 + this.dispatchEvent(new Event("loadedmetadata")); 151 + }); 152 + }, 153 + }); 154 + }); 155 + 156 + await login(page); 157 + await page.goto("/"); 158 + 159 + let composer = await openComposer(page); 160 + await composer.locator(".rich-text-input").click(); 161 + await composer.locator(".rich-text-input").type("Video for later"); 162 + await composer.locator(".media-picker-input").setInputFiles({ 163 + name: "clip.mp4", 164 + mimeType: "video/mp4", 165 + buffer: Buffer.from("fake-video-data"), 166 + }); 167 + await expect(composer.locator(".video-preview-item")).toBeVisible({ 168 + timeout: 10000, 169 + }); 170 + 171 + // Save as a draft from the cancel prompt 172 + await composer.locator(".post-composer-cancel-button").click(); 173 + const choiceModal = page.locator('[data-testid="choice-modal"]'); 174 + await expect(choiceModal).toBeVisible({ timeout: 10000 }); 175 + await choiceModal.locator('[data-testid="modal-choice-save"]').click(); 176 + await expect(composer).not.toBeVisible({ timeout: 10000 }); 177 + 178 + // The list row shows the video placeholder thumbnail 179 + composer = await openComposer(page); 180 + await composer.locator('[data-testid="composer-drafts-button"]').click(); 181 + const draftItem = page.locator( 182 + '[data-testid="drafts-dialog"] [data-testid="draft-item"]', 183 + ); 184 + await expect(draftItem).toHaveCount(1, { timeout: 10000 }); 185 + await expect( 186 + draftItem.locator('[data-testid="draft-item-media"]'), 187 + ).toBeVisible(); 188 + 189 + // Selecting restores the video through the normal upload pipeline 190 + await draftItem.click(); 191 + await expect(composer.locator(".rich-text-input")).toContainText( 192 + "Video for later", 193 + { timeout: 10000 }, 194 + ); 195 + await expect(composer.locator(".video-preview-item")).toBeVisible({ 196 + timeout: 10000, 197 + }); 198 + await expect(composer.locator(".video-preview-overlay")).toHaveCount(0, { 199 + timeout: 10000, 200 + }); 201 + }); 202 + 203 + test("draft limit error keeps the composer open with a toast", async ({ 204 + page, 205 + }) => { 206 + const mockServer = new MockServer(); 207 + mockServer.draftLimitReached = true; 208 + await mockServer.setup(page); 209 + 210 + await login(page); 211 + await page.goto("/"); 212 + 213 + const composer = await openComposer(page); 214 + await composer.locator(".rich-text-input").click(); 215 + await composer.locator(".rich-text-input").type("One draft too many"); 216 + await composer.locator(".post-composer-cancel-button").click(); 217 + 218 + const choiceModal = page.locator('[data-testid="choice-modal"]'); 219 + await expect(choiceModal).toBeVisible({ timeout: 10000 }); 220 + await choiceModal.locator('[data-testid="modal-choice-save"]').click(); 221 + 222 + await expect(page.locator('[data-testid="toast"]')).toContainText( 223 + "maximum number of drafts", 224 + { timeout: 10000 }, 225 + ); 226 + await expect(composer).toBeVisible(); 227 + }); 228 + });
+590 -3
tests/unit/specs/components/post-composer.test.js
··· 1 1 import { describe, it, beforeEach, afterEach } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 3 import { waitFor } from "../../testHelpers.js"; 4 + import { ApiError } from "/js/api.js"; 5 + import { getDraftDeviceId } from "/js/drafts.js"; 4 6 import "/js/components/post-composer.js"; 5 7 6 8 describe("post-composer", () => { ··· 236 238 }); 237 239 238 240 element.send(); 239 - assert.deepEqual(receivedDetail.postText, "Hello world"); 241 + assert.deepEqual(receivedDetail.post.postText, "Hello world"); 242 + assert.deepEqual(receivedDetail.draft, null); 243 + }); 244 + 245 + it("carries draft passthrough fields in the send-post detail", () => { 246 + const element = createPostComposer(); 247 + connectElement(element); 248 + element.state.$postText.set("Hello world"); 249 + const labels = { 250 + $type: "com.atproto.label.defs#selfLabels", 251 + values: [{ val: "porn" }], 252 + }; 253 + const threadgateAllow = [ 254 + { $type: "app.bsky.feed.threadgate#followingRule" }, 255 + ]; 256 + element._draftPassthrough = { labels, threadgateAllow }; 257 + 258 + let receivedDetail = null; 259 + element.addEventListener("send-post", (e) => { 260 + receivedDetail = e.detail; 261 + }); 262 + 263 + element.send(); 264 + assert.deepEqual(receivedDetail.post.labels, labels); 265 + assert.deepEqual(receivedDetail.post.threadgateAllow, threadgateAllow); 266 + assert.deepEqual(receivedDetail.post.postgateEmbeddingRules, null); 240 267 }); 241 268 242 269 it("should show loading spinner when sending", async () => { ··· 278 305 }), 279 306 ); 280 307 assert(receivedDetail !== null); 281 - assert.deepEqual(receivedDetail.postText, "Hello world"); 308 + assert.deepEqual(receivedDetail.post.postText, "Hello world"); 282 309 }); 283 310 284 311 it("should send post on Ctrl+Enter", () => { ··· 972 999 receivedDetail = e.detail; 973 1000 }); 974 1001 element.send(); 975 - assert.deepEqual(receivedDetail.quotedRecord, element.quotedRecord); 1002 + assert.deepEqual(receivedDetail.post.quotedRecord, element.quotedRecord); 976 1003 }); 977 1004 978 1005 it("attaches a record embed instead of an external link when pasted", async () => { ··· 990 1017 "https://bsky.app/profile/creator1.test/feed/cool-feed", 991 1018 ); 992 1019 assert.deepEqual(element._externalLinkUrl, null); 1020 + }); 1021 + }); 1022 + 1023 + describe("PostComposer - drafts", () => { 1024 + afterEach(() => { 1025 + delete globalThis.__testChoice; 1026 + delete globalThis.__testConfirmation; 1027 + }); 1028 + 1029 + it("renders the Drafts button for new posts", () => { 1030 + const element = createPostComposer(); 1031 + connectElement(element); 1032 + assert( 1033 + element.querySelector('[data-testid="composer-drafts-button"]') !== 1034 + null, 1035 + ); 1036 + }); 1037 + 1038 + it("does not render the Drafts button for replies", () => { 1039 + const element = createPostComposer(); 1040 + element.replyTo = { 1041 + author: { handle: "user.bsky.social", displayName: "User" }, 1042 + record: { text: "Original post", createdAt: new Date().toISOString() }, 1043 + indexedAt: new Date().toISOString(), 1044 + }; 1045 + connectElement(element); 1046 + assert.deepEqual( 1047 + element.querySelector('[data-testid="composer-drafts-button"]'), 1048 + null, 1049 + ); 1050 + }); 1051 + 1052 + it("starts clean and marks dirty on input", () => { 1053 + const element = createPostComposer(); 1054 + connectElement(element); 1055 + assert.deepEqual(element._isDirty, false); 1056 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1057 + assert.deepEqual(element._isDirty, true); 1058 + }); 1059 + 1060 + it("markSaved records the draft id and key set", () => { 1061 + const element = createPostComposer(); 1062 + connectElement(element); 1063 + element.markSaved("draft-1", ["image:a"]); 1064 + assert.deepEqual(element._draftId, "draft-1"); 1065 + assert.deepEqual([...element._originalLocalRefs], ["image:a"]); 1066 + }); 1067 + 1068 + it("closes without a prompt when a loaded draft is unmodified", async () => { 1069 + const element = createPostComposer(); 1070 + element.dataLayer = { 1071 + mutations: { 1072 + createDraft: async () => "draft-1", 1073 + }, 1074 + }; 1075 + connectElement(element); 1076 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1077 + assert.deepEqual(await element.saveDraft(), true); 1078 + const result = await element.confirmClose(); 1079 + assert.deepEqual(result, true); 1080 + }); 1081 + 1082 + it("prompts with the save choice when there is unsaved content", async () => { 1083 + const element = createPostComposer(); 1084 + connectElement(element); 1085 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1086 + globalThis.__testChoice = (resolve) => resolve("keep"); 1087 + const result = await element.confirmClose(); 1088 + assert.deepEqual(result, false); 1089 + }); 1090 + 1091 + it("closes when the prompt choice is discard", async () => { 1092 + const element = createPostComposer(); 1093 + connectElement(element); 1094 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1095 + globalThis.__testChoice = (resolve) => resolve("discard"); 1096 + const result = await element.confirmClose(); 1097 + assert.deepEqual(result, true); 1098 + }); 1099 + 1100 + it("saves and closes when the prompt choice is save", async () => { 1101 + const element = createPostComposer(); 1102 + let savedArgs = null; 1103 + element.dataLayer = { 1104 + mutations: { 1105 + createDraft: async (args) => { 1106 + savedArgs = args; 1107 + return "draft-9"; 1108 + }, 1109 + }, 1110 + }; 1111 + connectElement(element); 1112 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1113 + globalThis.__testChoice = (resolve) => resolve("save"); 1114 + const result = await element.confirmClose(); 1115 + assert.deepEqual(result, true); 1116 + assert.deepEqual(savedArgs.draft.posts[0].text, "hello"); 1117 + assert.deepEqual(element._draftId, "draft-9"); 1118 + assert.deepEqual(element._isDirty, false); 1119 + }); 1120 + 1121 + it("offers only discard when the text is over the draft limit", async () => { 1122 + const element = createPostComposer(); 1123 + connectElement(element); 1124 + element.handleInput({ 1125 + detail: { text: "x".repeat(1001), facets: [] }, 1126 + }); 1127 + let confirmationSeen = false; 1128 + globalThis.__testConfirmation = (resolve) => { 1129 + confirmationSeen = true; 1130 + resolve(true); 1131 + }; 1132 + globalThis.__testChoice = () => { 1133 + throw new Error("choice prompt should not be shown over the limit"); 1134 + }; 1135 + const result = await element.confirmClose(); 1136 + assert.deepEqual(result, true); 1137 + assert(confirmationSeen); 1138 + }); 1139 + 1140 + it("saveDraft updates in place when a draft id is set", async () => { 1141 + const element = createPostComposer(); 1142 + let savedArgs = null; 1143 + element.dataLayer = { 1144 + mutations: { 1145 + createDraft: async () => { 1146 + throw new Error("createDraft should not be called for an update"); 1147 + }, 1148 + updateDraft: async (args) => { 1149 + savedArgs = args; 1150 + }, 1151 + }, 1152 + }; 1153 + connectElement(element); 1154 + element.handleInput({ detail: { text: "edited", facets: [] } }); 1155 + element._draftId = "draft-1"; 1156 + element._originalLocalRefs = new Set(["image:old"]); 1157 + const result = await element.saveDraft(); 1158 + assert.deepEqual(result, true); 1159 + assert.deepEqual(savedArgs.draftId, "draft-1"); 1160 + assert.deepEqual(savedArgs.pruneLocalRefs, ["image:old"]); 1161 + assert.deepEqual([...element._originalLocalRefs], []); 1162 + }); 1163 + 1164 + it("saveDraft allows text at exactly 1000 graphemes", async () => { 1165 + const element = createPostComposer(); 1166 + element.dataLayer = { 1167 + mutations: { 1168 + createDraft: async () => "draft-1", 1169 + }, 1170 + }; 1171 + connectElement(element); 1172 + element.handleInput({ detail: { text: "x".repeat(1000), facets: [] } }); 1173 + assert.deepEqual(await element.saveDraft(), true); 1174 + }); 1175 + 1176 + it("saveDraft surfaces the specific draft-limit error copy", async () => { 1177 + const originalError = console.error; 1178 + console.error = () => {}; 1179 + try { 1180 + document.querySelectorAll(".toast").forEach((toast) => toast.remove()); 1181 + const element = createPostComposer(); 1182 + element.dataLayer = { 1183 + mutations: { 1184 + createDraft: async () => { 1185 + throw new ApiError({ 1186 + status: 400, 1187 + statusText: "Bad Request", 1188 + data: { error: "DraftLimitReached" }, 1189 + headers: {}, 1190 + url: "", 1191 + }); 1192 + }, 1193 + }, 1194 + }; 1195 + connectElement(element); 1196 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1197 + assert.deepEqual(await element.saveDraft(), false); 1198 + await waitFor(() => 1199 + [...document.querySelectorAll(".toast")].some((toast) => 1200 + toast.textContent.includes("maximum number of drafts"), 1201 + ), 1202 + ); 1203 + } finally { 1204 + console.error = originalError; 1205 + } 1206 + }); 1207 + 1208 + it("saveDraft refuses over-limit text without calling the mutation", async () => { 1209 + const element = createPostComposer(); 1210 + let called = false; 1211 + element.dataLayer = { 1212 + mutations: { 1213 + createDraft: async () => { 1214 + called = true; 1215 + }, 1216 + }, 1217 + }; 1218 + connectElement(element); 1219 + element.handleInput({ 1220 + detail: { text: "x".repeat(1001), facets: [] }, 1221 + }); 1222 + const result = await element.saveDraft(); 1223 + assert.deepEqual(result, false); 1224 + assert(!called); 1225 + }); 1226 + 1227 + it("saveDraft surfaces failure and stays dirty", async () => { 1228 + const originalError = console.error; 1229 + console.error = () => {}; 1230 + try { 1231 + const element = createPostComposer(); 1232 + element.dataLayer = { 1233 + mutations: { 1234 + createDraft: async () => { 1235 + throw new Error("boom"); 1236 + }, 1237 + }, 1238 + }; 1239 + connectElement(element); 1240 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1241 + const result = await element.saveDraft(); 1242 + assert.deepEqual(result, false); 1243 + assert.deepEqual(element._draftId, null); 1244 + assert.deepEqual(element._isDirty, true); 1245 + } finally { 1246 + console.error = originalError; 1247 + } 1248 + }); 1249 + 1250 + it("saveDraft writes minted image keys back onto composer state", async () => { 1251 + const element = createPostComposer(); 1252 + element.dataLayer = { 1253 + mutations: { 1254 + createDraft: async () => "draft-1", 1255 + }, 1256 + }; 1257 + connectElement(element); 1258 + element.handleInput({ detail: { text: "with image", facets: [] } }); 1259 + element.state.$selectedImages.set([{ file: {}, dataUrl: "data:a" }]); 1260 + const result = await element.saveDraft(); 1261 + assert.deepEqual(result, true); 1262 + const image = element.state.$selectedImages.get()[0]; 1263 + assert(image.localRefPath.startsWith("image:")); 1264 + }); 1265 + 1266 + it("stays dirty when edits land while a save is in flight", async () => { 1267 + const element = createPostComposer(); 1268 + let resolveCreate; 1269 + element.dataLayer = { 1270 + mutations: { 1271 + createDraft: () => 1272 + new Promise((resolve) => { 1273 + resolveCreate = resolve; 1274 + }), 1275 + }, 1276 + }; 1277 + connectElement(element); 1278 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1279 + const savePromise = element.saveDraft(); 1280 + element.handleInput({ detail: { text: "hello edited", facets: [] } }); 1281 + resolveCreate("draft-1"); 1282 + assert.deepEqual(await savePromise, true); 1283 + assert.deepEqual(element._draftId, "draft-1"); 1284 + assert.deepEqual(element._isDirty, true); 1285 + }); 1286 + 1287 + it("does not misassign minted keys to images added during a save", async () => { 1288 + const element = createPostComposer(); 1289 + let resolveCreate; 1290 + element.dataLayer = { 1291 + mutations: { 1292 + createDraft: () => 1293 + new Promise((resolve) => { 1294 + resolveCreate = resolve; 1295 + }), 1296 + }, 1297 + }; 1298 + connectElement(element); 1299 + element.handleInput({ detail: { text: "with image", facets: [] } }); 1300 + const originalImage = { file: {}, dataUrl: "data:a" }; 1301 + element.state.$selectedImages.set([originalImage]); 1302 + const savePromise = element.saveDraft(); 1303 + const addedImage = { file: {}, dataUrl: "data:b" }; 1304 + element.state.$selectedImages.set([addedImage, originalImage]); 1305 + resolveCreate("draft-1"); 1306 + assert.deepEqual(await savePromise, true); 1307 + const [first, second] = element.state.$selectedImages.get(); 1308 + assert.deepEqual(first.localRefPath, undefined); 1309 + assert(second.localRefPath.startsWith("image:")); 1310 + }); 1311 + 1312 + it("treats content as a new post after the loaded draft is deleted", async () => { 1313 + const element = createPostComposer(); 1314 + let created = false; 1315 + element.dataLayer = { 1316 + mutations: { 1317 + createDraft: async () => { 1318 + created = true; 1319 + return "draft-2"; 1320 + }, 1321 + updateDraft: async () => { 1322 + throw new Error("updateDraft should not be called after deletion"); 1323 + }, 1324 + }, 1325 + }; 1326 + connectElement(element); 1327 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1328 + element.markSaved("draft-1", ["image:a"]); 1329 + element._isDirty = false; 1330 + element.handleDraftDeleted("draft-1"); 1331 + assert.deepEqual(element._draftId, null); 1332 + assert.deepEqual(element._originalLocalRefs, null); 1333 + assert.deepEqual(element._isDirty, true); 1334 + assert.deepEqual(await element.saveDraft(), true); 1335 + assert(created); 1336 + assert.deepEqual(element._draftId, "draft-2"); 1337 + }); 1338 + 1339 + it("ignores deletions of drafts other than the loaded one", () => { 1340 + const element = createPostComposer(); 1341 + connectElement(element); 1342 + element.markSaved("draft-1", ["image:a"]); 1343 + element.handleDraftDeleted("draft-2"); 1344 + assert.deepEqual(element._draftId, "draft-1"); 1345 + assert.deepEqual([...element._originalLocalRefs], ["image:a"]); 1346 + }); 1347 + 1348 + it("clearComposer resets content and draft state", () => { 1349 + const element = createPostComposer(); 1350 + connectElement(element); 1351 + element.open(); 1352 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1353 + element.state.$selectedImages.set([{ file: {}, dataUrl: "data:a" }]); 1354 + element.markSaved("draft-1", ["image:a"]); 1355 + element.clearComposer(); 1356 + assert.deepEqual(element.state.$postText.get(), ""); 1357 + assert.deepEqual(element.state.$selectedImages.get(), []); 1358 + assert.deepEqual(element._draftId, null); 1359 + assert.deepEqual(element._originalLocalRefs, null); 1360 + assert.deepEqual(element._isDirty, false); 1361 + assert.deepEqual(element.querySelector("rich-text-input").text, ""); 1362 + }); 1363 + 1364 + it("restoreFromDraft seeds text, external link, and quote, and ends clean", async () => { 1365 + globalThis.fetch = () => Promise.resolve({ ok: false }); 1366 + try { 1367 + const element = createPostComposer(); 1368 + element.dataLayer = { 1369 + declarative: { 1370 + ensurePost: async (uri) => ({ 1371 + uri, 1372 + cid: "postcid", 1373 + author: { 1374 + did: "did:plc:creator1", 1375 + handle: "creator1.test", 1376 + displayName: "Creator One", 1377 + avatar: null, 1378 + }, 1379 + record: { text: "Quoted", createdAt: "2025-01-01T00:00:00Z" }, 1380 + indexedAt: "2025-01-01T00:00:00.000Z", 1381 + labels: [], 1382 + }), 1383 + }, 1384 + }; 1385 + connectElement(element); 1386 + element.open(); 1387 + const draftView = { 1388 + id: "draft-1", 1389 + draft: { 1390 + deviceId: "another-device", 1391 + deviceName: "Web", 1392 + posts: [ 1393 + { 1394 + text: "restored text", 1395 + embedExternals: [{ uri: "https://example.com/article" }], 1396 + embedRecords: [ 1397 + { 1398 + record: { 1399 + uri: "at://did:plc:creator1/app.bsky.feed.post/3abc", 1400 + cid: "postcid", 1401 + }, 1402 + }, 1403 + ], 1404 + }, 1405 + ], 1406 + }, 1407 + }; 1408 + await element.restoreFromDraft(draftView); 1409 + assert.deepEqual(element.state.$postText.get(), "restored text"); 1410 + assert.deepEqual( 1411 + element.querySelector("rich-text-input").text, 1412 + "restored text", 1413 + ); 1414 + assert.deepEqual( 1415 + element.state.$externalLinkEmbedData.get().url, 1416 + "https://example.com/article", 1417 + ); 1418 + assert.deepEqual( 1419 + element.quotedRecord.uri, 1420 + "at://did:plc:creator1/app.bsky.feed.post/3abc", 1421 + ); 1422 + assert.deepEqual(element._draftId, "draft-1"); 1423 + assert.deepEqual(element._isDirty, false); 1424 + } finally { 1425 + delete globalThis.fetch; 1426 + } 1427 + }); 1428 + 1429 + it("re-saving a draft restored from another device keeps its media embeds", async () => { 1430 + const element = createPostComposer(); 1431 + let savedArgs = null; 1432 + element.dataLayer = { 1433 + mutations: { 1434 + updateDraft: async (args) => { 1435 + savedArgs = args; 1436 + }, 1437 + }, 1438 + }; 1439 + connectElement(element); 1440 + const galleryItems = [ 1441 + { 1442 + $type: "app.bsky.draft.defs#draftEmbedImage", 1443 + alt: "an image", 1444 + localRef: { 1445 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 1446 + path: "image:foreign", 1447 + }, 1448 + }, 1449 + ]; 1450 + const videoEmbed = { 1451 + $type: "app.bsky.draft.defs#draftEmbedVideo", 1452 + localRef: { 1453 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 1454 + path: "video:video/mp4:foreign.mp4", 1455 + }, 1456 + }; 1457 + await element.restoreFromDraft({ 1458 + id: "draft-1", 1459 + draft: { 1460 + deviceId: "another-device", 1461 + deviceName: "Web", 1462 + posts: [ 1463 + { 1464 + text: "foreign media", 1465 + embedGallery: { 1466 + $type: "app.bsky.draft.defs#draftEmbedGallery", 1467 + items: galleryItems, 1468 + }, 1469 + embedVideos: [videoEmbed], 1470 + }, 1471 + ], 1472 + }, 1473 + }); 1474 + element.handleInput({ 1475 + detail: { text: "foreign media edited", facets: [] }, 1476 + }); 1477 + assert.deepEqual(await element.saveDraft(), true); 1478 + assert.deepEqual( 1479 + savedArgs.draft.posts[0].embedGallery.items, 1480 + galleryItems, 1481 + ); 1482 + assert.deepEqual(savedArgs.draft.posts[0].embedVideos, [videoEmbed]); 1483 + assert.deepEqual(savedArgs.pruneLocalRefs, []); 1484 + }); 1485 + 1486 + it("re-saving keeps the video embed when its local bytes are missing", async () => { 1487 + const element = createPostComposer(); 1488 + let savedArgs = null; 1489 + element.dataLayer = { 1490 + mutations: { 1491 + updateDraft: async (args) => { 1492 + savedArgs = args; 1493 + }, 1494 + }, 1495 + draftMediaStore: { 1496 + readBlob: async () => null, 1497 + }, 1498 + }; 1499 + connectElement(element); 1500 + const videoEmbed = { 1501 + $type: "app.bsky.draft.defs#draftEmbedVideo", 1502 + localRef: { 1503 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 1504 + path: "video:video/mp4:evicted.mp4", 1505 + }, 1506 + }; 1507 + await element.restoreFromDraft({ 1508 + id: "draft-1", 1509 + draft: { 1510 + deviceId: getDraftDeviceId(), 1511 + deviceName: "Web", 1512 + posts: [{ text: "video draft", embedVideos: [videoEmbed] }], 1513 + }, 1514 + }); 1515 + element.handleInput({ 1516 + detail: { text: "video draft edited", facets: [] }, 1517 + }); 1518 + assert.deepEqual(await element.saveDraft(), true); 1519 + assert.deepEqual(savedArgs.draft.posts[0].embedVideos, [videoEmbed]); 1520 + assert.deepEqual(savedArgs.pruneLocalRefs, []); 1521 + }); 1522 + 1523 + it("composer media replaces unrestored media on save", async () => { 1524 + const element = createPostComposer(); 1525 + let savedArgs = null; 1526 + element.dataLayer = { 1527 + mutations: { 1528 + updateDraft: async (args) => { 1529 + savedArgs = args; 1530 + }, 1531 + }, 1532 + }; 1533 + connectElement(element); 1534 + await element.restoreFromDraft({ 1535 + id: "draft-1", 1536 + draft: { 1537 + deviceId: "another-device", 1538 + deviceName: "Web", 1539 + posts: [ 1540 + { 1541 + text: "foreign media", 1542 + embedGallery: { 1543 + $type: "app.bsky.draft.defs#draftEmbedGallery", 1544 + items: [ 1545 + { 1546 + $type: "app.bsky.draft.defs#draftEmbedImage", 1547 + localRef: { 1548 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 1549 + path: "image:foreign", 1550 + }, 1551 + }, 1552 + ], 1553 + }, 1554 + }, 1555 + ], 1556 + }, 1557 + }); 1558 + element.state.$selectedImages.set([{ file: {}, dataUrl: "data:new" }]); 1559 + assert.deepEqual(await element.saveDraft(), true); 1560 + const items = savedArgs.draft.posts[0].embedGallery.items; 1561 + assert.deepEqual(items.length, 1); 1562 + assert(items[0].localRef.path !== "image:foreign"); 1563 + assert.deepEqual(savedArgs.pruneLocalRefs, ["image:foreign"]); 1564 + }); 1565 + 1566 + it("send includes the draft id and local refs for publish cleanup", () => { 1567 + const element = createPostComposer(); 1568 + connectElement(element); 1569 + element.handleInput({ detail: { text: "hello", facets: [] } }); 1570 + element.markSaved("draft-1", ["image:a"]); 1571 + let receivedDetail = null; 1572 + element.addEventListener("send-post", (e) => { 1573 + receivedDetail = e.detail; 1574 + }); 1575 + element.send(); 1576 + assert.deepEqual(receivedDetail.draft, { 1577 + draftId: "draft-1", 1578 + localRefs: ["image:a"], 1579 + }); 993 1580 }); 994 1581 }); 995 1582
+44
tests/unit/specs/dataHelpers.test.js
··· 6 6 getIsLiked, 7 7 isListFeed, 8 8 getQuotedPost, 9 + getImagesFromDraftPost, 10 + getLocalRefsFromDraft, 9 11 getBlockedQuote, 10 12 createEmbedFromPost, 11 13 embedViewRecordToPostView, ··· 2316 2318 assert(!isVideoLink(null)); 2317 2319 }); 2318 2320 }); 2321 + 2322 + describe("getImagesFromDraftPost", () => { 2323 + it("reads embedGallery items", () => { 2324 + const images = getImagesFromDraftPost({ 2325 + embedGallery: { items: [{ localRef: { path: "image:a" } }] }, 2326 + }); 2327 + assert.deepEqual(images.length, 1); 2328 + }); 2329 + 2330 + it("reads legacy embedImages", () => { 2331 + const images = getImagesFromDraftPost({ 2332 + embedImages: [{ localRef: { path: "image:a" }, alt: "old" }], 2333 + }); 2334 + assert.deepEqual(images[0].alt, "old"); 2335 + }); 2336 + 2337 + it("skips entries without a localRef path", () => { 2338 + const images = getImagesFromDraftPost({ 2339 + embedGallery: { items: [{ localRef: {} }, {}] }, 2340 + }); 2341 + assert.deepEqual(images, []); 2342 + }); 2343 + }); 2344 + 2345 + describe("getLocalRefsFromDraft", () => { 2346 + it("collects image and video refs across posts, including legacy images", () => { 2347 + const refs = getLocalRefsFromDraft({ 2348 + posts: [ 2349 + { 2350 + embedGallery: { items: [{ localRef: { path: "image:a" } }] }, 2351 + embedVideos: [{ localRef: { path: "video:video/mp4:v.mp4" } }], 2352 + }, 2353 + { embedImages: [{ localRef: { path: "image:b" } }] }, 2354 + ], 2355 + }); 2356 + assert.deepEqual(refs, ["image:a", "video:video/mp4:v.mp4", "image:b"]); 2357 + }); 2358 + 2359 + it("returns an empty array for drafts with no media", () => { 2360 + assert.deepEqual(getLocalRefsFromDraft({ posts: [{ text: "hi" }] }), []); 2361 + }); 2362 + });
+8 -3
tests/unit/specs/dataLayer/dataLayer.test.js
··· 1 1 import { describe, it } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 3 import { DataLayer } from "/js/dataLayer/dataLayer.js"; 4 + import { DraftMediaStore } from "/js/drafts.js"; 4 5 import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; 5 6 6 7 function createMockApi(options = {}) { ··· 14 15 } 15 16 16 17 function createDataLayer(api) { 17 - return new DataLayer(api, null, new PreferencesProvider(api), { 18 - resolveHandle: async () => null, 19 - }); 18 + return new DataLayer( 19 + api, 20 + null, 21 + new PreferencesProvider(api), 22 + { resolveHandle: async () => null }, 23 + new DraftMediaStore("test-media"), 24 + ); 20 25 } 21 26 22 27 describe("constructor", () => {
+155 -1
tests/unit/specs/dataLayer/derived.test.js
··· 2 2 import assert from "node:assert/strict"; 3 3 import { Derived } from "/js/dataLayer/derived.js"; 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 + import { DraftMediaStore, getDraftDeviceId } from "/js/drafts.js"; 5 6 import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 7 import { Preferences } from "/js/preferences.js"; 7 8 import { Signal, SignalMap } from "/js/signals.js"; 8 9 9 - function makeDerived(dataStore, { preferences } = {}) { 10 + function makeDerived(dataStore, { preferences, draftMediaStore } = {}) { 10 11 const patchStore = new PatchStore(dataStore); 11 12 const prefs = preferences ?? Preferences.createLoggedOutPreferences(); 12 13 const preferencesProvider = { ··· 22 23 preferencesProvider, 23 24 pluginService, 24 25 false, 26 + draftMediaStore ?? new DraftMediaStore("test-media"), 25 27 ); 26 28 return { derived, patchStore }; 27 29 } ··· 970 972 assert.deepEqual(afterBlock.messages[0].reactions.length, 0); 971 973 }); 972 974 }); 975 + 976 + describe("$hydratedDrafts", () => { 977 + function makeDraftView(draftOverrides = {}) { 978 + return { 979 + id: "draft-1", 980 + createdAt: "2026-07-01T00:00:00.000Z", 981 + updatedAt: "2026-07-02T00:00:00.000Z", 982 + draft: { 983 + $type: "app.bsky.draft.defs#draft", 984 + deviceId: getDraftDeviceId(), 985 + deviceName: "Web", 986 + posts: [{ $type: "app.bsky.draft.defs#draftPost", text: "hi" }], 987 + ...draftOverrides, 988 + }, 989 + }; 990 + } 991 + 992 + // `localMedia` seeds the store's $media: path -> { url } | null 993 + function hydrateDraftPosts(draftViews, { localMedia = {} } = {}) { 994 + const dataStore = new DataStore(); 995 + const draftMediaStore = new DraftMediaStore("test-media"); 996 + draftMediaStore.$media.set(localMedia); 997 + const { derived } = makeDerived(dataStore, { draftMediaStore }); 998 + dataStore.$drafts.set({ drafts: draftViews, cursor: null }); 999 + return derived.$hydratedDrafts.get().drafts[0].posts; 1000 + } 1001 + 1002 + it("is null before loading and carries the view fields and cursor through", () => { 1003 + const dataStore = new DataStore(); 1004 + const { derived } = makeDerived(dataStore); 1005 + assert.deepEqual(derived.$hydratedDrafts.get(), null); 1006 + dataStore.$drafts.set({ drafts: [makeDraftView()], cursor: "next" }); 1007 + const data = derived.$hydratedDrafts.get(); 1008 + assert.deepEqual(data.cursor, "next"); 1009 + assert.deepEqual(data.drafts[0].id, "draft-1"); 1010 + assert.deepEqual(data.drafts[0].updatedAt, "2026-07-02T00:00:00.000Z"); 1011 + assert.deepEqual(data.drafts[0].draft.deviceName, "Web"); 1012 + }); 1013 + 1014 + it("passes a text-only draft post through unchanged", () => { 1015 + const posts = hydrateDraftPosts([makeDraftView()]); 1016 + assert.deepEqual(posts, [ 1017 + { $type: "app.bsky.draft.defs#draftPost", text: "hi" }, 1018 + ]); 1019 + }); 1020 + 1021 + it("decorates gallery images with exists and previewUrl on the originating device", () => { 1022 + const posts = hydrateDraftPosts( 1023 + [ 1024 + makeDraftView({ 1025 + posts: [ 1026 + { 1027 + text: "pics", 1028 + embedGallery: { 1029 + items: [{ localRef: { path: "image:a" }, alt: "cat" }], 1030 + }, 1031 + }, 1032 + ], 1033 + }), 1034 + ], 1035 + { localMedia: { "image:a": { url: "blob:stub-image:a" } } }, 1036 + ); 1037 + assert.deepEqual(posts[0].embedGallery.items, [ 1038 + { 1039 + localRef: { path: "image:a" }, 1040 + alt: "cat", 1041 + exists: true, 1042 + previewUrl: "blob:stub-image:a", 1043 + }, 1044 + ]); 1045 + }); 1046 + 1047 + it("decorates legacy embedImages the same way", () => { 1048 + const posts = hydrateDraftPosts( 1049 + [ 1050 + makeDraftView({ 1051 + posts: [ 1052 + { 1053 + text: "", 1054 + embedImages: [{ localRef: { path: "image:a" }, alt: "old" }], 1055 + }, 1056 + ], 1057 + }), 1058 + ], 1059 + { localMedia: { "image:a": { url: null } } }, 1060 + ); 1061 + assert.deepEqual(posts[0].embedImages[0].exists, true); 1062 + }); 1063 + 1064 + it("marks media missing when bytes are gone on the originating device", () => { 1065 + const posts = hydrateDraftPosts([ 1066 + makeDraftView({ 1067 + posts: [ 1068 + { 1069 + text: "", 1070 + embedVideos: [{ localRef: { path: "video:video/mp4:v.mp4" } }], 1071 + }, 1072 + ], 1073 + }), 1074 + ]); 1075 + assert.deepEqual(posts[0].embedVideos[0].exists, false); 1076 + }); 1077 + 1078 + it("hydrates from local byte presence even for drafts saved on another device", () => { 1079 + const posts = hydrateDraftPosts( 1080 + [ 1081 + makeDraftView({ 1082 + deviceId: "some-other-device", 1083 + posts: [ 1084 + { 1085 + text: "", 1086 + embedGallery: { 1087 + items: [ 1088 + { localRef: { path: "image:a" } }, 1089 + { localRef: { path: "image:elsewhere" } }, 1090 + ], 1091 + }, 1092 + }, 1093 + ], 1094 + }), 1095 + ], 1096 + { localMedia: { "image:a": { url: "blob:stub-image:a" } } }, 1097 + ); 1098 + assert.deepEqual(posts[0].embedGallery.items[0].exists, true); 1099 + assert.deepEqual( 1100 + posts[0].embedGallery.items[0].previewUrl, 1101 + "blob:stub-image:a", 1102 + ); 1103 + assert.deepEqual(posts[0].embedGallery.items[1].exists, false); 1104 + assert.deepEqual(posts[0].embedGallery.items[1].previewUrl, null); 1105 + }); 1106 + 1107 + it("hydrates every post of a thread draft", () => { 1108 + const posts = hydrateDraftPosts([ 1109 + makeDraftView({ 1110 + posts: [ 1111 + { 1112 + text: "a", 1113 + embedRecords: [{ record: { uri: "at://x", cid: "c" } }], 1114 + }, 1115 + { text: "b" }, 1116 + { text: "c" }, 1117 + ], 1118 + }), 1119 + ]); 1120 + assert.deepEqual(posts.length, 3); 1121 + assert.deepEqual(posts[0].embedRecords, [ 1122 + { record: { uri: "at://x", cid: "c" } }, 1123 + ]); 1124 + assert.deepEqual(posts[1].embedRecords, undefined); 1125 + }); 1126 + });
+3
tests/unit/specs/dataLayer/mutations.test.js
··· 2 2 import assert from "node:assert/strict"; 3 3 import { Mutations } from "/js/dataLayer/mutations.js"; 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 + import { DraftMediaStore } from "/js/drafts.js"; 5 6 import { PatchStore } from "/js/dataLayer/patchStore.js"; 6 7 import { Derived } from "/js/dataLayer/derived.js"; 7 8 import { Preferences } from "/js/preferences.js"; ··· 18 19 patchStore, 19 20 preferencesProvider, 20 21 mockIdentityResolver, 22 + new DraftMediaStore("test-media"), 21 23 ); 22 24 } 23 25 ··· 59 61 provider, 60 62 makePluginService(), 61 63 isAuthenticated, 64 + new DraftMediaStore("test-media"), 62 65 ); 63 66 } 64 67
+11 -3
tests/unit/specs/dataLayer/requests.test.js
··· 2 2 import assert from "node:assert/strict"; 3 3 import { Requests } from "/js/dataLayer/requests.js"; 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 + import { DraftMediaStore } from "/js/drafts.js"; 5 6 import { Preferences } from "/js/preferences.js"; 6 7 import { ApiError } from "/js/api.js"; 7 8 import { SignalMap } from "/js/signals.js"; ··· 13 14 }; 14 15 15 16 function createRequests(api, dataStore, preferencesProvider) { 16 - return new Requests(api, dataStore, preferencesProvider, stubPluginService, { 17 - constellation: stubConstellation, 18 - }); 17 + return new Requests( 18 + api, 19 + dataStore, 20 + preferencesProvider, 21 + stubPluginService, 22 + new DraftMediaStore("test-media"), 23 + { constellation: stubConstellation }, 24 + ); 19 25 } 20 26 21 27 describe("loadPostThread", () => { ··· 237 243 dataStore, 238 244 { requirePreferences: () => Preferences.createLoggedOutPreferences() }, 239 245 pluginService, 246 + new DraftMediaStore("test-media"), 240 247 { constellation: stubConstellation }, 241 248 ); 242 249 ··· 345 352 dataStore, 346 353 { requirePreferences: () => Preferences.createLoggedOutPreferences() }, 347 354 pluginService, 355 + new DraftMediaStore("test-media"), 348 356 { constellation: stubConstellation }, 349 357 ); 350 358 }
+469
tests/unit/specs/drafts.test.js
··· 1 + import { describe, it, beforeEach, afterEach } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { 4 + DraftMediaStore, 5 + buildDraftFromComposerSnapshot, 6 + getDraftDeviceId, 7 + } from "/js/drafts.js"; 8 + 9 + describe("getDraftDeviceId", () => { 10 + it("mints a device id once and returns the same value after", () => { 11 + const first = getDraftDeviceId(); 12 + assert(first.length > 0); 13 + assert.deepEqual(getDraftDeviceId(), first); 14 + }); 15 + }); 16 + 17 + // JSDOM has no IndexedDB; this minimal fake covers the subset the store uses 18 + // (open/transaction/objectStore/put/get/getKey/delete). 19 + class FakeIDBRequest { 20 + constructor() { 21 + this.onsuccess = null; 22 + this.onerror = null; 23 + this.onupgradeneeded = null; 24 + } 25 + _resolve(result) { 26 + this.result = result; 27 + queueMicrotask(() => this.onsuccess?.()); 28 + } 29 + _reject(error) { 30 + this.error = error; 31 + queueMicrotask(() => this.onerror?.()); 32 + } 33 + } 34 + 35 + class FakeObjectStore { 36 + constructor(records, { failWrites = false } = {}) { 37 + this.records = records; 38 + this.failWrites = failWrites; 39 + } 40 + put(value, key) { 41 + const request = new FakeIDBRequest(); 42 + if (this.failWrites) { 43 + request._reject(new Error("QuotaExceededError")); 44 + } else { 45 + this.records.set(key, value); 46 + request._resolve(undefined); 47 + } 48 + return request; 49 + } 50 + get(key) { 51 + const request = new FakeIDBRequest(); 52 + request._resolve(this.records.get(key)); 53 + return request; 54 + } 55 + getKey(key) { 56 + const request = new FakeIDBRequest(); 57 + request._resolve(this.records.has(key) ? key : undefined); 58 + return request; 59 + } 60 + delete(key) { 61 + const request = new FakeIDBRequest(); 62 + this.records.delete(key); 63 + request._resolve(undefined); 64 + return request; 65 + } 66 + } 67 + 68 + function installFakeIndexedDB({ failWrites = false } = {}) { 69 + const records = new Map(); 70 + globalThis.indexedDB = { 71 + open() { 72 + const request = new FakeIDBRequest(); 73 + const db = { 74 + createObjectStore() {}, 75 + transaction() { 76 + return { 77 + objectStore: () => new FakeObjectStore(records, { failWrites }), 78 + }; 79 + }, 80 + }; 81 + request.result = db; 82 + queueMicrotask(() => { 83 + request.onupgradeneeded?.(); 84 + request.onsuccess?.(); 85 + }); 86 + return request; 87 + }, 88 + }; 89 + return records; 90 + } 91 + 92 + describe("DraftMediaStore.parseVideoMimeType", () => { 93 + it("parses the mime type out of a video key", () => { 94 + assert.deepEqual( 95 + DraftMediaStore.parseVideoMimeType("video:video/quicktime:abc123.mov"), 96 + "video/quicktime", 97 + ); 98 + }); 99 + 100 + it("parses legacy keys without a mime segment as mp4", () => { 101 + assert.deepEqual( 102 + DraftMediaStore.parseVideoMimeType("video:abc123"), 103 + "video/mp4", 104 + ); 105 + }); 106 + 107 + it("falls back to mp4 for unknown mime segments", () => { 108 + assert.deepEqual( 109 + DraftMediaStore.parseVideoMimeType("video:video/x-unknown:abc123.bin"), 110 + "video/mp4", 111 + ); 112 + }); 113 + }); 114 + 115 + describe("DraftMediaStore", () => { 116 + let records; 117 + let store; 118 + let originalCreateObjectURL; 119 + let originalRevokeObjectURL; 120 + let mintedUrls; 121 + let revokedUrls; 122 + 123 + beforeEach(() => { 124 + records = installFakeIndexedDB(); 125 + store = new DraftMediaStore("test-media"); 126 + originalCreateObjectURL = URL.createObjectURL; 127 + originalRevokeObjectURL = URL.revokeObjectURL; 128 + let urlCounter = 0; 129 + mintedUrls = []; 130 + revokedUrls = []; 131 + URL.createObjectURL = () => { 132 + const url = `blob:fake-${urlCounter++}`; 133 + mintedUrls.push(url); 134 + return url; 135 + }; 136 + URL.revokeObjectURL = (url) => revokedUrls.push(url); 137 + }); 138 + 139 + afterEach(() => { 140 + delete globalThis.indexedDB; 141 + URL.createObjectURL = originalCreateObjectURL; 142 + URL.revokeObjectURL = originalRevokeObjectURL; 143 + }); 144 + 145 + describe("load", () => { 146 + it("records url entries for present images and null for missing paths", async () => { 147 + records.set("image:a", { blob: new Blob(["bytes"]) }); 148 + await store.load(["image:a", "image:gone"]); 149 + const media = store.$media.get(); 150 + assert(media["image:a"].url.startsWith("blob:fake-")); 151 + assert.deepEqual(media["image:gone"], null); 152 + }); 153 + 154 + it("checks video presence without minting URLs", async () => { 155 + records.set("video:video/mp4:a.mp4", { blob: new Blob(["big"]) }); 156 + await store.load(["video:video/mp4:a.mp4", "video:video/mp4:b.mp4"]); 157 + assert.deepEqual(store.$media.get(), { 158 + "video:video/mp4:a.mp4": { url: null }, 159 + "video:video/mp4:b.mp4": null, 160 + }); 161 + assert.deepEqual(mintedUrls, []); 162 + }); 163 + 164 + it("does not re-read paths that already have entries", async () => { 165 + records.set("image:a", { blob: new Blob(["bytes"]) }); 166 + await store.load(["image:a"]); 167 + const firstUrl = store.$media.get()["image:a"].url; 168 + await store.load(["image:a", "image:a"]); 169 + assert.deepEqual(store.$media.get()["image:a"].url, firstUrl); 170 + assert.deepEqual(mintedUrls.length, 1); 171 + assert.deepEqual(revokedUrls, []); 172 + }); 173 + 174 + it("does not re-check paths already recorded as missing", async () => { 175 + await store.load(["image:gone"]); 176 + assert.deepEqual(store.$media.get()["image:gone"], null); 177 + // Bytes appearing later are not picked up - the null entry stands 178 + // until a save() records them 179 + records.set("image:gone", { blob: new Blob(["late"]) }); 180 + await store.load(["image:gone"]); 181 + assert.deepEqual(store.$media.get()["image:gone"], null); 182 + }); 183 + }); 184 + 185 + it("save writes bytes with a timestamp and records an entry with a URL for images", async () => { 186 + await store.save("image:a", new Blob(["bytes"])); 187 + assert(records.get("image:a").blob instanceof Blob); 188 + assert(typeof records.get("image:a").createdAt === "string"); 189 + assert(store.$media.get()["image:a"].url.startsWith("blob:fake-")); 190 + }); 191 + 192 + it("save records video paths without minting a URL", async () => { 193 + await store.save("video:video/mp4:a.mp4", new Blob(["x"])); 194 + assert(records.has("video:video/mp4:a.mp4")); 195 + assert.deepEqual(store.$media.get()["video:video/mp4:a.mp4"], { 196 + url: null, 197 + }); 198 + assert.deepEqual(mintedUrls, []); 199 + }); 200 + 201 + it("re-saving an image replaces the entry and revokes the old URL", async () => { 202 + await store.save("image:a", new Blob(["one"])); 203 + const firstUrl = store.$media.get()["image:a"].url; 204 + await store.save("image:a", new Blob(["two"])); 205 + assert(store.$media.get()["image:a"].url !== firstUrl); 206 + assert.deepEqual(revokedUrls, [firstUrl]); 207 + }); 208 + 209 + it("readBlob returns raw bytes without minting a URL, null when missing", async () => { 210 + const blob = new Blob(["bytes"]); 211 + await store.save("video:video/mp4:a.mp4", blob); 212 + assert.deepEqual(await store.readBlob("video:video/mp4:a.mp4"), blob); 213 + assert.deepEqual(await store.readBlob("image:missing"), null); 214 + assert.deepEqual(mintedUrls, []); 215 + }); 216 + 217 + it("delete removes bytes, marks the path missing, and revokes its URL", async () => { 218 + await store.save("image:a", new Blob(["bytes"])); 219 + const url = store.$media.get()["image:a"].url; 220 + await store.delete("image:a"); 221 + assert(!records.has("image:a")); 222 + assert.deepEqual(store.$media.get()["image:a"], null); 223 + assert.deepEqual(revokedUrls, [url]); 224 + }); 225 + 226 + it("propagates write failures without recording an entry", async () => { 227 + installFakeIndexedDB({ failWrites: true }); 228 + const failingStore = new DraftMediaStore("test-media"); 229 + await assert.rejects(() => failingStore.save("image:a", new Blob(["x"]))); 230 + assert.deepEqual(failingStore.$media.get(), {}); 231 + }); 232 + 233 + it("fetches string sources (data URLs) into blobs", async () => { 234 + const originalFetch = globalThis.fetch; 235 + globalThis.fetch = async () => ({ 236 + blob: async () => new Blob(["fetched"]), 237 + }); 238 + try { 239 + await store.save("image:from-url", "blob:some-url"); 240 + assert(records.get("image:from-url").blob instanceof Blob); 241 + } finally { 242 + if (originalFetch) { 243 + globalThis.fetch = originalFetch; 244 + } else { 245 + delete globalThis.fetch; 246 + } 247 + } 248 + }); 249 + }); 250 + 251 + describe("buildDraftFromComposerSnapshot", () => { 252 + function makeDraftSnapshot(overrides = {}) { 253 + return { 254 + postText: "hello world", 255 + images: [], 256 + video: null, 257 + external: null, 258 + quotedRecord: null, 259 + labels: null, 260 + threadgateAllow: null, 261 + postgateEmbeddingRules: null, 262 + ...overrides, 263 + }; 264 + } 265 + 266 + it("serializes a text-only snapshot into a single-post draft", () => { 267 + const { draft, media } = 268 + buildDraftFromComposerSnapshot(makeDraftSnapshot()); 269 + assert.deepEqual(draft.$type, "app.bsky.draft.defs#draft"); 270 + assert.deepEqual(draft.deviceId, getDraftDeviceId()); 271 + assert.deepEqual(draft.deviceName, "Web"); 272 + assert.deepEqual(draft.posts.length, 1); 273 + assert.deepEqual(draft.posts[0].text, "hello world"); 274 + assert.deepEqual(draft.posts[0].embedGallery, undefined); 275 + assert.deepEqual(media, []); 276 + }); 277 + 278 + it("reuses image localRefPath keys and mints keys for new images", () => { 279 + const fileA = {}; 280 + const fileB = {}; 281 + const { draft, media } = buildDraftFromComposerSnapshot( 282 + makeDraftSnapshot({ 283 + images: [ 284 + { file: fileA, dataUrl: "data:a", localRefPath: "image:existing" }, 285 + { file: fileB, dataUrl: "data:b", alt: "a bird" }, 286 + { file: {}, dataUrl: "data:c" }, 287 + ], 288 + }), 289 + ); 290 + const items = draft.posts[0].embedGallery.items; 291 + assert.deepEqual(items.length, 3); 292 + assert.deepEqual(items[0].localRef.path, "image:existing"); 293 + assert.deepEqual(items[0].alt, undefined); 294 + assert(items[1].localRef.path.startsWith("image:")); 295 + assert.deepEqual(items[1].alt, "a bird"); 296 + assert(items[2].localRef.path !== items[1].localRef.path); 297 + assert.deepEqual(media.length, 3); 298 + assert.deepEqual(media[0], { path: "image:existing", source: fileA }); 299 + assert.deepEqual(media[1].source, fileB); 300 + }); 301 + 302 + it("always mints a fresh video key", () => { 303 + const file = { type: "video/webm" }; 304 + const first = buildDraftFromComposerSnapshot( 305 + makeDraftSnapshot({ video: { file, alt: "clip", captions: null } }), 306 + ); 307 + const second = buildDraftFromComposerSnapshot( 308 + makeDraftSnapshot({ video: { file, alt: "clip", captions: null } }), 309 + ); 310 + const firstPath = first.draft.posts[0].embedVideos[0].localRef.path; 311 + const secondPath = second.draft.posts[0].embedVideos[0].localRef.path; 312 + assert(firstPath.startsWith("video:video/webm:")); 313 + assert(firstPath.endsWith(".webm")); 314 + assert(firstPath !== secondPath); 315 + assert.deepEqual(first.draft.posts[0].embedVideos[0].alt, "clip"); 316 + assert.deepEqual(first.media, [{ path: firstPath, source: file }]); 317 + }); 318 + 319 + it("mints mp4 video keys for unknown mime types", () => { 320 + const { draft } = buildDraftFromComposerSnapshot( 321 + makeDraftSnapshot({ video: { file: { type: "video/x-unknown" } } }), 322 + ); 323 + const path = draft.posts[0].embedVideos[0].localRef.path; 324 + assert(path.startsWith("video:video/mp4:")); 325 + assert(path.endsWith(".mp4")); 326 + }); 327 + 328 + it("carries video captions through verbatim", () => { 329 + const captions = [ 330 + { 331 + $type: "app.bsky.draft.defs#draftEmbedCaption", 332 + lang: "en", 333 + content: "hello", 334 + }, 335 + ]; 336 + const { draft } = buildDraftFromComposerSnapshot( 337 + makeDraftSnapshot({ video: { file: { type: "video/mp4" }, captions } }), 338 + ); 339 + assert.deepEqual(draft.posts[0].embedVideos[0].captions, captions); 340 + }); 341 + 342 + it("serializes external links and quotes without media entries", () => { 343 + const { draft, media } = buildDraftFromComposerSnapshot( 344 + makeDraftSnapshot({ 345 + external: { url: "https://example.com/article", title: "t" }, 346 + quotedRecord: { 347 + uri: "at://did:plc:a/app.bsky.feed.post/1", 348 + cid: "c1", 349 + }, 350 + }), 351 + ); 352 + assert.deepEqual(draft.posts[0].embedExternals, [ 353 + { 354 + $type: "app.bsky.draft.defs#draftEmbedExternal", 355 + uri: "https://example.com/article", 356 + }, 357 + ]); 358 + assert.deepEqual(draft.posts[0].embedRecords, [ 359 + { 360 + $type: "app.bsky.draft.defs#draftEmbedRecord", 361 + record: { uri: "at://did:plc:a/app.bsky.feed.post/1", cid: "c1" }, 362 + }, 363 + ]); 364 + assert.deepEqual(media, []); 365 + }); 366 + 367 + it("carries labels, threadgate, and postgate passthrough fields", () => { 368 + const labels = { 369 + $type: "com.atproto.label.defs#selfLabels", 370 + values: [{ val: "porn" }], 371 + }; 372 + const { draft } = buildDraftFromComposerSnapshot( 373 + makeDraftSnapshot({ 374 + labels, 375 + threadgateAllow: [{ $type: "app.bsky.feed.threadgate#followingRule" }], 376 + postgateEmbeddingRules: [ 377 + { $type: "app.bsky.feed.postgate#disableRule" }, 378 + ], 379 + }), 380 + ); 381 + assert.deepEqual(draft.posts[0].labels, labels); 382 + assert.deepEqual(draft.threadgateAllow.length, 1); 383 + assert.deepEqual(draft.postgateEmbeddingRules.length, 1); 384 + }); 385 + 386 + it("omits empty postgate rules", () => { 387 + const { draft } = buildDraftFromComposerSnapshot( 388 + makeDraftSnapshot({ postgateEmbeddingRules: [] }), 389 + ); 390 + assert.deepEqual(draft.postgateEmbeddingRules, undefined); 391 + }); 392 + 393 + it("carries unrestored media embeds through verbatim", () => { 394 + const unrestoredImages = [ 395 + { 396 + $type: "app.bsky.draft.defs#draftEmbedImage", 397 + alt: "kept", 398 + localRef: { 399 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 400 + path: "image:foreign", 401 + }, 402 + }, 403 + ]; 404 + const unrestoredVideo = { 405 + $type: "app.bsky.draft.defs#draftEmbedVideo", 406 + localRef: { 407 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 408 + path: "video:video/mp4:foreign.mp4", 409 + }, 410 + }; 411 + const { draft, media } = buildDraftFromComposerSnapshot( 412 + makeDraftSnapshot({ unrestoredImages, unrestoredVideo }), 413 + ); 414 + assert.deepEqual(draft.posts[0].embedGallery.items, unrestoredImages); 415 + assert.deepEqual(draft.posts[0].embedVideos, [unrestoredVideo]); 416 + assert.deepEqual(media, []); 417 + }); 418 + 419 + it("composer media wins over unrestored media", () => { 420 + const videoFile = { type: "video/mp4" }; 421 + const { draft, media } = buildDraftFromComposerSnapshot( 422 + makeDraftSnapshot({ 423 + images: [{ file: {}, dataUrl: "data:a" }], 424 + video: { file: videoFile, alt: "", captions: null }, 425 + unrestoredImages: [ 426 + { 427 + $type: "app.bsky.draft.defs#draftEmbedImage", 428 + localRef: { 429 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 430 + path: "image:foreign", 431 + }, 432 + }, 433 + ], 434 + unrestoredVideo: { 435 + $type: "app.bsky.draft.defs#draftEmbedVideo", 436 + localRef: { 437 + $type: "app.bsky.draft.defs#draftEmbedLocalRef", 438 + path: "video:video/mp4:foreign.mp4", 439 + }, 440 + }, 441 + }), 442 + ); 443 + const items = draft.posts[0].embedGallery.items; 444 + assert.deepEqual(items.length, 1); 445 + assert(items[0].localRef.path !== "image:foreign"); 446 + assert.deepEqual(draft.posts[0].embedVideos.length, 1); 447 + assert( 448 + draft.posts[0].embedVideos[0].localRef.path !== 449 + "video:video/mp4:foreign.mp4", 450 + ); 451 + assert.deepEqual(media.length, 2); 452 + }); 453 + }); 454 + 455 + describe("DraftMediaStore.parseVideoExtension", () => { 456 + it("derives the extension from the key's mime type", () => { 457 + assert.deepEqual( 458 + DraftMediaStore.parseVideoExtension("video:video/quicktime:abc.mov"), 459 + "mov", 460 + ); 461 + }); 462 + 463 + it("defaults to mp4 for legacy keys without a mime segment", () => { 464 + assert.deepEqual( 465 + DraftMediaStore.parseVideoExtension("video:abc123"), 466 + "mp4", 467 + ); 468 + }); 469 + });
+106 -1
tests/unit/specs/postCreator.test.js
··· 14 14 mimeType: "image/jpeg", 15 15 size: 100, 16 16 }), 17 - createPost: async ({ embed, langs }) => { 17 + createPost: async ({ embed, langs, labels }) => { 18 18 api.lastEmbed = embed; 19 19 api.lastLangs = langs; 20 + api.lastLabels = labels; 20 21 return { 21 22 uri: "at://did:plc:user/app.bsky.feed.post/abc", 22 23 cid: "cid1", 23 24 }; 25 + }, 26 + threadgateCalls: [], 27 + createThreadgate: async (postUri, allow) => { 28 + api.threadgateCalls.push({ postUri, allow }); 29 + }, 30 + postgateCalls: [], 31 + createPostgate: async (postUri, embeddingRules) => { 32 + api.postgateCalls.push({ postUri, embeddingRules }); 24 33 }, 25 34 getPostCalls: 0, 26 35 getPost: async function () { ··· 495 504 } 496 505 }); 497 506 }); 507 + 508 + describe("draft passthrough fields", () => { 509 + const labels = { 510 + $type: "com.atproto.label.defs#selfLabels", 511 + values: [{ val: "porn" }], 512 + }; 513 + 514 + it("forwards labels to api.createPost", async () => { 515 + const api = makeApi(); 516 + const pc = new PostCreator( 517 + api, 518 + mockIdentityResolver, 519 + makeImageCompressor(), 520 + ); 521 + await pc.createPost({ postText: "hi", labels }); 522 + assert.deepEqual(api.lastLabels, labels); 523 + }); 524 + 525 + it("omits labels when not provided", async () => { 526 + const api = makeApi(); 527 + const pc = new PostCreator( 528 + api, 529 + mockIdentityResolver, 530 + makeImageCompressor(), 531 + ); 532 + await pc.createPost({ postText: "hi" }); 533 + assert.deepEqual(api.lastLabels, null); 534 + }); 535 + 536 + it("creates threadgate and postgate records against the new post", async () => { 537 + const api = makeApi(); 538 + const pc = new PostCreator( 539 + api, 540 + mockIdentityResolver, 541 + makeImageCompressor(), 542 + ); 543 + const threadgateAllow = [ 544 + { $type: "app.bsky.feed.threadgate#followingRule" }, 545 + ]; 546 + const postgateEmbeddingRules = [ 547 + { $type: "app.bsky.feed.postgate#disableRule" }, 548 + ]; 549 + await pc.createPost({ 550 + postText: "hi", 551 + threadgateAllow, 552 + postgateEmbeddingRules, 553 + }); 554 + assert.deepEqual(api.threadgateCalls, [ 555 + { 556 + postUri: "at://did:plc:user/app.bsky.feed.post/abc", 557 + allow: threadgateAllow, 558 + }, 559 + ]); 560 + assert.deepEqual(api.postgateCalls, [ 561 + { 562 + postUri: "at://did:plc:user/app.bsky.feed.post/abc", 563 + embeddingRules: postgateEmbeddingRules, 564 + }, 565 + ]); 566 + }); 567 + 568 + it("creates no gate records when the fields are absent or empty", async () => { 569 + const api = makeApi(); 570 + const pc = new PostCreator( 571 + api, 572 + mockIdentityResolver, 573 + makeImageCompressor(), 574 + ); 575 + await pc.createPost({ postText: "hi", postgateEmbeddingRules: [] }); 576 + assert.deepEqual(api.threadgateCalls, []); 577 + assert.deepEqual(api.postgateCalls, []); 578 + }); 579 + 580 + it("still resolves the post when gate creation fails", async () => { 581 + const originalError = console.error; 582 + console.error = () => {}; 583 + try { 584 + const api = makeApi(); 585 + api.createThreadgate = async () => { 586 + throw new Error("gate failed"); 587 + }; 588 + const pc = new PostCreator( 589 + api, 590 + mockIdentityResolver, 591 + makeImageCompressor(), 592 + ); 593 + const res = await pc.createPost({ 594 + postText: "hi", 595 + threadgateAllow: [{ $type: "app.bsky.feed.threadgate#followingRule" }], 596 + }); 597 + assert.deepEqual(res.uri, "at://did:plc:user/app.bsky.feed.post/abc"); 598 + } finally { 599 + console.error = originalError; 600 + } 601 + }); 602 + });