extract reusable loading/unavailable states for chat invites

Add ChatInvite.Loading and ChatInvite.Unavailable presentational parts,
and expose a single `status` enum from context instead of loose
loading/error booleans. Consumers (JoinRequestEmbed, ChatInviteEmbed,
MessageItemInviteEmbed, MessageInputEmbed) now branch on `status` and
share the markup. The message embed gains the "no longer available"
state it was previously missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-09 20:11:38 +03:00
parent c52231a449
commit e16447546b
9 changed files with 122 additions and 89 deletions
@@ -38,9 +38,9 @@ function ChatInviteEmbedBody({
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const {error} = ChatInvite.useChatInvite()
const {status} = ChatInvite.useChatInvite()
if (error) {
if (status === 'error') {
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
}
+15 -53
View File
@@ -1,6 +1,4 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {ChatBskyGroupDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {
type ChatInvitePreview,
@@ -8,9 +6,6 @@ import {
} from '#/state/queries/join-links'
import {atoms as a, useTheme} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
const JOIN_REQUEST_EMBED_HEIGHT = 140
@@ -58,62 +53,29 @@ export function JoinRequestEmbedBody({
onOpen?: () => void
}) {
const t = useTheme()
const {loading, preview} = ChatInvite.useChatInvite()
const {status} = ChatInvite.useChatInvite()
if (loading) {
return (
<View
style={[
a.align_center,
a.justify_center,
a.p_lg,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<Loader size="md" fill={t.atoms.text.color} />
</View>
)
const box = [
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
]
if (status === 'loading') {
return <ChatInvite.Loading style={[box, a.p_lg, style]} />
}
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
if (status !== 'available') {
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.gap_xs,
a.border,
a.rounded_lg,
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
<ChatInvite.Unavailable
style={[box, a.p_lg, t.atoms.bg_contrast_25, style]}
/>
)
}
return (
<View
style={[
a.justify_between,
a.border,
a.rounded_lg,
a.p_lg,
a.gap_lg,
t.atoms.border_contrast_high,
{height: JOIN_REQUEST_EMBED_HEIGHT},
style,
]}>
<View style={[a.justify_between, a.p_lg, a.gap_lg, box, style]}>
<ChatInvite.Card size="large" />
<ChatInvite.JoinButton onPress={onOpen} />
</View>
+12 -2
View File
@@ -22,10 +22,20 @@ export type ChatInviteAction = {
side: 'left' | 'right'
}
/**
* The resolved state of a chat invite:
* - `loading`: the preview is still being fetched.
* - `error`: the fetch failed (e.g. network). Surfaces may want to fall back to
* a plain link rather than show a chat-specific error.
* - `unavailable`: the preview resolved but the link is disabled, invalid, or an
* unrecognized variant - there's nothing to join.
* - `available`: a usable `JoinLinkPreviewView` is present.
*/
export type ChatInviteStatus = 'loading' | 'error' | 'unavailable' | 'available'
export type ChatInviteContextValue = {
code: string
loading: boolean
error: boolean
status: ChatInviteStatus
preview: ChatInvitePreview | undefined
/**
* The derived action descriptor. Undefined while loading or when there's no
+18
View File
@@ -0,0 +1,18 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
/**
* Loading state for a chat invite: a centered spinner. The outer container
* (height, border, etc.) varies per surface, so pass it via `style`.
*/
export function Loading({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme()
return (
<View style={[a.align_center, a.justify_center, style]}>
<Loader size="md" fill={t.atoms.text.color} />
</View>
)
}
+18 -4
View File
@@ -18,7 +18,11 @@ import {type Props as SVGIconProps} from '#/components/icons/common'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import * as Toast from '#/components/Toast'
import {type ChatInviteAction, ChatInviteProvider} from './Context'
import {
type ChatInviteAction,
ChatInviteProvider,
type ChatInviteStatus,
} from './Context'
/**
* Headless data + state owner for a chat invite. Fetches the join link preview
@@ -61,7 +65,18 @@ export function Root({
})
const preview = data?.joinLinkPreviews[0]
const loading = isPending && !preview
let status: ChatInviteStatus
if (isPending && !preview) {
status = 'loading'
} else if (error) {
status = 'error'
} else if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
status = 'available'
} else {
// Resolved to a disabled/invalid/unrecognized preview - nothing to join.
status = 'unavailable'
}
let action: ChatInviteAction | undefined
if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
@@ -135,8 +150,7 @@ export function Root({
}
return (
<ChatInviteProvider
value={{code, loading, error: !!error, preview, action, hasFixedHeight}}>
<ChatInviteProvider value={{code, status, preview, action, hasFixedHeight}}>
{children}
</ChatInviteProvider>
)
@@ -0,0 +1,29 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Text} from '#/components/Typography'
import {useChatInvite} from './Context'
/**
* "No longer available" state for a chat invite, shown when the link is
* disabled, invalid, or otherwise can't be resolved. The outer container
* (height, border, etc.) varies per surface, so pass it via `style`.
*/
export function Unavailable({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme()
const {hasFixedHeight} = useChatInvite()
return (
<View
style={[a.flex_row, a.align_center, a.justify_center, a.gap_xs, style]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text
style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}
allowFontScaling={!hasFixedHeight}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
)
}
+3
View File
@@ -2,7 +2,10 @@ export {Card} from './Card'
export {
type ChatInviteAction,
type ChatInviteContextValue,
type ChatInviteStatus,
useChatInvite,
} from './Context'
export {JoinButton} from './JoinButton'
export {Loading} from './Loading'
export {Root} from './Root'
export {Unavailable} from './Unavailable'
+20 -2
View File
@@ -82,8 +82,7 @@ let MessageItemInviteEmbed = ({
initialPreview={embed.joinLinkPreview}
currentConvoId={convo.convo.view.id}
hasFixedHeight={false}>
<ChatInvite.Card size="small" />
<ChatInvite.JoinButton />
<MessageItemInviteEmbedBody />
</ChatInvite.Root>
</View>
</View>
@@ -92,3 +91,22 @@ let MessageItemInviteEmbed = ({
}
MessageItemInviteEmbed = memo(MessageItemInviteEmbed)
export {MessageItemInviteEmbed}
function MessageItemInviteEmbedBody() {
const {status} = ChatInvite.useChatInvite()
if (status === 'loading') {
return <ChatInvite.Loading style={a.py_lg} />
}
if (status !== 'available') {
return <ChatInvite.Unavailable style={a.py_sm} />
}
return (
<>
<ChatInvite.Card size="small" />
<ChatInvite.JoinButton />
</>
)
}
@@ -4,7 +4,6 @@ import {
AppBskyFeedPost,
AppBskyRichtextFacet,
AtUri,
ChatBskyGroupDefs,
moderatePost,
RichText as RichTextAPI,
} from '@atproto/api'
@@ -31,7 +30,6 @@ import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import * as MediaPreview from '#/components/MediaPreview'
import {ContentHider} from '#/components/moderation/ContentHider'
@@ -305,33 +303,14 @@ function MessageInputInviteEmbed({
}
function MessageInputInviteEmbedBody() {
const t = useTheme()
const {loading, preview} = ChatInvite.useChatInvite()
const {status} = ChatInvite.useChatInvite()
if (loading) {
return (
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
<Loader />
</View>
)
if (status === 'loading') {
return <ChatInvite.Loading style={{minHeight: 64}} />
}
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
return (
<View
style={[
{minHeight: 64},
a.flex_row,
a.gap_xs,
a.justify_center,
a.align_center,
]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
)
if (status !== 'available') {
return <ChatInvite.Unavailable style={{minHeight: 64}} />
}
return <ChatInvite.Card size="small" />