diff --git a/.gitignore b/.gitignore index 6dc2fe592b..015e13d3de 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ bskyweb/static/media/*.svg # superpowers plugin plans/specs — local-only workspace docs/superpowers/ +.claude/worktrees diff --git a/eslint-suppressions.json b/eslint-suppressions.json index d8555ace5b..483ed83e7d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 5f74595029..2d02bb832f 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -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( ( { children, - variant, + variant: variantProp, color, size, shape = 'default', @@ -160,7 +160,8 @@ export const Button = forwardRef( * 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( 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( 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( } 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'] diff --git a/src/components/Post/Embed/ChatInviteEmbed.tsx b/src/components/Post/Embed/ChatInviteEmbed.tsx index 744fcfb7fa..f50085426f 100644 --- a/src/components/Post/Embed/ChatInviteEmbed.tsx +++ b/src/components/Post/Embed/ChatInviteEmbed.tsx @@ -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/` 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 }) { - const {hasSession} = useSession() - const {data, error, isPending} = useJoinLinkPreviewsQuery({ - codes: [code], - hasSession, - }) + return ( + + + + ) +} - const preview = data?.joinLinkPreviews[0] +function ChatInviteEmbedBody({ + link, + onOpen, + style, +}: { + link: AppBskyEmbedExternal.ViewExternal + onOpen?: () => void + style?: StyleProp +}) { + const {error} = ChatInvite.useChatInvite() if (error) { return } - return ( - - ) + return } diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx index fe0be2bd25..472c403060 100644 --- a/src/components/Post/Embed/ExternalEmbed/index.tsx +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -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]) diff --git a/src/components/Post/Embed/JoinRequestEmbed.tsx b/src/components/Post/Embed/JoinRequestEmbed.tsx index 77a0223bec..db7ec589bb 100644 --- a/src/components/Post/Embed/JoinRequestEmbed.tsx +++ b/src/components/Post/Embed/JoinRequestEmbed.tsx @@ -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 onOpen?: () => void +}) { + const resolvedCode = code ?? preview?.code + if (!resolvedCode) return null + + return ( + + + + ) +} + +/** + * 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 + onOpen?: () => void }) { const t = useTheme() + const {loading, preview} = ChatInvite.useChatInvite() if (loading) { return ( @@ -81,60 +94,6 @@ export function JoinRequestEmbed({ ) } - return ( - - ) -} - -function JoinRequestEmbedInner({ - preview, - style, - onOpen, -}: { - preview: ChatBskyGroupDefs.JoinLinkPreviewView - style?: StyleProp - onOpen?: () => void -}) { - const t = useTheme() - const {t: l} = useLingui() - const navigation = useNavigation() - - 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 ( - - - - - {preview.name} - - - - Group chat - - - - {preview.memberCount}/{preview.memberLimit}{' '} - - - - - - - - By{' '} - - {ownerDisplayName} - - - - - - {ownerHandle} - - - - - {convoId ? ( - - ) : ( - - )} + + ) } diff --git a/src/components/dms/ChatInvite/Card.tsx b/src/components/dms/ChatInvite/Card.tsx new file mode 100644 index 0000000000..518371325e --- /dev/null +++ b/src/components/dms/ChatInvite/Card.tsx @@ -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 ( + + + + + {preview.name} + + + + Group chat + + + + {preview.memberCount}/{preview.memberLimit}{' '} + + + + + + + + By {ownerDisplayName} + + + + + {ownerHandle} + + + + + ) +} diff --git a/src/components/dms/ChatInvite/Context.tsx b/src/components/dms/ChatInvite/Context.tsx new file mode 100644 index 0000000000..53c7a6cc22 --- /dev/null +++ b/src/components/dms/ChatInvite/Context.tsx @@ -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 + 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(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 diff --git a/src/components/dms/ChatInvite/JoinButton.tsx b/src/components/dms/ChatInvite/JoinButton.tsx new file mode 100644 index 0000000000..0036391b0e --- /dev/null +++ b/src/components/dms/ChatInvite/JoinButton.tsx @@ -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 +}) { + const {action} = useChatInvite() + + if (!action) return null + + return ( + + ) +} diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx new file mode 100644 index 0000000000..a9f2c54e3a --- /dev/null +++ b/src/components/dms/ChatInvite/Root.tsx @@ -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() + 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 = 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 ( + + {children} + + ) +} diff --git a/src/components/dms/ChatInvite/index.tsx b/src/components/dms/ChatInvite/index.tsx new file mode 100644 index 0000000000..bcea3f7651 --- /dev/null +++ b/src/components/dms/ChatInvite/index.tsx @@ -0,0 +1,8 @@ +export {Card} from './Card' +export { + type ChatInviteAction, + type ChatInviteContextValue, + useChatInvite, +} from './Context' +export {JoinButton} from './JoinButton' +export {Root} from './Root' diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index ac6acb5891..f4f3cdacc3 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -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) && ( + + )} {rt.text.length > 0 && ( + isFromSelf: boolean + isGroupChat: boolean + squaredTopCorner: boolean + squaredBottomCorner: boolean +}): React.ReactNode => { + const t = useTheme() + const screen = useWindowDimensions() + const convo = useConvoActive() + + return ( + + + + + + + + + + + ) +} +MessageItemInviteEmbed = memo(MessageItemInviteEmbed) +export {MessageItemInviteEmbed} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 0aa132e232..c6899fb9d0 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -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) } }} diff --git a/src/screens/Messages/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx index 5bc2f38a05..a73a6564fb 100644 --- a/src/screens/Messages/components/MessageInputEmbed.tsx +++ b/src/screens/Messages/components/MessageInputEmbed.tsx @@ -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>() const navigation = useNavigation() const embedFromParams = route.params.embed - const [embedUri, setEmbedUri] = useState(embedFromParams) + const [embed, setEmbedState] = useState( + 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 + case 'invite': + return + } +} + +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 ( + + + + + + + ) +} + +function MessageInputInviteEmbedBody() { + const t = useTheme() + const {loading, preview} = ChatInvite.useChatInvite() + + if (loading) { + return ( + + + + ) + } + + if (!preview) { + return ( + + + Could not load invite + + + ) + } + + return +} + function SimpleContainer({ children, onRemove, diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index fbebdc0745..6f56c6a898 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -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 | undefined - let embedView: $Typed | undefined + let embed: + | $Typed + | $Typed + | undefined + let embedView: + | $Typed + | $Typed + | 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}> @@ -607,11 +639,11 @@ export function MessagesList({ diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 08b609f71a..e89965eb45 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -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 + optimisticEmbedView?: + | $Typed + | $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -942,7 +945,9 @@ export class Convo { sendMessage( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView?: $Typed, + optimisticEmbedView?: + | $Typed + | $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 51d111356a..269f32c515 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -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 type SendMessage = ( message: ChatBskyConvoSendMessage.InputSchema['message'], - optimisticEmbedView: $Typed | undefined, + optimisticEmbedView: + | $Typed + | $Typed + | undefined, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index 99a37b6b06..df87ec0a24 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -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 => { + 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], + ) +} diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 5cc87c63f5..0b14f16ed3 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -117,7 +117,7 @@ export const ExternalEmbedLink = ({ /> ) } else if (data.type === 'chat-invite') { - return + return } else if (data.kind === 'feed') { return (