import { html, render, keyed } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; import { richTextTemplate } from "/js/templates/richText.template.js"; import { classnames, graphemeCount, readFileAsDataUrl, sanitizeUri, } from "/js/utils.js"; import { externalLinkTemplate } from "/js/templates/externalLink.template.js"; import { confirmModal } from "/js/modals/confirm.modal.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; import { enableDragToDismiss } from "/js/dragHelpers.js"; import { DragAndDropObserver } from "/js/dragAndDropObserver.js"; import { showToast } from "/js/toasts.js"; import { validateVideoFile, readVideoMetadata, VideoUploader, VideoValidationError, } from "/js/videoUtils.js"; import { recordEmbedTemplate, gifExternalTemplate, } from "/js/templates/postEmbed.template.js"; import { chatJoinLinkEmbedTemplate } from "/js/templates/chatJoinLinkEmbed.template.js"; import { parseRecordLink, resolveRecordFromLink, buildGifExternal, restoreGifFromDraftUri, getLinkCardMeta, } from "/js/embedHelpers.js"; import "/js/components/app-icon.js"; import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; import { choiceModal } from "/js/modals/choice.modal.js"; import { parseUri, createEmbedFromPost, getLocalRefsFromDraft, getImagesFromDraftPost, getInviteCodeFromUrl, isInviteLinkUrl, isAvailableJoinLinkPreview, } from "/js/dataHelpers.js"; import { DraftMediaStore, buildDraftFromComposerSnapshot, getDraftDeviceId, } from "/js/drafts.js"; import { ApiError } from "/js/api.js"; import "/js/components/post-interaction-settings-dialog.js"; import "/js/components/rich-text-input.js"; import "/js/components/image-alt-text-dialog.js"; import "/js/components/emoji-picker-dialog.js"; import "/js/components/gif-picker-dialog.js"; import "/js/components/drafts-dialog.js"; const MAX_DRAFT_GRAPHEME_LENGTH = 1000; // Threshold for detecting pasted text, since InputEvent.inputType is unreliable const BULK_TEXT_INSERT_THRESHOLD = 8; function isDraftTextSavable(text) { return graphemeCount(text) <= MAX_DRAFT_GRAPHEME_LENGTH; } function isDraftLimitError(error) { return error instanceof ApiError && error.data?.error === "DraftLimitReached"; } function hasPostStateContent(postState) { return ( postState.text.length > 0 || postState.images.length > 0 || postState.video !== null || postState.gif !== null || postState.external !== null || postState.quotedRecord !== null ); } function buildExternalForChatInvitePreview(url, preview) { return { url, title: preview.name ?? url, description: `${preview.memberCount}/${preview.memberLimit}`, image: "", }; } function getSendErrorMessage(error) { if (error instanceof ApiError) { if (error.status >= 500) { return "The server appears to be experiencing issues. Please try again in a few moments."; } const message = error.data?.message; if (message?.includes("not locate record")) { return "The post you are replying to has been deleted."; } if (message) { return message; } } if (error instanceof TypeError) { return "Unable to connect. Please check your internet connection and try again."; } return "Failed to send post. Please try again."; } function errorMessageBannerTemplate({ message, onDismiss }) { return html``; } function isVideoUploadPending(video) { return ( !!video && (video.status === "uploading" || video.status === "processing") ); } function replyToTemplate({ post }) { return html`
${avatarTemplate({ author: post.author, clickAction: "none" })}
${postHeaderTextTemplate({ author: post.author, timestamp: post.indexedAt, includeHandle: false, includeTime: false, })}
${post.record.text ? html`
${richTextTemplate({ text: post.record.text, facets: post.record.facets, })}
` : ""}

