diff options
Diffstat (limited to 'src')
28 files changed, 1224 insertions, 532 deletions
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 76fc74dc1..c5ccfa5ec 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -244,6 +244,38 @@ export function ItemRadio({selected}: {selected: boolean}) { ) } +/** + * NATIVE ONLY - for adding non-pressable items to the menu + * + * @platform ios, android + */ +export function ContainerItem({ + children, + style, +}: { + children: React.ReactNode + style?: StyleProp<ViewStyle> +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + a.px_md, + a.rounded_md, + a.border, + t.atoms.bg_contrast_25, + t.atoms.border_contrast_low, + {paddingVertical: 10}, + style, + ]}> + {children} + </View> + ) +} + export function LabelText({children}: {children: React.ReactNode}) { const t = useTheme() return ( @@ -272,13 +304,14 @@ export function Group({children, style}: GroupProps) { style, ]}> {flattenReactChildren(children).map((child, i) => { - return React.isValidElement(child) && child.type === Item ? ( + return React.isValidElement(child) && + (child.type === Item || child.type === ContainerItem) ? ( <React.Fragment key={i}> {i > 0 ? ( <View style={[a.border_b, t.atoms.border_contrast_low]} /> ) : null} {React.cloneElement(child, { - // @ts-ignore + // @ts-expect-error cloneElement is not aware of the types style: { borderRadius: 0, borderWidth: 0, diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index 27678bf2f..7d6e50556 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -390,3 +390,7 @@ export function Divider() { /> ) } + +export function ContainerItem() { + return null +} diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx new file mode 100644 index 000000000..796981f0c --- /dev/null +++ b/src/components/PostControls/DiscoverDebug.tsx @@ -0,0 +1,54 @@ +import {Pressable} from 'react-native' +import * as Clipboard from 'expo-clipboard' +import {t} from '@lingui/macro' + +import {IS_INTERNAL} from '#/lib/app-info' +import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' +import {useGate} from '#/lib/statsig/statsig' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function DiscoverDebug({ + feedContext, +}: { + feedContext: string | undefined +}) { + const {currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const gate = useGate() + const isDiscoverDebugUser = + IS_INTERNAL || + DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || + gate('debug_show_feedcontext') + const theme = useTheme() + + return ( + isDiscoverDebugUser && + feedContext && ( + <Pressable + accessible={false} + hitSlop={10} + style={[ + a.absolute, + a.bottom_0, + {zIndex: 1000}, + gtMobile ? a.right_0 : a.left_0, + ]} + onPress={e => { + e.stopPropagation() + Clipboard.setStringAsync(feedContext) + Toast.show(t`Copied to clipboard`, 'clipboard-check') + }}> + <Text + style={{ + color: theme.palette.contrast_400, + fontSize: 7, + }}> + {feedContext} + </Text> + </Pressable> + ) + ) +} diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx new file mode 100644 index 000000000..1585d429d --- /dev/null +++ b/src/components/PostControls/PostControlButton.tsx @@ -0,0 +1,126 @@ +import {createContext, useContext, useMemo} from 'react' +import {type GestureResponderEvent, type View} from 'react-native' + +import {POST_CTRL_HITSLOP} from '#/lib/constants' +import {useHaptics} from '#/lib/haptics' +import {atoms as a, useTheme} from '#/alf' +import {Button, type ButtonProps} from '#/components/Button' +import {type Props as SVGIconProps} from '#/components/icons/common' +import {Text, type TextProps} from '#/components/Typography' + +const PostControlContext = createContext<{ + big?: boolean + active?: boolean + color?: {color: string} +}>({}) + +// Base button style, which the the other ones extend +export function PostControlButton({ + ref, + onPress, + onLongPress, + children, + big, + active, + activeColor, + ...props +}: ButtonProps & { + ref?: React.Ref<View> + active?: boolean + big?: boolean + color?: string + activeColor?: string +}) { + const t = useTheme() + const playHaptic = useHaptics() + + const ctx = useMemo( + () => ({ + big, + active, + color: { + color: activeColor && active ? activeColor : t.palette.contrast_500, + }, + }), + [big, active, activeColor, t.palette.contrast_500], + ) + + const style = useMemo( + () => [ + a.flex_row, + a.align_center, + a.gap_xs, + a.bg_transparent, + {padding: 5}, + ], + [], + ) + + const handlePress = useMemo(() => { + if (!onPress) return + return (evt: GestureResponderEvent) => { + playHaptic('Light') + onPress(evt) + } + }, [onPress, playHaptic]) + + const handleLongPress = useMemo(() => { + if (!onLongPress) return + return (evt: GestureResponderEvent) => { + playHaptic('Heavy') + onLongPress(evt) + } + }, [onLongPress, playHaptic]) + + return ( + <Button + ref={ref} + onPress={handlePress} + onLongPress={handleLongPress} + style={style} + hoverStyle={t.atoms.bg_contrast_25} + shape="round" + variant="ghost" + color="secondary" + hitSlop={POST_CTRL_HITSLOP} + {...props}> + {typeof children === 'function' ? ( + args => ( + <PostControlContext.Provider value={ctx}> + {children(args)} + </PostControlContext.Provider> + ) + ) : ( + <PostControlContext.Provider value={ctx}> + {children} + </PostControlContext.Provider> + )} + </Button> + ) +} + +export function PostControlButtonIcon({ + icon: Comp, +}: { + icon: React.ComponentType<SVGIconProps> +}) { + const {big, color} = useContext(PostControlContext) + + return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} /> +} + +export function PostControlButtonText({style, ...props}: TextProps) { + const {big, active, color} = useContext(PostControlContext) + + return ( + <Text + style={[ + color, + big ? a.text_md : {fontSize: 15}, + active && a.font_bold, + style, + ]} + {...props} + /> + ) +} diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index a5f41ea7a..51991589f 100644 --- a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react' +import {memo, useMemo} from 'react' import { Platform, type PressableProps, @@ -13,7 +13,7 @@ import { AtUri, type RichText as RichTextAPI, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -26,13 +26,11 @@ import { type CommonNavigatorParams, type NavigationProp, } from '#/lib/routes/types' -import {shareText, shareUrl} from '#/lib/sharing' import {logEvent} from '#/lib/statsig/statsig' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {type Shadow} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' @@ -52,20 +50,16 @@ import { import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {useBreakpoints} from '#/alf' +import * as Toast from '#/view/com/util/Toast' import {useDialogControl} from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' -import {EmbedDialog} from '#/components/dialogs/Embed' import { PostInteractionSettingsDialog, usePrefetchPostInteractionSettings, } from '#/components/dialogs/PostInteractionSettingsDialog' -import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom' import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' -import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' import { EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, @@ -75,7 +69,6 @@ import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/E import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' -import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' @@ -90,17 +83,14 @@ import { useReportDialogControl, } from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' -import {useDevMode} from '#/storage/hooks/dev-mode' import * as bsky from '#/types/bsky' -import * as Toast from '../Toast' -let PostDropdownMenuItems = ({ +let PostMenuItems = ({ post, postFeedContext, postReqId, record, richText, - timestamp, threadgateRecord, onShowLess, }: { @@ -118,7 +108,6 @@ let PostDropdownMenuItems = ({ onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void }): React.ReactNode => { const {hasSession, currentAccount} = useSession() - const {gtMobile} = useBreakpoints() const {_} = useLingui() const langPrefs = useLanguagePrefs() const {mutateAsync: deletePostMutate} = usePostDeleteMutation() @@ -134,20 +123,16 @@ let PostDropdownMenuItems = ({ const reportDialogControl = useReportDialogControl() const deletePromptControl = useDialogControl() const hidePromptControl = useDialogControl() - const loggedOutWarningPromptControl = useDialogControl() - const embedPostControl = useDialogControl() - const sendViaChatControl = useDialogControl() const postInteractionSettingsDialogControl = useDialogControl() const quotePostDetachConfirmControl = useDialogControl() const hideReplyConfirmControl = useDialogControl() const {mutateAsync: toggleReplyVisibility} = useToggleReplyVisibilityMutation() - const [devModeEnabled] = useDevMode() const postUri = post.uri const postCid = post.cid const postAuthor = useProfileShadow(post.author) - const quoteEmbed = React.useMemo(() => { + const quoteEmbed = useMemo(() => { if (!currentAccount || !post.embed) return return getMaybeDetachedQuoteEmbed({ viewerDid: currentAccount.did, @@ -181,7 +166,7 @@ let PostDropdownMenuItems = ({ rootPostUri: rootUri, }) - const href = React.useMemo(() => { + const href = useMemo(() => { const urip = new AtUri(postUri) return makeProfileLink(postAuthor, 'post', urip.rkey) }, [postUri, postAuthor]) @@ -273,14 +258,6 @@ let PostDropdownMenuItems = ({ label => label.val === '!no-unauthenticated', ) - const showLoggedOutWarning = - postAuthor.did !== currentAccount?.did && hideInPWI - - const onSharePost = () => { - const url = toShareUrl(href) - shareUrl(url) - } - const onPressShowMore = () => { feedFeedback.sendInteraction({ event: 'app.bsky.feed.defs#requestMore', @@ -308,13 +285,6 @@ let PostDropdownMenuItems = ({ } } - const onSelectChatToShareTo = (conversation: string) => { - navigation.navigate('MessagesConversation', { - conversation, - embed: postUri, - }) - } - const onToggleQuotePostAttachment = async () => { if (!quoteEmbed) return @@ -341,7 +311,6 @@ let PostDropdownMenuItems = ({ } const canHidePostForMe = !isAuthor && !isPostHidden - const canEmbed = isWeb && gtMobile && !hideInPWI const canHideReplyForEveryone = !isAuthor && isRootPostAuthor && !isPostHidden && isReply const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer @@ -417,14 +386,6 @@ let PostDropdownMenuItems = ({ } } - const onShareATURI = () => { - shareText(postUri) - } - - const onShareAuthorDID = () => { - shareText(postAuthor.did) - } - const onReportMisclassification = () => { const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( href, @@ -482,44 +443,6 @@ let PostDropdownMenuItems = ({ </Menu.Item> </> )} - - {hasSession && ( - <Menu.Item - testID="postDropdownSendViaDMBtn" - label={_(msg`Send via direct message`)} - onPress={() => sendViaChatControl.open()}> - <Menu.ItemText> - <Trans>Send via direct message</Trans> - </Menu.ItemText> - <Menu.ItemIcon icon={Send} position="right" /> - </Menu.Item> - )} - - <Menu.Item - testID="postDropdownShareBtn" - label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - onPress={() => { - if (showLoggedOutWarning) { - loggedOutWarningPromptControl.open() - } else { - onSharePost() - } - }}> - <Menu.ItemText> - {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} - </Menu.ItemText> - <Menu.ItemIcon icon={Share} position="right" /> - </Menu.Item> - - {canEmbed && ( - <Menu.Item - testID="postDropdownEmbedBtn" - label={_(msg`Embed post`)} - onPress={() => embedPostControl.open()}> - <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> - <Menu.ItemIcon icon={CodeBrackets} position="right" /> - </Menu.Item> - )} </Menu.Group> {hasSession && feedFeedback.enabled && ( @@ -550,11 +473,9 @@ let PostDropdownMenuItems = ({ DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && ( <Menu.Item testID="postDropdownReportMisclassificationBtn" - label={_(msg`Assign topic - help train Discover!`)} + label={_(msg`Assign topic for algo`)} onPress={onReportMisclassification}> - <Menu.ItemText> - {_(msg`Assign topic - help train Discover!`)} - </Menu.ItemText> + <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> <Menu.ItemIcon icon={AtomIcon} position="right" /> </Menu.Item> )} @@ -747,28 +668,6 @@ let PostDropdownMenuItems = ({ </> )} </Menu.Group> - - {devModeEnabled ? ( - <> - <Menu.Divider /> - <Menu.Group> - <Menu.Item - testID="postAtUriShareBtn" - label={_(msg`Copy post at:// URI`)} - onPress={onShareATURI}> - <Menu.ItemText>{_(msg`Copy post at:// URI`)}</Menu.ItemText> - <Menu.ItemIcon icon={Share} position="right" /> - </Menu.Item> - <Menu.Item - testID="postAuthorDIDShareBtn" - label={_(msg`Copy author DID`)} - onPress={onShareAuthorDID}> - <Menu.ItemText>{_(msg`Copy author DID`)}</Menu.ItemText> - <Menu.ItemIcon icon={Share} position="right" /> - </Menu.Item> - </Menu.Group> - </> - ) : null} </> )} </Menu.Outer> @@ -802,32 +701,6 @@ let PostDropdownMenuItems = ({ }} /> - <Prompt.Basic - control={loggedOutWarningPromptControl} - title={_(msg`Note about sharing`)} - description={_( - msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, - )} - onConfirm={onSharePost} - confirmButtonCta={_(msg`Share anyway`)} - /> - - {canEmbed && ( - <EmbedDialog - control={embedPostControl} - postCid={postCid} - postUri={postUri} - record={record} - postAuthor={postAuthor} - timestamp={timestamp} - /> - )} - - <SendViaChatDialog - control={sendViaChatControl} - onSelectChat={onSelectChatToShareTo} - /> - <PostInteractionSettingsDialog control={postInteractionSettingsDialogControl} postUri={post.uri} @@ -868,5 +741,5 @@ let PostDropdownMenuItems = ({ </> ) } -PostDropdownMenuItems = memo(PostDropdownMenuItems) -export {PostDropdownMenuItems} +PostMenuItems = memo(PostMenuItems) +export {PostMenuItems} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/components/PostControls/PostMenu/index.tsx index 57ee95e31..63aa460fb 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -1,11 +1,5 @@ import {memo, useMemo, useState} from 'react' import { - Pressable, - type PressableProps, - type StyleProp, - type ViewStyle, -} from 'react-native' -import { type AppBskyFeedDefs, type AppBskyFeedPost, type AppBskyFeedThreadgate, @@ -15,25 +9,22 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import type React from 'react' -import {useTheme} from '#/lib/ThemeContext' import {type Shadow} from '#/state/cache/post-shadow' -import {atoms as a, useTheme as useAlf} from '#/alf' +import {EventStopper} from '#/view/com/util/EventStopper' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' -import {EventStopper} from '../EventStopper' -import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems' +import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' +import {PostMenuItems} from './PostMenuItems' -let PostDropdownBtn = ({ +let PostMenuButton = ({ testID, post, postFeedContext, postReqId, + big, record, richText, - style, - hitSlop, - size, timestamp, threadgateRecord, onShowLess, @@ -42,19 +33,15 @@ let PostDropdownBtn = ({ post: Shadow<AppBskyFeedDefs.PostView> postFeedContext: string | undefined postReqId: string | undefined + big?: boolean record: AppBskyFeedPost.Record richText: RichTextAPI - style?: StyleProp<ViewStyle> - hitSlop?: PressableProps['hitSlop'] - size?: 'lg' | 'md' | 'sm' timestamp: string threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void }): React.ReactNode => { - const theme = useTheme() - const alf = useAlf() const {_} = useLingui() - const defaultCtrlColor = theme.palette.default.postCtrl + const menuControl = useMenuControl() const [hasBeenOpen, setHasBeenOpen] = useState(false) const lazyMenuControl = useMemo( @@ -73,31 +60,21 @@ let PostDropdownBtn = ({ <EventStopper onKeyDown={false}> <Menu.Root control={lazyMenuControl}> <Menu.Trigger label={_(msg`Open post options menu`)}> - {({props, state}) => { + {({props}) => { return ( - <Pressable - {...props} - hitSlop={hitSlop} - testID={testID} - style={[ - style, - a.rounded_full, - (state.hovered || state.pressed) && [ - alf.atoms.bg_contrast_25, - ], - ]}> - <DotsHorizontal - fill={defaultCtrlColor} - style={{pointerEvents: 'none'}} - size={size} - /> - </Pressable> + <PostControlButton + testID="postDropdownBtn" + big={big} + label={props.accessibilityLabel} + {...props}> + <PostControlButtonIcon icon={DotsHorizontal} /> + </PostControlButton> ) }} </Menu.Trigger> {hasBeenOpen && ( // Lazily initialized. Once mounted, they stay mounted. - <PostDropdownMenuItems + <PostMenuItems testID={testID} post={post} postFeedContext={postFeedContext} @@ -114,5 +91,5 @@ let PostDropdownBtn = ({ ) } -PostDropdownBtn = memo(PostDropdownBtn) -export {PostDropdownBtn} +PostMenuButton = memo(PostMenuButton) +export {PostMenuButton} diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx index ca1647a99..db63a7383 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/components/PostControls/RepostButton.tsx @@ -1,18 +1,22 @@ -import React, {memo, useCallback} from 'react' +import {memo, useCallback} from 'react' import {View} from 'react-native' import {msg, plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {POST_CTRL_HITSLOP} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {useRequireAuth} from '#/state/session' +import {formatCount} from '#/view/com/util/numeric/format' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' import {Text} from '#/components/Typography' -import {formatCount} from '../numeric/format' +import { + PostControlButton, + PostControlButtonIcon, + PostControlButtonText, +} from './PostControlButton' interface Props { isReposted: boolean @@ -35,65 +39,46 @@ let RepostButton = ({ const {_, i18n} = useLingui() const requireAuth = useRequireAuth() const dialogControl = Dialog.useDialogControl() - const playHaptic = useHaptics() - const color = React.useMemo( - () => ({ - color: isReposted ? t.palette.positive_600 : t.palette.contrast_500, - }), - [t, isReposted], - ) + return ( <> - <Button + <PostControlButton testID="repostBtn" - onPress={() => { - playHaptic('Light') - requireAuth(() => dialogControl.open()) - }} - onLongPress={() => { - playHaptic('Heavy') - requireAuth(() => onQuote()) - }} - style={[ - a.flex_row, - a.align_center, - a.gap_xs, - a.bg_transparent, - {padding: 5}, - ]} - hoverStyle={t.atoms.bg_contrast_25} + active={isReposted} + activeColor={t.palette.positive_600} + big={big} + onPress={() => requireAuth(() => dialogControl.open())} + onLongPress={() => requireAuth(() => onQuote())} label={ isReposted ? _( - msg`Undo repost (${plural(repostCount || 0, { - one: '# repost', - other: '# reposts', - })})`, + msg({ + message: `Undo repost (${plural(repostCount || 0, { + one: '# repost', + other: '# reposts', + })})`, + comment: + 'Accessibility label for the repost button when the post has been reposted, verb followed by number of reposts and noun', + }), ) : _( - msg`Repost (${plural(repostCount || 0, { - one: '# repost', - other: '# reposts', - })})`, + msg({ + message: `Repost (${plural(repostCount || 0, { + one: '# repost', + other: '# reposts', + })})`, + comment: + 'Accessibility label for the repost button when the post has not been reposted, verb form followed by number of reposts and noun form', + }), ) - } - shape="round" - variant="ghost" - color="secondary" - hitSlop={POST_CTRL_HITSLOP}> - <Repost style={color} width={big ? 22 : 18} /> - {typeof repostCount !== 'undefined' && repostCount > 0 ? ( - <Text - testID="repostCount" - style={[ - color, - big ? a.text_md : {fontSize: 15}, - isReposted && a.font_bold, - ]}> + }> + <PostControlButtonIcon icon={Repost} /> + {typeof repostCount !== 'undefined' && repostCount > 0 && ( + <PostControlButtonText testID="repostCount"> {formatCount(i18n, repostCount)} - </Text> - ) : undefined} - </Button> + </PostControlButtonText> + )} + </PostControlButton> <Dialog.Outer control={dialogControl} nativeOptions={{preventExpansion: true}}> diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/components/PostControls/RepostButton.web.tsx index 54119b532..48720b753 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/components/PostControls/RepostButton.web.tsx @@ -1,18 +1,19 @@ -import React from 'react' -import {Pressable, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useRequireAuth} from '#/state/session' import {useSession} from '#/state/session' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' +import {EventStopper} from '#/view/com/util/EventStopper' +import {formatCount} from '#/view/com/util/numeric/format' +import {useTheme} from '#/alf' import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' import * as Menu from '#/components/Menu' -import {Text} from '#/components/Typography' -import {EventStopper} from '../EventStopper' -import {formatCount} from '../numeric/format' +import { + PostControlButton, + PostControlButtonIcon, + PostControlButtonText, +} from './PostControlButton' interface Props { isReposted: boolean @@ -32,38 +33,30 @@ export const RepostButton = ({ embeddingDisabled, }: Props) => { const t = useTheme() - const {_} = useLingui() + const {_, i18n} = useLingui() const {hasSession} = useSession() const requireAuth = useRequireAuth() - const color = React.useMemo( - () => ({ - color: isReposted ? t.palette.positive_600 : t.palette.contrast_500, - }), - [t, isReposted], - ) - return hasSession ? ( <EventStopper onKeyDown={false}> <Menu.Root> <Menu.Trigger label={_(msg`Repost or quote post`)}> - {({props, state}) => { + {({props}) => { return ( - <Pressable - {...props} - style={[ - a.rounded_full, - (state.hovered || state.pressed) && { - backgroundColor: t.palette.contrast_25, - }, - ]}> - <RepostInner - isReposted={isReposted} - color={color} - repostCount={repostCount} - big={big} - /> - </Pressable> + <PostControlButton + testID="repostBtn" + active={isReposted} + activeColor={t.palette.positive_600} + label={props.accessibilityLabel} + big={big} + {...props}> + <PostControlButtonIcon icon={Repost} /> + {typeof repostCount !== 'undefined' && repostCount > 0 && ( + <PostControlButtonText testID="repostCount"> + {formatCount(i18n, repostCount)} + </PostControlButtonText> + )} + </PostControlButton> ) }} </Menu.Trigger> @@ -97,51 +90,18 @@ export const RepostButton = ({ </Menu.Root> </EventStopper> ) : ( - <Button - onPress={() => { - requireAuth(() => {}) - }} + <PostControlButton + onPress={() => requireAuth(() => {})} + active={isReposted} + activeColor={t.palette.positive_600} label={_(msg`Repost or quote post`)} - style={{padding: 0}} - hoverStyle={t.atoms.bg_contrast_25} - shape="round"> - <RepostInner - isReposted={isReposted} - color={color} - repostCount={repostCount} - big={big} - /> - </Button> - ) -} - -const RepostInner = ({ - isReposted, - color, - repostCount, - big, -}: { - isReposted: boolean - color: {color: string} - repostCount?: number - big?: boolean -}) => { - const {i18n} = useLingui() - return ( - <View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}> - <Repost style={color} width={big ? 22 : 18} /> - {typeof repostCount !== 'undefined' && repostCount > 0 ? ( - <Text - testID="repostCount" - style={[ - color, - big ? a.text_md : {fontSize: 15}, - isReposted && [a.font_bold], - a.user_select_none, - ]}> + big={big}> + <PostControlButtonIcon icon={Repost} /> + {typeof repostCount !== 'undefined' && repostCount > 0 && ( + <PostControlButtonText testID="repostCount"> {formatCount(i18n, repostCount)} - </Text> - ) : undefined} - </View> + </PostControlButtonText> + )} + </PostControlButton> ) } diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx new file mode 100644 index 000000000..ca5d0029e --- /dev/null +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -0,0 +1,200 @@ +import {ScrollView, View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useListConvosQuery} from '#/state/queries/messages/list-conversations' +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, tokens, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useDialogContext} from '#/components/Dialog' +import {Text} from '#/components/Typography' +import {useSimpleVerificationState} from '#/components/verification' +import {VerificationCheck} from '#/components/verification/VerificationCheck' +import type * as bsky from '#/types/bsky' + +export function RecentChats({postUri}: {postUri: string}) { + const control = useDialogContext() + const {_} = useLingui() + const {currentAccount} = useSession() + const {data} = useListConvosQuery({status: 'accepted'}) + const convos = data?.pages[0]?.convos?.slice(0, 10) + const moderationOpts = useModerationOpts() + const navigation = useNavigation<NavigationProp>() + + const onSelectChat = (convoId: string) => { + control.close(() => { + logger.metric('share:press:recentDm', {}, {statsig: true}) + navigation.navigate('MessagesConversation', { + conversation: convoId, + embed: postUri, + }) + }) + } + + if (!moderationOpts) return null + + return ( + <View + style={[a.relative, a.flex_1, {marginHorizontal: tokens.space.md * -1}]}> + <ScrollView + horizontal + style={[a.flex_1, a.pt_2xs, {minHeight: 98}]} + contentContainerStyle={[a.gap_sm, a.px_md]} + showsHorizontalScrollIndicator={false} + fadingEdgeLength={64} + nestedScrollEnabled> + {convos && convos.length > 0 ? ( + convos.map(convo => { + const otherMember = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherMember || otherMember.handle === 'missing.invalid') + return null + + return ( + <RecentChatItem + key={convo.id} + profile={otherMember} + onPress={() => onSelectChat(convo.id)} + moderationOpts={moderationOpts} + /> + ) + }) + ) : ( + <> + <ConvoSkeleton /> + <ConvoSkeleton /> + <ConvoSkeleton /> + <ConvoSkeleton /> + <ConvoSkeleton /> + </> + )} + </ScrollView> + {convos && convos.length === 0 && <NoConvos />} + </View> + ) +} + +const WIDTH = 80 + +function RecentChatItem({ + profile, + onPress, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + onPress: () => void + moderationOpts: ModerationOpts +}) { + const {_} = useLingui() + const t = useTheme() + + const moderation = moderateProfile(profile, moderationOpts) + const name = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + const verification = useSimpleVerificationState({profile}) + + return ( + <Button + onPress={onPress} + label={_(msg`Send post to ${name}`)} + style={[ + a.flex_col, + {width: WIDTH}, + a.gap_sm, + a.justify_start, + a.align_center, + ]}> + <UserAvatar + avatar={profile.avatar} + size={WIDTH - 8} + type={profile.associated?.labeler ? 'labeler' : 'user'} + moderation={moderation.ui('avatar')} + /> + <View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}> + <Text + emoji + style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {name} + </Text> + {verification.showBadge && ( + <View style={[a.pl_2xs]}> + <VerificationCheck + width={10} + verifier={verification.role === 'verifier'} + /> + </View> + )} + </View> + </Button> + ) +} + +function ConvoSkeleton() { + const t = useTheme() + return ( + <View + style={[ + a.flex_col, + {width: WIDTH, height: WIDTH + 15}, + a.gap_xs, + a.justify_start, + a.align_center, + ]}> + <View + style={[ + t.atoms.bg_contrast_50, + {width: WIDTH - 8, height: WIDTH - 8}, + a.rounded_full, + ]} + /> + <View + style={[ + t.atoms.bg_contrast_50, + {width: WIDTH - 8, height: 10}, + a.rounded_xs, + ]} + /> + </View> + ) +} + +function NoConvos() { + const t = useTheme() + + return ( + <View + style={[ + a.absolute, + a.inset_0, + a.justify_center, + a.align_center, + a.px_2xl, + ]}> + <View + style={[a.absolute, a.inset_0, t.atoms.bg_contrast_25, {opacity: 0.5}]} + /> + <Text + style={[ + a.text_sm, + t.atoms.text_contrast_high, + a.text_center, + a.font_bold, + ]}> + <Trans>Start a conversation, and it will appear here.</Trans> + </Text> + </View> + ) +} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx new file mode 100644 index 000000000..94369fcff --- /dev/null +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -0,0 +1,197 @@ +import {memo, useMemo} from 'react' +import * as ExpoClipboard from 'expo-clipboard' +import {AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {makeProfileLink} from '#/lib/routes/links' +import {type NavigationProp} from '#/lib/routes/types' +import {shareText, shareUrl} from '#/lib/sharing' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {useDialogControl} from '#/components/Dialog' +import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {useDevMode} from '#/storage/hooks/dev-mode' +import {RecentChats} from './RecentChats' +import {type ShareMenuItemsProps} from './ShareMenuItems.types' + +let ShareMenuItems = ({ + post, + onShare: onShareProp, +}: ShareMenuItemsProps): React.ReactNode => { + const {hasSession, currentAccount} = useSession() + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + const pwiWarningShareControl = useDialogControl() + const pwiWarningCopyControl = useDialogControl() + const sendViaChatControl = useDialogControl() + const [devModeEnabled] = useDevMode() + + const postUri = post.uri + const postAuthor = useProfileShadow(post.author) + + const href = useMemo(() => { + const urip = new AtUri(postUri) + return makeProfileLink(postAuthor, 'post', urip.rkey) + }, [postUri, postAuthor]) + + const hideInPWI = useMemo(() => { + return !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + }, [postAuthor]) + + const showLoggedOutWarning = + postAuthor.did !== currentAccount?.did && hideInPWI + + const onSharePost = () => { + logger.metric('share:press:nativeShare', {}, {statsig: true}) + const url = toShareUrl(href) + shareUrl(url) + onShareProp() + } + + const onCopyLink = () => { + logger.metric('share:press:copyLink', {}, {statsig: true}) + const url = toShareUrl(href) + ExpoClipboard.setUrlAsync(url).then(() => + Toast.show(_(msg`Copied to clipboard`), 'clipboard-check'), + ) + onShareProp() + } + + const onSelectChatToShareTo = (conversation: string) => { + navigation.navigate('MessagesConversation', { + conversation, + embed: postUri, + }) + } + + const onShareATURI = () => { + shareText(postUri) + } + + const onShareAuthorDID = () => { + shareText(postAuthor.did) + } + + return ( + <> + <Menu.Outer> + {hasSession && ( + <Menu.Group> + <Menu.ContainerItem> + <RecentChats postUri={postUri} /> + </Menu.ContainerItem> + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={() => { + logger.metric('share:press:openDmSearch', {}, {statsig: true}) + sendViaChatControl.open() + }}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={PaperPlaneIcon} position="right" /> + </Menu.Item> + </Menu.Group> + )} + + <Menu.Group> + <Menu.Item + testID="postDropdownShareBtn" + label={_(msg`Share via...`)} + onPress={() => { + if (showLoggedOutWarning) { + pwiWarningShareControl.open() + } else { + onSharePost() + } + }}> + <Menu.ItemText> + <Trans>Share via...</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ArrowOutOfBoxIcon} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownShareBtn" + label={_(msg`Copy link to post`)} + onPress={() => { + if (showLoggedOutWarning) { + pwiWarningCopyControl.open() + } else { + onCopyLink() + } + }}> + <Menu.ItemText> + <Trans>Copy link to post</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> + </Menu.Item> + </Menu.Group> + + {devModeEnabled && ( + <Menu.Group> + <Menu.Item + testID="postAtUriShareBtn" + label={_(msg`Share post at:// URI`)} + onPress={onShareATURI}> + <Menu.ItemText> + <Trans>Share post at:// URI</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + <Menu.Item + testID="postAuthorDIDShareBtn" + label={_(msg`Share author DID`)} + onPress={onShareAuthorDID}> + <Menu.ItemText> + <Trans>Share author DID</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </Menu.Group> + )} + </Menu.Outer> + + <Prompt.Basic + control={pwiWarningShareControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, + )} + onConfirm={onSharePost} + confirmButtonCta={_(msg`Share anyway`)} + /> + + <Prompt.Basic + control={pwiWarningCopyControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, + )} + onConfirm={onCopyLink} + confirmButtonCta={_(msg`Copy anyway`)} + /> + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> + </> + ) +} +ShareMenuItems = memo(ShareMenuItems) +export {ShareMenuItems} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx new file mode 100644 index 000000000..5bc2a8fb6 --- /dev/null +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx @@ -0,0 +1,22 @@ +import {type PressableProps, type StyleProp, type ViewStyle} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + type RichText as RichTextAPI, +} from '@atproto/api' + +import {type Shadow} from '#/state/cache/post-shadow' + +export interface ShareMenuItemsProps { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + richText: RichTextAPI + style?: StyleProp<ViewStyle> + hitSlop?: PressableProps['hitSlop'] + size?: 'lg' | 'md' | 'sm' + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record + onShare: () => void +} diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx new file mode 100644 index 000000000..0da259678 --- /dev/null +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx @@ -0,0 +1,192 @@ +import {memo, useMemo} from 'react' +import {AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import type React from 'react' + +import {makeProfileLink} from '#/lib/routes/links' +import {type NavigationProp} from '#/lib/routes/types' +import {shareText, shareUrl} from '#/lib/sharing' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import {useBreakpoints} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {EmbedDialog} from '#/components/dialogs/Embed' +import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' +import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {useDevMode} from '#/storage/hooks/dev-mode' +import {type ShareMenuItemsProps} from './ShareMenuItems.types' + +let ShareMenuItems = ({ + post, + record, + timestamp, + onShare: onShareProp, +}: ShareMenuItemsProps): React.ReactNode => { + const {hasSession, currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() + const loggedOutWarningPromptControl = useDialogControl() + const embedPostControl = useDialogControl() + const sendViaChatControl = useDialogControl() + const [devModeEnabled] = useDevMode() + + const postUri = post.uri + const postCid = post.cid + const postAuthor = useProfileShadow(post.author) + + const href = useMemo(() => { + const urip = new AtUri(postUri) + return makeProfileLink(postAuthor, 'post', urip.rkey) + }, [postUri, postAuthor]) + + const hideInPWI = useMemo(() => { + return !!postAuthor.labels?.find( + label => label.val === '!no-unauthenticated', + ) + }, [postAuthor]) + + const showLoggedOutWarning = + postAuthor.did !== currentAccount?.did && hideInPWI + + const onCopyLink = () => { + logger.metric('share:press:copyLink', {}, {statsig: true}) + const url = toShareUrl(href) + shareUrl(url) + onShareProp() + } + + const onSelectChatToShareTo = (conversation: string) => { + logger.metric('share:press:dmSelected', {}, {statsig: true}) + navigation.navigate('MessagesConversation', { + conversation, + embed: postUri, + }) + } + + const canEmbed = isWeb && gtMobile && !hideInPWI + + const onShareATURI = () => { + shareText(postUri) + } + + const onShareAuthorDID = () => { + shareText(postAuthor.did) + } + + return ( + <> + <Menu.Outer> + <Menu.Group> + <Menu.Item + testID="postDropdownShareBtn" + label={_(msg`Copy link to post`)} + onPress={() => { + if (showLoggedOutWarning) { + loggedOutWarningPromptControl.open() + } else { + onCopyLink() + } + }}> + <Menu.ItemText> + <Trans>Copy link to post</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ChainLinkIcon} position="right" /> + </Menu.Item> + + {hasSession && ( + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={() => { + logger.metric('share:press:openDmSearch', {}, {statsig: true}) + sendViaChatControl.open() + }}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Send} position="right" /> + </Menu.Item> + )} + + {canEmbed && ( + <Menu.Item + testID="postDropdownEmbedBtn" + label={_(msg`Embed post`)} + onPress={() => { + logger.metric('share:press:embed', {}, {statsig: true}) + embedPostControl.open() + }}> + <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> + <Menu.ItemIcon icon={CodeBracketsIcon} position="right" /> + </Menu.Item> + )} + </Menu.Group> + + {devModeEnabled && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="postAtUriShareBtn" + label={_(msg`Copy post at:// URI`)} + onPress={onShareATURI}> + <Menu.ItemText> + <Trans>Copy post at:// URI</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + <Menu.Item + testID="postAuthorDIDShareBtn" + label={_(msg`Copy author DID`)} + onPress={onShareAuthorDID}> + <Menu.ItemText> + <Trans>Copy author DID</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + </Menu.Group> + </> + )} + </Menu.Outer> + + <Prompt.Basic + control={loggedOutWarningPromptControl} + title={_(msg`Note about sharing`)} + description={_( + msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, + )} + onConfirm={onCopyLink} + confirmButtonCta={_(msg`Share anyway`)} + /> + + {canEmbed && ( + <EmbedDialog + control={embedPostControl} + postCid={postCid} + postUri={postUri} + record={record} + postAuthor={postAuthor} + timestamp={timestamp} + /> + )} + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> + </> + ) +} +ShareMenuItems = memo(ShareMenuItems) +export {ShareMenuItems} diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx new file mode 100644 index 000000000..d4ea18bb0 --- /dev/null +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -0,0 +1,119 @@ +import {memo, useMemo, useState} from 'react' +import { + type AppBskyFeedDefs, + type AppBskyFeedPost, + type AppBskyFeedThreadgate, + AtUri, + type RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type React from 'react' + +import {makeProfileLink} from '#/lib/routes/links' +import {shareUrl} from '#/lib/sharing' +import {useGate} from '#/lib/statsig/statsig' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/post-shadow' +import {EventStopper} from '#/view/com/util/EventStopper' +import {native} from '#/alf' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' +import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' +import {useMenuControl} from '#/components/Menu' +import * as Menu from '#/components/Menu' +import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' +import {ShareMenuItems} from './ShareMenuItems' + +let ShareMenuButton = ({ + testID, + post, + big, + record, + richText, + timestamp, + threadgateRecord, + onShare, +}: { + testID: string + post: Shadow<AppBskyFeedDefs.PostView> + big?: boolean + record: AppBskyFeedPost.Record + richText: RichTextAPI + timestamp: string + threadgateRecord?: AppBskyFeedThreadgate.Record + onShare: () => void +}): React.ReactNode => { + const {_} = useLingui() + const gate = useGate() + + const ShareIcon = gate('alt_share_icon') + ? ArrowShareRightIcon + : ArrowOutOfBoxIcon + + const menuControl = useMenuControl() + const [hasBeenOpen, setHasBeenOpen] = useState(false) + const lazyMenuControl = useMemo( + () => ({ + ...menuControl, + open() { + setHasBeenOpen(true) + // HACK. We need the state update to be flushed by the time + // menuControl.open() fires but RN doesn't expose flushSync. + setTimeout(menuControl.open) + + logger.metric( + 'share:open', + {context: big ? 'thread' : 'feed'}, + {statsig: true}, + ) + }, + }), + [menuControl, setHasBeenOpen, big], + ) + + const onNativeLongPress = () => { + logger.metric('share:press:nativeShare', {}, {statsig: true}) + const urip = new AtUri(post.uri) + const href = makeProfileLink(post.author, 'post', urip.rkey) + const url = toShareUrl(href) + shareUrl(url) + onShare() + } + + return ( + <EventStopper onKeyDown={false}> + <Menu.Root control={lazyMenuControl}> + <Menu.Trigger label={_(msg`Open share menu`)}> + {({props}) => { + return ( + <PostControlButton + testID="postShareBtn" + big={big} + label={props.accessibilityLabel} + {...props} + onLongPress={native(onNativeLongPress)}> + <PostControlButtonIcon icon={ShareIcon} /> + </PostControlButton> + ) + }} + </Menu.Trigger> + {hasBeenOpen && ( + // Lazily initialized. Once mounted, they stay mounted. + <ShareMenuItems + testID={testID} + post={post} + record={record} + richText={richText} + timestamp={timestamp} + threadgateRecord={threadgateRecord} + onShare={onShare} + /> + )} + </Menu.Root> + </EventStopper> + ) +} + +ShareMenuButton = memo(ShareMenuButton) +export {ShareMenuButton} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/components/PostControls/index.tsx index 3f82eb294..7739da56b 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/components/PostControls/index.tsx @@ -1,55 +1,43 @@ -import React, {memo} from 'react' -import { - Pressable, - type PressableStateCallbackType, - type StyleProp, - View, - type ViewStyle, -} from 'react-native' -import * as Clipboard from 'expo-clipboard' +import {memo, useState} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' import { type AppBskyFeedDefs, type AppBskyFeedPost, type AppBskyFeedThreadgate, - AtUri, type RichText as RichTextAPI, } from '@atproto/api' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {IS_INTERNAL} from '#/lib/app-info' -import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' -import {makeProfileLink} from '#/lib/routes/links' -import {shareUrl} from '#/lib/sharing' -import {useGate} from '#/lib/statsig/statsig' -import {toShareUrl} from '#/lib/strings/url-helpers' import {type Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' import { usePostLikeMutationQueue, usePostRepostMutationQueue, } from '#/state/queries/post' -import {useRequireAuth, useSession} from '#/state/session' +import {useRequireAuth} from '#/state/session' import { ProgressGuideAction, useProgressGuideControls, } from '#/state/shell/progress-guide' -import {atoms as a, useTheme} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' +import {formatCount} from '#/view/com/util/numeric/format' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints} from '#/alf' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' -import * as Prompt from '#/components/Prompt' -import {PostDropdownBtn} from '../forms/PostDropdownBtn' -import {formatCount} from '../numeric/format' -import {Text} from '../text/Text' -import * as Toast from '../Toast' +import { + PostControlButton, + PostControlButtonIcon, + PostControlButtonText, +} from './PostControlButton' +import {PostMenuButton} from './PostMenu' import {RepostButton} from './RepostButton' +import {ShareMenuButton} from './ShareMenu' -let PostCtrls = ({ +let PostControls = ({ big, post, record, @@ -76,25 +64,18 @@ let PostCtrls = ({ threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void }): React.ReactNode => { - const t = useTheme() const {_, i18n} = useLingui() + const {gtMobile} = useBreakpoints() const {openComposer} = useOpenComposer() - const {currentAccount} = useSession() const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( post, logContext, ) const requireAuth = useRequireAuth() - const loggedOutWarningPromptControl = useDialogControl() const {sendInteraction} = useFeedFeedbackContext() const {captureAction} = useProgressGuideControls() const playHaptic = useHaptics() - const gate = useGate() - const isDiscoverDebugUser = - IS_INTERNAL || - DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || - gate('debug_show_feedcontext') const isBlocked = Boolean( post.author.viewer?.blocking || post.author.viewer?.blockedBy || @@ -102,22 +83,7 @@ let PostCtrls = ({ ) const replyDisabled = post.viewer?.replyDisabled - const shouldShowLoggedOutWarning = React.useMemo(() => { - return ( - post.author.did !== currentAccount?.did && - !!post.author.labels?.find(label => label.val === '!no-unauthenticated') - ) - }, [currentAccount, post]) - - const defaultCtrlColor = React.useMemo( - () => ({ - color: t.palette.contrast_500, - }), - [t], - ) as StyleProp<ViewStyle> - - const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = - React.useState(false) + const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) const onPressToggleLike = async () => { if (isBlocked) { @@ -200,10 +166,6 @@ let PostCtrls = ({ } const onShare = () => { - const urip = new AtUri(post.uri) - const href = makeProfileLink(post.author, 'post', urip.rkey) - const url = toShareUrl(href) - shareUrl(url) sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionShare', @@ -212,20 +174,6 @@ let PostCtrls = ({ }) } - const btnStyle = React.useCallback( - ({pressed, hovered}: PressableStateCallbackType) => [ - a.gap_xs, - a.rounded_full, - a.flex_row, - a.justify_center, - a.align_center, - a.overflow_hidden, - {padding: 5}, - (pressed || hovered) && t.atoms.bg_contrast_25, - ], - [t.atoms.bg_contrast_25], - ) - return ( <View style={[a.flex_row, a.justify_between, a.align_center, style]}> <View @@ -233,39 +181,29 @@ let PostCtrls = ({ big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}], replyDisabled ? {opacity: 0.5} : undefined, ]}> - <Pressable + <PostControlButton testID="replyBtn" - style={btnStyle} - onPress={() => { - if (!replyDisabled) { - playHaptic('Light') - requireAuth(() => onPressReply()) - } - }} - accessibilityRole="button" - accessibilityLabel={_( - msg`Reply (${plural(post.replyCount || 0, { - one: '# reply', - other: '# replies', - })})`, + onPress={ + !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined + } + label={_( + msg({ + message: `Reply (${plural(post.replyCount || 0, { + one: '# reply', + other: '# replies', + })})`, + comment: + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', + }), )} - accessibilityHint="" - hitSlop={POST_CTRL_HITSLOP}> - <Bubble - style={[defaultCtrlColor, {pointerEvents: 'none'}]} - width={big ? 22 : 18} - /> - {typeof post.replyCount !== 'undefined' && post.replyCount > 0 ? ( - <Text - style={[ - defaultCtrlColor, - big ? a.text_md : {fontSize: 15}, - a.user_select_none, - ]}> + big={big}> + <PostControlButtonIcon icon={Bubble} /> + {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( + <PostControlButtonText> {formatCount(i18n, post.replyCount)} - </Text> - ) : undefined} - </Pressable> + </PostControlButtonText> + )} + </PostControlButton> </View> <View style={big ? a.align_center : [a.flex_1, a.align_start]}> <RepostButton @@ -278,28 +216,33 @@ let PostCtrls = ({ /> </View> <View style={big ? a.align_center : [a.flex_1, a.align_start]}> - <Pressable + <PostControlButton testID="likeBtn" - style={btnStyle} + big={big} onPress={() => requireAuth(() => onPressToggleLike())} - accessibilityRole="button" - accessibilityLabel={ + label={ post.viewer?.like ? _( - msg`Unlike (${plural(post.likeCount || 0, { - one: '# like', - other: '# likes', - })})`, + msg({ + message: `Unlike (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', + }), ) : _( - msg`Like (${plural(post.likeCount || 0, { - one: '# like', - other: '# likes', - })})`, + msg({ + message: `Like (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', + }), ) - } - accessibilityHint="" - hitSlop={POST_CTRL_HITSLOP}> + }> <AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} @@ -311,84 +254,39 @@ let PostCtrls = ({ isLiked={Boolean(post.viewer?.like)} hasBeenToggled={hasLikeIconBeenToggled} /> - </Pressable> + </PostControlButton> </View> - {big && ( - <> - <View style={a.align_center}> - <Pressable - testID="shareBtn" - style={btnStyle} - onPress={() => { - if (shouldShowLoggedOutWarning) { - loggedOutWarningPromptControl.open() - } else { - onShare() - } - }} - accessibilityRole="button" - accessibilityLabel={_(msg`Share`)} - accessibilityHint="" - hitSlop={POST_CTRL_HITSLOP}> - <ArrowOutOfBox - style={[defaultCtrlColor, {pointerEvents: 'none'}]} - width={22} - /> - </Pressable> - </View> - <Prompt.Basic - control={loggedOutWarningPromptControl} - title={_(msg`Note about sharing`)} - description={_( - msg`This post is only visible to logged-in users. It won't be visible to people who aren't signed in.`, - )} - onConfirm={onShare} - confirmButtonCta={_(msg`Share anyway`)} - /> - </> - )} <View style={big ? a.align_center : [a.flex_1, a.align_start]}> - <PostDropdownBtn + <View style={[!big && a.ml_sm]}> + <ShareMenuButton + testID="postShareBtn" + post={post} + big={big} + record={record} + richText={richText} + timestamp={post.indexedAt} + threadgateRecord={threadgateRecord} + onShare={onShare} + /> + </View> + </View> + <View + style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}> + <PostMenuButton testID="postDropdownBtn" post={post} postFeedContext={feedContext} postReqId={reqId} + big={big} record={record} richText={richText} - style={{padding: 5}} - hitSlop={POST_CTRL_HITSLOP} timestamp={post.indexedAt} threadgateRecord={threadgateRecord} onShowLess={onShowLess} /> </View> - {isDiscoverDebugUser && feedContext && ( - <Pressable - accessible={false} - style={{ - position: 'absolute', - top: 0, - bottom: 0, - right: 0, - display: 'flex', - justifyContent: 'center', - }} - onPress={e => { - e.stopPropagation() - Clipboard.setStringAsync(feedContext) - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') - }}> - <Text - style={{ - color: t.palette.contrast_400, - fontSize: 7, - }}> - {feedContext} - </Text> - </Pressable> - )} </View> ) } -PostCtrls = memo(PostCtrls) -export {PostCtrls} +PostControls = memo(PostControls) +export {PostControls} diff --git a/src/components/icons/ArrowOutOfBox.tsx b/src/components/icons/ArrowOutOfBox.tsx index 8b395016b..23fee7de0 100644 --- a/src/components/icons/ArrowOutOfBox.tsx +++ b/src/components/icons/ArrowOutOfBox.tsx @@ -3,3 +3,8 @@ import {createSinglePathSVG} from './TEMPLATE' export const ArrowOutOfBox_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M12.707 3.293a1 1 0 0 0-1.414 0l-4.5 4.5a1 1 0 0 0 1.414 1.414L11 6.414v8.836a1 1 0 1 0 2 0V6.414l2.793 2.793a1 1 0 1 0 1.414-1.414l-4.5-4.5ZM5 12.75a1 1 0 1 0-2 0V20a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-7.25a1 1 0 1 0-2 0V19H5v-6.25Z', }) + +export const ArrowOutOfBoxModified_Stroke2_Corner2_Rounded = + createSinglePathSVG({ + path: 'M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25a1 1 0 1 1 2 0V18a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293l4.5 4.5a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z', + }) diff --git a/src/components/icons/ArrowShareRight.tsx b/src/components/icons/ArrowShareRight.tsx new file mode 100644 index 000000000..499034da7 --- /dev/null +++ b/src/components/icons/ArrowShareRight.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowShareRight_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M11.839 4.744c0-1.488 1.724-2.277 2.846-1.364l.107.094 7.66 7.256.128.134c.558.652.558 1.62 0 2.272l-.128.135-7.66 7.255c-1.115 1.057-2.953.267-2.953-1.27v-2.748c-3.503.055-5.417.41-6.592.97-.997.474-1.525 1.122-2.084 2.14l-.243.46c-.558 1.088-2.09.583-2.08-.515l.015-.748c.111-3.68.777-6.5 2.546-8.415 1.83-1.98 4.63-2.771 8.438-2.884V4.744Zm2 3.256c0 .79-.604 1.41-1.341 1.494l-.149.01c-3.9.057-6.147.813-7.48 2.254-.963 1.043-1.562 2.566-1.842 4.79.38-.327.826-.622 1.361-.877 1.656-.788 4.08-1.14 7.938-1.169l.153.007c.754.071 1.36.704 1.36 1.491v2.675L20.884 12l-7.045-6.676V8Z', +}) diff --git a/src/components/icons/ChainLink.tsx b/src/components/icons/ChainLink.tsx new file mode 100644 index 000000000..be19b556a --- /dev/null +++ b/src/components/icons/ChainLink.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z', +}) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index d3334d82f..c67bb60a3 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,5 +1,6 @@ export type Gate = // Keep this alphabetic please. + | 'alt_share_icon' | 'debug_show_feedcontext' | 'debug_subscriptions' | 'explore_show_suggested_feeds' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index d64e44b40..dfb8cd541 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -395,4 +395,12 @@ export type MetricEvents = { 'live:card:openProfile': {subject: string} 'live:view:profile': {subject: string} 'live:view:post': {subject: string; feed?: string} + + 'share:open': {context: 'feed' | 'thread'} + 'share:press:copyLink': {} + 'share:press:nativeShare': {} + 'share:press:openDmSearch': {} + 'share:press:dmSelected': {} + 'share:press:recentDm': {} + 'share:press:embed': {} } diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index fd1bdffa7..d1b7ab0db 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -1,14 +1,14 @@ import React from 'react' -import {ListRenderItemInfo, View} from 'react-native' -import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {type ListRenderItemInfo, View} from 'react-native' +import {type PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {HITSLOP_10} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' import {cleanError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' @@ -21,7 +21,7 @@ import {Post} from '#/view/com/post/Post' import {List} from '#/view/com/util/List' import {atoms as a, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import * as Layout from '#/components/Layout' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx index e2ae3171c..26fa08fdb 100644 --- a/src/screens/Profile/components/ProfileFeedHeader.tsx +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -29,7 +29,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Divider} from '#/components/Divider' import {useRichText} from '#/components/hooks/useRichText' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import { diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index 9fae5d4d5..c0d0341a6 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -5,25 +5,29 @@ import { AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri, - ModerationOpts, + type ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {useQueryClient} from '@tanstack/react-query' import {batchedUpdates} from '#/lib/batchedUpdates' import {HITSLOP_20} from '#/lib/constants' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links' -import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NavigationProp, +} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' import {updateProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {getAllListMembers} from '#/state/queries/list-members' @@ -46,7 +50,8 @@ import {bulkWriteFollows} from '#/screens/Onboarding/util' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' @@ -600,13 +605,24 @@ function OverflowMenu({ <> <Menu.Group> <Menu.Item - label={_(msg`Share`)} + label={ + isWeb + ? _(msg`Copy link to starter pack`) + : _(msg`Share via...`) + } testID="shareStarterPackLinkBtn" onPress={onOpenShareDialog}> <Menu.ItemText> - <Trans>Share link</Trans> + {isWeb ? ( + <Trans>Copy link</Trans> + ) : ( + <Trans>Share via...</Trans> + )} </Menu.ItemText> - <Menu.ItemIcon icon={ArrowOutOfBox} position="right" /> + <Menu.ItemIcon + icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} + position="right" + /> </Menu.Item> </Menu.Group> diff --git a/src/screens/Topic.tsx b/src/screens/Topic.tsx index 62726bcc6..6cf7cf65b 100644 --- a/src/screens/Topic.tsx +++ b/src/screens/Topic.tsx @@ -1,14 +1,14 @@ import React from 'react' -import {ListRenderItemInfo, View} from 'react-native' -import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {type ListRenderItemInfo, View} from 'react-native' +import {type PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {HITSLOP_10} from '#/lib/constants' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {type CommonNavigatorParams} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' import {cleanError} from '#/lib/strings/errors' import {enforceLen} from '#/lib/strings/helpers' @@ -20,7 +20,7 @@ import {Post} from '#/view/com/post/Post' import {List} from '#/view/com/util/List' import {atoms as a, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import * as Layout from '#/components/Layout' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 047961766..2a61db715 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -82,7 +82,6 @@ import {useSetMinimalShellMode} from '#/state/shell' import {useSetLightStatusBar} from '#/state/shell/light-status-bar' import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' import {List} from '#/view/com/util/List' -import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' import {UserAvatar} from '#/view/com/util/UserAvatar' import {Header} from '#/screens/VideoFeed/components/Header' import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf' @@ -97,6 +96,7 @@ import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {ListFooter} from '#/components/Lists' import * as Hider from '#/components/moderation/Hider' +import {PostControls} from '#/components/PostControls' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' @@ -861,7 +861,7 @@ function Overlay({ )} {record && ( <View style={[{left: -5}]}> - <PostCtrls + <PostControls richText={richText} post={post} record={record} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 3925ce9bd..82852aa62 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -43,7 +43,6 @@ import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {Link, TextLink} from '#/view/com/util/Link' import {formatCount} from '#/view/com/util/numeric/format' -import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' @@ -60,6 +59,7 @@ import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostHider} from '#/components/moderation/PostHider' import {type AppModerationCause} from '#/components/Pills' +import {PostControls} from '#/components/PostControls' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' @@ -494,7 +494,7 @@ let PostThreadItemLoaded = ({ marginLeft: -5, }, ]}> - <PostCtrls + <PostControls big post={post} record={record} @@ -642,7 +642,7 @@ let PostThreadItemLoaded = ({ /> </View> )} - <PostCtrls + <PostControls post={post} record={record} richText={richText} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 03463f977..1a48d64d8 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,21 +27,21 @@ import { import {useModerationOpts} from '#/state/preferences/moderation-opts' import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' +import {Link, TextLink} from '#/view/com/util/Link' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostMeta} from '#/view/com/util/PostMeta' +import {Text} from '#/view/com/util/text/Text' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +import {UserInfoText} from '#/view/com/util/UserInfoText' import {atoms as a} from '#/alf' +import {ContentHider} from '#/components/moderation/ContentHider' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {PostControls} from '#/components/PostControls' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' -import {ContentHider} from '../../../components/moderation/ContentHider' -import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' -import {PostAlerts} from '../../../components/moderation/PostAlerts' -import {Link, TextLink} from '../util/Link' -import {PostCtrls} from '../util/post-ctrls/PostCtrls' -import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds' -import {PostMeta} from '../util/PostMeta' -import {Text} from '../util/text/Text' -import {PreviewableUserAvatar} from '../util/UserAvatar' -import {UserInfoText} from '../util/UserInfoText' export function Post({ post, @@ -255,7 +255,7 @@ function PostInner({ /> ) : null} </ContentHider> - <PostCtrls + <PostControls post={post} record={record} richText={richText} diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 2cc749404..3735bbb5a 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -37,7 +37,7 @@ import {precacheProfile} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {FeedNameText} from '#/view/com/util/FeedInfoText' -import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' +import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link' import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' import {PostMeta} from '#/view/com/util/PostMeta' import {Text} from '#/view/com/util/text/Text' @@ -49,11 +49,12 @@ import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {type AppModerationCause} from '#/components/Pills' +import {PostControls} from '#/components/PostControls' +import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' -import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -439,7 +440,7 @@ let FeedItemInner = ({ post={post} threadgateRecord={threadgateRecord} /> - <PostCtrls + <PostControls post={post} record={record} richText={richText} @@ -451,6 +452,8 @@ let FeedItemInner = ({ onShowLess={onShowLess} /> </View> + + <DiscoverDebug feedContext={feedContext} /> </View> </Link> ) diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index f1fd237ec..d18ba12c1 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -12,6 +12,7 @@ import {type NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' import {type Shadow} from '#/state/cache/types' import {useModalControls} from '#/state/modals' import { @@ -26,9 +27,11 @@ import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' import {Button, ButtonIcon} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' -import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' +import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' @@ -236,7 +239,9 @@ let ProfileMenu = ({ <Menu.Group> <Menu.Item testID="profileHeaderDropdownShareBtn" - label={_(msg`Share`)} + label={ + isWeb ? _(msg`Copy link to profile`) : _(msg`Share via...`) + } onPress={() => { if (showLoggedOutWarning) { loggedOutWarningPromptControl.open() @@ -245,9 +250,13 @@ let ProfileMenu = ({ } }}> <Menu.ItemText> - <Trans>Share</Trans> + {isWeb ? ( + <Trans>Copy link to profile</Trans> + ) : ( + <Trans>Share via...</Trans> + )} </Menu.ItemText> - <Menu.ItemIcon icon={Share} /> + <Menu.ItemIcon icon={isWeb ? ChainLinkIcon : ArrowOutOfBoxIcon} /> </Menu.Item> <Menu.Item testID="profileHeaderDropdownSearchBtn" @@ -329,7 +338,7 @@ let ProfileMenu = ({ <Menu.ItemText> <Trans>Remove verification</Trans> </Menu.ItemText> - <Menu.ItemIcon icon={CircleX} /> + <Menu.ItemIcon icon={CircleXIcon} /> </Menu.Item> ) : ( <Menu.Item @@ -339,7 +348,7 @@ let ProfileMenu = ({ <Menu.ItemText> <Trans>Verify account</Trans> </Menu.ItemText> - <Menu.ItemIcon icon={CircleCheck} /> + <Menu.ItemIcon icon={CircleCheckIcon} /> </Menu.Item> ))} {!isSelf && ( @@ -414,7 +423,7 @@ let ProfileMenu = ({ <Menu.ItemText> <Trans>Copy at:// URI</Trans> </Menu.ItemText> - <Menu.ItemIcon icon={Share} /> + <Menu.ItemIcon icon={ClipboardIcon} /> </Menu.Item> <Menu.Item testID="profileHeaderDropdownShareDIDBtn" @@ -423,7 +432,7 @@ let ProfileMenu = ({ <Menu.ItemText> <Trans>Copy DID</Trans> </Menu.ItemText> - <Menu.ItemIcon icon={Share} /> + <Menu.ItemIcon icon={ClipboardIcon} /> </Menu.Item> </Menu.Group> </> |