Add group chat join links to supported embed types (#10454)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {describe, expect, it} from '@jest/globals'
|
import {describe, expect, it} from '@jest/globals'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getChatInviteCodeFromUrl,
|
||||||
isPossiblyAUrl,
|
isPossiblyAUrl,
|
||||||
isTrustedUrl,
|
isTrustedUrl,
|
||||||
linkRequiresWarning,
|
linkRequiresWarning,
|
||||||
@@ -178,3 +179,47 @@ describe('isTrustedUrl', () => {
|
|||||||
expect(output).toEqual(expected)
|
expect(output).toEqual(expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getChatInviteCodeFromUrl', () => {
|
||||||
|
type Case = [string, string | undefined]
|
||||||
|
|
||||||
|
const cases: Case[] = [
|
||||||
|
['https://bsky.app/c/abcdefg', 'abcdefg'],
|
||||||
|
['https://bsky.app/c/abcdefghij', 'abcdefghij'],
|
||||||
|
// http is not recognized as a bsky.app url
|
||||||
|
['http://bsky.app/c/abcdefg', undefined],
|
||||||
|
['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
|
||||||
|
['https://bsky.app/c/abcdefg#section', 'abcdefg'],
|
||||||
|
['/c/abcdefg', 'abcdefg'],
|
||||||
|
['/c/abcdefg?utm=foo', 'abcdefg'],
|
||||||
|
['/c/abcdefg#section', 'abcdefg'],
|
||||||
|
|
||||||
|
// too short
|
||||||
|
['https://bsky.app/c/abcdef', undefined],
|
||||||
|
['/c/abcdef', undefined],
|
||||||
|
// too long
|
||||||
|
['https://bsky.app/c/abcdefghijk', undefined],
|
||||||
|
['/c/abcdefghijk', undefined],
|
||||||
|
// invalid characters
|
||||||
|
['https://bsky.app/c/abc-def', undefined],
|
||||||
|
['/c/abc def', undefined],
|
||||||
|
// trailing path
|
||||||
|
['https://bsky.app/c/abcdefg/extra', undefined],
|
||||||
|
['/c/abcdefg/extra', undefined],
|
||||||
|
// wrong path
|
||||||
|
['https://bsky.app/profile/abcdefg', undefined],
|
||||||
|
['https://bsky.app/c', undefined],
|
||||||
|
// wrong host
|
||||||
|
['https://example.com/c/abcdefg', undefined],
|
||||||
|
// not a url, not a path
|
||||||
|
['c/abcdefg', undefined],
|
||||||
|
['abcdefg', undefined],
|
||||||
|
['', undefined],
|
||||||
|
// malformed url
|
||||||
|
['https://[invalid/c/abcdefg', undefined],
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(cases)('given input %p, returns %p', (input, expected) => {
|
||||||
|
expect(getChatInviteCodeFromUrl(input)).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -811,14 +811,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/lib/api/index.ts": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 5
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": {
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/lib/async/retry.ts": {
|
"src/lib/async/retry.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||||
|
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||||
|
|
||||||
|
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
|
||||||
|
import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed'
|
||||||
|
|
||||||
|
export function ChatInviteEmbed({
|
||||||
|
code,
|
||||||
|
link,
|
||||||
|
onOpen,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
code: string
|
||||||
|
link: AppBskyEmbedExternal.ViewExternal
|
||||||
|
onOpen?: () => void
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const {data, error, isPending} = useJoinLinkPreviewsQuery({
|
||||||
|
codes: [code],
|
||||||
|
hasSession,
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview = data?.joinLinkPreviews[0]
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JoinRequestEmbed
|
||||||
|
loading={isPending}
|
||||||
|
preview={preview}
|
||||||
|
style={[a.mt_sm, style]}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||||
|
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||||
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
type ButtonColor,
|
||||||
|
ButtonIcon,
|
||||||
|
ButtonText,
|
||||||
|
} from '#/components/Button'
|
||||||
|
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||||||
|
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
|
||||||
|
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
|
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 {Loader} from '#/components/Loader'
|
||||||
|
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const JOIN_REQUEST_EMBED_HEIGHT = 152
|
||||||
|
|
||||||
|
export function JoinRequestEmbed({
|
||||||
|
loading = false,
|
||||||
|
preview,
|
||||||
|
style,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
loading?: boolean
|
||||||
|
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
onOpen?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JoinRequestEmbedInner preview={preview} style={style} onOpen={onOpen} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function JoinRequestEmbedInner({
|
||||||
|
preview,
|
||||||
|
style,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
preview: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
onOpen?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs()
|
||||||
|
|
||||||
|
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
|
||||||
|
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
|
||||||
|
|
||||||
|
const avatarProfiles = preview.convo?.members ?? [preview.owner]
|
||||||
|
|
||||||
|
const convoId = preview.convo?.id
|
||||||
|
const isFollowing = preview.owner.viewer?.following ?? false
|
||||||
|
const hasRequested = !convoId && preview.viewer?.requestedAt != null
|
||||||
|
|
||||||
|
let canJoin = true
|
||||||
|
let ButtonIconImage = JoinIcon
|
||||||
|
let buttonText = preview.requireApproval ? l`Request to join` : l`Join`
|
||||||
|
let buttonColor: ButtonColor = 'primary'
|
||||||
|
if (preview.enabledStatus !== 'enabled') {
|
||||||
|
canJoin = false
|
||||||
|
ButtonIconImage = WarningIcon
|
||||||
|
buttonText = l`Chat invite link no longer available`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
} else if (preview.memberCount >= preview.memberLimit) {
|
||||||
|
canJoin = false
|
||||||
|
ButtonIconImage = HandIcon
|
||||||
|
buttonText = l`This chat is full`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
} else if (preview.joinRule === 'followedByOwner' && !isFollowing) {
|
||||||
|
canJoin = false
|
||||||
|
ButtonIconImage = HandIcon
|
||||||
|
buttonText = l`Only people the chat owner follows can join`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
} else if (hasRequested) {
|
||||||
|
ButtonIconImage = CheckIcon
|
||||||
|
buttonText = l`Requested`
|
||||||
|
buttonColor = 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
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.flex_row, a.gap_md, a.align_center]}>
|
||||||
|
<AvatarBubbles size={56} self profiles={avatarProfiles} />
|
||||||
|
<View style={[a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
style={[a.text_lg, a.font_bold, a.leading_tight, t.atoms.text]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{preview.name}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[a.flex_row, a.align_center, a.gap_sm, a.mt_2xs, a.mb_sm]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_tight,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}
|
||||||
|
allowFontScaling
|
||||||
|
numberOfLines={1}>
|
||||||
|
<Trans>Group chat</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_tight,
|
||||||
|
a.font_medium,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}
|
||||||
|
allowFontScaling
|
||||||
|
numberOfLines={1}>
|
||||||
|
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
|
||||||
|
{preview.memberCount}/{preview.memberLimit}{' '}
|
||||||
|
<Plural
|
||||||
|
value={preview.memberCount}
|
||||||
|
one="member"
|
||||||
|
other="members"
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
style={[
|
||||||
|
a.flex_shrink,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text,
|
||||||
|
]}
|
||||||
|
allowFontScaling
|
||||||
|
numberOfLines={1}>
|
||||||
|
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
|
||||||
|
By{' '}
|
||||||
|
<Text style={[a.font_medium, t.atoms.text]}>
|
||||||
|
{ownerDisplayName}
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
<ProfileBadges profile={preview.owner} size="sm" />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_shrink,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_medium,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}
|
||||||
|
allowFontScaling
|
||||||
|
numberOfLines={1}>
|
||||||
|
{ownerHandle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{convoId ? (
|
||||||
|
<Button
|
||||||
|
testID="openButton"
|
||||||
|
onPress={() => {
|
||||||
|
onOpen?.()
|
||||||
|
navigation.navigate('MessagesConversation', {conversation: convoId})
|
||||||
|
}}
|
||||||
|
label={l`Open group chat`}
|
||||||
|
accessibilityHint={l`Tap to open this group chat`}
|
||||||
|
size="large"
|
||||||
|
color="primary"
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Open chat</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={ArrowRightIcon} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
testID="joinButton"
|
||||||
|
onPress={() => {
|
||||||
|
onOpen?.()
|
||||||
|
setGroupChatJoinState({code: preview.code})
|
||||||
|
groupChatJoinDialogControl.open()
|
||||||
|
}}
|
||||||
|
label={
|
||||||
|
preview.requireApproval
|
||||||
|
? l`Request access to group chat`
|
||||||
|
: l`Join group chat`
|
||||||
|
}
|
||||||
|
accessibilityHint={
|
||||||
|
preview.requireApproval
|
||||||
|
? l`Tap to request access to join this group chat`
|
||||||
|
: l`Tap to join this group chat immediately`
|
||||||
|
}
|
||||||
|
size="large"
|
||||||
|
color={buttonColor}
|
||||||
|
disabled={!canJoin}
|
||||||
|
style={[a.w_full]}>
|
||||||
|
<ButtonIcon icon={ButtonIconImage} />
|
||||||
|
<ButtonText>{buttonText}</ButtonText>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
|
import {getChatInviteCodeFromUrl} from '#/lib/strings/url-helpers'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
type EmbedType,
|
type EmbedType,
|
||||||
parseEmbed,
|
parseEmbed,
|
||||||
} from '#/types/bsky/post'
|
} from '#/types/bsky/post'
|
||||||
|
import {ChatInviteEmbed} from './ChatInviteEmbed'
|
||||||
import {ExternalEmbed} from './ExternalEmbed'
|
import {ExternalEmbed} from './ExternalEmbed'
|
||||||
import {ModeratedFeedEmbed} from './FeedEmbed'
|
import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||||
import {ImageEmbed} from './ImageEmbed'
|
import {ImageEmbed} from './ImageEmbed'
|
||||||
@@ -110,6 +112,21 @@ function MediaEmbed({
|
|||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const chatInviteCode = getChatInviteCodeFromUrl(embed.view.external.uri)
|
||||||
|
if (chatInviteCode) {
|
||||||
|
return (
|
||||||
|
<ContentHider
|
||||||
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
|
activeStyle={[a.mt_sm]}>
|
||||||
|
<ChatInviteEmbed
|
||||||
|
code={chatInviteCode}
|
||||||
|
link={embed.view.external}
|
||||||
|
onOpen={rest.onOpen}
|
||||||
|
style={rest.style}
|
||||||
|
/>
|
||||||
|
</ContentHider>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
modui={rest.moderation?.ui('contentMedia')}
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
|||||||
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
|
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
|
||||||
codes: code ? [code] : undefined,
|
codes: code ? [code] : undefined,
|
||||||
hasSession,
|
hasSession,
|
||||||
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const {mutate: joinGroupChat, isPending: isJoinPending} =
|
const {mutate: joinGroupChat, isPending: isJoinPending} =
|
||||||
@@ -326,6 +327,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
|||||||
<View
|
<View
|
||||||
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
|
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
|
||||||
<Text
|
<Text
|
||||||
|
emoji
|
||||||
style={[
|
style={[
|
||||||
a.mb_2xs,
|
a.mb_2xs,
|
||||||
a.text_center,
|
a.text_center,
|
||||||
@@ -402,7 +404,9 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
|||||||
color="primary"
|
color="primary"
|
||||||
disabled={!code}
|
disabled={!code}
|
||||||
style={[a.w_full]}>
|
style={[a.w_full]}>
|
||||||
<ButtonText>Open chat</ButtonText>
|
<ButtonText>
|
||||||
|
<Trans>Open chat</Trans>
|
||||||
|
</ButtonText>
|
||||||
<ButtonIcon icon={ArrowRightIcon} />
|
<ButtonIcon icon={ArrowRightIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -416,8 +420,8 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
|||||||
}
|
}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
joinLinkPreview.requireApproval
|
joinLinkPreview.requireApproval
|
||||||
? l`Request access to join this group chat`
|
? l`Tap to request access to join this group chat`
|
||||||
: l`Join this group chat`
|
: l`Tap to join this group chat immediately`
|
||||||
}
|
}
|
||||||
size="large"
|
size="large"
|
||||||
color={buttonColor}
|
color={buttonColor}
|
||||||
|
|||||||
+16
-2
@@ -177,7 +177,8 @@ export async function post(
|
|||||||
writes: writes,
|
writes: writes,
|
||||||
validate: true,
|
validate: true,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
|
const e = err as Error
|
||||||
logger.error(`Failed to create post`, {
|
logger.error(`Failed to create post`, {
|
||||||
safeMessage: e.message,
|
safeMessage: e.message,
|
||||||
})
|
})
|
||||||
@@ -427,6 +428,16 @@ async function resolveMedia(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
|
||||||
|
return {
|
||||||
|
$type: 'app.bsky.embed.external',
|
||||||
|
external: {
|
||||||
|
uri: resolvedLink.uri,
|
||||||
|
title: resolvedLink.view.name,
|
||||||
|
description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@@ -470,6 +481,7 @@ async function computeCid(record: AppBskyFeedPost.Record): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns a transformed version of the object for use in DAG-CBOR.
|
// Returns a transformed version of the object for use in DAG-CBOR.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function prepareForHashing(v: any): any {
|
function prepareForHashing(v: any): any {
|
||||||
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
|
// IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing,
|
||||||
// the API client will convert this for you but we're hashing in the client,
|
// the API client will convert this for you but we're hashing in the client,
|
||||||
@@ -492,9 +504,10 @@ function prepareForHashing(v: any): any {
|
|||||||
|
|
||||||
// Walk through plain objects
|
// Walk through plain objects
|
||||||
if (isPlainObject(v)) {
|
if (isPlainObject(v)) {
|
||||||
const obj: any = {}
|
const obj: Record<string, unknown> = {}
|
||||||
let pure = true
|
let pure = true
|
||||||
for (const key in v) {
|
for (const key in v) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
let value = v[key]
|
let value = v[key]
|
||||||
// `value` is undefined
|
// `value` is undefined
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
@@ -513,6 +526,7 @@ function prepareForHashing(v: any): any {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function isPlainObject(v: any): boolean {
|
function isPlainObject(v: any): boolean {
|
||||||
if (typeof v !== 'object' || v === null) {
|
if (typeof v !== 'object' || v === null) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+24
-1
@@ -2,11 +2,12 @@ import {
|
|||||||
type AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
type AppBskyGraphDefs,
|
type AppBskyGraphDefs,
|
||||||
type BskyAgent,
|
type BskyAgent,
|
||||||
|
type ChatBskyGroupDefs,
|
||||||
type ComAtprotoRepoStrongRef,
|
type ComAtprotoRepoStrongRef,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
|
|
||||||
import {POST_IMG_MAX} from '#/lib/constants'
|
import {DM_SERVICE_HEADERS, POST_IMG_MAX} from '#/lib/constants'
|
||||||
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
|
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
|
||||||
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
|
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
|
||||||
import {downloadAndResize} from '#/lib/media/manip'
|
import {downloadAndResize} from '#/lib/media/manip'
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
} from '#/lib/strings/starter-pack'
|
} from '#/lib/strings/starter-pack'
|
||||||
import {
|
import {
|
||||||
convertBskyAppUrlIfNeeded,
|
convertBskyAppUrlIfNeeded,
|
||||||
|
getChatInviteCodeFromUrl,
|
||||||
isBskyCustomFeedUrl,
|
isBskyCustomFeedUrl,
|
||||||
isBskyListUrl,
|
isBskyListUrl,
|
||||||
isBskyPostUrl,
|
isBskyPostUrl,
|
||||||
@@ -71,12 +73,20 @@ type ResolvedStarterPackRecord = {
|
|||||||
view: AppBskyGraphDefs.StarterPackView
|
view: AppBskyGraphDefs.StarterPackView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResolvedChatInvite = {
|
||||||
|
type: 'chat-invite'
|
||||||
|
uri: string
|
||||||
|
code: string
|
||||||
|
view?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||||
|
}
|
||||||
|
|
||||||
export type ResolvedLink =
|
export type ResolvedLink =
|
||||||
| ResolvedExternalLink
|
| ResolvedExternalLink
|
||||||
| ResolvedPostRecord
|
| ResolvedPostRecord
|
||||||
| ResolvedFeedRecord
|
| ResolvedFeedRecord
|
||||||
| ResolvedListRecord
|
| ResolvedListRecord
|
||||||
| ResolvedStarterPackRecord
|
| ResolvedStarterPackRecord
|
||||||
|
| ResolvedChatInvite
|
||||||
|
|
||||||
export class EmbeddingDisabledError extends Error {
|
export class EmbeddingDisabledError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -141,6 +151,19 @@ export async function resolveLink(
|
|||||||
view: res.data.list,
|
view: res.data.list,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const chatInviteCode = getChatInviteCodeFromUrl(uri)
|
||||||
|
if (chatInviteCode) {
|
||||||
|
const res = await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||||
|
{codes: [chatInviteCode]},
|
||||||
|
{headers: DM_SERVICE_HEADERS},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
type: 'chat-invite',
|
||||||
|
uri,
|
||||||
|
code: chatInviteCode,
|
||||||
|
view: res.data.joinLinkPreviews[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
|
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
|
||||||
const parsed = parseStarterPackUri(uri)
|
const parsed = parseStarterPackUri(uri)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
import psl from 'psl'
|
import {parse} from 'psl'
|
||||||
import TLDs from 'tlds'
|
import TLDs from 'tlds'
|
||||||
|
|
||||||
import {BSKY_SERVICE} from '#/lib/constants'
|
import {BSKY_SERVICE} from '#/lib/constants'
|
||||||
@@ -178,6 +178,7 @@ export function isBskyStarterPackUrl(url: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof.
|
||||||
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
|
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
|
||||||
|
|
||||||
export function getChatInviteCodeFromUrl(url: string): string | undefined {
|
export function getChatInviteCodeFromUrl(url: string): string | undefined {
|
||||||
@@ -328,7 +329,7 @@ export function isPossiblyAUrl(str: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function splitApexDomain(hostname: string): [string, string] {
|
export function splitApexDomain(hostname: string): [string, string] {
|
||||||
const hostnamep = psl.parse(hostname)
|
const hostnamep = parse(hostname)
|
||||||
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
|
if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
|
||||||
return ['', hostname]
|
return ['', hostname]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ export const createJoinLinkPreviewQueryKey = (args: {
|
|||||||
export function useJoinLinkPreviewsQuery({
|
export function useJoinLinkPreviewsQuery({
|
||||||
codes,
|
codes,
|
||||||
hasSession,
|
hasSession,
|
||||||
|
staleTime = STALE.MINUTES.ONE,
|
||||||
}: {
|
}: {
|
||||||
codes?: string[]
|
codes?: string[]
|
||||||
hasSession: boolean
|
hasSession: boolean
|
||||||
|
staleTime?: number
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export function useJoinLinkPreviewsQuery({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: codes != null && codes.length > 0,
|
enabled: codes != null && codes.length > 0,
|
||||||
staleTime: STALE.SECONDS.FIFTEEN,
|
staleTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ export function usePrefetchJoinLinkPreviews() {
|
|||||||
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
staleTime: STALE.SECONDS.FIFTEEN,
|
staleTime: STALE.MINUTES.ONE,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {atoms as a, useTheme} from '#/alf'
|
|||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
|
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
|
||||||
import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
|
import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
|
||||||
|
import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed'
|
||||||
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
|
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
|
||||||
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
|
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
|
||||||
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
|
||||||
@@ -115,6 +116,8 @@ export const ExternalEmbedLink = ({
|
|||||||
hideAlt
|
hideAlt
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else if (data.type === 'chat-invite') {
|
||||||
|
return <JoinRequestEmbed preview={data.view} />
|
||||||
} else if (data.kind === 'feed') {
|
} else if (data.kind === 'feed') {
|
||||||
return (
|
return (
|
||||||
<ModeratedFeedEmbed
|
<ModeratedFeedEmbed
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
|
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
@@ -11,12 +10,12 @@ export function ExternalEmbedRemoveBtn({
|
|||||||
style,
|
style,
|
||||||
}: {onRemove: () => void} & ViewStyleProp) {
|
}: {onRemove: () => void} & ViewStyleProp) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.absolute, {top: 8, right: 8}, a.z_50, style]}>
|
<View style={[a.absolute, {top: 8, right: 8}, a.z_50, style]}>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Remove attachment`)}
|
label={l`Remove attachment`}
|
||||||
onPress={onRemove}
|
onPress={onRemove}
|
||||||
size="small"
|
size="small"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
|
|||||||
Reference in New Issue
Block a user