restructure post controls, basic share menu

This commit is contained in:
Samuel Newman
2025-02-25 16:02:20 +00:00
parent 5c5e2a3189
commit bb2c6fe2d1
12 changed files with 696 additions and 374 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 610 B

+5
View File
@@ -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',
})
@@ -0,0 +1,125 @@
import React, {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 PostCtrlContext = React.createContext<{
big?: boolean
active?: boolean
color?: {color: string}
}>({})
// Base button style, which the the other ones extend
export const PostCtrlButton = React.forwardRef<
View,
ButtonProps & {
active?: boolean
big?: boolean
color?: string
activeColor?: string
}
>(
(
{onPress, onLongPress, children, big, active, activeColor, ...props},
ref,
) => {
const t = useTheme()
const playHaptic = useHaptics()
const ctx = React.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 => (
<PostCtrlContext.Provider value={ctx}>
{children(args)}
</PostCtrlContext.Provider>
)
) : (
<PostCtrlContext.Provider value={ctx}>
{children}
</PostCtrlContext.Provider>
)}
</Button>
)
},
)
PostCtrlButton.displayName = 'PostCtrlButton'
export function PostCtrlButtonIcon({
icon: Comp,
}: {
icon: React.ComponentType<SVGIconProps>
}) {
const {big, color} = useContext(PostCtrlContext)
return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} />
}
export function PostCtrlButtonText({style, ...props}: TextProps) {
const {big, active, color} = useContext(PostCtrlContext)
return (
<Text
style={[
color,
big ? a.text_md : {fontSize: 15},
active && a.font_bold,
style,
]}
{...props}
/>
)
}
+45 -121
View File
@@ -1,32 +1,22 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import { import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
Pressable,
type PressableStateCallbackType,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import { import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyFeedPost, type AppBskyFeedPost,
type AppBskyFeedThreadgate, type AppBskyFeedThreadgate,
AtUri,
type RichText as RichTextAPI, type RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg, plural} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {IS_INTERNAL} from '#/lib/app-info' import {IS_INTERNAL} from '#/lib/app-info'
import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {CountWheel} from '#/lib/custom-animations/CountWheel' import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import { import {
@@ -41,13 +31,16 @@ import {
import {formatCount} from '#/view/com/util/numeric/format' import {formatCount} from '#/view/com/util/numeric/format'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import * as Prompt from '#/components/Prompt' import {
import {PostDropdownBtn} from './PostDropdownBtn' PostCtrlButton,
PostCtrlButtonIcon,
PostCtrlButtonText,
} from './PostCtrlButton'
import {PostMenuButton} from './PostMenuButton'
import {RepostButton} from './RepostButton' import {RepostButton} from './RepostButton'
import {ShareMenuButton} from './ShareMenuButton'
let PostCtrls = ({ let PostCtrls = ({
big, big,
@@ -77,6 +70,7 @@ let PostCtrls = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -86,7 +80,6 @@ let PostCtrls = ({
logContext, logContext,
) )
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const loggedOutWarningPromptControl = useDialogControl()
const {sendInteraction} = useFeedFeedbackContext() const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls() const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics() const playHaptic = useHaptics()
@@ -102,20 +95,6 @@ let PostCtrls = ({
) )
const replyDisabled = post.viewer?.replyDisabled 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] = const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] =
React.useState(false) React.useState(false)
@@ -200,10 +179,6 @@ let PostCtrls = ({
} }
const onShare = () => { const onShare = () => {
const urip = new AtUri(post.uri)
const href = makeProfileLink(post.author, 'post', urip.rkey)
const url = toShareUrl(href)
shareUrl(url)
sendInteraction({ sendInteraction({
item: post.uri, item: post.uri,
event: 'app.bsky.feed.defs#interactionShare', event: 'app.bsky.feed.defs#interactionShare',
@@ -212,20 +187,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 ( return (
<View style={[a.flex_row, a.justify_between, a.align_center, style]}> <View style={[a.flex_row, a.justify_between, a.align_center, style]}>
<View <View
@@ -233,39 +194,25 @@ let PostCtrls = ({
big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}], big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
replyDisabled ? {opacity: 0.5} : undefined, replyDisabled ? {opacity: 0.5} : undefined,
]}> ]}>
<Pressable <PostCtrlButton
testID="replyBtn" testID="replyBtn"
style={btnStyle} onPress={
onPress={() => { !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined
if (!replyDisabled) { }
playHaptic('Light') label={_(
requireAuth(() => onPressReply())
}
}}
accessibilityRole="button"
accessibilityLabel={_(
msg`Reply (${plural(post.replyCount || 0, { msg`Reply (${plural(post.replyCount || 0, {
one: '# reply', one: '# reply',
other: '# replies', other: '# replies',
})})`, })})`,
)} )}
accessibilityHint="" big={big}>
hitSlop={POST_CTRL_HITSLOP}> <PostCtrlButtonIcon icon={Bubble} />
<Bubble {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
style={[defaultCtrlColor, {pointerEvents: 'none'}]} <PostCtrlButtonText>
width={big ? 22 : 18}
/>
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 ? (
<Text
style={[
defaultCtrlColor,
big ? a.text_md : {fontSize: 15},
a.user_select_none,
]}>
{formatCount(i18n, post.replyCount)} {formatCount(i18n, post.replyCount)}
</Text> </PostCtrlButtonText>
) : undefined} )}
</Pressable> </PostCtrlButton>
</View> </View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}> <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<RepostButton <RepostButton
@@ -278,12 +225,11 @@ let PostCtrls = ({
/> />
</View> </View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}> <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<Pressable <PostCtrlButton
testID="likeBtn" testID="likeBtn"
style={btnStyle} big={big}
onPress={() => requireAuth(() => onPressToggleLike())} onPress={() => requireAuth(() => onPressToggleLike())}
accessibilityRole="button" label={
accessibilityLabel={
post.viewer?.like post.viewer?.like
? _( ? _(
msg`Unlike (${plural(post.likeCount || 0, { msg`Unlike (${plural(post.likeCount || 0, {
@@ -297,9 +243,7 @@ let PostCtrls = ({
other: '# likes', other: '# likes',
})})`, })})`,
) )
} }>
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
<AnimatedLikeIcon <AnimatedLikeIcon
isLiked={Boolean(post.viewer?.like)} isLiked={Boolean(post.viewer?.like)}
big={big} big={big}
@@ -311,52 +255,32 @@ let PostCtrls = ({
isLiked={Boolean(post.viewer?.like)} isLiked={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled} hasBeenToggled={hasLikeIconBeenToggled}
/> />
</Pressable> </PostCtrlButton>
</View>
<View
style={
big ? a.align_center : [gtMobile ? a.mr_sm : a.mr_xs, a.align_start]
}>
<ShareMenuButton
testID="postShareBtn"
post={post}
big={big}
record={record}
richText={richText}
timestamp={post.indexedAt}
threadgateRecord={threadgateRecord}
onShare={onShare}
/>
</View> </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]}> <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<PostDropdownBtn <PostMenuButton
testID="postDropdownBtn" testID="postDropdownBtn"
post={post} post={post}
postFeedContext={feedContext} postFeedContext={feedContext}
postReqId={reqId} postReqId={reqId}
big={big}
record={record} record={record}
richText={richText} richText={richText}
style={{padding: 5}}
hitSlop={POST_CTRL_HITSLOP}
timestamp={post.indexedAt} timestamp={post.indexedAt}
threadgateRecord={threadgateRecord} threadgateRecord={threadgateRecord}
onShowLess={onShowLess} onShowLess={onShowLess}
@@ -1,10 +1,4 @@
import {memo, useMemo, useState} from 'react' import {memo, useMemo, useState} from 'react'
import {
Pressable,
type PressableProps,
type StyleProp,
type ViewStyle,
} from 'react-native'
import { import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyFeedPost, type AppBskyFeedPost,
@@ -15,25 +9,22 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import type React from 'react' import type React from 'react'
import {useTheme} from '#/lib/ThemeContext'
import {type Shadow} from '#/state/cache/post-shadow' 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 {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {useMenuControl} from '#/components/Menu' import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {EventStopper} from '../../view/com/util/EventStopper' import {PostCtrlButton, PostCtrlButtonIcon} from './PostCtrlButton'
import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems' import {PostMenuItems} from './PostMenuItems'
let PostDropdownBtn = ({ let PostMenuButton = ({
testID, testID,
post, post,
postFeedContext, postFeedContext,
postReqId, postReqId,
big,
record, record,
richText, richText,
style,
hitSlop,
size,
timestamp, timestamp,
threadgateRecord, threadgateRecord,
onShowLess, onShowLess,
@@ -42,19 +33,15 @@ let PostDropdownBtn = ({
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
postFeedContext: string | undefined postFeedContext: string | undefined
postReqId: string | undefined postReqId: string | undefined
big?: boolean
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
richText: RichTextAPI richText: RichTextAPI
style?: StyleProp<ViewStyle>
hitSlop?: PressableProps['hitSlop']
size?: 'lg' | 'md' | 'sm'
timestamp: string timestamp: string
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
}): React.ReactNode => { }): React.ReactNode => {
const theme = useTheme()
const alf = useAlf()
const {_} = useLingui() const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const menuControl = useMenuControl() const menuControl = useMenuControl()
const [hasBeenOpen, setHasBeenOpen] = useState(false) const [hasBeenOpen, setHasBeenOpen] = useState(false)
const lazyMenuControl = useMemo( const lazyMenuControl = useMemo(
@@ -73,31 +60,21 @@ let PostDropdownBtn = ({
<EventStopper onKeyDown={false}> <EventStopper onKeyDown={false}>
<Menu.Root control={lazyMenuControl}> <Menu.Root control={lazyMenuControl}>
<Menu.Trigger label={_(msg`Open post options menu`)}> <Menu.Trigger label={_(msg`Open post options menu`)}>
{({props, state}) => { {({props}) => {
return ( return (
<Pressable <PostCtrlButton
{...props} testID="postDropdownBtn"
hitSlop={hitSlop} big={big}
testID={testID} label={props.accessibilityLabel}
style={[ {...props}>
style, <PostCtrlButtonIcon icon={DotsHorizontal} />
a.rounded_full, </PostCtrlButton>
(state.hovered || state.pressed) && [
alf.atoms.bg_contrast_25,
],
]}>
<DotsHorizontal
fill={defaultCtrlColor}
style={{pointerEvents: 'none'}}
size={size}
/>
</Pressable>
) )
}} }}
</Menu.Trigger> </Menu.Trigger>
{hasBeenOpen && ( {hasBeenOpen && (
// Lazily initialized. Once mounted, they stay mounted. // Lazily initialized. Once mounted, they stay mounted.
<PostDropdownMenuItems <PostMenuItems
testID={testID} testID={testID}
post={post} post={post}
postFeedContext={postFeedContext} postFeedContext={postFeedContext}
@@ -114,5 +91,5 @@ let PostDropdownBtn = ({
) )
} }
PostDropdownBtn = memo(PostDropdownBtn) PostMenuButton = memo(PostMenuButton)
export {PostDropdownBtn} export {PostMenuButton}
@@ -13,7 +13,7 @@ import {
AtUri, AtUri,
type RichText as RichTextAPI, type RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@@ -26,13 +26,12 @@ import {
type CommonNavigatorParams, type CommonNavigatorParams,
type NavigationProp, type NavigationProp,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing' import {shareText} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {getTranslatorLink} from '#/locale/helpers' import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {type Shadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
@@ -53,20 +52,16 @@ import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {useBreakpoints} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {EmbedDialog} from '#/components/dialogs/Embed'
import { import {
PostInteractionSettingsDialog, PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings, usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog' } from '#/components/dialogs/PostInteractionSettingsDialog'
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom' import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
import { import {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
@@ -76,7 +71,6 @@ import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/E
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 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 MuteIcon} from '#/components/icons/Mute'
import {Mute_Stroke2_Corner0_Rounded as Mute} 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 {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
@@ -94,13 +88,12 @@ import * as Prompt from '#/components/Prompt'
import {useDevMode} from '#/storage/hooks/dev-mode' import {useDevMode} from '#/storage/hooks/dev-mode'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
let PostDropdownMenuItems = ({ let PostMenuItems = ({
post, post,
postFeedContext, postFeedContext,
postReqId, postReqId,
record, record,
richText, richText,
timestamp,
threadgateRecord, threadgateRecord,
onShowLess, onShowLess,
}: { }: {
@@ -118,7 +111,6 @@ let PostDropdownMenuItems = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
}): React.ReactNode => { }): React.ReactNode => {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const {gtMobile} = useBreakpoints()
const {_} = useLingui() const {_} = useLingui()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation() const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
@@ -134,9 +126,6 @@ let PostDropdownMenuItems = ({
const reportDialogControl = useReportDialogControl() const reportDialogControl = useReportDialogControl()
const deletePromptControl = useDialogControl() const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl() const hidePromptControl = useDialogControl()
const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const postInteractionSettingsDialogControl = useDialogControl() const postInteractionSettingsDialogControl = useDialogControl()
const quotePostDetachConfirmControl = useDialogControl() const quotePostDetachConfirmControl = useDialogControl()
const hideReplyConfirmControl = useDialogControl() const hideReplyConfirmControl = useDialogControl()
@@ -273,14 +262,6 @@ let PostDropdownMenuItems = ({
label => label.val === '!no-unauthenticated', label => label.val === '!no-unauthenticated',
) )
const showLoggedOutWarning =
postAuthor.did !== currentAccount?.did && hideInPWI
const onSharePost = () => {
const url = toShareUrl(href)
shareUrl(url)
}
const onPressShowMore = () => { const onPressShowMore = () => {
feedFeedback.sendInteraction({ feedFeedback.sendInteraction({
event: 'app.bsky.feed.defs#requestMore', event: 'app.bsky.feed.defs#requestMore',
@@ -308,13 +289,6 @@ let PostDropdownMenuItems = ({
} }
} }
const onSelectChatToShareTo = (conversation: string) => {
navigation.navigate('MessagesConversation', {
conversation,
embed: postUri,
})
}
const onToggleQuotePostAttachment = async () => { const onToggleQuotePostAttachment = async () => {
if (!quoteEmbed) return if (!quoteEmbed) return
@@ -341,7 +315,6 @@ let PostDropdownMenuItems = ({
} }
const canHidePostForMe = !isAuthor && !isPostHidden const canHidePostForMe = !isAuthor && !isPostHidden
const canEmbed = isWeb && gtMobile && !hideInPWI
const canHideReplyForEveryone = const canHideReplyForEveryone =
!isAuthor && isRootPostAuthor && !isPostHidden && isReply !isAuthor && isRootPostAuthor && !isPostHidden && isReply
const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
@@ -482,44 +455,6 @@ let PostDropdownMenuItems = ({
</Menu.Item> </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> </Menu.Group>
{hasSession && feedFeedback.enabled && ( {hasSession && feedFeedback.enabled && (
@@ -802,32 +737,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 <PostInteractionSettingsDialog
control={postInteractionSettingsDialogControl} control={postInteractionSettingsDialogControl}
postUri={post.uri} postUri={post.uri}
@@ -868,5 +777,5 @@ let PostDropdownMenuItems = ({
</> </>
) )
} }
PostDropdownMenuItems = memo(PostDropdownMenuItems) PostMenuItems = memo(PostMenuItems)
export {PostDropdownMenuItems} export {PostMenuItems}
+20 -43
View File
@@ -1,9 +1,8 @@
import React, {memo, useCallback} from 'react' import {memo, useCallback} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg, plural, Trans} from '@lingui/macro' import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {POST_CTRL_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useRequireAuth} from '#/state/session' import {useRequireAuth} from '#/state/session'
import {formatCount} from '#/view/com/util/numeric/format' import {formatCount} from '#/view/com/util/numeric/format'
@@ -13,6 +12,11 @@ import * as Dialog from '#/components/Dialog'
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {
PostCtrlButton,
PostCtrlButtonIcon,
PostCtrlButtonText,
} from './PostCtrlButton'
interface Props { interface Props {
isReposted: boolean isReposted: boolean
@@ -35,33 +39,16 @@ let RepostButton = ({
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const dialogControl = Dialog.useDialogControl() const dialogControl = Dialog.useDialogControl()
const playHaptic = useHaptics()
const color = React.useMemo(
() => ({
color: isReposted ? t.palette.positive_600 : t.palette.contrast_500,
}),
[t, isReposted],
)
return ( return (
<> <>
<Button <PostCtrlButton
testID="repostBtn" testID="repostBtn"
onPress={() => { active={isReposted}
playHaptic('Light') activeColor={t.palette.positive_600}
requireAuth(() => dialogControl.open()) big={big}
}} onPress={() => requireAuth(() => dialogControl.open())}
onLongPress={() => { onLongPress={() => requireAuth(() => onQuote())}
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}
label={ label={
isReposted isReposted
? _( ? _(
@@ -76,24 +63,14 @@ let RepostButton = ({
other: '# reposts', other: '# reposts',
})})`, })})`,
) )
} }>
shape="round" <PostCtrlButtonIcon icon={Repost} />
variant="ghost" {typeof repostCount !== 'undefined' && repostCount > 0 && (
color="secondary" <PostCtrlButtonText testID="repostCount">
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,
]}>
{formatCount(i18n, repostCount)} {formatCount(i18n, repostCount)}
</Text> </PostCtrlButtonText>
) : undefined} )}
</Button> </PostCtrlButton>
<Dialog.Outer <Dialog.Outer
control={dialogControl} control={dialogControl}
nativeOptions={{preventExpansion: true}}> nativeOptions={{preventExpansion: true}}>
+33 -73
View File
@@ -1,5 +1,3 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -7,12 +5,15 @@ import {useRequireAuth} from '#/state/session'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper' import {EventStopper} from '#/view/com/util/EventStopper'
import {formatCount} from '#/view/com/util/numeric/format' import {formatCount} from '#/view/com/util/numeric/format'
import {atoms as a, useTheme} from '#/alf' import {useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography' import {
PostCtrlButton,
PostCtrlButtonIcon,
PostCtrlButtonText,
} from './PostCtrlButton'
interface Props { interface Props {
isReposted: boolean isReposted: boolean
@@ -32,38 +33,30 @@ export const RepostButton = ({
embeddingDisabled, embeddingDisabled,
}: Props) => { }: Props) => {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const {hasSession} = useSession() const {hasSession} = useSession()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const color = React.useMemo(
() => ({
color: isReposted ? t.palette.positive_600 : t.palette.contrast_500,
}),
[t, isReposted],
)
return hasSession ? ( return hasSession ? (
<EventStopper onKeyDown={false}> <EventStopper onKeyDown={false}>
<Menu.Root> <Menu.Root>
<Menu.Trigger label={_(msg`Repost or quote post`)}> <Menu.Trigger label={_(msg`Repost or quote post`)}>
{({props, state}) => { {({props}) => {
return ( return (
<Pressable <PostCtrlButton
{...props} testID="repostBtn"
style={[ active={isReposted}
a.rounded_full, activeColor={t.palette.positive_600}
(state.hovered || state.pressed) && { label={props.accessibilityLabel}
backgroundColor: t.palette.contrast_25, big={big}
}, {...props}>
]}> <PostCtrlButtonIcon icon={Repost} />
<RepostInner {typeof repostCount !== 'undefined' && repostCount > 0 && (
isReposted={isReposted} <PostCtrlButtonText testID="repostCount">
color={color} {formatCount(i18n, repostCount)}
repostCount={repostCount} </PostCtrlButtonText>
big={big} )}
/> </PostCtrlButton>
</Pressable>
) )
}} }}
</Menu.Trigger> </Menu.Trigger>
@@ -97,51 +90,18 @@ export const RepostButton = ({
</Menu.Root> </Menu.Root>
</EventStopper> </EventStopper>
) : ( ) : (
<Button <PostCtrlButton
onPress={() => { onPress={() => requireAuth(() => {})}
requireAuth(() => {}) active={isReposted}
}} activeColor={t.palette.positive_600}
label={_(msg`Repost or quote post`)} label={_(msg`Repost or quote post`)}
style={{padding: 0}} big={big}>
hoverStyle={t.atoms.bg_contrast_25} <PostCtrlButtonIcon icon={Repost} />
shape="round"> {typeof repostCount !== 'undefined' && repostCount > 0 && (
<RepostInner <PostCtrlButtonText testID="repostCount">
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,
]}>
{formatCount(i18n, repostCount)} {formatCount(i18n, repostCount)}
</Text> </PostCtrlButtonText>
) : undefined} )}
</View> </PostCtrlButton>
) )
} }
@@ -0,0 +1,89 @@
import {memo, useMemo, useState} from 'react'
import {
type AppBskyFeedDefs,
type AppBskyFeedPost,
type AppBskyFeedThreadgate,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type React from 'react'
import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {PostCtrlButton, PostCtrlButtonIcon} from './PostCtrlButton'
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 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)
},
}),
[menuControl, setHasBeenOpen],
)
return (
<EventStopper onKeyDown={false}>
<Menu.Root control={lazyMenuControl}>
<Menu.Trigger label={_(msg`Open share menu`)}>
{({props}) => {
return (
<PostCtrlButton
testID="postShareBtn"
big={big}
label={props.accessibilityLabel}
{...props}>
<PostCtrlButtonIcon icon={ArrowOutOfBoxIcon} />
</PostCtrlButton>
)
}}
</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}
@@ -0,0 +1,150 @@
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 {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 {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {useDialogControl} from '#/components/Dialog'
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
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 {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBoxIcon} from '../icons/ArrowOutOfBox'
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 loggedOutWarningPromptControl = 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 = () => {
const url = toShareUrl(href)
shareUrl(url)
onShareProp()
}
const onSelectChatToShareTo = (conversation: string) => {
navigation.navigate('MessagesConversation', {
conversation,
embed: postUri,
})
}
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 {
onSharePost()
}
}}>
<Menu.ItemText>
<Trans>Share post</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowOutOfBoxIcon} position="right" />
</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.Group>
{devModeEnabled && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="postAtUriShareBtn"
label={_(msg`Copy 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`Copy 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={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`)}
/>
<SendViaChatDialog
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
/>
</>
)
}
ShareMenuItems = memo(ShareMenuItems)
export {ShareMenuItems}
@@ -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
}
@@ -0,0 +1,183 @@
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 {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 CodeBrackets} 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 onSharePost = () => {
const url = toShareUrl(href)
shareUrl(url)
onShareProp()
}
const onSelectChatToShareTo = (conversation: string) => {
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 {
onSharePost()
}
}}>
<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={() => 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={() => embedPostControl.open()}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
<Menu.ItemIcon icon={CodeBrackets} 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={onSharePost}
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}