[Chat] Add chat invite as message embed type (#10728)
This commit is contained in:
@@ -133,3 +133,4 @@ bskyweb/static/media/*.svg
|
||||
|
||||
# superpowers plugin plans/specs — local-only workspace
|
||||
docs/superpowers/
|
||||
.claude/worktrees
|
||||
|
||||
@@ -124,11 +124,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Button.tsx": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Composer/index.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -261,11 +256,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ExternalEmbed/index.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
@@ -45,7 +45,7 @@ export type ButtonColor =
|
||||
| 'negative'
|
||||
| 'primary_subtle'
|
||||
| 'negative_subtle'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -136,7 +136,7 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant,
|
||||
variant: variantProp,
|
||||
color,
|
||||
size,
|
||||
shape = 'default',
|
||||
@@ -160,7 +160,8 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
* If a `color` is set, then we want to use the existing codepaths for
|
||||
* "solid" buttons. This is to maintain backwards compatibility.
|
||||
*/
|
||||
if (!variant && color) {
|
||||
let variant: VariantProps['variant'] = variantProp
|
||||
if (!variantProp && color) {
|
||||
variant = 'solid'
|
||||
}
|
||||
|
||||
@@ -458,6 +459,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
paddingHorizontal: 24,
|
||||
gap: 6,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 28,
|
||||
gap: 5,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.rounded_full, {
|
||||
paddingVertical: 8,
|
||||
@@ -479,6 +486,13 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
borderRadius: 10,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
gap: 3,
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 8,
|
||||
@@ -505,6 +519,12 @@ export const Button = forwardRef<View, ButtonProps>(
|
||||
} else {
|
||||
baseStyles.push({height: 44, width: 44})
|
||||
}
|
||||
} else if (size === 'medium') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
} else {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 33, width: 33})
|
||||
@@ -758,6 +778,8 @@ export function useSharedButtonTextStyles() {
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.text_md, a.font_medium)
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.text_sm, a.font_medium)
|
||||
} else if (size === 'tiny') {
|
||||
@@ -799,6 +821,7 @@ export function ButtonIcon({
|
||||
size ??
|
||||
(({
|
||||
large: 'md',
|
||||
medium: 'sm',
|
||||
small: 'sm',
|
||||
tiny: 'xs',
|
||||
}[buttonSize || 'small'] || 'sm') as Exclude<
|
||||
@@ -828,6 +851,7 @@ export function ButtonIcon({
|
||||
*/
|
||||
const iconContainerSize = {
|
||||
large: 20,
|
||||
medium: 17,
|
||||
small: 17,
|
||||
tiny: 15,
|
||||
}[buttonSize || 'small']
|
||||
@@ -841,6 +865,7 @@ export function ButtonIcon({
|
||||
if (buttonShape === 'default') {
|
||||
iconNegativeMargin = {
|
||||
large: -2,
|
||||
medium: -2,
|
||||
small: -2,
|
||||
tiny: -1,
|
||||
}[buttonSize || 'small']
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
|
||||
import {JoinRequestEmbed} from '#/components/Post/Embed/JoinRequestEmbed'
|
||||
import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed'
|
||||
|
||||
/**
|
||||
* Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g.
|
||||
* a `bsky.app/c/<code>` link posted to the feed) as a join request card,
|
||||
* falling back to a plain external embed if the invite can't be resolved.
|
||||
*/
|
||||
export function ChatInviteEmbed({
|
||||
code,
|
||||
link,
|
||||
@@ -18,24 +22,27 @@ export function ChatInviteEmbed({
|
||||
onOpen?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
const {data, error, isPending} = useJoinLinkPreviewsQuery({
|
||||
codes: [code],
|
||||
hasSession,
|
||||
})
|
||||
return (
|
||||
<ChatInvite.Root code={code}>
|
||||
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
|
||||
</ChatInvite.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const preview = data?.joinLinkPreviews[0]
|
||||
function ChatInviteEmbedBody({
|
||||
link,
|
||||
onOpen,
|
||||
style,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
onOpen?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {error} = ChatInvite.useChatInvite()
|
||||
|
||||
if (error) {
|
||||
return <ExternalEmbed link={link} onOpen={onOpen} style={style} />
|
||||
}
|
||||
|
||||
return (
|
||||
<JoinRequestEmbed
|
||||
loading={isPending}
|
||||
preview={preview}
|
||||
style={[a.mt_sm, style]}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)
|
||||
return <JoinRequestEmbedBody style={[a.mt_sm, style]} onOpen={onOpen} />
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const ExternalEmbed = ({
|
||||
const onShareExternal = useCallback(() => {
|
||||
if (link.uri && IS_NATIVE) {
|
||||
playHaptic('Heavy')
|
||||
shareUrl(link.uri)
|
||||
void shareUrl(link.uri)
|
||||
}
|
||||
}, [link.uri, playHaptic])
|
||||
|
||||
|
||||
@@ -1,43 +1,56 @@
|
||||
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 {Trans} from '@lingui/react/macro'
|
||||
|
||||
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 * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
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
|
||||
const JOIN_REQUEST_EMBED_HEIGHT = 140
|
||||
|
||||
/**
|
||||
* The "join request" presentation of a chat invite, used as a post embed (in
|
||||
* feeds and the post composer). Composes the headless `ChatInvite` primitive:
|
||||
* pass either a `code` to fetch by, or an already-resolved `preview` as the
|
||||
* initial data to avoid a loading flash.
|
||||
*/
|
||||
export function JoinRequestEmbed({
|
||||
loading = false,
|
||||
code,
|
||||
preview,
|
||||
style,
|
||||
onOpen,
|
||||
}: {
|
||||
loading?: boolean
|
||||
code?: string
|
||||
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
style?: StyleProp<ViewStyle>
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const resolvedCode = code ?? preview?.code
|
||||
if (!resolvedCode) return null
|
||||
|
||||
return (
|
||||
<ChatInvite.Root code={resolvedCode} initialPreview={preview}>
|
||||
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
|
||||
</ChatInvite.Root>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The context-consuming presentation (loading / no-longer-available / card +
|
||||
* join button). Exported so surfaces that own their own `ChatInvite.Root` (e.g.
|
||||
* to add an error fallback) can render it without nesting another Root.
|
||||
*/
|
||||
export function JoinRequestEmbedBody({
|
||||
style,
|
||||
onOpen,
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {loading, preview} = ChatInvite.useChatInvite()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -81,60 +94,6 @@ export function JoinRequestEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
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={[
|
||||
@@ -147,125 +106,8 @@ function JoinRequestEmbedInner({
|
||||
{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>
|
||||
)}
|
||||
<ChatInvite.Card size="large" />
|
||||
<ChatInvite.JoinButton onPress={onOpen} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {View} from 'react-native'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useChatInvite} from './Context'
|
||||
|
||||
/**
|
||||
* Presentational preview of a chat invite: member avatars, group name, member
|
||||
* count, and owner. Reads the preview from `ChatInvite.Root` context. Renders
|
||||
* nothing if there's no preview (use a fallback alongside it for that case).
|
||||
*/
|
||||
export function Card({size}: {size: 'large' | 'small'}) {
|
||||
const t = useTheme()
|
||||
const {preview} = useChatInvite()
|
||||
|
||||
if (!preview) return null
|
||||
|
||||
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
|
||||
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
|
||||
const avatarProfiles = preview.convo?.members ?? [preview.owner]
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.align_center]}>
|
||||
<AvatarBubbles size={56} self profiles={avatarProfiles} />
|
||||
<View style={[a.flex_1, size === 'large' ? a.gap_2xs : a.gap_xs]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]}
|
||||
numberOfLines={1}>
|
||||
{preview.name}
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text
|
||||
style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]}
|
||||
allowFontScaling
|
||||
numberOfLines={1}>
|
||||
<Trans>Group chat</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xs,
|
||||
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,
|
||||
size === 'large' && a.mt_2xs,
|
||||
]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.flex_shrink, a.text_sm, a.font_medium]}
|
||||
allowFontScaling
|
||||
numberOfLines={1}>
|
||||
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
|
||||
By <Text style={[a.font_medium]}>{ownerDisplayName}</Text>
|
||||
</Trans>
|
||||
</Text>
|
||||
<ProfileBadges profile={preview.owner} size="sm" />
|
||||
<Text
|
||||
style={[a.flex_shrink, t.atoms.text_contrast_medium]}
|
||||
allowFontScaling
|
||||
numberOfLines={1}>
|
||||
{ownerHandle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {createContext, useContext} from 'react'
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
|
||||
import {type ButtonColor} from '#/components/Button'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
/**
|
||||
* The derived state of the join/open action for a chat invite, computed once in
|
||||
* `Root` and consumed by `JoinButton` (or any custom action UI).
|
||||
*/
|
||||
export type ChatInviteAction = {
|
||||
label: string
|
||||
accessibilityHint: string
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
color: ButtonColor
|
||||
/**
|
||||
* Whether the action can be performed. False when the link is disabled, the
|
||||
* chat is full, or the viewer doesn't meet the join rule.
|
||||
*/
|
||||
disabled: boolean
|
||||
onPress: () => void
|
||||
side: 'left' | 'right'
|
||||
}
|
||||
|
||||
export type ChatInviteContextValue = {
|
||||
code: string
|
||||
loading: boolean
|
||||
error: boolean
|
||||
preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined
|
||||
/**
|
||||
* The derived action descriptor. Undefined while loading or when there's no
|
||||
* preview to act on.
|
||||
*/
|
||||
action: ChatInviteAction | undefined
|
||||
}
|
||||
|
||||
const ChatInviteContext = createContext<ChatInviteContextValue | null>(null)
|
||||
|
||||
export function useChatInvite(): ChatInviteContextValue {
|
||||
const ctx = useContext(ChatInviteContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useChatInvite must be used within a ChatInvite.Root')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export const ChatInviteProvider = ChatInviteContext.Provider
|
||||
@@ -0,0 +1,42 @@
|
||||
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useChatInvite} from './Context'
|
||||
|
||||
/**
|
||||
* The join/open action button for a chat invite. Reads the derived action from
|
||||
* `ChatInvite.Root` context. Pass `onPress` to intercept (e.g. to close a
|
||||
* surface before navigating); it runs before the default action. Renders
|
||||
* nothing while loading or when there's no preview to act on.
|
||||
*/
|
||||
export function JoinButton({
|
||||
onPress,
|
||||
style,
|
||||
}: {
|
||||
onPress?: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {action} = useChatInvite()
|
||||
|
||||
if (!action) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
testID="joinButton"
|
||||
onPress={() => {
|
||||
onPress?.()
|
||||
action.onPress()
|
||||
}}
|
||||
label={action.label}
|
||||
accessibilityHint={action.accessibilityHint}
|
||||
size="medium"
|
||||
color={action.color}
|
||||
disabled={action.disabled}
|
||||
style={[a.w_full, style]}>
|
||||
{action.side === 'left' && <ButtonIcon icon={action.icon} />}
|
||||
<ButtonText>{action.label}</ButtonText>
|
||||
{action.side === 'right' && <ButtonIcon icon={action.icon} />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {setStringAsync} from 'expo-clipboard'
|
||||
import {type 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 {useSession} from '#/state/session'
|
||||
import {type ButtonColor} 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 {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink'
|
||||
import {CheckThick_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'
|
||||
|
||||
/**
|
||||
* Headless data + state owner for a chat invite. Fetches the join link preview
|
||||
* by code and derives the join/open action, exposing both via context for the
|
||||
* composable parts (`Card`, `JoinButton`) or any custom UI to consume.
|
||||
*
|
||||
* Pass `initialPreview` when the preview is already known (e.g. a DM message
|
||||
* embed already carries the resolved view) to avoid a loading flash.
|
||||
*/
|
||||
export function Root({
|
||||
code,
|
||||
initialPreview,
|
||||
currentConvoId,
|
||||
children,
|
||||
}: {
|
||||
code: string
|
||||
initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
/**
|
||||
* 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
|
||||
* open/join (you're already here).
|
||||
*/
|
||||
currentConvoId?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
const {t: l} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {groupChatJoinDialogControl, setGroupChatJoinState} = useIntentDialogs()
|
||||
|
||||
const {data, error, isPending} = useJoinLinkPreviewsQuery({
|
||||
codes: [code],
|
||||
hasSession,
|
||||
// Seed the cache with the already-resolved preview so we don't refetch.
|
||||
initialData: initialPreview
|
||||
? {joinLinkPreviews: [initialPreview]}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const preview = data?.joinLinkPreviews[0]
|
||||
const loading = isPending && !preview
|
||||
|
||||
let action: ChatInviteAction | undefined
|
||||
if (preview) {
|
||||
const convoId = preview.convo?.id
|
||||
const isFollowing = preview.owner.viewer?.following ?? false
|
||||
const hasRequested = !convoId && preview.viewer?.requestedAt != null
|
||||
|
||||
if (convoId && convoId === currentConvoId) {
|
||||
// You're already in the chat this invite links to - offer to copy the
|
||||
// link rather than open/join.
|
||||
action = {
|
||||
label: l`Copy link`,
|
||||
accessibilityHint: l`Tap to copy this invite link`,
|
||||
icon: LinkIcon,
|
||||
side: 'left',
|
||||
color: 'primary',
|
||||
disabled: false,
|
||||
onPress: () => {
|
||||
void setStringAsync(`https://bsky.app/c/${preview.code}`)
|
||||
Toast.show(l`Copied to clipboard`, {type: 'success'})
|
||||
},
|
||||
}
|
||||
} else if (convoId) {
|
||||
action = {
|
||||
label: l`Open chat`,
|
||||
accessibilityHint: l`Tap to open this group chat`,
|
||||
icon: ArrowRightIcon,
|
||||
side: 'right',
|
||||
color: 'primary',
|
||||
disabled: false,
|
||||
onPress: () => {
|
||||
navigation.push('MessagesConversation', {conversation: convoId})
|
||||
},
|
||||
}
|
||||
} else {
|
||||
let canJoin = true
|
||||
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) {
|
||||
canJoin = false
|
||||
icon = HandIcon
|
||||
label = l`This chat is full`
|
||||
color = 'secondary'
|
||||
} else if (preview.joinRule === 'followedByOwner' && !isFollowing) {
|
||||
canJoin = false
|
||||
icon = HandIcon
|
||||
label = l`Only people the chat owner follows can join`
|
||||
color = 'secondary'
|
||||
} else if (hasRequested) {
|
||||
icon = CheckIcon
|
||||
label = l`Requested`
|
||||
color = 'secondary'
|
||||
}
|
||||
|
||||
action = {
|
||||
label,
|
||||
side: 'left',
|
||||
accessibilityHint: preview.requireApproval
|
||||
? l`Tap to request access to join this group chat`
|
||||
: l`Tap to join this group chat immediately`,
|
||||
icon,
|
||||
color,
|
||||
disabled: !canJoin,
|
||||
onPress: () => {
|
||||
setGroupChatJoinState({code: preview.code})
|
||||
groupChatJoinDialogControl.open()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatInviteProvider
|
||||
value={{code, loading, error: !!error, preview, action}}>
|
||||
{children}
|
||||
</ChatInviteProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {Card} from './Card'
|
||||
export {
|
||||
type ChatInviteAction,
|
||||
type ChatInviteContextValue,
|
||||
useChatInvite,
|
||||
} from './Context'
|
||||
export {JoinButton} from './JoinButton'
|
||||
export {Root} from './Root'
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
type ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyEmbedJoinLink,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
@@ -49,6 +50,7 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'
|
||||
import {groupReactions} from './ReactionsDialog'
|
||||
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
||||
|
||||
@@ -185,8 +187,10 @@ let MessageItem = ({
|
||||
|
||||
const rt = new RichTextAPI({text: message.text, facets: message.facets})
|
||||
|
||||
const hasEmbedAndText =
|
||||
AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
|
||||
const hasEmbed =
|
||||
AppBskyEmbedRecord.isView(message.embed) ||
|
||||
ChatBskyEmbedJoinLink.isView(message.embed)
|
||||
const hasEmbedAndText = hasEmbed && rt.text.length > 0
|
||||
|
||||
const targetBottomRadius = squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
@@ -427,6 +431,15 @@ let MessageItem = ({
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{ChatBskyEmbedJoinLink.isView(message.embed) && (
|
||||
<MessageItemInviteEmbed
|
||||
embed={message.embed}
|
||||
isFromSelf={isFromSelf}
|
||||
isGroupChat={isGroupChat}
|
||||
squaredBottomCorner={squaredBottomCorner || hasEmbedAndText}
|
||||
squaredTopCorner={squaredTopCorner}
|
||||
/>
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<Animated.View
|
||||
accessibilityHint={l`Double tap or long press the message to add a reaction`}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {memo} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
|
||||
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {MessageContextProvider} from './MessageContext'
|
||||
|
||||
const BORDER_RADIUS = 20
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
|
||||
let MessageItemInviteEmbed = ({
|
||||
embed,
|
||||
isFromSelf,
|
||||
isGroupChat,
|
||||
squaredTopCorner,
|
||||
squaredBottomCorner,
|
||||
}: {
|
||||
embed: $Typed<ChatBskyEmbedJoinLink.View>
|
||||
isFromSelf: boolean
|
||||
isGroupChat: boolean
|
||||
squaredTopCorner: boolean
|
||||
squaredBottomCorner: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const screen = useWindowDimensions()
|
||||
const convo = useConvoActive()
|
||||
|
||||
return (
|
||||
<MessageContextProvider>
|
||||
<View
|
||||
style={[
|
||||
!isFromSelf && isGroupChat && a.ml_sm,
|
||||
native({
|
||||
flexBasis: 0,
|
||||
width: Math.min(screen.width, 600) / 1.4,
|
||||
}),
|
||||
web({
|
||||
width: '100%',
|
||||
minWidth: 280,
|
||||
maxWidth: 360,
|
||||
}),
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.p_md,
|
||||
a.gap_md,
|
||||
a.overflow_hidden,
|
||||
isFromSelf
|
||||
? {
|
||||
backgroundColor: t.palette.primary_50,
|
||||
borderBottomRightRadius: squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopRightRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderBottomLeftRadius: BORDER_RADIUS,
|
||||
borderTopLeftRadius: BORDER_RADIUS,
|
||||
}
|
||||
: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
borderBottomLeftRadius: squaredBottomCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderTopLeftRadius: squaredTopCorner
|
||||
? SQUARED_BORDER_RADIUS
|
||||
: BORDER_RADIUS,
|
||||
borderBottomRightRadius: BORDER_RADIUS,
|
||||
borderTopRightRadius: BORDER_RADIUS,
|
||||
},
|
||||
]}>
|
||||
<ChatInvite.Root
|
||||
code={embed.joinLinkPreview.code}
|
||||
initialPreview={embed.joinLinkPreview}
|
||||
currentConvoId={convo.convo.view.id}>
|
||||
<ChatInvite.Card size="small" />
|
||||
<ChatInvite.JoinButton />
|
||||
</ChatInvite.Root>
|
||||
</View>
|
||||
</View>
|
||||
</MessageContextProvider>
|
||||
)
|
||||
}
|
||||
MessageItemInviteEmbed = memo(MessageItemInviteEmbed)
|
||||
export {MessageItemInviteEmbed}
|
||||
@@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {useEmail} from '#/state/email-verification'
|
||||
import {
|
||||
useMessageDraft,
|
||||
@@ -233,7 +233,11 @@ export function MessageComposer({
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onFacetCommitted={facet => {
|
||||
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
||||
if (
|
||||
facet.type === 'url' &&
|
||||
(isBskyPostUrl(facet.value) ||
|
||||
isBskyChatInviteUrl(facet.value))
|
||||
) {
|
||||
setEmbed(facet.value)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyChatInviteUrl,
|
||||
isBskyPostUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
@@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
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 {Loader} from '#/components/Loader'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
@@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* The embed staged in the message composer. A message can carry at most one
|
||||
* embed: either a quoted post or a group chat invite link.
|
||||
*/
|
||||
export type MessageEmbedState =
|
||||
| {type: 'post'; uri: string}
|
||||
| {type: 'invite'; code: string}
|
||||
|
||||
export function useMessageEmbed() {
|
||||
const route =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedFromParams = route.params.embed
|
||||
|
||||
const [embedUri, setEmbedUri] = useState(embedFromParams)
|
||||
const [embed, setEmbedState] = useState<MessageEmbedState | undefined>(
|
||||
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
|
||||
)
|
||||
|
||||
if (embedFromParams && embedUri !== embedFromParams) {
|
||||
setEmbedUri(embedFromParams)
|
||||
if (embedFromParams && embed?.type !== 'post') {
|
||||
setEmbedState({type: 'post', uri: embedFromParams})
|
||||
}
|
||||
|
||||
return {
|
||||
embedUri,
|
||||
embed,
|
||||
setEmbed: useCallback(
|
||||
(embedUrl: string | undefined) => {
|
||||
if (!embedUrl) {
|
||||
// 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: ''})
|
||||
setEmbedUri(undefined)
|
||||
setEmbedState(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (embedFromParams) return
|
||||
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
if (isBskyChatInviteUrl(embedUrl)) {
|
||||
const code = getChatInviteCodeFromUrl(embedUrl)
|
||||
if (code) {
|
||||
setEmbedState({type: 'invite', code})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setEmbedUri(uri)
|
||||
if (isBskyPostUrl(embedUrl)) {
|
||||
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})
|
||||
}
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
),
|
||||
@@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets(
|
||||
|
||||
for (const facet of rt.facets ?? []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
|
||||
if (
|
||||
AppBskyRichtextFacet.isLink(feature) &&
|
||||
(isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri))
|
||||
) {
|
||||
uriFromFacet = feature.uri
|
||||
break
|
||||
}
|
||||
@@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets(
|
||||
}
|
||||
|
||||
export function MessageInputEmbed({
|
||||
embedUri,
|
||||
embed,
|
||||
setEmbed,
|
||||
}: {
|
||||
embedUri: string | undefined
|
||||
embed: MessageEmbedState | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
}) {
|
||||
const onRemove = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}, [setEmbed])
|
||||
|
||||
if (!embed) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (embed.type) {
|
||||
case 'post':
|
||||
return <MessageInputPostEmbed uri={embed.uri} onRemove={onRemove} />
|
||||
case 'invite':
|
||||
return <MessageInputInviteEmbed code={embed.code} onRemove={onRemove} />
|
||||
}
|
||||
}
|
||||
|
||||
function MessageInputPostEmbed({
|
||||
uri,
|
||||
onRemove,
|
||||
}: {
|
||||
uri: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const {data: post, status} = usePostQuery(embedUri)
|
||||
const {data: post, status} = usePostQuery(uri)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
@@ -134,15 +185,6 @@ export function MessageInputEmbed({
|
||||
return {rt: undefined, record: undefined}
|
||||
}, [post])
|
||||
|
||||
if (!embedUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onRemove = () => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pending': {
|
||||
return (
|
||||
@@ -220,6 +262,71 @@ export function MessageInputEmbed({
|
||||
}
|
||||
}
|
||||
|
||||
function MessageInputInviteEmbed({
|
||||
code,
|
||||
onRemove,
|
||||
}: {
|
||||
code: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<ChatInvite.Root code={code}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.border_contrast_high,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_sm,
|
||||
a.mt_sm,
|
||||
a.mx_sm,
|
||||
]}>
|
||||
<MessageInputInviteEmbedBody />
|
||||
<Button
|
||||
label={l`Remove embed`}
|
||||
onPress={onRemove}
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: 10, right: 8},
|
||||
a.px_2xs,
|
||||
{transform: [{translateY: -2}]},
|
||||
]}
|
||||
hitSlop={HITSLOP_20}>
|
||||
<XIcon size="xs" style={t.atoms.text_contrast_high} />
|
||||
</Button>
|
||||
</View>
|
||||
</ChatInvite.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageInputInviteEmbedBody() {
|
||||
const t = useTheme()
|
||||
const {loading, preview} = ChatInvite.useChatInvite()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!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>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return <ChatInvite.Card size="small" />
|
||||
}
|
||||
|
||||
function SimpleContainer({
|
||||
children,
|
||||
onRemove,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
|
||||
@@ -37,6 +38,7 @@ import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyPostUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
@@ -46,9 +48,10 @@ import {
|
||||
useConvoActive,
|
||||
} from '#/state/messages/convo'
|
||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||
@@ -131,8 +134,10 @@ export function MessagesList({
|
||||
const ax = useAnalytics()
|
||||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
const {hasSession} = useSession()
|
||||
const getPost = useGetPost()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
const getJoinLinkPreview = useGetJoinLinkPreview()
|
||||
const {embed: messageEmbed, setEmbed} = useMessageEmbed()
|
||||
const t = useTheme()
|
||||
|
||||
const textInputId = 'chat-input-' + useId()
|
||||
@@ -348,12 +353,38 @@ export function MessagesList({
|
||||
// we want to remove the post link from the text, re-trim, then detect facets
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let embed: $Typed<AppBskyEmbedRecord.Main> | undefined
|
||||
let embedView: $Typed<AppBskyEmbedRecord.View> | undefined
|
||||
let embed:
|
||||
| $Typed<AppBskyEmbedRecord.Main>
|
||||
| $Typed<ChatBskyEmbedJoinLink.Main>
|
||||
| undefined
|
||||
let embedView:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
| undefined
|
||||
|
||||
if (embedUri) {
|
||||
// Find the embedded link facet and, if it's at the start or end of the
|
||||
// message, remove it from the text (the embed card replaces it).
|
||||
const stripLinkFacet = (predicate: (uri: string) => boolean) => {
|
||||
const linkFacet = rt.facets?.find(facet =>
|
||||
facet.features.find(
|
||||
feature =>
|
||||
AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri),
|
||||
),
|
||||
)
|
||||
if (linkFacet) {
|
||||
const isAtStart = linkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
linkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd)
|
||||
}
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
|
||||
if (messageEmbed?.type === 'post') {
|
||||
try {
|
||||
const post = await getPost({uri: embedUri})
|
||||
const post = await getPost({uri: messageEmbed.uri})
|
||||
if (post) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.record',
|
||||
@@ -368,42 +399,34 @@ export function MessagesList({
|
||||
record: createEmbedViewRecordFromPost(post),
|
||||
}
|
||||
|
||||
// look for the embed uri in the facets, so we can remove it from the text
|
||||
const postLinkFacet = rt.facets?.find(facet => {
|
||||
return facet.features.find(feature => {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isBskyPostUrl(feature.uri)) {
|
||||
const url = convertBskyAppUrlIfNeeded(feature.uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
stripLinkFacet(uri => {
|
||||
if (!isBskyPostUrl(uri)) return false
|
||||
const url = convertBskyAppUrlIfNeeded(uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
const isAtStart = postLinkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
|
||||
// remove the post link from the text
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
}
|
||||
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get post as quote for DM', {error})
|
||||
}
|
||||
} else if (messageEmbed?.type === 'invite') {
|
||||
const code = messageEmbed.code
|
||||
embed = {
|
||||
$type: 'chat.bsky.embed.joinLink',
|
||||
code,
|
||||
}
|
||||
|
||||
const joinLinkPreview = await getJoinLinkPreview({code, hasSession})
|
||||
if (joinLinkPreview) {
|
||||
embedView = {
|
||||
$type: 'chat.bsky.embed.joinLink#view',
|
||||
joinLinkPreview,
|
||||
}
|
||||
}
|
||||
|
||||
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
|
||||
}
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
@@ -424,7 +447,16 @@ export function MessagesList({
|
||||
embedView,
|
||||
)
|
||||
},
|
||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||
[
|
||||
agent,
|
||||
convoState,
|
||||
messageEmbed,
|
||||
getPost,
|
||||
getJoinLinkPreview,
|
||||
hasSession,
|
||||
hasScrolled,
|
||||
setHasScrolled,
|
||||
],
|
||||
)
|
||||
|
||||
const scrollToEndOnPress = useCallback(() => {
|
||||
@@ -595,11 +627,11 @@ export function MessagesList({
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
@@ -607,11 +639,11 @@ export function MessagesList({
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyConvoGetLog,
|
||||
type ChatBskyConvoSendMessage,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
type ChatBskyGroupDefs,
|
||||
} from '@atproto/api'
|
||||
import {XRPCError} from '@atproto/api'
|
||||
@@ -109,7 +110,9 @@ export class Convo {
|
||||
{
|
||||
id: string
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message']
|
||||
optimisticEmbedView?: $Typed<AppBskyEmbedRecord.View>
|
||||
optimisticEmbedView?:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
}
|
||||
> = new Map()
|
||||
private deletedMessages: Set<string> = new Set()
|
||||
@@ -942,7 +945,9 @@ export class Convo {
|
||||
|
||||
sendMessage(
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
optimisticEmbedView?: $Typed<AppBskyEmbedRecord.View>,
|
||||
optimisticEmbedView?:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>,
|
||||
) {
|
||||
// Ignore empty messages for now since they have no other purpose atm
|
||||
if (!message.text.trim() && !message.embed) return
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ChatBskyActorDefs,
|
||||
type ChatBskyConvoDefs,
|
||||
type ChatBskyConvoSendMessage,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {type MessagesEventBus} from '#/state/messages/events/agent'
|
||||
@@ -108,7 +109,10 @@ export type ConvoItem =
|
||||
type DeleteMessage = (messageId: string) => Promise<void>
|
||||
type SendMessage = (
|
||||
message: ChatBskyConvoSendMessage.InputSchema['message'],
|
||||
optimisticEmbedView: $Typed<AppBskyEmbedRecord.View> | undefined,
|
||||
optimisticEmbedView:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
| undefined,
|
||||
) => void
|
||||
type FetchMessageHistory = () => Promise<void>
|
||||
type MarkConvoAccepted = () => void
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
import {useCallback} from 'react'
|
||||
import {
|
||||
AtpAgent,
|
||||
type ChatBskyGroupDefs,
|
||||
type ChatBskyGroupGetJoinLinkPreviews,
|
||||
} from '@atproto/api'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
@@ -17,14 +22,39 @@ export const createJoinLinkPreviewQueryKey = (args: {
|
||||
persistedVersion: 1,
|
||||
})
|
||||
|
||||
async function fetchJoinLinkPreviews({
|
||||
agent,
|
||||
codes,
|
||||
hasSession,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
codes: string[]
|
||||
hasSession: boolean
|
||||
}) {
|
||||
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||
const res = hasSession
|
||||
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function useJoinLinkPreviewsQuery({
|
||||
codes,
|
||||
hasSession,
|
||||
staleTime = STALE.MINUTES.ONE,
|
||||
initialData,
|
||||
}: {
|
||||
codes?: string[]
|
||||
hasSession: boolean
|
||||
staleTime?: number
|
||||
/**
|
||||
* Seed the query with an already-known preview (e.g. a DM message embed
|
||||
* already carries the resolved view), avoiding a duplicate fetch.
|
||||
*/
|
||||
initialData?: ChatBskyGroupGetJoinLinkPreviews.OutputSchema
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -33,14 +63,7 @@ export function useJoinLinkPreviewsQuery({
|
||||
queryFn: async () => {
|
||||
if (!codes) throw new Error('No invite code')
|
||||
try {
|
||||
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||
const res = hasSession
|
||||
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||
return res.data
|
||||
return await fetchJoinLinkPreviews({agent, codes, hasSession})
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch join link preview', {safeMessage: error})
|
||||
throw error
|
||||
@@ -48,6 +71,7 @@ export function useJoinLinkPreviewsQuery({
|
||||
},
|
||||
enabled: codes != null && codes.length > 0,
|
||||
staleTime,
|
||||
initialData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,17 +82,42 @@ export function usePrefetchJoinLinkPreviews() {
|
||||
return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
|
||||
return queryClient.prefetchQuery({
|
||||
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
|
||||
queryFn: async () => {
|
||||
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
|
||||
const res = hasSession
|
||||
? await agent.chat.bsky.group.getJoinLinkPreviews(
|
||||
{codes},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
)
|
||||
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
|
||||
return res.data
|
||||
},
|
||||
queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}),
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperatively fetch (or read from cache) a single join link preview by code.
|
||||
* Used when sending a DM invite embed so we can build an optimistic view.
|
||||
* Returns undefined if the preview can't be resolved.
|
||||
*/
|
||||
export function useGetJoinLinkPreview() {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useCallback(
|
||||
async ({
|
||||
code,
|
||||
hasSession,
|
||||
}: {
|
||||
code: string
|
||||
hasSession: boolean
|
||||
}): Promise<ChatBskyGroupDefs.JoinLinkPreviewView | undefined> => {
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
|
||||
queryFn: () =>
|
||||
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
})
|
||||
return data.joinLinkPreviews[0]
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch join link preview', {safeMessage: error})
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
[agent, queryClient],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const ExternalEmbedLink = ({
|
||||
/>
|
||||
)
|
||||
} else if (data.type === 'chat-invite') {
|
||||
return <JoinRequestEmbed preview={data.view} />
|
||||
return <JoinRequestEmbed code={data.code} preview={data.view} />
|
||||
} else if (data.kind === 'feed') {
|
||||
return (
|
||||
<ModeratedFeedEmbed
|
||||
|
||||
Reference in New Issue
Block a user