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':