From f03bf2a65f03d1ebe4e77cff9e8d8f3ac77be94c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 09:38:18 +0000 Subject: [PATCH] Defer AppView wait behind a pending toast on post Step 5 of sending a post (waiting for the AppView to index the new record) can take an unbounded amount of time, but the post is already created after step 4. Close the composer immediately after the record is written and show a pending toast while the AppView wait runs in the background. When it settles, swap the pending toast in place for the existing success toast. Adds a 'pending' ToastType that renders a spinner via the existing Loader, and a new Toast.promise() helper that drives the in-place swap on both sonner and sonner-native. --- eslint-suppressions.json | 5 - src/components/Toast/Toast.tsx | 12 ++ src/components/Toast/index.tsx | 54 ++++++++ src/components/Toast/index.web.tsx | 55 ++++++++ src/components/Toast/types.ts | 8 +- src/view/com/composer/Composer.tsx | 211 ++++++++++++++++------------- src/view/com/util/Toast.tsx | 1 + 7 files changed, 243 insertions(+), 103 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index c8b285ca1c..f0952175f9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -539,11 +539,6 @@ "count": 8 } }, - "src/view/com/composer/Composer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "src/view/com/composer/drafts/state/queries.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx index ac5bc4889a..d5540e1f99 100644 --- a/src/components/Toast/Toast.tsx +++ b/src/components/Toast/Toast.tsx @@ -12,6 +12,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' import {type Props as SVGIconProps} from '#/components/icons/common' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {Loader} from '#/components/Loader' import {dismiss} from '#/components/Toast/sonner' import {type ToastType} from '#/components/Toast/types' import {Text as BaseText} from '#/components/Typography' @@ -22,6 +23,7 @@ export const ICONS = { error: ErrorIcon, warning: WarningIcon, info: CircleInfo, + pending: CircleCheck, } const ToastConfigContext = createContext<{ @@ -79,6 +81,9 @@ export function Outer({children}: {children: React.ReactNode}) { export function Icon({icon}: {icon?: React.ComponentType}) { const {type} = useContext(ToastConfigContext) const styles = useToastStyles({type}) + if (!icon && type === 'pending') { + return + } const IconComponent = icon || ICONS[type] return } @@ -173,6 +178,7 @@ export function Action( }, warning: base, info: base, + pending: base, }[type] }, [t, type]) @@ -304,6 +310,12 @@ function useToastStyles({type}: {type: ToastType}) { iconColor: t.atoms.text.color, textColor: t.atoms.text.color, }, + pending: { + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, + borderColor: t.atoms.border_contrast_low.borderColor, + iconColor: t.atoms.text.color, + textColor: t.atoms.text.color, + }, }[type] }, [t, type]) } diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index a33ee55332..6f432bdda1 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -78,3 +78,57 @@ export function show( ) } } + +type PromiseToastOptions = Omit & { + loading: React.ReactNode + success: React.ReactNode | ((data: T) => React.ReactNode) + error?: React.ReactNode | ((err: unknown) => React.ReactNode) +} + +/** + * Show a toast tied to a promise. While the promise is pending, the toast + * displays the `loading` content with a spinner. When the promise settles, the + * same toast is swapped in place with the `success` or `error` content. + */ +export function promise( + input: Promise, + {loading, success, error, ...options}: PromiseToastOptions, +): Promise { + const id = nanoid() + + const render = ( + content: React.ReactNode, + type: 'pending' | 'success' | 'error', + ) => { + sonner.custom( + + {content} + , + { + ...options, + id, + duration: type === 'pending' ? Infinity : (options?.duration ?? DURATION), + dismissible: type === 'pending' ? false : options?.dismissible, + }, + ) + } + + render(loading, 'pending') + + return input.then( + data => { + const content = typeof success === 'function' ? success(data) : success + render(content, 'success') + return data + }, + err => { + if (error !== undefined) { + const content = typeof error === 'function' ? error(err) : error + render(content, 'error') + } else { + sonner.dismiss(id) + } + throw err + }, + ) +} diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx index 1bc43e19f9..9d8cd2559c 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -78,3 +78,58 @@ export function show( ) } } + +type PromiseToastOptions = Omit & { + loading: React.ReactNode + success: React.ReactNode | ((data: T) => React.ReactNode) + error?: React.ReactNode | ((err: unknown) => React.ReactNode) +} + +/** + * Show a toast tied to a promise. While the promise is pending, the toast + * displays the `loading` content with a spinner. When the promise settles, the + * same toast is swapped in place with the `success` or `error` content. + */ +export function promise( + input: Promise, + {loading, success, error, ...options}: PromiseToastOptions, +): Promise { + const id = nanoid() + + const render = ( + content: React.ReactNode, + type: 'pending' | 'success' | 'error', + ) => { + sonner( + + {content} + , + { + ...options, + unstyled: true, // required on web + id, + duration: type === 'pending' ? Infinity : (options?.duration ?? DURATION), + dismissible: type === 'pending' ? false : options?.dismissible, + }, + ) + } + + render(loading, 'pending') + + return input.then( + data => { + const content = typeof success === 'function' ? success(data) : success + render(content, 'success') + return data + }, + err => { + if (error !== undefined) { + const content = typeof error === 'function' ? error(err) : error + render(content, 'error') + } else { + sonner.dismiss(id) + } + throw err + }, + ) +} diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts index 463e6d66ca..2e393bc10a 100644 --- a/src/components/Toast/types.ts +++ b/src/components/Toast/types.ts @@ -8,7 +8,13 @@ export type ExternalToast = Exclude< undefined > -export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' +export type ToastType = + | 'default' + | 'success' + | 'error' + | 'warning' + | 'info' + | 'pending' /** * Not all properties are available on all platforms, so we pick out only those diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2201a6a2a4..b263a1881a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -873,7 +873,6 @@ export const ComposePost = ({ setIsPublishing(true) let postUri: string | undefined - let postSuccessData: OnPostSuccessData try { logger.info(`composer: posting...`) postUri = ( @@ -896,15 +895,93 @@ export const ComposePost = ({ }, ) ).uris[0] + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)) + logger.error(error, { + message: `Composer: create post failed`, + hasImages: filteredThread.posts.some( + p => p.embed.media?.type === 'images', + ), + }) - /* - * Wait for app view to have received the post(s). If this fails, it's - * ok, because the post _was_ actually published above. - */ + let err = cleanError(error.message) + if ( + e instanceof apilib.ReplyDeletedError || + err.includes('not locate record') + ) { + err = l`We're sorry! The post you are replying to has been deleted.` + } else if (e instanceof EmbeddingDisabledError) { + err = l`This post's author has disabled quote posts.` + } + setError(err) + setIsPublishing(false) + return + } + + // Stage 4 succeeded. Everything from here on runs against a post that + // already exists - fire metrics, clean up local state, close the composer, + // and let the AppView-ready wait happen in the background under a toast. + if (postUri) { + let index = 0 + for (let post of filteredThread.posts) { + ax.metric('post:create', { + imageCount: + post.embed.media?.type === 'images' + ? post.embed.media.images.length + : 0, + isReply: index > 0 || !!replyTo, + isPartOfThread: filteredThread.posts.length > 1, + hasLink: !!post.embed.link, + hasQuote: !!post.embed.quote, + langs: fromPostLanguages(currentLanguages), + logContext: 'Composer', + }) + index++ + } + } + if (filteredThread.posts.length > 1) { + ax.metric('thread:create', { + postCount: filteredThread.posts.length, + isReply: !!replyTo, + }) + } + if (postUri && !replyTo) { + emitPostCreated() + } + // Clean up draft and its media after successful publish + if (composerState.draftId && composerState.originalLocalRefs) { + // Fire draft:post metric + if (loadedDraftCreatedAt) { + const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime() + ax.metric('draft:post', { + draftAgeMs, + wasEdited: composerState.isDirty, + }) + } + + logger.debug('post published, cleaning up draft', { + draftId: composerState.draftId, + mediaFileCount: composerState.originalLocalRefs.size, + }) + cleanupPublishedDraft({ + draftId: composerState.draftId, + originalLocalRefs: composerState.originalLocalRefs, + }) + } + setLangPrefs.savePostLanguageToHistory() + onClose() + + /* + * Wait for the AppView to have received the post(s) in the background. + * If this fails, it's ok - the post _was_ actually published above. We + * still want onPost/onPostSuccess to fire once the AppView is ready so + * downstream query invalidation reads back the new post. + */ + const appViewReady = (async () => { + let postSuccessData: OnPostSuccessData try { if (postUri) { logger.info(`composer: waiting for app view`) - const posts = await retry( 5, _e => true, @@ -934,102 +1011,43 @@ export const ComposePost = ({ posts, } } - } catch (waitErr: any) { + } catch (waitErr) { logger.info(`composer: waiting for app view failed`, { safeMessage: waitErr, }) } - } catch (e: any) { - logger.error(e, { - message: `Composer: create post failed`, - hasImages: filteredThread.posts.some( - p => p.embed.media?.type === 'images', - ), - }) - - let err = cleanError(e.message) - if ( - e instanceof apilib.ReplyDeletedError || - err.includes('not locate record') - ) { - err = l`We're sorry! The post you are replying to has been deleted.` - } else if (e instanceof EmbeddingDisabledError) { - err = l`This post's author has disabled quote posts.` - } - setError(err) - setIsPublishing(false) - return - } finally { - if (postUri) { - let index = 0 - for (let post of filteredThread.posts) { - ax.metric('post:create', { - imageCount: - post.embed.media?.type === 'images' - ? post.embed.media.images.length - : 0, - isReply: index > 0 || !!replyTo, - isPartOfThread: filteredThread.posts.length > 1, - hasLink: !!post.embed.link, - hasQuote: !!post.embed.quote, - langs: fromPostLanguages(currentLanguages), - logContext: 'Composer', + if (initQuote) { + // Wait for the quote count to update before triggering refetches. + try { + await whenAppViewReady(agent, initQuote.uri, res => { + const anchor = res.data.thread.at(0) + return ( + AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) && + anchor.value.post.quoteCount !== initQuote.quoteCount + ) }) - index++ + } catch (e) { + logger.info(`composer: quote count wait failed`, {safeMessage: e}) } } - if (filteredThread.posts.length > 1) { - ax.metric('thread:create', { - postCount: filteredThread.posts.length, - isReply: !!replyTo, - }) - } - } - if (postUri && !replyTo) { - emitPostCreated() - } - // Clean up draft and its media after successful publish - if (composerState.draftId && composerState.originalLocalRefs) { - // Fire draft:post metric - if (loadedDraftCreatedAt) { - const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime() - ax.metric('draft:post', { - draftAgeMs, - wasEdited: composerState.isDirty, - }) - } - - logger.debug('post published, cleaning up draft', { - draftId: composerState.draftId, - mediaFileCount: composerState.originalLocalRefs.size, - }) - cleanupPublishedDraft({ - draftId: composerState.draftId, - originalLocalRefs: composerState.originalLocalRefs, - }) - } - setLangPrefs.savePostLanguageToHistory() - if (initQuote) { - // We want to wait for the quote count to update before we call `onPost`, which will refetch data - whenAppViewReady(agent, initQuote.uri, res => { - const anchor = res.data.thread.at(0) - if ( - AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) && - anchor.value.post.quoteCount !== initQuote.quoteCount - ) { - onPost?.(postUri) - onPostSuccess?.(postSuccessData) - return true - } - return false - }) - } else { onPost?.(postUri) onPostSuccess?.(postSuccessData) - } - onClose() - setTimeout(() => { - Toast.show( + })() + + Toast.promise(appViewReady, { + loading: ( + + + + {filteredThread.posts.length > 1 + ? l`Sending posts…` + : replyTo + ? l`Sending reply…` + : l`Sending post…`} + + + ), + success: () => ( @@ -1051,10 +1069,9 @@ export const ComposePost = ({ )} - , - {type: 'success'}, - ) - }, 500) + + ), + }) }, [ l, ax, diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 820c9f9d71..639c585d01 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -21,6 +21,7 @@ export const convertLegacyToastType = ( case 'error': case 'warning': case 'info': + case 'pending': return type // legacy ones need conversion case 'xmark':