`; } function joinLinkPreviewTemplate({ preview, onClose }) { return html`
${chatJoinLinkEmbedTemplate({ embed: { $type: "chat.bsky.embed.joinLink#view", joinLinkPreview: preview, }, currentConvoId: null, })}
`; } function joinLinkEmbedTemplate({ preview, status, url, onClose, onRetry }) { if (preview) { return joinLinkPreviewTemplate({ preview, onClose }); } if (status === "loading") { return joinLinkLoadingTemplate({ onClose }); } if (status === "error") { return joinLinkErrorTemplate({ url, onClose, onRetry }); } return ""; } function joinLinkLoadingTemplate({ onClose }) { return html`
`; } function joinLinkErrorTemplate({ url, onClose, onRetry }) { return html`
${url}
Couldn't load invite preview.
`; } function externalLinkEmbedPreviewTemplate({ data, onClose }) { return html`
${externalLinkTemplate({ url: data.url, title: data.title, description: data.description, image: data.image, showCloseButton: true, disableNavigation: true, onClose, })}
`; } function altIndicatorContentTemplate(hasAlt) { return html`${hasAlt ? html`` : html``} ALT`; } function videoPreviewTemplate({ video, onRemove, onEditAltText }) { const isReady = video.status === "done"; const isError = video.status === "error"; let progressLabel = ""; if (video.status === "uploading") { progressLabel = "Uploading..."; } else if (video.status === "processing") { progressLabel = video.progress > 0 ? `Processing... ${video.progress}%` : "Processing..."; } else if (isError) { progressLabel = video.error || "Upload failed"; } return html`
${!isReady ? html`
${!isError ? html`
` : ""} ${progressLabel}
` : ""}
`; } function gifPreviewTemplate({ gif, onRemove, onEditAltText }) { const external = buildGifExternal(gif); const hasAlt = gif.alt.trim().length > 0; return html`
${external ? gifExternalTemplate({ uri: external.url, alt: "" }) : ""}
`; } function imagePreviewTemplate({ images, onRemove, onEditAltText }) { return html`
${images.map( (img, index) => html`
${img.alt || onEditAltText(index)} style="cursor: pointer;" />
${altIndicatorContentTemplate(!!img.alt)}
`, )}
`; } function composerPostTemplate({ postState, joinLinkPreview, isActive, canRemove, promptText, currentUser, pluginService, onActivate, onRemovePost, onInput, onPaste, onRemoveImage, onEditAltText, onRemoveVideo, onEditVideoAltText, onRemoveGif, onEditGifAltText, onCloseExternal, onRetryJoinLink, onCloseQuote, }) { return html`
onActivate()} @click=${() => onActivate()} > ${canRemove ? html`` : ""}
${avatarTemplate({ author: currentUser, clickAction: "none", })}
onInput(e)} @paste=${(e) => onPaste(e)} placeholder="${promptText}" >
${joinLinkPreview || postState.joinLinkStatus ? joinLinkEmbedTemplate({ preview: joinLinkPreview, status: postState.joinLinkStatus, url: postState.externalLinkUrl, onClose: onCloseExternal, onRetry: onRetryJoinLink, }) : postState.external ? externalLinkEmbedPreviewTemplate({ data: postState.external, onClose: onCloseExternal, }) : ""} ${postState.images.length > 0 ? imagePreviewTemplate({ images: postState.images, onRemove: (index) => onRemoveImage(index), onEditAltText: (index) => onEditAltText(index), }) : ""} ${postState.video ? videoPreviewTemplate({ video: postState.video, onRemove: () => onRemoveVideo(), onEditAltText: () => onEditVideoAltText(), }) : ""} ${postState.gif ? gifPreviewTemplate({ gif: postState.gif, onRemove: () => onRemoveGif(), onEditAltText: () => onEditGifAltText(), }) : ""} ${postState.quotedRecord ? html`
${recordEmbedTemplate({ record: postState.quotedRecord, isAuthenticated: true, pluginService, })}
` : ""}
`; } class PostComposer extends Component { connectedCallback() { if (this.initialized) { return; } this.setAttribute("data-dialog-wrapper", ""); this.scrollLock = null; this.innerHTML = ""; this._draftId = null; this._isDirty = false; this._originalLocalRefs = null; this._sendAbortController = null; this._sendAttemptId = null; this._interactionSettingsTouched = false; this._defaultInteractionSettings = null; // postId -> AbortController for that post's in-flight video upload this._videoAbortControllers = new Map(); this.state = new ReactiveStore("postComposer"); this.state.$posts = new Signal.State([ this._createPostState({ quotedRecord: this._pendingQuotedRecord ?? null, }), ]); this.state.$activePostIndex = new Signal.State(0); this.state.$isSending = new Signal.State(false); this.state.$isCancellingSend = new Signal.State(false); this.state.$errorMessage = new Signal.State(null); this.state.$isSavingDraft = new Signal.State(false); this._pendingQuotedRecord = null; this.state.$draftsEnabled = new Signal.State( this._pendingDraftsEnabled ?? false, ); this._pendingDraftsEnabled = null; this.state.$isDraggingFiles = new Signal.State(false); this.state.$threadgateAllow = new Signal.State(null); this.state.$postgateEmbeddingRules = new Signal.State(null); this._dragAndDropObserver = null; this._disposers = [ effect(() => { this.render(); }), ]; this.initialized = true; } disconnectedCallback() { if (!this.initialized) return; this._sendAbortController?.abort(); this._sendAbortController = null; this._abortAllVideoUploads(); this._disposers?.forEach((dispose) => dispose()); this._disposers = null; this._disconnectDragAndDropObserver(); } // State helpers _createPostState(overrides = {}) { return { id: crypto.randomUUID(), text: "", unresolvedFacets: [], images: [], video: null, gif: null, external: null, externalLinkUrl: null, joinLinkStatus: null, // null | 'error' | 'loading' quotedRecord: null, quotedRecordUrl: null, rejectedLinkEmbeds: new Set(), videoToken: null, draftVideoCaptions: null, labels: null, unrestoredImages: null, unrestoredVideo: null, ...overrides, }; } _getPosts() { return untrack(() => this.state.$posts.get()); } _getPost(postId) { return this._getPosts().find((postState) => postState.id === postId); } _getJoinLinkPreview(url) { if (!url) return null; const code = getInviteCodeFromUrl(url); if (!code) return null; return this.dataLayer?.derived?.$joinLinkPreviewsByCode.get(code) ?? null; } _hasAnyUnavailableInvite(posts) { return posts.some((postState) => { const url = postState.externalLinkUrl; if (!url || !isInviteLinkUrl(url)) return false; const preview = this._getJoinLinkPreview(url); return preview !== null && !isAvailableJoinLinkPreview(preview); }); } _getActivePost() { const posts = this._getPosts(); const activeIndex = untrack(() => this.state.$activePostIndex.get()); return posts[Math.min(activeIndex, posts.length - 1)]; } _updatePost(postId, patch) { const posts = this._getPosts(); const index = posts.findIndex((postState) => postState.id === postId); if (index === -1) return null; const updated = { ...posts[index], ...patch }; this.state.$posts.set( posts.map((postState, postIndex) => postIndex === index ? updated : postState, ), ); return updated; } _getInputForPost(postId) { return this.querySelector(`[data-post-id="${postId}"] rich-text-input`); } // The posts list renders with lit's `keyed`, so any structural change to the // posts array (add/remove/restore) recreates the inputs downstream of the // change with empty DOM. After mutating the array outside of handleInput, // call render() then this, to write state text back into the fresh inputs. _syncInputsFromState() { for (const postState of this._getPosts()) { const input = this._getInputForPost(postState.id); if (input && input.text !== postState.text) { input.setText(postState.text); } } } get quotedRecord() { if (!this.state) return this._pendingQuotedRecord ?? null; return this._getPosts()[0].quotedRecord; } set quotedRecord(value) { if (!this.state) { this._pendingQuotedRecord = value; return; } this._updatePost(this._getPosts()[0].id, { quotedRecord: value }); } get draftsEnabled() { if (!this.state) return this._pendingDraftsEnabled ?? false; return untrack(() => this.state.$draftsEnabled.get()); } set draftsEnabled(value) { if (!this.state) { this._pendingDraftsEnabled = value; return; } this.state.$draftsEnabled.set(value); } render() { const isSending = this.state.$isSending.get(); const isCancellingSend = this.state.$isCancellingSend.get(); const sendError = this.state.$errorMessage.get(); const isSavingDraft = this.state.$isSavingDraft.get(); const draftsEnabled = this.state.$draftsEnabled.get(); const posts = this.state.$posts.get(); const activePostIndex = Math.min( this.state.$activePostIndex.get(), posts.length - 1, ); const activePost = posts[activePostIndex]; const isThread = posts.length > 1; const isDraggingFiles = this.state.$isDraggingFiles.get(); const threadgateAllow = this.state.$threadgateAllow.get(); const postgateEmbeddingRules = this.state.$postgateEmbeddingRules.get(); const isInteractionLimited = threadgateAllow !== null || postgateEmbeddingRules?.length > 0; const currentCharCount = graphemeCount(activePost.text); const charCountPercentage = Math.min( Math.round((currentCharCount / 300) * 100), 100, ); const isAboveCharLimit = currentCharCount > 300; const isAnyPostAboveCharLimit = posts.some( (postState) => hasPostStateContent(postState) && graphemeCount(postState.text) > 300, ); const isAnyVideoBlocking = posts.some( (postState) => isVideoUploadPending(postState.video) || postState.video?.status === "error", ); const hasAnyUnavailableInvite = this._hasAnyUnavailableInvite(posts); const hasVideo = !!activePost.video; const hasGif = !!activePost.gif; const nextPost = posts[activePostIndex + 1] ?? null; const canAddPost = hasPostStateContent(activePost) && (!nextPost || hasPostStateContent(nextPost)); let submitLabel = "Post"; let submitTestState = "post"; if (this.replyTo) { submitLabel = "Reply"; submitTestState = "reply"; } if (isThread) { submitLabel = "Post All"; submitTestState = "post-all"; } render( html` { if (e.target === e.currentTarget) { if (await this.confirmClose()) { this.close(); } } }} @cancel=${async (event) => { event.preventDefault(); if (await this.confirmClose()) { this.close(); } }} @close=${() => { this.scrollLock?.release(); this.scrollLock = null; this._disconnectDragAndDropObserver(); this.dispatchEvent(new CustomEvent("post-composer-closed")); }} @keydown=${(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); if (this.hasContent()) { this.send(); } } }} > ${isDraggingFiles ? html`
Drop to add files
` : ""}
${!this.replyTo && draftsEnabled ? html`` : ""}
${sendError ? errorMessageBannerTemplate({ message: sendError, onDismiss: () => this.state.$errorMessage.set(null), }) : ""}
${this.replyTo ? replyToTemplate({ post: this.replyTo }) : ""} ${posts.map((postState, index) => keyed( postState.id, composerPostTemplate({ postState, joinLinkPreview: this._getJoinLinkPreview( postState.externalLinkUrl, ), isActive: index === activePostIndex, canRemove: isThread && index === activePostIndex, promptText: this._getPromptText(index), currentUser: this.currentUser, pluginService: this.pluginService, onActivate: () => this.handleActivatePost(index), onRemovePost: () => this.handleRemovePost(postState.id), onInput: (e) => this.handleInput(postState.id, e), onPaste: (e) => this.handlePaste(postState.id, e), onRemoveImage: (imageIndex) => this.handleRemoveImage(postState.id, imageIndex), onEditAltText: (imageIndex) => this.handleEditAltText(postState.id, imageIndex), onRemoveVideo: () => this.handleRemoveVideo(postState.id), onEditVideoAltText: () => this.handleEditVideoAltText(postState.id), onRemoveGif: () => this.handleRemoveGif(postState.id), onEditGifAltText: () => this.handleEditGifAltText(postState.id), onCloseExternal: () => this.handleExternalLinkEmbedPreviewClose(postState.id), onRetryJoinLink: () => this.loadJoinLinkEmbedPreview(postState.id), onCloseQuote: () => this.handleQuotedEmbedPreviewClose(postState.id), }), ), )}
${!this.replyTo ? html`
` : ""}
this.handleMediaSelect(e)} @cancel=${(e) => { e.stopPropagation(); }} /> this.handleGifSelected(e.detail.gif)} >
{ this.handleEmojiSelect(e.detail.emoji); e.currentTarget.close(); }} >
${canAddPost ? html`` : ""}
${300 - currentCharCount}
`, this, ); } _getPromptText(index) { if (index > 0) { return "Write another post"; } return this.replyTo ? "Write your reply" : "What's up?"; } isSendBlocked() { const isSending = untrack(() => this.state.$isSending.get()); const isCancellingSend = untrack(() => this.state.$isCancellingSend.get()); const posts = this._getPosts(); const isAnyPostAboveCharLimit = posts.some( (postState) => hasPostStateContent(postState) && graphemeCount(postState.text) > 300, ); const isAnyVideoBlocking = posts.some( (postState) => isVideoUploadPending(postState.video) || postState.video?.status === "error", ); const hasUnavailableInvite = this._hasAnyUnavailableInvite(posts); return ( isSending || isCancellingSend || isAnyPostAboveCharLimit || isAnyVideoBlocking || hasUnavailableInvite ); } handleActivatePost(index) { if (untrack(() => this.state.$activePostIndex.get()) !== index) { this.state.$activePostIndex.set(index); } } handleAddPost() { const posts = this._getPosts(); const activeIndex = untrack(() => this.state.$activePostIndex.get()); const activePost = posts[activeIndex]; const nextPost = posts[activeIndex + 1] ?? null; if ( !hasPostStateContent(activePost) || (nextPost && !hasPostStateContent(nextPost)) ) { return; } const newPost = this._createPostState(); const updated = [...posts]; updated.splice(activeIndex + 1, 0, newPost); this.state.$posts.set(updated); this.state.$activePostIndex.set(activeIndex + 1); this._isDirty = true; // Render + focus synchronously so iOS keeps the keyboard open (focus only // works inside the user-gesture call stack) this.render(); this._syncInputsFromState(); this._getInputForPost(newPost.id)?.focus({ preventScroll: false }); } async handleRemovePost(postId) { const posts = this._getPosts(); if (posts.length < 2) return; const index = posts.findIndex((postState) => postState.id === postId); if (index === -1) return; const postState = posts[index]; if (hasPostStateContent(postState)) { const confirmed = await confirmModal( "Are you sure you'd like to delete this post from the thread?", { title: "Delete post?", confirmButtonStyle: "danger-subtle", confirmButtonText: "Delete", }, ); if (!confirmed) return; } this._abortVideoUpload(postId); if (postState.video?.previewUrl) { URL.revokeObjectURL(postState.video.previewUrl); } const latestPosts = this._getPosts().filter( (latestPost) => latestPost.id !== postId, ); if (latestPosts.length === 0) return; const newActiveIndex = Math.max(0, index - 1); this.state.$posts.set(latestPosts); this.state.$activePostIndex.set(newActiveIndex); this._isDirty = true; this.render(); this._syncInputsFromState(); this._getInputForPost(latestPosts[newActiveIndex].id)?.focus({ preventScroll: true, }); } handleEmojiButtonClick(event) { const dialog = this.querySelector("emoji-picker-dialog"); if (!dialog) return; if (dialog.isOpen) { dialog.close(); return; } const activePost = this._getActivePost(); const richTextInput = this._getInputForPost(activePost.id); this._savedEmojiCursor = richTextInput?.getCursor() ?? null; dialog.open(event.currentTarget); } handleEmojiSelect(emoji) { const activePost = this._getActivePost(); const richTextInput = this._getInputForPost(activePost.id); if (!richTextInput) return; richTextInput.insertText(emoji, this._savedEmojiCursor); richTextInput.focus(); this._savedEmojiCursor = null; } handleGifButtonClick() { this.querySelector("gif-picker-dialog")?.open(); } handleGifSelected(gif) { const activePost = this._getActivePost(); const patch = { gif: { gif, alt: "" } }; // Drop link embed if (activePost.externalLinkUrl) { activePost.rejectedLinkEmbeds.add(activePost.externalLinkUrl); patch.externalLinkUrl = null; patch.external = null; patch.joinLinkStatus = null; } this._updatePost(activePost.id, patch); this._isDirty = true; } handleRemoveGif(postId) { this._updatePost(postId, { gif: null }); this._isDirty = true; } _openAltTextDialog({ value, imageUrl = null, placeholder = null, onSave }) { const dialog = document.createElement("image-alt-text-dialog"); dialog.value = value || ""; if (imageUrl) { dialog.imageUrl = imageUrl; } if (placeholder) { dialog.placeholder = placeholder; } dialog.addEventListener("alt-text-saved", (e) => { onSave(e.detail.altText); dialog.remove(); }); dialog.addEventListener("alt-text-dialog-closed", () => { dialog.remove(); }); document.body.appendChild(dialog); dialog.open(); } handleEditGifAltText(postId) { const postState = this._getPost(postId); if (!postState?.gif) return; const { gif, alt } = postState.gif; this._openAltTextDialog({ value: alt, placeholder: gif.content_description || gif.title || "", onSave: (altText) => { const latestPost = this._getPost(postId); if (!latestPost?.gif) return; this._updatePost(postId, { gif: { ...latestPost.gif, alt: altText }, }); this._isDirty = true; }, }); } handleExternalLinkEmbedPreviewClose(postId) { const postState = this._getPost(postId); if (!postState) return; postState.rejectedLinkEmbeds.add(postState.externalLinkUrl); this._updatePost(postId, { externalLinkUrl: null, external: null, joinLinkStatus: null, }); this._isDirty = true; } handleQuotedEmbedPreviewClose(postId) { this._updatePost(postId, { quotedRecordUrl: null, quotedRecord: null }); this._isDirty = true; } async loadQuotedRecordFromLink(postId) { const url = this._getPost(postId)?.quotedRecordUrl; if (!url) return; try { const record = await resolveRecordFromLink(url, { identityResolver: this.identityResolver, dataLayer: this.dataLayer, }); // the embed may have been closed or replaced while the record was loading if (this._getPost(postId)?.quotedRecordUrl !== url) return; this._updatePost(postId, { quotedRecord: record }); } catch (error) { console.error("Error loading record embed from link: ", error); const postState = this._getPost(postId); if (!postState) return; postState.rejectedLinkEmbeds.add(url); if (postState.quotedRecordUrl === url) { this._updatePost(postId, { quotedRecordUrl: null }); } } } handleMediaButtonClick() { const input = this.querySelector(".media-picker-input"); if (input) { input.click(); } } async handleMediaSelect(e) { const files = Array.from(e.target.files); e.target.value = ""; await this.addMediaFiles(this._getActivePost().id, files); } _initDragAndDropObserver() { if (this._dragAndDropObserver) return; this._dragAndDropObserver = new DragAndDropObserver(window, { onDragStart: () => this.state.$isDraggingFiles.set(true), onDragEnd: () => this.state.$isDraggingFiles.set(false), onDrop: (files) => this.addMediaFiles(this._getActivePost().id, files), }); } _disconnectDragAndDropObserver() { if (!this._dragAndDropObserver) return; this._dragAndDropObserver.disconnect(); this._dragAndDropObserver = null; } async addMediaFiles(postId, files) { if (files.length === 0) return; const imageFiles = files.filter((file) => file.type.startsWith("image/")); const videoFiles = files.filter((file) => file.type.startsWith("video/")); if (imageFiles.length + videoFiles.length < files.length) { showToast("Unsupported file type", { style: "warning" }); return; } if (imageFiles.length > 0 && videoFiles.length > 0) { showToast("Selecting multiple media types is not supported", { style: "warning", }); return; } if (videoFiles.length > 0) { const postState = this._getPost(postId); if (!postState) return; if (postState.images.length > 0 || postState.gif) { showToast("Selecting multiple media types is not supported", { style: "warning", }); return; } if (videoFiles.length > 1) { showToast("You can only select one video at a time", { style: "warning", }); } this._isDirty = true; await this.processVideoFile(postId, videoFiles[0]); return; } if (imageFiles.length > 0) { const postState = this._getPost(postId); if (!postState) return; if (postState.video || postState.gif) { showToast("Selecting multiple media types is not supported", { style: "warning", }); return; } await this.addImageFiles(postId, imageFiles); } } async addImageFiles(postId, files) { const maxImages = 4; const currentImages = this._getPost(postId)?.images; if (!currentImages) return; const remainingSlots = maxImages - currentImages.length; if (files.length > remainingSlots) { showToast("You can select up to 4 images in total", { style: "warning" }); } const newImages = []; for (let i = 0; i < Math.min(files.length, remainingSlots); i++) { const file = files[i]; const dataUrl = await readFileAsDataUrl(file); newImages.push({ file, dataUrl, }); } const postState = this._getPost(postId); if (!postState) return; const selectedImages = [...postState.images, ...newImages]; const patch = { images: selectedImages }; // Reject external link embed if images are added if (selectedImages.length > 0 && postState.externalLinkUrl) { postState.rejectedLinkEmbeds.add(postState.externalLinkUrl); patch.externalLinkUrl = null; patch.external = null; patch.joinLinkStatus = null; } this._updatePost(postId, patch); this._isDirty = true; } handleRemoveImage(postId, index) { const postState = this._getPost(postId); if (!postState) return; this._updatePost(postId, { images: postState.images.filter( (image, imageIndex) => imageIndex !== index, ), }); this._isDirty = true; } handleEditAltText(postId, index) { const postState = this._getPost(postId); if (!postState) return; const image = postState.images[index]; this._openAltTextDialog({ value: image.alt, imageUrl: image.dataUrl, onSave: (altText) => { const latestPost = this._getPost(postId); if (!latestPost) return; this._updatePost(postId, { images: latestPost.images.map((selectedImage, imageIndex) => imageIndex === index ? { ...selectedImage, alt: altText } : selectedImage, ), }); this._isDirty = true; }, }); } async processVideoFile(postId, file) { try { validateVideoFile(file); } catch (error) { const msg = error instanceof VideoValidationError ? error.message : "Could not load video"; showToast(msg, { style: "warning" }); return; } let metadata; try { metadata = await readVideoMetadata(file); } catch (error) { const msg = error instanceof VideoValidationError ? error.message : "Could not load video"; showToast(msg, { style: "warning" }); return; } const token = Symbol(); const updated = this._updatePost(postId, { videoToken: token, draftVideoCaptions: null, video: { file, previewUrl: URL.createObjectURL(file), alt: "", aspectRatio: metadata.aspectRatio, status: "uploading", progress: 0, jobId: null, blob: null, error: null, }, }); if (!updated) return; this.uploadSelectedVideo(postId, token); } // Applies a partial update to the post's video, unless it has been removed // or replaced since `token` was issued. patchSelectedVideo(postId, token, patch) { const postState = this._getPost(postId); if (!postState || postState.videoToken !== token) return null; const video = { ...postState.video, ...patch }; this._updatePost(postId, { video }); return video; } _abortVideoUpload(postId) { const controller = this._videoAbortControllers.get(postId); if (controller) { controller.abort(); this._videoAbortControllers.delete(postId); } } _abortAllVideoUploads() { for (const controller of this._videoAbortControllers.values()) { controller.abort(); } this._videoAbortControllers.clear(); } async uploadSelectedVideo(postId, token) { const video = this._getPost(postId)?.video; if (!video) return; this._abortVideoUpload(postId); const controller = new AbortController(); this._videoAbortControllers.set(postId, controller); try { const uploader = new VideoUploader(this.dataLayer.api); const blob = await uploader.upload(video.file, { onJobStart: (job) => { this.patchSelectedVideo(postId, token, { jobId: job.jobId, status: "processing", }); }, onProgress: (_state, progress) => { this.patchSelectedVideo(postId, token, { progress }); }, signal: controller.signal, }); this.patchSelectedVideo(postId, token, { blob, status: "done" }); } catch (error) { if (error.name === "AbortError") return; console.error("Video upload error: ", error); const failedVideo = this.patchSelectedVideo(postId, token, { status: "error", error: error.message || "Upload failed", }); if (failedVideo) { showToast(failedVideo.error, { style: "error" }); } } finally { if (this._videoAbortControllers.get(postId) === controller) { this._videoAbortControllers.delete(postId); } } } handleRemoveVideo(postId) { const postState = this._getPost(postId); if (!postState) return; this._abortVideoUpload(postId); if (postState.video?.previewUrl) { URL.revokeObjectURL(postState.video.previewUrl); } this._updatePost(postId, { videoToken: null, video: null, draftVideoCaptions: null, }); this._isDirty = true; } handleEditVideoAltText(postId) { const postState = this._getPost(postId); if (!postState?.video) return; const token = postState.videoToken; this._openAltTextDialog({ value: postState.video.alt, onSave: (altText) => { this.patchSelectedVideo(postId, token, { alt: altText }); this._isDirty = true; }, }); } handleInput(postId, e) { const postState = this._getPost(postId); if (!postState) return; this._isDirty = true; const previousText = postState.text; const previousFacets = postState.unresolvedFacets; const unresolvedFacets = e.detail.facets; this._updatePost(postId, { text: e.detail.text, unresolvedFacets }); // If the facets *haven't* changed, and the latest change was a space or // newline, check for possible link embeds. Also check for embeds // immediately on bulk inserts (paste). const facetsChanged = JSON.stringify(previousFacets) !== JSON.stringify(unresolvedFacets); const isCommit = e.detail.text.endsWith(" ") || e.detail.text.endsWith("\n"); const insertedLength = graphemeCount(e.detail.text) - graphemeCount(previousText); const isBulkInsert = e.detail.inputType === "insertFromPaste" || e.detail.inputType === "insertFromDrop" || insertedLength > BULK_TEXT_INSERT_THRESHOLD; if ((!facetsChanged && isCommit) || isBulkInsert) { for (const facet of unresolvedFacets) { // Only handle one feature for now const feature = facet.features[0]; if (feature.$type === "app.bsky.richtext.facet#link") { const url = feature.uri; const latestPost = this._getPost(postId); if (!latestPost) return; if (latestPost.externalLinkUrl || latestPost.gif) { // automatically reject links if there's an existing link embed latestPost.rejectedLinkEmbeds.add(url); } else if (!latestPost.rejectedLinkEmbeds.has(url)) { if (parseRecordLink(url)) { if (!latestPost.quotedRecord && !latestPost.quotedRecordUrl) { this._updatePost(postId, { quotedRecordUrl: url }); this.loadQuotedRecordFromLink(postId); } } else { this._updatePost(postId, { externalLinkUrl: url }); this.loadExternalLinkEmbedPreview(postId); } } } } } // If the facets have changed, check to see if links have been removed. // This will allow links to be re-added after being rejected. if (facetsChanged) { const linkFacetUrls = unresolvedFacets .filter( (facet) => facet.features[0].$type === "app.bsky.richtext.facet#link", ) .map((facet) => facet.features[0].uri); for (const rejectedLinkEmbed of postState.rejectedLinkEmbeds) { if (!linkFacetUrls.includes(rejectedLinkEmbed)) { postState.rejectedLinkEmbeds.delete(rejectedLinkEmbed); } } } } handlePaste(postId, e) { const pastedFiles = Array.from(e.clipboardData?.files ?? []); if (pastedFiles.length > 0) { e.preventDefault(); this.addMediaFiles(postId, pastedFiles); return; } } async loadExternalLinkEmbedPreview(postId) { const url = this._getPost(postId)?.externalLinkUrl; if (!url) return; if (isInviteLinkUrl(url)) { await this.loadJoinLinkEmbedPreview(postId); return; } // preliminary data this._updatePost(postId, { external: { url, title: url, description: "", image: "", }, }); const data = await getLinkCardMeta(url); if (!data) return; // preview may have been closed or replaced while metadata was loading const current = this._getPost(postId)?.external; if (!current || current.url !== url) return; const updated = { ...current }; if (data.title) updated.title = data.title; if (data.description) updated.description = data.description; this._updatePost(postId, { external: updated }); if (data.image) { // only show image if it can be loaded let imageRes = null; try { imageRes = await fetch(sanitizeUri(data.image)); } catch (error) {} // preview may have been closed or replaced while the image was loading const latest = this._getPost(postId)?.external; if (imageRes && imageRes.ok && latest && latest.url === url) { this._updatePost(postId, { external: { ...latest, image: data.image }, }); } } } async loadJoinLinkEmbedPreview(postId) { const url = this._getPost(postId)?.externalLinkUrl; if (!url) return; const code = getInviteCodeFromUrl(url); if (!code) return; this._updatePost(postId, { external: null, joinLinkStatus: "loading", }); let preview = null; let didError = false; try { preview = await this.dataLayer.declarative.ensureJoinLinkPreview(code); } catch (error) { didError = true; console.warn("Error loading join link preview: ", error); } // The embed may have been closed or replaced while loading if (this._getPost(postId)?.externalLinkUrl !== url) return; if (didError) { this._updatePost(postId, { joinLinkStatus: "error" }); return; } if (!isAvailableJoinLinkPreview(preview)) { this._updatePost(postId, { joinLinkStatus: null }); return; } this._updatePost(postId, { external: buildExternalForChatInvitePreview(url, preview), joinLinkStatus: null, }); } open() { this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector(".post-composer"); if (dialog?.open) return; dialog.showModal(); this.querySelector("rich-text-input")?.focus({ preventScroll: true }); // Setup mobile swipe-to-dismiss enableDragToDismiss(dialog, { confirmDismiss: () => this.confirmClose(), onDismiss: () => this.close(), scrollContainer: this.querySelector(".post-composer-scroll-area"), dragHandle: this.querySelector(".post-composer-top-bar"), ignoreTouchTarget: (el) => !!el.closest("button") || el.tagName === "TEXTAREA" || el.isContentEditable || !!el.closest("[contenteditable]") || !!el.closest("gif-picker-dialog"), disableWhenKeyboardOpen: true, }); resetScrollOnBlur(dialog, this.querySelector(".post-composer-scroll-area")); this._initDragAndDropObserver(); } applyComposerInit({ text, cursor }) { if (this._isDirty) return; const activePost = this._getActivePost(); const richTextInput = this._getInputForPost(activePost.id); if (!richTextInput) return; if (text != null) { richTextInput.setText(text); } if (cursor != null) { richTextInput.setCursor(cursor); } } close() { return closeWithAnimation(this.querySelector(".post-composer")); } // Drop trailing empty posts and confirm mid-thread empty posts async _buildPostsForSend() { const posts = this._getPosts(); let lastNonEmptyIndex = -1; for (let i = posts.length - 1; i >= 0; i--) { if (hasPostStateContent(posts[i])) { lastNonEmptyIndex = i; break; } } if (lastNonEmptyIndex === -1) { return [posts[0]]; } const trimmed = posts.slice(0, lastNonEmptyIndex + 1); const nonEmpty = trimmed.filter((postState) => hasPostStateContent(postState), ); if (nonEmpty.length < trimmed.length) { const confirmed = await confirmModal( "Some posts in your thread are empty. Would you like to skip them and post the rest?", { title: "Skip empty posts?", confirmButtonText: "Skip and post", }, ); if (!confirmed) return null; } return nonEmpty; } // Ensure all invite links embeds are loaded and ready to send async _ensureInviteExternals(posts) { await Promise.all( posts.map(async (postState) => { const url = postState.externalLinkUrl; if (!url || !isInviteLinkUrl(url)) return; const cached = this._getJoinLinkPreview(url); if (isAvailableJoinLinkPreview(cached)) { this._updatePost(postState.id, { external: buildExternalForChatInvitePreview(url, cached), joinLinkStatus: null, }); return; } const code = getInviteCodeFromUrl(url); if (!code) throw new Error("Invalid invite link"); const preview = await this.dataLayer.declarative.ensureJoinLinkPreview(code); if (!isAvailableJoinLinkPreview(preview)) { throw new Error("Invite link is unavailable"); } this._updatePost(postState.id, { external: buildExternalForChatInvitePreview(url, preview), joinLinkStatus: null, }); }), ); const currentPosts = this._getPosts(); // Replace posts with the latest versions return posts.map((stale) => currentPosts.find((p) => p.id === stale.id), ); } async send() { if (this.isSendBlocked()) return; let postsToSend = await this._buildPostsForSend(); if (!postsToSend) return; this.state.$errorMessage.set(null); this.state.$isSending.set(true); try { postsToSend = await this._ensureInviteExternals(postsToSend); } catch (error) { console.warn("Error resolving invite link preview for send: ", error); this.state.$isSending.set(false); this.state.$errorMessage.set( "Couldn't load an invite link in your post. Please try again or remove it.", ); return; } const attemptId = Symbol(); this._sendAttemptId = attemptId; this._sendAbortController = new AbortController(); const successCallback = () => this.close(); const errorCallback = (error) => { const isStale = this._sendAttemptId !== attemptId; const isCancelled = untrack(() => this.state.$isCancellingSend.get()); if (isStale || isCancelled) return; this.state.$isSending.set(false); this.state.$errorMessage.set(getSendErrorMessage(error)); }; const settledCallback = () => { if (this._sendAttemptId !== attemptId) return; this._sendAttemptId = null; this._sendAbortController = null; this.state.$isCancellingSend.set(false); }; this.dispatchEvent( new CustomEvent("send-post", { detail: { posts: postsToSend.map((postState) => { const external = postState.gif ? buildGifExternal(postState.gif) : postState.external; return { postText: postState.text, images: postState.images, video: postState.video, external, quotedRecord: postState.quotedRecord, labels: postState.labels ?? null, }; }), replyTo: this.replyTo, replyRoot: this.replyRoot, threadgateAllow: untrack(() => this.state.$threadgateAllow.get()), postgateEmbeddingRules: untrack(() => this.state.$postgateEmbeddingRules.get(), ), // Publishing a saved/restored draft consumes it draft: this._draftId ? { draftId: this._draftId, localRefs: [...this._originalLocalRefs], } : null, signal: this._sendAbortController.signal, successCallback, errorCallback, settledCallback, }, }), ); } // Only the work before the repo commit is abortable, so a cancelled send may // still publish (and still closes the composer on success). Disable // sending until the previous send settles either way. _cancelSend() { if (this._sendAbortController) { this._sendAbortController.abort(); } this.state.$isSending.set(false); if (this._sendAttemptId) { this.state.$isCancellingSend.set(true); } } hasContent() { return this._getPosts().some((postState) => hasPostStateContent(postState)); } buildDraftSnapshot() { const posts = this._getPosts(); return { posts: posts.map((postState) => ({ postText: postState.text, images: postState.images, video: postState.video ? { file: postState.video.file, alt: postState.video.alt, captions: postState.draftVideoCaptions, } : null, gif: postState.gif, external: postState.external, quotedRecord: postState.quotedRecord, labels: postState.labels, unrestoredImages: postState.unrestoredImages, unrestoredVideo: postState.unrestoredVideo, })), threadgateAllow: untrack(() => this.state.$threadgateAllow.get()), postgateEmbeddingRules: untrack(() => this.state.$postgateEmbeddingRules.get(), ), }; } async saveDraft() { const posts = this._getPosts(); if (!posts.every((postState) => isDraftTextSavable(postState.text))) { showToast( `You can only save drafts up to ${MAX_DRAFT_GRAPHEME_LENGTH} characters`, { style: "warning" }, ); return false; } this.state.$isSavingDraft.set(true); const snapshot = this.buildDraftSnapshot(); // Clear dirty so any edits made during save set it again this._isDirty = false; try { const { draft, media } = buildDraftFromComposerSnapshot(snapshot); const localRefs = getLocalRefsFromDraft(draft); let id = this._draftId; if (!id) { id = await this.dataLayer.mutations.createDraft({ draft, media }); } else { // prune outdated refs along with update const refsToPrune = [...this._originalLocalRefs].filter( (key) => !localRefs.includes(key), ); await this.dataLayer.mutations.updateDraft({ draftId: id, draft, media, pruneLocalRefs: refsToPrune, }); } // Add new image keys back onto composer state so they can be reused. const currentPosts = this._getPosts(); this.state.$posts.set( currentPosts.map((postState, postIndex) => { const draftImages = draft.posts[postIndex]?.embedGallery?.items ?? []; const snapshotImages = snapshot.posts[postIndex]?.images ?? []; return { ...postState, images: postState.images.map((image) => { if (image.localRefPath) return image; const snapshotIndex = snapshotImages.indexOf(image); if (snapshotIndex === -1) return image; return { ...image, localRefPath: draftImages[snapshotIndex].localRef.path, }; }), }; }), ); this.markSaved(id, localRefs); return true; } catch (error) { this._isDirty = true; console.error("Failed to save draft", error); showToast( isDraftLimitError(error) ? "You've reached the maximum number of drafts" : "Failed to save draft", { style: "error" }, ); return false; } finally { this.state.$isSavingDraft.set(false); } } markSaved(draftId, localRefs) { this._draftId = draftId; this._originalLocalRefs = new Set(localRefs); } clearComposer() { this._abortAllVideoUploads(); for (const postState of this._getPosts()) { if (postState.video?.previewUrl) { URL.revokeObjectURL(postState.video.previewUrl); } } this.state.$posts.set([this._createPostState()]); this.state.$activePostIndex.set(0); this.state.$threadgateAllow.set(null); this.state.$postgateEmbeddingRules.set(null); this._draftId = null; this._originalLocalRefs = null; this.render(); this._syncInputsFromState(); this._isDirty = false; } async handleDraftsButtonClick() { if (!this.draftsEnabled) return; if (untrack(() => this.state.$isSavingDraft.get())) return; if (!this.hasContent() || (this._draftId && !this._isDirty)) { this.openDraftsDialog(); return; } const posts = this._getPosts(); if (!posts.every((postState) => isDraftTextSavable(postState.text))) { const discard = await confirmModal( `You can only save drafts up to ${MAX_DRAFT_GRAPHEME_LENGTH} characters. Your post will be discarded.`, { title: "Discard post?", confirmButtonStyle: "danger-subtle", confirmButtonText: "Discard", }, ); if (!discard) return; this.clearComposer(); this.openDraftsDialog(); return; } const choice = await this.promptSaveChoice({ forDraftsList: true }); if (choice === "save") { if (await this.saveDraft()) { this.openDraftsDialog(); } } else if (choice === "discard") { this.clearComposer(); this.openDraftsDialog(); } } promptSaveChoice({ forDraftsList = false } = {}) { const isEditingDraft = this._draftId !== null; let message; if (forDraftsList) { message = isEditingDraft ? "You have unsaved changes. Would you like to save them before viewing your drafts?" : "Would you like to save this post as a draft before viewing your drafts?"; } else { message = isEditingDraft ? "You have unsaved changes to this draft. Would you like to save them?" : "Would you like to save this post as a draft?"; } return choiceModal(message, { title: isEditingDraft ? "Save changes?" : "Save draft?", choices: [ { value: "save", label: isEditingDraft ? "Save changes" : "Save draft", style: "primary", }, { value: "discard", label: "Discard", style: "danger-subtle" }, { value: "keep", label: "Keep editing", style: "cancel" }, ], }); } setDefaultInteractionSettings(settings) { this._defaultInteractionSettings = settings; if (!this._interactionSettingsTouched && !this._draftId) { this.state.$threadgateAllow.set(settings.threadgateAllowRules); this.state.$postgateEmbeddingRules.set(settings.postgateEmbeddingRules); } } openInteractionSettingsDialog() { const dialog = document.createElement("post-interaction-settings-dialog"); dialog.stacked = true; dialog.dataLayer = this.dataLayer; dialog.threadgateAllow = untrack(() => this.state.$threadgateAllow.get()); dialog.postgateEmbeddingRules = untrack(() => this.state.$postgateEmbeddingRules.get(), ); if (this._defaultInteractionSettings) { dialog.defaultInteractionSettings = this._defaultInteractionSettings; } dialog.addEventListener("save-interaction-settings", async (e) => { this.state.$threadgateAllow.set(e.detail.threadgateAllow); this.state.$postgateEmbeddingRules.set(e.detail.postgateEmbeddingRules); this._interactionSettingsTouched = true; this._isDirty = true; if (e.detail.saveAsDefault) { const newDefault = { threadgateAllowRules: e.detail.threadgateAllow, postgateEmbeddingRules: e.detail.postgateEmbeddingRules, }; try { await this.dataLayer.mutations.updatePostInteractionSettings( newDefault, ); this._defaultInteractionSettings = newDefault; } catch (error) { console.error("Failed to save default interaction settings", error); } } e.detail.successCallback(); }); dialog.addEventListener("dialog-closed", () => { dialog.remove(); }); document.body.appendChild(dialog); dialog.open(); } openDraftsDialog() { const dialog = document.createElement("drafts-dialog"); dialog.dataLayer = this.dataLayer; dialog.addEventListener("draft-selected", (e) => { this.restoreFromDraft(e.detail.draftView); }); dialog.addEventListener("draft-deleted", (e) => { this.handleDraftDeleted(e.detail.draftId); }); dialog.addEventListener("dialog-closed", () => { dialog.remove(); }); document.body.appendChild(dialog); dialog.open(); } // The server silently ignores updates to a deleted draft id, so once the // loaded draft is deleted from the list, treat the content as a new // unsaved post handleDraftDeleted(draftId) { if (draftId === null || draftId !== this._draftId) return; this._draftId = null; this._originalLocalRefs = null; this._isDirty = true; } async restoreFromDraft(draftView) { const draft = draftView.draft; const draftPosts = draft.posts?.length > 0 ? draft.posts : [{ text: "" }]; const isOriginatingDevice = draft.deviceId === getDraftDeviceId(); this.clearComposer(); this._interactionSettingsTouched = true; this.state.$threadgateAllow.set(draft.threadgateAllow ?? null); this.state.$postgateEmbeddingRules.set( draft.postgateEmbeddingRules ?? null, ); this._draftId = draftView.id; this._originalLocalRefs = new Set(getLocalRefsFromDraft(draft)); const postStates = draftPosts.map((draftPost) => this._createPostState({ text: draftPost.text ?? "", labels: draftPost.labels ?? null, }), ); this.state.$posts.set(postStates); this.state.$activePostIndex.set(0); this.render(); this._syncInputsFromState(); this._isDirty = false; for (let i = 0; i < draftPosts.length; i++) { const draftPost = draftPosts[i]; const postId = postStates[i].id; const unrestoredImages = []; if (isOriginatingDevice) { const restoredImages = []; for (const item of getImagesFromDraftPost(draftPost)) { try { const blob = await this.dataLayer.draftMediaStore.readBlob( item.localRef.path, ); if (!blob) { unrestoredImages.push(item); continue; } const file = new File([blob], "draft-image", { type: blob.type || "image/jpeg", }); const image = { file, dataUrl: await readFileAsDataUrl(blob), localRefPath: item.localRef.path, }; if (item.alt) { image.alt = item.alt; } restoredImages.push(image); } catch (error) { console.warn("Failed to restore draft image", error); unrestoredImages.push(item); } } if (restoredImages.length > 0) { this._updatePost(postId, { images: restoredImages }); } } else { unrestoredImages.push(...getImagesFromDraftPost(draftPost)); } const externalUri = draftPost.embedExternals?.[0]?.uri ?? null; if (externalUri) { const gifState = restoreGifFromDraftUri(externalUri); if (gifState) { this._updatePost(postId, { gif: gifState }); } else { this._updatePost(postId, { externalLinkUrl: externalUri }); this.loadExternalLinkEmbedPreview(postId); } } const quoteRef = draftPost.embedRecords?.[0]?.record ?? null; if (quoteRef) { await this.restoreQuotedRecord(postId, quoteRef); } const videoEmbed = draftPost.embedVideos?.[0] ?? null; let videoRestored = false; if (videoEmbed?.localRef?.path && isOriginatingDevice) { videoRestored = await this.restoreDraftVideo(postId, videoEmbed); } const unrestoredVideo = videoEmbed && !videoRestored ? videoEmbed : null; this._updatePost(postId, { unrestoredImages: unrestoredImages.length > 0 ? unrestoredImages : null, unrestoredVideo, }); } } async restoreQuotedRecord(postId, recordRef) { try { const { collection } = parseUri(recordRef.uri); let record = null; if (collection === "app.bsky.feed.generator") { const view = await this.dataLayer.declarative.ensureFeedGenerator( recordRef.uri, ); record = { ...view, $type: "app.bsky.feed.defs#generatorView" }; } else if (collection === "app.bsky.graph.list") { const view = await this.dataLayer.declarative.ensureList(recordRef.uri); record = { ...view, $type: "app.bsky.graph.defs#listView" }; } else if (collection === "app.bsky.graph.starterpack") { const view = await this.dataLayer.declarative.ensureStarterPack( recordRef.uri, ); record = { ...view, $type: "app.bsky.graph.defs#starterPackViewBasic" }; } else { const post = await this.dataLayer.declarative.ensurePost(recordRef.uri); record = createEmbedFromPost(post); } this._updatePost(postId, { quotedRecord: record }); } catch (error) { console.warn("Failed to restore draft quote", error); showToast("Couldn't load this draft's quoted record", { style: "warning", }); } } async restoreDraftVideo(postId, videoEmbed) { try { const path = videoEmbed.localRef.path; const blob = await this.dataLayer.draftMediaStore.readBlob(path); if (!blob) { showToast("This draft's video is not available on this device", { style: "warning", }); return false; } const extension = DraftMediaStore.parseVideoExtension(path); const file = new File([blob], `draft-video.${extension}`, { type: blob.type || DraftMediaStore.parseVideoMimeType(path), }); await this.processVideoFile(postId, file); const postState = this._getPost(postId); if (!postState?.video) { return false; } if (videoEmbed.alt) { this.patchSelectedVideo(postId, postState.videoToken, { alt: videoEmbed.alt, }); } this._updatePost(postId, { draftVideoCaptions: videoEmbed.captions ?? null, }); return true; } catch (error) { console.warn("Failed to restore draft video", error); showToast("Couldn't restore this draft's video", { style: "warning" }); return false; } } async confirmClose() { // Cancel before prompting so uploads stop while the user decides if (this.state.$isSending.get()) { this._cancelSend(); } if (this.replyTo) { if (!this.hasContent()) { return true; } return confirmModal("Are you sure you'd like to discard this reply?", { title: "Discard reply?", confirmButtonStyle: "danger-subtle", confirmButtonText: "Discard", }); } if (!this.hasContent()) { return true; } if (!this.draftsEnabled) { return confirmModal("Are you sure you'd like to discard this post?", { title: "Discard post?", confirmButtonStyle: "danger-subtle", confirmButtonText: "Discard", }); } if (this._draftId && !this._isDirty) { return true; } const posts = this._getPosts(); if (!posts.every((postState) => isDraftTextSavable(postState.text))) { return confirmModal( `You can only save drafts up to ${MAX_DRAFT_GRAPHEME_LENGTH} characters. Your post will be discarded.`, { title: "Discard post?", confirmButtonStyle: "danger-subtle", confirmButtonText: "Discard", }, ); } const choice = await this.promptSaveChoice(); if (choice === "discard") { return true; } if (choice === "save") { return this.saveDraft(); } return false; } } PostComposer.register();