Update handling of join link previews (#10798)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-06-09 07:23:33 -07:00
committed by GitHub
parent 4c4ebd0510
commit c52231a449
24 changed files with 192 additions and 103 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@atproto/api": "0.20.6",
"@atproto/api": "0.20.11",
"@atproto/common": "^0.6.1",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
+5 -5
View File
@@ -208,8 +208,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.6
version: 0.20.6
specifier: 0.20.11
version: 0.20.11
'@atproto/common':
specifier: ^0.6.1
version: 0.6.1
@@ -259,8 +259,8 @@ importers:
packages:
'@atproto/api@0.20.6':
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
'@atproto/api@0.20.11':
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1154,7 +1154,7 @@ packages:
snapshots:
'@atproto/api@0.20.6':
'@atproto/api@0.20.11':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {ChatBskyGroupDefs} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
codes: [code],
})
const found = result.data.joinLinkPreviews[0]
if (!found) {
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
return res.status(404).end('not found')
}
preview = found
+1 -1
View File
@@ -93,7 +93,7 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.9",
"@atproto/api": "0.20.11",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
+5 -5
View File
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.9
version: 0.20.9
specifier: 0.20.11
version: 0.20.11
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -877,8 +877,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.9':
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
'@atproto/api@0.20.11':
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.9':
'@atproto/api@0.20.11':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
@@ -1,7 +1,11 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {ChatBskyGroupDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {
type ChatInvitePreview,
isKnownJoinLinkPreview,
} 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'
@@ -23,11 +27,12 @@ export function JoinRequestEmbed({
onOpen,
}: {
code?: string
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
preview?: ChatInvitePreview
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const resolvedCode = code ?? preview?.code
const resolvedCode =
code ?? (isKnownJoinLinkPreview(preview) ? preview.code : undefined)
if (!resolvedCode) return null
return (
@@ -73,7 +78,7 @@ export function JoinRequestEmbedBody({
)
}
if (!preview) {
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
return (
<View
style={[
+2 -1
View File
@@ -1,4 +1,5 @@
import {View} from 'react-native'
import {ChatBskyGroupDefs} from '@atproto/api'
import {Plural, Trans} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -20,7 +21,7 @@ export function Card({size}: {size: 'large' | 'small'}) {
const t = useTheme()
const {preview, hasFixedHeight} = useChatInvite()
if (!preview) return null
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) return null
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
+2 -2
View File
@@ -1,6 +1,6 @@
import {createContext, useContext} from 'react'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type ButtonColor} from '#/components/Button'
import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -26,7 +26,7 @@ export type ChatInviteContextValue = {
code: string
loading: boolean
error: boolean
preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined
preview: ChatInvitePreview | undefined
/**
* The derived action descriptor. Undefined while loading or when there's no
* preview to act on.
+8 -11
View File
@@ -1,10 +1,13 @@
import {setStringAsync} from 'expo-clipboard'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {ChatBskyGroupDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {
type ChatInvitePreview,
useJoinLinkPreviewsQuery,
} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {type ButtonColor} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
@@ -13,7 +16,6 @@ import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import * as Toast from '#/components/Toast'
import {type ChatInviteAction, ChatInviteProvider} from './Context'
@@ -34,7 +36,7 @@ export function Root({
children,
}: {
code: string
initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView
initialPreview?: ChatInvitePreview
/**
* The convo this invite is being viewed within, if any. When the invite
* links to the same chat, the action becomes "Copy link" instead of
@@ -62,7 +64,7 @@ export function Root({
const loading = isPending && !preview
let action: ChatInviteAction | undefined
if (preview) {
if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
const convoId = preview.convo?.id
const isFollowing = preview.owner.viewer?.following ?? false
const hasRequested = !convoId && preview.viewer?.requestedAt != null
@@ -99,12 +101,7 @@ export function Root({
let icon: React.ComponentType<SVGIconProps> = JoinIcon
let label = preview.requireApproval ? l`Request to join` : l`Join`
let color: ButtonColor = 'primary'
if (preview.enabledStatus !== 'enabled') {
canJoin = false
icon = WarningIcon
label = l`Chat invite link no longer available`
color = 'secondary'
} else if (preview.memberCount >= preview.memberLimit) {
if (preview.memberCount >= preview.memberLimit) {
canJoin = false
icon = HandIcon
label = l`This chat is full`
@@ -3,6 +3,7 @@ import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo'
import {isKnownJoinLinkPreview} from '#/state/queries/join-links'
import {atoms as a, native, useTheme, web} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {MessageContextProvider} from './MessageContext'
@@ -27,6 +28,11 @@ let MessageItemInviteEmbed = ({
const screen = useWindowDimensions()
const convo = useConvoActive()
const code = isKnownJoinLinkPreview(embed.joinLinkPreview)
? embed.joinLinkPreview.code
: undefined
if (!code) return null
return (
<MessageContextProvider>
<View
@@ -72,7 +78,7 @@ let MessageItemInviteEmbed = ({
},
]}>
<ChatInvite.Root
code={embed.joinLinkPreview.code}
code={code}
initialPreview={embed.joinLinkPreview}
currentConvoId={convo.convo.view.id}
hasFixedHeight={false}>
+4 -2
View File
@@ -121,7 +121,8 @@ function ProfileHeaderReady({
<View style={[a.flex_row, a.align_center, a.flex_1, web(a.mb_2xs)]}>
<Text
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
numberOfLines={1}>
numberOfLines={1}
emoji>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
@@ -185,7 +186,8 @@ function GroupHeaderReady({
<View style={[a.flex_row, a.flex_1, a.align_center]}>
<Text
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
numberOfLines={1}>
numberOfLines={1}
emoji>
{convo.details.name}
</Text>
<MuteStatus muted={convo.view.muted} />
+2 -1
View File
@@ -45,7 +45,8 @@ export function SystemMessageItem({
a.text_center,
t.atoms.text_contrast_medium,
{includeFontPadding: false, textAlignVertical: 'center'},
]}>
]}
emoji>
{text}
</Text>
</View>
+5 -12
View File
@@ -1,5 +1,6 @@
import {View} from 'react-native'
import {
ChatBskyGroupDefs,
ChatBskyGroupRequestJoin,
ChatBskyGroupWithdrawJoinRequest,
moderateProfile,
@@ -211,7 +212,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const joinLinkPreview = data.joinLinkPreviews[0]
if (!joinLinkPreview) {
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
return (
<>
<View style={[a.py_lg, a.align_center]}>
@@ -253,12 +254,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
? l`Request to join`
: l`Join`
let buttonColor: ButtonColor = 'primary'
if (joinLinkPreview.enabledStatus !== 'enabled') {
canJoin = false
ButtonIconImage = WarningIcon
buttonText = l`Chat invite link no longer available`
buttonColor = 'secondary'
} else if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
canJoin = false
ButtonIconImage = HandIcon
buttonText = l`This chat is full`
@@ -311,10 +307,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</Trans>
</Text>
<View style={[a.flex_row, a.ml_md]}>
<PersonGroupIcon
size="xs"
style={[a.mr_xs, t.atoms.text, {marginTop: -2}]}
/>
<PersonGroupIcon size="xs" style={[a.mr_xs, t.atoms.text]} />
</View>
<Text
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
@@ -371,7 +364,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</InlineLinkText>
</Text>
<ProfileBadges
profile={data.joinLinkPreviews[0].owner}
profile={joinLinkPreview.owner}
size="sm"
style={{marginTop: -3}}
/>
+5 -1
View File
@@ -9,6 +9,7 @@ import {
AppBskyFeedPost,
BlobRef,
type BskyAgent,
ChatBskyGroupDefs,
type ComAtprotoLabelDefs,
type ComAtprotoRepoApplyWrites,
type ComAtprotoRepoStrongRef,
@@ -463,7 +464,10 @@ async function resolveMedia(
},
}
}
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
if (
resolvedLink.type === 'chat-invite' &&
ChatBskyGroupDefs.isJoinLinkPreviewView(resolvedLink.view)
) {
return {
$type: 'app.bsky.embed.external',
external: {
+6 -6
View File
@@ -1,8 +1,7 @@
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type BskyAgent,
type ChatBskyGroupDefs,
type AtpAgent,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
@@ -28,6 +27,7 @@ import {
} from '#/lib/strings/url-helpers'
import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery'
import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type Gif} from '#/features/gifPicker/types'
import {createGIFDescription} from '../gif-alt-text'
@@ -77,7 +77,7 @@ type ResolvedChatInvite = {
type: 'chat-invite'
uri: string
code: string
view?: ChatBskyGroupDefs.JoinLinkPreviewView
view?: ChatInvitePreview
}
export type ResolvedLink =
@@ -95,7 +95,7 @@ export class EmbeddingDisabledError extends Error {
}
export async function resolveLink(
agent: BskyAgent,
agent: AtpAgent,
uri: string,
): Promise<ResolvedLink> {
if (isShortLink(uri)) {
@@ -217,7 +217,7 @@ export async function resolveLink(
}
export async function resolveGif(
agent: BskyAgent,
agent: AtpAgent,
gif: Gif,
): Promise<ResolvedExternalLink> {
const gifUrl = gif.media_formats.gif.url
@@ -259,7 +259,7 @@ function getFileSlug(url: string | undefined): string | undefined {
}
async function resolveExternal(
agent: BskyAgent,
agent: AtpAgent,
uri: string,
): Promise<ResolvedExternalLink> {
const result = await getLinkMeta(agent, uri)
+24 -26
View File
@@ -1,6 +1,6 @@
import {View} from 'react-native'
import {ImageBackground} from 'expo-image'
import {moderateProfile} from '@atproto/api'
import {ChatBskyGroupDefs, moderateProfile} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -52,9 +52,7 @@ export function JoinRequest({setScreenState}: Props) {
? mobileDarkBg
: mobileLightBg
const requiresApproval = data?.joinLinkPreviews[0]?.requireApproval
const requiresFollow =
data?.joinLinkPreviews[0]?.joinRule === 'followedByOwner'
const joinLinkPreview = data?.joinLinkPreviews[0]
return (
<View style={[a.util_screen_outer, a.w_full, t.atoms.bg_contrast_25]}>
@@ -69,7 +67,9 @@ export function JoinRequest({setScreenState}: Props) {
a.justify_center,
a.align_center,
]}>
{error ? (
{error ||
(data &&
!ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) ? (
<Wrapper>
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
<Text
@@ -80,20 +80,19 @@ export function JoinRequest({setScreenState}: Props) {
a.font_semi_bold,
t.atoms.text,
]}>
{l`This invite link has expired`}
<Trans>Chat invite link no longer available</Trans>
</Text>
<ActionButtons setScreenState={setScreenState} />
</Wrapper>
) : data && moderationOpts ? (
) : data &&
moderationOpts &&
ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview) ? (
<Wrapper>
<AvatarBubbles
profiles={[
data.joinLinkPreviews[0].owner,
joinLinkPreview.owner,
...Array(
Math.min(
3,
Math.max(0, data.joinLinkPreviews[0].memberCount - 1),
),
Math.min(3, Math.max(0, joinLinkPreview.memberCount - 1)),
).fill(undefined),
]}
size={135}
@@ -130,8 +129,8 @@ export function JoinRequest({setScreenState}: Props) {
<Trans
context="group-chat-member-count"
comment="The number of active group chat members out of the total number allowed.">
{data.joinLinkPreviews[0].memberCount}/
{data.joinLinkPreviews[0].memberLimit}
{joinLinkPreview.memberCount}/
{joinLinkPreview.memberLimit}
</Trans>
</Text>
</View>
@@ -144,7 +143,7 @@ export function JoinRequest({setScreenState}: Props) {
a.font_bold,
t.atoms.text,
]}>
{data.joinLinkPreviews[0].name}
{joinLinkPreview.name}
</Text>
</View>
<View style={[a.w_full]}>
@@ -168,17 +167,17 @@ export function JoinRequest({setScreenState}: Props) {
<Trans comment="The owner (creator) of a group chat.">
By{' '}
{createSanitizedDisplayName(
data.joinLinkPreviews[0].owner,
joinLinkPreview.owner,
true,
moderateProfile(
data.joinLinkPreviews[0].owner,
joinLinkPreview.owner,
moderationOpts,
).ui('displayName'),
)}
</Trans>
</Text>
<ProfileBadges
profile={data.joinLinkPreviews[0].owner}
profile={joinLinkPreview.owner}
size="sm"
style={{marginTop: -4}}
/>
@@ -192,7 +191,7 @@ export function JoinRequest({setScreenState}: Props) {
t.atoms.text_contrast_medium,
a.max_w_full,
]}>
{sanitizeHandle(data.joinLinkPreviews[0].owner.handle, '@')}
{sanitizeHandle(joinLinkPreview.owner.handle, '@')}
</Text>
</View>
<Text
@@ -202,17 +201,16 @@ export function JoinRequest({setScreenState}: Props) {
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{requiresApproval
{joinLinkPreview.requireApproval
? l`Sign in to request access to this group chat.`
: l`Sign in to accept invite.`}{' '}
{requiresFollow &&
{joinLinkPreview.joinRule === 'followedByOwner' &&
l`Only people ${createSanitizedDisplayName(
data.joinLinkPreviews[0].owner,
joinLinkPreview.owner,
true,
moderateProfile(
data.joinLinkPreviews[0].owner,
moderationOpts,
).ui('displayName'),
moderateProfile(joinLinkPreview.owner, moderationOpts).ui(
'displayName',
),
)} follows can join.`}
</Text>
<ActionButtons setScreenState={setScreenState} />
@@ -4,6 +4,7 @@ import {
AppBskyFeedPost,
AppBskyRichtextFacet,
AtUri,
ChatBskyGroupDefs,
moderatePost,
RichText as RichTextAPI,
} from '@atproto/api'
@@ -30,6 +31,7 @@ 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'
@@ -52,12 +54,12 @@ export function useMessageEmbed() {
const navigation = useNavigation<NavigationProp>()
const embedFromParams = route.params.embed
const [embed, setEmbedState] = useState<MessageEmbedState | undefined>(
const [embed, setEmbed] = useState<MessageEmbedState | undefined>(
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
)
if (embedFromParams && embed?.type !== 'post') {
setEmbedState({type: 'post', uri: embedFromParams})
setEmbed({type: 'post', uri: embedFromParams})
}
return {
@@ -68,7 +70,7 @@ export function useMessageEmbed() {
// Only the post embed is reflected in the route param (used by the
// share-to-DM intent flow); invites are local-only.
navigation.setParams({embed: ''})
setEmbedState(undefined)
setEmbed(undefined)
return
}
@@ -77,7 +79,7 @@ export function useMessageEmbed() {
if (isBskyChatInviteUrl(embedUrl)) {
const code = getChatInviteCodeFromUrl(embedUrl)
if (code) {
setEmbedState({type: 'invite', code})
setEmbed({type: 'invite', code})
}
return
}
@@ -86,7 +88,7 @@ export function useMessageEmbed() {
const url = convertBskyAppUrlIfNeeded(embedUrl)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
setEmbedState({type: 'post', uri})
setEmbed({type: 'post', uri})
}
},
[embedFromParams, navigation],
@@ -314,11 +316,19 @@ function MessageInputInviteEmbedBody() {
)
}
if (!preview) {
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
return (
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
<Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}>
<Trans>Could not load invite</Trans>
<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>
)
@@ -94,7 +94,8 @@ export function MessagesListGroupInfoPanel({
/>
{convo.details.name ? (
<Text
style={[a.text_2xl, a.font_bold, a.mt_lg, a.px_xl, a.text_center]}>
style={[a.text_2xl, a.font_bold, a.mt_lg, a.px_xl, a.text_center]}
emoji>
{convo.details.name}
</Text>
) : null}
@@ -107,7 +108,8 @@ export function MessagesListGroupInfoPanel({
a.text_sm,
t.atoms.text_contrast_high,
showButtons ? null : a.mb_4xl,
]}>
]}
emoji>
{names}
</Text>
) : null}
@@ -60,7 +60,8 @@ export function MessagesListInfoPanel({
]}>
<Text
style={[a.text_2xl, a.font_bold, a.text_center, a.flex_shrink]}
numberOfLines={1}>
numberOfLines={1}
emoji>
{displayName}
</Text>
<ProfileBadges profile={profile} size="lg" />
@@ -76,7 +76,14 @@ export function OutgoingRequestListItem({
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.pb_2xs]}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_xs,
a.pb_2xs,
]}>
<View style={[a.flex_shrink]}>
<Text
emoji
@@ -85,8 +92,8 @@ export function OutgoingRequestListItem({
{convoView.name}
</Text>
</View>
<View style={[a.pl_xs]}>
<TimeElapsed timestamp={convoView.requestedAt}>
{convoView.viewer?.requestedAt ? (
<TimeElapsed timestamp={convoView.viewer.requestedAt}>
{({timeElapsed}) => (
<Text
style={[
@@ -98,7 +105,7 @@ export function OutgoingRequestListItem({
</Text>
)}
</TimeElapsed>
</View>
) : null}
</View>
<Text
numberOfLines={1}
+60 -5
View File
@@ -1,17 +1,48 @@
import {useCallback} from 'react'
import {
type $Typed,
AtpAgent,
type ChatBskyGroupDefs,
ChatBskyGroupDefs,
type ChatBskyGroupGetJoinLinkPreviews,
} from '@atproto/api'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {STALE} from '#/state/queries/index'
import {createQueryKey} from '#/state/queries/util'
import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
/**
* The three preview shapes we currently support. Excludes the `{$type: string}`
* open-union fallback for unrecognized future variants - use
* `ChatInvitePreview` for that.
*/
export type KnownChatInvitePreview =
| $Typed<ChatBskyGroupDefs.JoinLinkPreviewView>
| $Typed<ChatBskyGroupDefs.DisabledJoinLinkPreviewView>
| $Typed<ChatBskyGroupDefs.InvalidJoinLinkPreviewView>
/**
* The full open-union shape, including the `{$type: string}` fallback for
* future variants.
*/
export type ChatInvitePreview = KnownChatInvitePreview | {$type: string}
/**
* Narrows a preview to one of the three known variants, filtering out the
* `{$type: string}` open-union fallback for unrecognized future shapes.
*/
export function isKnownJoinLinkPreview(
preview: unknown,
): preview is KnownChatInvitePreview {
return (
ChatBskyGroupDefs.isJoinLinkPreviewView(preview) ||
ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(preview) ||
ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(preview)
)
}
const joinLinkPreviewQueryKeyRoot = 'join-link-preview'
export const createJoinLinkPreviewQueryKey = (args: {
@@ -22,6 +53,29 @@ export const createJoinLinkPreviewQueryKey = (args: {
persistedVersion: 1,
})
/**
* Invalidate any join link preview queries whose `codes` include the given
* code. Use this when a link's state changes (e.g. it's disabled) so cached
* previews refetch and reflect the new state.
*/
export function invalidateJoinLinkPreviewsForCode(
queryClient: QueryClient,
code: string,
) {
return queryClient.invalidateQueries({
predicate: query => {
const [root, args] = query.queryKey as Partial<
StructuredQueryKey<{codes?: string[]}>
>
return (
root === joinLinkPreviewQueryKeyRoot &&
Array.isArray(args?.codes) &&
args.codes.includes(code)
)
},
})
}
async function fetchJoinLinkPreviews({
agent,
codes,
@@ -104,7 +158,7 @@ export function useGetJoinLinkPreview() {
}: {
code: string
hasSession: boolean
}): Promise<ChatBskyGroupDefs.JoinLinkPreviewView | undefined> => {
}): Promise<KnownChatInvitePreview | undefined> => {
try {
const data = await queryClient.fetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
@@ -112,7 +166,8 @@ export function useGetJoinLinkPreview() {
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
staleTime: STALE.SECONDS.FIFTEEN,
})
return data.joinLinkPreviews[0]
const found = data.joinLinkPreviews[0]
return isKnownJoinLinkPreview(found) ? found : undefined
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
return undefined
@@ -6,6 +6,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {invalidateJoinLinkPreviewsForCode} from '#/state/queries/join-links'
import {useAgent} from '#/state/session'
import {
rollbackConvoOptimistic,
@@ -59,6 +60,7 @@ export function useDisableJoinLink(
}
})
}
void invalidateJoinLinkPreviewsForCode(queryClient, data.joinLink.code)
onSuccess?.(data)
},
onError: (e, _variables, context) => {
@@ -3,6 +3,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {invalidateJoinLinkPreviewsForCode} from '#/state/queries/join-links'
import {useAgent} from '#/state/session'
import {
rollbackConvoOptimistic,
@@ -56,6 +57,7 @@ export function useEnableJoinLink(
}
})
}
void invalidateJoinLinkPreviewsForCode(queryClient, data.joinLink.code)
onSuccess?.(data)
},
onError: (e, _variables, context) => {
+4 -1
View File
@@ -52,6 +52,7 @@ import {
type AppBskyUnspeccedGetPostThreadV2,
AtUri,
type BskyAgent,
ChatBskyGroupDefs,
type RichText,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
@@ -886,7 +887,9 @@ export const ComposePost = ({
})),
})
const hasUnavailableChatInvite = linkQueries.some(
q => q.data?.type === 'chat-invite' && !q.data.view,
q =>
q.data?.type === 'chat-invite' &&
!ChatBskyGroupDefs.isJoinLinkPreviewView(q.data.view),
)
const canPost =