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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<SVGIconProps>}) {
|
||||
const {type} = useContext(ToastConfigContext)
|
||||
const styles = useToastStyles({type})
|
||||
if (!icon && type === 'pending') {
|
||||
return <Loader size="md" fill={styles.iconColor} />
|
||||
}
|
||||
const IconComponent = icon || ICONS[type]
|
||||
return <IconComponent size="md" fill={styles.iconColor} />
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -78,3 +78,57 @@ export function show(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type PromiseToastOptions<T> = Omit<BaseToastOptions, 'type'> & {
|
||||
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<T>(
|
||||
input: Promise<T>,
|
||||
{loading, success, error, ...options}: PromiseToastOptions<T>,
|
||||
): Promise<T> {
|
||||
const id = nanoid()
|
||||
|
||||
const render = (
|
||||
content: React.ReactNode,
|
||||
type: 'pending' | 'success' | 'error',
|
||||
) => {
|
||||
sonner.custom(
|
||||
<ToastConfigProvider id={id} type={type}>
|
||||
{content}
|
||||
</ToastConfigProvider>,
|
||||
{
|
||||
...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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,3 +78,58 @@ export function show(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type PromiseToastOptions<T> = Omit<BaseToastOptions, 'type'> & {
|
||||
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<T>(
|
||||
input: Promise<T>,
|
||||
{loading, success, error, ...options}: PromiseToastOptions<T>,
|
||||
): Promise<T> {
|
||||
const id = nanoid()
|
||||
|
||||
const render = (
|
||||
content: React.ReactNode,
|
||||
type: 'pending' | 'success' | 'error',
|
||||
) => {
|
||||
sonner(
|
||||
<ToastConfigProvider id={id} type={type}>
|
||||
{content}
|
||||
</ToastConfigProvider>,
|
||||
{
|
||||
...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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: (
|
||||
<Toast.Outer>
|
||||
<Toast.Icon />
|
||||
<Toast.Text>
|
||||
{filteredThread.posts.length > 1
|
||||
? l`Sending posts…`
|
||||
: replyTo
|
||||
? l`Sending reply…`
|
||||
: l`Sending post…`}
|
||||
</Toast.Text>
|
||||
</Toast.Outer>
|
||||
),
|
||||
success: () => (
|
||||
<Toast.Outer>
|
||||
<Toast.Icon />
|
||||
<Toast.Text>
|
||||
@@ -1051,10 +1069,9 @@ export const ComposePost = ({
|
||||
</Trans>
|
||||
</Toast.Action>
|
||||
)}
|
||||
</Toast.Outer>,
|
||||
{type: 'success'},
|
||||
)
|
||||
}, 500)
|
||||
</Toast.Outer>
|
||||
),
|
||||
})
|
||||
}, [
|
||||
l,
|
||||
ax,
|
||||
|
||||
@@ -21,6 +21,7 @@ export const convertLegacyToastType = (
|
||||
case 'error':
|
||||
case 'warning':
|
||||
case 'info':
|
||||
case 'pending':
|
||||
return type
|
||||
// legacy ones need conversion
|
||||
case 'xmark':
|
||||
|
||||
Reference in New Issue
Block a user