Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
75 kB · 2284 lines
JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285import { 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 unreliableconst 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`<div class="composer-error-banner" data-testid="composer-error-banner" role="alert" > <app-icon icon="alert-circle-line"></app-icon> <span class="composer-error-banner-message">${message}</span> <button class="composer-error-banner-dismiss" data-testid="composer-error-dismiss" aria-label="Dismiss error" @click=${onDismiss} > <app-icon icon="close-line"></app-icon> </button> </div>`;}
function isVideoUploadPending(video) { return ( !!video && (video.status === "uploading" || video.status === "processing") );}
function replyToTemplate({ post }) { return html` <div class="reply-to"> <div class="post-content-with-space"> <div class="post-content-left"> <div> ${avatarTemplate({ author: post.author, clickAction: "none" })} </div> </div> <div class="post-content-right"> ${postHeaderTextTemplate({ author: post.author, timestamp: post.indexedAt, includeHandle: false, includeTime: false, })} <div class="post-body"> ${post.record.text ? html`<div class="post-text"> ${richTextTemplate({ text: post.record.text, facets: post.record.facets, })} </div>` : ""} </div> </div> </div> <hr style="margin-top: 18px;" /> </div> `;}
function joinLinkPreviewTemplate({ preview, onClose }) { return html` <div class="post-composer-embed-preview"> <button class="embed-preview-close-button" data-testid="composer-join-link-remove" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onClose(); }} > <app-icon icon="close-line"></app-icon> </button> <div inert> ${chatJoinLinkEmbedTemplate({ embed: { $type: "chat.bsky.embed.joinLink#view", joinLinkPreview: preview, }, currentConvoId: null, })} </div> </div> `;}
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` <div class="post-composer-embed-preview post-composer-join-link-loading"> <button class="embed-preview-close-button" data-testid="composer-join-link-remove" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onClose(); }} > <app-icon icon="close-line"></app-icon> </button> <div class="loading-spinner"></div> </div> `;}
function joinLinkErrorTemplate({ url, onClose, onRetry }) { return html` <div class="post-composer-embed-preview post-composer-join-link-error" data-testid="composer-join-link-error" > <button class="embed-preview-close-button" data-testid="composer-join-link-remove" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onClose(); }} > <app-icon icon="close-line"></app-icon> </button> <div class="post-composer-join-link-error-body"> <div class="post-composer-join-link-error-url">${url}</div> <div class="post-composer-join-link-error-message"> Couldn't load invite preview. </div> <button class="post-composer-join-link-error-retry" data-testid="composer-join-link-retry" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onRetry(); }} > Retry </button> </div> </div> `;}
function externalLinkEmbedPreviewTemplate({ data, onClose }) { return html` <div class="post-composer-embed-preview"> <button class="embed-preview-close-button" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onClose(); }} > <app-icon icon="close-line"></app-icon> </button> ${externalLinkTemplate({ url: data.url, title: data.title, description: data.description, image: data.image, showCloseButton: true, disableNavigation: true, onClose, })} </div> `;}
function altIndicatorContentTemplate(hasAlt) { return html`${hasAlt ? html`<app-icon icon="check"></app-icon>` : html`<app-icon icon="plus-line"></app-icon>`} 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` <div class="post-composer-video-preview"> <div class="video-preview-item"> <video src="${video.previewUrl}" controls playsinline></video> <button class="image-preview-remove-button" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onRemove(); }} > <app-icon icon="close-line"></app-icon> </button> ${!isReady ? html`<div class="video-preview-overlay"> ${!isError ? html`<div class="loading-spinner"></div>` : ""} <span>${progressLabel}</span> </div>` : ""} <button class="alt-indicator ${video.alt ? "has-alt" : "no-alt"}" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onEditAltText(); }} > ${altIndicatorContentTemplate(!!video.alt)} </button> </div> </div> `;}
function gifPreviewTemplate({ gif, onRemove, onEditAltText }) { const external = buildGifExternal(gif); const hasAlt = gif.alt.trim().length > 0; return html` <div class="post-composer-gif-preview" data-testid="composer-gif-preview"> <div class="gif-preview-item"> ${external ? gifExternalTemplate({ uri: external.url, alt: "" }) : ""} <button class="image-preview-remove-button" data-testid="composer-gif-remove" aria-label="Remove GIF" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onRemove(); }} > <app-icon icon="close-line"></app-icon> </button> <button class="alt-indicator ${hasAlt ? "has-alt" : "no-alt"}" data-testid="composer-gif-alt-button" data-teststate=${hasAlt ? "set" : "unset"} @click=${(e) => { e.preventDefault(); e.stopPropagation(); onEditAltText(); }} > ${altIndicatorContentTemplate(hasAlt)} </button> </div> </div> `;}
function imagePreviewTemplate({ images, onRemove, onEditAltText }) { return html` <div class="post-composer-image-preview"> ${images.map( (img, index) => html` <div class="image-preview-item"> <img src="${img.dataUrl}" alt="${img.alt || "Preview"}" @click=${() => onEditAltText(index)} style="cursor: pointer;" /> <button class="image-preview-remove-button" @click=${(e) => { e.preventDefault(); e.stopPropagation(); onRemove(index); }} > <app-icon icon="close-line"></app-icon> </button> <div class="alt-indicator ${img.alt ? "has-alt" : "no-alt"}"> ${altIndicatorContentTemplate(!!img.alt)} </div> </div> `, )} </div> `;}
function composerPostTemplate({ postState, joinLinkPreview, isActive, canRemove, promptText, currentUser, pluginService, onActivate, onRemovePost, onInput, onPaste, onRemoveImage, onEditAltText, onRemoveVideo, onEditVideoAltText, onRemoveGif, onEditGifAltText, onCloseExternal, onRetryJoinLink, onCloseQuote,}) { return html` <div class=${classnames("post-composer-post", { "is-inactive": !isActive })} data-testid="composer-post" data-teststate=${isActive ? "active" : "inactive"} data-post-id=${postState.id} @focusin=${() => onActivate()} @click=${() => onActivate()} > ${canRemove ? html`<button class="post-composer-remove-post-button" data-testid="composer-remove-post-button" aria-label="Remove post from thread" @click=${(e) => { e.stopPropagation(); onRemovePost(); }} > <app-icon icon="close-line"></app-icon> </button>` : ""} <div class="post-composer-body"> <div class="post-composer-body-left"> ${avatarTemplate({ author: currentUser, clickAction: "none", })} </div> <div class="post-composer-body-right"> <rich-text-input @input=${(e) => onInput(e)} @paste=${(e) => onPaste(e)} placeholder="${promptText}" ></rich-text-input> </div> </div> ${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`<div class="post-composer-embed-preview"> <button class="embed-preview-close-button" @click=${() => { onCloseQuote(); }} > <app-icon icon="close-line"></app-icon> </button> <div inert> ${recordEmbedTemplate({ record: postState.quotedRecord, isAuthenticated: true, pluginService, })} </div> </div>` : ""} </div> `;}
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` <dialog class="post-composer bottom-sheet bottom-sheet-fullscreen no-handle" autofocus @click=${async (e) => { 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`<div class="post-composer-drop-overlay" data-testid="post-composer-drop-overlay" > <span>Drop to add files</span> </div>` : ""} <div class="post-composer-content"> <div class="post-composer-top-bar"> <button class="text-pill-button post-composer-cancel-button" @click=${async () => { if (await this.confirmClose()) { this.close(); } }} > Cancel </button> ${!this.replyTo && draftsEnabled ? html`<button class="text-pill-button post-composer-drafts-button" data-testid="composer-drafts-button" .disabled=${isSavingDraft} @click=${() => this.handleDraftsButtonClick()} > Drafts </button>` : ""} <button class="rounded-button rounded-button-primary" data-testid="composer-submit-button" data-teststate=${submitTestState} @click=${() => this.send()} .disabled=${isSending || isCancellingSend || isAnyPostAboveCharLimit || isAnyVideoBlocking || hasAnyUnavailableInvite} > ${isSending ? html`Sending... <span> </span> <div class="loading-spinner"></div>` : html`<span>${submitLabel}</span>`} </button> </div> ${sendError ? errorMessageBannerTemplate({ message: sendError, onDismiss: () => this.state.$errorMessage.set(null), }) : ""} <div class="post-composer-scroll-area"> <div class="post-composer-scroll-area-content"> ${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), }), ), )} </div> <div class="post-composer-bottom-bar"> ${!this.replyTo ? html`<div class="post-composer-interaction-settings-row"> <button type="button" class="composer-interaction-settings-button rounded-button rounded-button-secondary" data-testid="composer-interaction-settings" data-teststate=${isInteractionLimited ? "limited" : "open"} @click=${() => this.openInteractionSettingsDialog()} > ${isInteractionLimited ? html`<app-icon icon="users-line"></app-icon> <span>Interaction limited</span>` : html`<app-icon icon="globe-grid-line"></app-icon> <span>Anyone can interact</span>`} </button> </div>` : ""} <div class="post-composer-bottom-bar-main"> <div class="post-composer-bottom-bar-left"> <input type="file" accept="image/*,video/*" class="media-picker-input" multiple style="display: none;" @change=${(e) => this.handleMediaSelect(e)} @cancel=${(e) => { e.stopPropagation(); }} /> <button class="icon-button image-picker-button" @click=${() => this.handleMediaButtonClick()} .disabled=${hasVideo || hasGif || activePost.images.length >= 4} > <app-icon icon="image-line"></app-icon> </button> <button type="button" class="icon-button post-composer-gif-button" data-testid="composer-gif-button" aria-label="Select GIF" .disabled=${hasVideo || hasGif || activePost.images.length > 0 || !!activePost.external} @click=${() => this.handleGifButtonClick()} > <app-icon icon="gif-square-line"></app-icon> </button> <gif-picker-dialog .dataLayer=${this.dataLayer} @gif-selected=${(e) => this.handleGifSelected(e.detail.gif)} ></gif-picker-dialog> <div class="post-composer-emoji-wrapper"> <button type="button" class="icon-button post-composer-emoji-button" aria-label="Open emoji picker" @click=${(e) => this.handleEmojiButtonClick(e)} > <app-icon icon="emoji-smile-line"></app-icon> </button> <emoji-picker-dialog @select=${(e) => { this.handleEmojiSelect(e.detail.emoji); e.currentTarget.close(); }} ></emoji-picker-dialog> </div> </div> <div class="post-composer-bottom-bar-right"> ${canAddPost ? html`<button class="icon-button post-composer-add-post-button" data-testid="composer-add-post-button" aria-label="Add another post" @click=${() => this.handleAddPost()} > <app-icon icon="plus-line"></app-icon> </button>` : ""} <div class=${classnames("word-count", { overflow: isAboveCharLimit, })} > <span class="word-count-text" >${300 - currentCharCount}</span > <div class="word-count-indicator"> <div class="word-count-indicator-bar" style="height: ${charCountPercentage}%" ></div> </div> </div> </div> </div> </div> </div> </div> </dialog> `, 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();