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