Share menu (#7840)

* move post ctrls to #/components

* restructure post controls, basic share menu

* add border radius to searchable people list for android

* Revert "add border radius to searchable people list for android"

This reverts commit 417449086e25b82f5683b12f6405d972f48ce50e.

* add copy link to native share menu

* reorg files again

* open native share menu on long press

* Translation comments

Thanks @surfdude29

* abs path

* update type imports, remove forwardRef

* rm react import

* equal spacing of buttons, extract disco debug

* add better icon

* add right offset to share button for visual alignment

* Add recent chats to share menu (#7853)

* add recent chats to share menu

* Update RecentChats.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update RecentChats.tsx

* add fading edge on andriod

* tweak scrollview

* Add metrics and A/B alt icon to share menu (#8401)

* add metrics

* add a/b tested alt icon

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* More descriptive share text/icon on web (#7854)

* more descriptive share text on web

* revert dev mode changes

* add missing import

* use modified share icon everywhere

* Add back conflicting changes

---------

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-05-24 02:02:38 +03:00
committed by GitHub
parent 5aadb9e41b
commit c3f88e0a48
31 changed files with 1227 additions and 532 deletions
@@ -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>
)
)
}
@@ -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}
/>
)
}
@@ -0,0 +1,745 @@
import {memo, useMemo} from 'react'
import {
Platform,
type PressableProps,
type StyleProp,
type ViewStyle,
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
type AppBskyFeedDefs,
AppBskyFeedPost,
type AppBskyFeedThreadgate,
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {IS_INTERNAL} from '#/lib/app-info'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
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 {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
import {usePinnedPostMutation} from '#/state/queries/pinned-post'
import {
usePostDeleteMutation,
useThreadMuteMutationQueue,
} from '#/state/queries/post'
import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
import {
useProfileBlockMutationQueue,
useProfileMuteMutationQueue,
} from '#/state/queries/profile'
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {useSession} from '#/state/session'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import * as Toast from '#/view/com/util/Toast'
import {useDialogControl} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
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 {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji'
import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
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 {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'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import {
ReportDialog,
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as bsky from '#/types/bsky'
let PostMenuItems = ({
post,
postFeedContext,
postReqId,
record,
richText,
threadgateRecord,
onShowLess,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
postFeedContext: string | undefined
postReqId: string | undefined
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 {hasSession, currentAccount} = useSession()
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
const {mutateAsync: pinPostMutate, isPending: isPinPending} =
usePinnedPostMutation()
const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
const navigation = useNavigation<NavigationProp>()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const blockPromptControl = useDialogControl()
const reportDialogControl = useReportDialogControl()
const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl()
const postInteractionSettingsDialogControl = useDialogControl()
const quotePostDetachConfirmControl = useDialogControl()
const hideReplyConfirmControl = useDialogControl()
const {mutateAsync: toggleReplyVisibility} =
useToggleReplyVisibilityMutation()
const postUri = post.uri
const postCid = post.cid
const postAuthor = useProfileShadow(post.author)
const quoteEmbed = useMemo(() => {
if (!currentAccount || !post.embed) return
return getMaybeDetachedQuoteEmbed({
viewerDid: currentAccount.did,
post,
})
}, [post, currentAccount])
const rootUri = record.reply?.root?.uri || postUri
const isReply = Boolean(record.reply)
const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
post,
rootUri,
)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did
const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri)
const isPinned = post.viewer?.pinned
const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} =
useToggleQuoteDetachmentMutation()
const [queueBlock] = useProfileBlockMutationQueue(postAuthor)
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(postAuthor)
const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
postUri: post.uri,
rootPostUri: rootUri,
})
const href = useMemo(() => {
const urip = new AtUri(postUri)
return makeProfileLink(postAuthor, 'post', urip.rkey)
}, [postUri, postAuthor])
const translatorUrl = getTranslatorLink(
record.text,
langPrefs.primaryLanguage,
)
const onDeletePost = () => {
deletePostMutate({uri: postUri}).then(
() => {
Toast.show(_(msg({message: 'Post deleted', context: 'toast'})))
const route = getCurrentRoute(navigation.getState())
if (route.name === 'PostThread') {
const params = route.params as CommonNavigatorParams['PostThread']
if (
currentAccount &&
isAuthor &&
(params.name === currentAccount.handle ||
params.name === currentAccount.did)
) {
const currentHref = makeProfileLink(postAuthor, 'post', params.rkey)
if (currentHref === href && navigation.canGoBack()) {
navigation.goBack()
}
}
}
},
e => {
logger.error('Failed to delete post', {message: e})
Toast.show(_(msg`Failed to delete post, please try again`), 'xmark')
},
)
}
const onToggleThreadMute = () => {
try {
if (isThreadMuted) {
unmuteThread()
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
Toast.show(
_(msg`You will no longer receive notifications for this thread`),
)
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(
_(msg`Failed to toggle thread mute, please try again`),
'xmark',
)
}
}
}
const onCopyPostText = () => {
const str = richTextToString(richText, true)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}
const onPressTranslate = async () => {
await openLink(translatorUrl, true)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
logger.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
}
const onHidePost = () => {
hidePost({uri: postUri})
}
const hideInPWI = !!postAuthor.labels?.find(
label => label.val === '!no-unauthenticated',
)
const onPressShowMore = () => {
feedFeedback.sendInteraction({
event: 'app.bsky.feed.defs#requestMore',
item: postUri,
feedContext: postFeedContext,
reqId: postReqId,
})
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
}
const onPressShowLess = () => {
feedFeedback.sendInteraction({
event: 'app.bsky.feed.defs#requestLess',
item: postUri,
feedContext: postFeedContext,
reqId: postReqId,
})
if (onShowLess) {
onShowLess({
item: postUri,
feedContext: postFeedContext,
})
} else {
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
}
}
const onToggleQuotePostAttachment = async () => {
if (!quoteEmbed) return
const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
const isDetach = action === 'detach'
try {
await toggleQuoteDetachment({
post,
quoteUri: quoteEmbed.uri,
action: quoteEmbed.isDetached ? 'reattach' : 'detach',
})
Toast.show(
isDetach
? _(msg`Quote post was successfully detached`)
: _(msg`Quote post was re-attached`),
)
} catch (e: any) {
Toast.show(
_(msg({message: 'Updating quote attachment failed', context: 'toast'})),
)
logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
}
}
const canHidePostForMe = !isAuthor && !isPostHidden
const canHideReplyForEveryone =
!isAuthor && isRootPostAuthor && !isPostHidden && isReply
const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
const onToggleReplyVisibility = async () => {
// TODO no threadgate?
if (!canHideReplyForEveryone) return
const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
const isHide = action === 'hide'
try {
await toggleReplyVisibility({
postUri: rootUri,
replyUri: postUri,
action,
})
Toast.show(
isHide
? _(msg`Reply was successfully hidden`)
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
)
} catch (e: any) {
Toast.show(
_(msg({message: 'Updating reply visibility failed', context: 'toast'})),
)
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
}
}
const onPressPin = () => {
logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({
postUri,
postCid,
action: isPinned ? 'unpin' : 'pin',
})
}
const onBlockAuthor = async () => {
try {
await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
}
}
}
const onMuteAuthor = async () => {
if (postAuthor.viewer?.muted) {
try {
await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
}
}
} else {
try {
await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
}
}
}
}
const onReportMisclassification = () => {
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
href,
)}`
openLink(url)
}
return (
<>
<Menu.Outer>
{isAuthor && (
<>
<Menu.Group>
<Menu.Item
testID="pinPostBtn"
label={
isPinned
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)
}
disabled={isPinPending}
onPress={onPressPin}>
<Menu.ItemText>
{isPinned
? _(msg`Unpin from profile`)
: _(msg`Pin to your profile`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={isPinPending ? Loader : PinIcon}
position="right"
/>
</Menu.Item>
</Menu.Group>
<Menu.Divider />
</>
)}
<Menu.Group>
{(!hideInPWI || hasSession) && (
<>
<Menu.Item
testID="postDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onPressTranslate}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownCopyTextBtn"
label={_(msg`Copy post text`)}
onPress={onCopyPostText}>
<Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</>
)}
</Menu.Group>
{hasSession && feedFeedback.enabled && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="postDropdownShowMoreBtn"
label={_(msg`Show more like this`)}
onPress={onPressShowMore}>
<Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSmile} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownShowLessBtn"
label={_(msg`Show less like this`)}
onPress={onPressShowLess}>
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSad} position="right" />
</Menu.Item>
</Menu.Group>
</>
)}
{hasSession &&
IS_INTERNAL &&
DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && (
<Menu.Item
testID="postDropdownReportMisclassificationBtn"
label={_(msg`Assign topic for algo`)}
onPress={onReportMisclassification}>
<Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText>
<Menu.ItemIcon icon={AtomIcon} position="right" />
</Menu.Item>
)}
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="postDropdownMuteThreadBtn"
label={
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
}
onPress={onToggleThreadMute}>
<Menu.ItemText>
{isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={isThreadMuted ? Unmute : Mute}
position="right"
/>
</Menu.Item>
<Menu.Item
testID="postDropdownMuteWordsBtn"
label={_(msg`Mute words & tags`)}
onPress={() => mutedWordsDialogControl.open()}>
<Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
<Menu.ItemIcon icon={Filter} position="right" />
</Menu.Item>
</Menu.Group>
</>
)}
{hasSession &&
(canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
<>
<Menu.Divider />
<Menu.Group>
{canHidePostForMe && (
<Menu.Item
testID="postDropdownHideBtn"
label={
isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)
}
onPress={() => hidePromptControl.open()}>
<Menu.ItemText>
{isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)}
</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item>
)}
{canHideReplyForEveryone && (
<Menu.Item
testID="postDropdownHideBtn"
label={
isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)
}
onPress={
isReplyHiddenByThreadgate
? onToggleReplyVisibility
: () => hideReplyConfirmControl.open()
}>
<Menu.ItemText>
{isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
position="right"
/>
</Menu.Item>
)}
{canDetachQuote && (
<Menu.Item
disabled={isDetachPending}
testID="postDropdownHideBtn"
label={
quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)
}
onPress={
quoteEmbed.isDetached
? onToggleQuotePostAttachment
: () => quotePostDetachConfirmControl.open()
}>
<Menu.ItemText>
{quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={
isDetachPending
? Loader
: quoteEmbed.isDetached
? Eye
: EyeSlash
}
position="right"
/>
</Menu.Item>
)}
</Menu.Group>
</>
)}
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
{!isAuthor && (
<>
<Menu.Item
testID="postDropdownMuteBtn"
label={
postAuthor.viewer?.muted
? _(msg`Unmute account`)
: _(msg`Mute account`)
}
onPress={onMuteAuthor}>
<Menu.ItemText>
{postAuthor.viewer?.muted
? _(msg`Unmute account`)
: _(msg`Mute account`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon}
position="right"
/>
</Menu.Item>
{!postAuthor.viewer?.blocking && (
<Menu.Item
testID="postDropdownBlockBtn"
label={_(msg`Block account`)}
onPress={() => blockPromptControl.open()}>
<Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText>
<Menu.ItemIcon icon={PersonX} position="right" />
</Menu.Item>
)}
<Menu.Item
testID="postDropdownReportBtn"
label={_(msg`Report post`)}
onPress={() => reportDialogControl.open()}>
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
</>
)}
{isAuthor && (
<>
<Menu.Item
testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)}
onPress={() => postInteractionSettingsDialogControl.open()}
{...(isAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}>
<Menu.ItemText>
{_(msg`Edit interaction settings`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={() => deletePromptControl.open()}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
</>
)}
</Menu.Group>
</>
)}
</Menu.Outer>
<Prompt.Basic
control={deletePromptControl}
title={_(msg`Delete this post?`)}
description={_(
msg`If you remove this post, you won't be able to recover it.`,
)}
onConfirm={onDeletePost}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={hidePromptControl}
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
description={_(
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
)}
onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)}
/>
<ReportDialog
control={reportDialogControl}
subject={{
...post,
$type: 'app.bsky.feed.defs#postView',
}}
/>
<PostInteractionSettingsDialog
control={postInteractionSettingsDialogControl}
postUri={post.uri}
rootPostUri={rootUri}
initialThreadgateView={post.threadgate}
/>
<Prompt.Basic
control={quotePostDetachConfirmControl}
title={_(msg`Detach quote post?`)}
description={_(
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)}
onConfirm={onToggleQuotePostAttachment}
confirmButtonCta={_(msg`Yes, detach`)}
/>
<Prompt.Basic
control={hideReplyConfirmControl}
title={_(msg`Hide this reply?`)}
description={_(
msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
)}
onConfirm={onToggleReplyVisibility}
confirmButtonCta={_(msg`Yes, hide`)}
/>
<Prompt.Basic
control={blockPromptControl}
title={_(msg`Block Account?`)}
description={_(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
onConfirm={onBlockAuthor}
confirmButtonCta={_(msg`Block`)}
confirmButtonColor="negative"
/>
</>
)
}
PostMenuItems = memo(PostMenuItems)
export {PostMenuItems}
@@ -0,0 +1,95 @@
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 {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
import {PostMenuItems} from './PostMenuItems'
let PostMenuButton = ({
testID,
post,
postFeedContext,
postReqId,
big,
record,
richText,
timestamp,
threadgateRecord,
onShowLess,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
postFeedContext: string | undefined
postReqId: string | undefined
big?: boolean
record: AppBskyFeedPost.Record
richText: RichTextAPI
timestamp: string
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => 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 post options menu`)}>
{({props}) => {
return (
<PostControlButton
testID="postDropdownBtn"
big={big}
label={props.accessibilityLabel}
{...props}>
<PostControlButtonIcon icon={DotsHorizontal} />
</PostControlButton>
)
}}
</Menu.Trigger>
{hasBeenOpen && (
// Lazily initialized. Once mounted, they stay mounted.
<PostMenuItems
testID={testID}
post={post}
postFeedContext={postFeedContext}
postReqId={postReqId}
record={record}
richText={richText}
timestamp={timestamp}
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
/>
)}
</Menu.Root>
</EventStopper>
)
}
PostMenuButton = memo(PostMenuButton)
export {PostMenuButton}
@@ -0,0 +1,206 @@
import {memo, useCallback} from 'react'
import {View} from 'react-native'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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 {
PostControlButton,
PostControlButtonIcon,
PostControlButtonText,
} from './PostControlButton'
interface Props {
isReposted: boolean
repostCount?: number
onRepost: () => void
onQuote: () => void
big?: boolean
embeddingDisabled: boolean
}
let RepostButton = ({
isReposted,
repostCount,
onRepost,
onQuote,
big,
embeddingDisabled,
}: Props): React.ReactNode => {
const t = useTheme()
const {_, i18n} = useLingui()
const requireAuth = useRequireAuth()
const dialogControl = Dialog.useDialogControl()
return (
<>
<PostControlButton
testID="repostBtn"
active={isReposted}
activeColor={t.palette.positive_600}
big={big}
onPress={() => requireAuth(() => dialogControl.open())}
onLongPress={() => requireAuth(() => onQuote())}
label={
isReposted
? _(
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({
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',
}),
)
}>
<PostControlButtonIcon icon={Repost} />
{typeof repostCount !== 'undefined' && repostCount > 0 && (
<PostControlButtonText testID="repostCount">
{formatCount(i18n, repostCount)}
</PostControlButtonText>
)}
</PostControlButton>
<Dialog.Outer
control={dialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<RepostButtonDialogInner
isReposted={isReposted}
onRepost={onRepost}
onQuote={onQuote}
embeddingDisabled={embeddingDisabled}
/>
</Dialog.Outer>
</>
)
}
RepostButton = memo(RepostButton)
export {RepostButton}
let RepostButtonDialogInner = ({
isReposted,
onRepost,
onQuote,
embeddingDisabled,
}: {
isReposted: boolean
onRepost: () => void
onQuote: () => void
embeddingDisabled: boolean
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const playHaptic = useHaptics()
const control = Dialog.useDialogContext()
const onPressRepost = useCallback(() => {
if (!isReposted) playHaptic()
control.close(() => {
onRepost()
})
}, [control, isReposted, onRepost, playHaptic])
const onPressQuote = useCallback(() => {
playHaptic()
control.close(() => {
onQuote()
})
}, [control, onQuote, playHaptic])
const onPressClose = useCallback(() => control.close(), [control])
return (
<Dialog.ScrollableInner label={_(msg`Repost or quote post`)}>
<View style={a.gap_xl}>
<View style={a.gap_xs}>
<Button
style={[a.justify_start, a.px_md]}
label={
isReposted
? _(msg`Remove repost`)
: _(msg({message: `Repost`, context: 'action'}))
}
onPress={onPressRepost}
size="large"
variant="ghost"
color="primary">
<Repost size="lg" fill={t.palette.primary_500} />
<Text style={[a.font_bold, a.text_xl]}>
{isReposted ? (
<Trans>Remove repost</Trans>
) : (
<Trans context="action">Repost</Trans>
)}
</Text>
</Button>
<Button
disabled={embeddingDisabled}
testID="quoteBtn"
style={[a.justify_start, a.px_md]}
label={
embeddingDisabled
? _(msg`Quote posts disabled`)
: _(msg`Quote post`)
}
onPress={onPressQuote}
size="large"
variant="ghost"
color="primary">
<Quote
size="lg"
fill={
embeddingDisabled
? t.atoms.text_contrast_low.color
: t.palette.primary_500
}
/>
<Text
style={[
a.font_bold,
a.text_xl,
embeddingDisabled && t.atoms.text_contrast_low,
]}>
{embeddingDisabled ? (
<Trans>Quote posts disabled</Trans>
) : (
<Trans>Quote post</Trans>
)}
</Text>
</Button>
</View>
<Button
label={_(msg`Cancel quote post`)}
onPress={onPressClose}
size="large"
variant="outline"
color="primary">
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</Dialog.ScrollableInner>
)
}
RepostButtonDialogInner = memo(RepostButtonDialogInner)
export {RepostButtonDialogInner}
@@ -0,0 +1,107 @@
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequireAuth} from '#/state/session'
import {useSession} from '#/state/session'
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 {
PostControlButton,
PostControlButtonIcon,
PostControlButtonText,
} from './PostControlButton'
interface Props {
isReposted: boolean
repostCount?: number
onRepost: () => void
onQuote: () => void
big?: boolean
embeddingDisabled: boolean
}
export const RepostButton = ({
isReposted,
repostCount,
onRepost,
onQuote,
big,
embeddingDisabled,
}: Props) => {
const t = useTheme()
const {_, i18n} = useLingui()
const {hasSession} = useSession()
const requireAuth = useRequireAuth()
return hasSession ? (
<EventStopper onKeyDown={false}>
<Menu.Root>
<Menu.Trigger label={_(msg`Repost or quote post`)}>
{({props}) => {
return (
<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>
<Menu.Outer style={{minWidth: 170}}>
<Menu.Item
label={isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
testID="repostDropdownRepostBtn"
onPress={onRepost}>
<Menu.ItemText>
{isReposted ? _(msg`Undo repost`) : _(msg`Repost`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Repost} position="right" />
</Menu.Item>
<Menu.Item
disabled={embeddingDisabled}
label={
embeddingDisabled
? _(msg`Quote posts disabled`)
: _(msg`Quote post`)
}
testID="repostDropdownQuoteBtn"
onPress={onQuote}>
<Menu.ItemText>
{embeddingDisabled
? _(msg`Quote posts disabled`)
: _(msg`Quote post`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Quote} position="right" />
</Menu.Item>
</Menu.Outer>
</Menu.Root>
</EventStopper>
) : (
<PostControlButton
onPress={() => requireAuth(() => {})}
active={isReposted}
activeColor={t.palette.positive_600}
label={_(msg`Repost or quote post`)}
big={big}>
<PostControlButtonIcon icon={Repost} />
{typeof repostCount !== 'undefined' && repostCount > 0 && (
<PostControlButtonText testID="repostCount">
{formatCount(i18n, repostCount)}
</PostControlButtonText>
)}
</PostControlButton>
)
}
@@ -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>
)
}
@@ -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}
@@ -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,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}
@@ -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}
+292
View File
@@ -0,0 +1,292 @@
import {memo, useState} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedPost,
type AppBskyFeedThreadgate,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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 {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
usePostLikeMutationQueue,
usePostRepostMutationQueue,
} from '#/state/queries/post'
import {useRequireAuth} from '#/state/session'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
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 {
PostControlButton,
PostControlButtonIcon,
PostControlButtonText,
} from './PostControlButton'
import {PostMenuButton} from './PostMenu'
import {RepostButton} from './RepostButton'
import {ShareMenuButton} from './ShareMenu'
let PostControls = ({
big,
post,
record,
richText,
feedContext,
reqId,
style,
onPressReply,
onPostReply,
logContext,
threadgateRecord,
onShowLess,
}: {
big?: boolean
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
richText: RichTextAPI
feedContext?: string | undefined
reqId?: string | undefined
style?: StyleProp<ViewStyle>
onPressReply: () => void
onPostReply?: (postUri: string | undefined) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
}): React.ReactNode => {
const {_, i18n} = useLingui()
const {gtMobile} = useBreakpoints()
const {openComposer} = useOpenComposer()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
post,
logContext,
)
const requireAuth = useRequireAuth()
const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics()
const isBlocked = Boolean(
post.author.viewer?.blocking ||
post.author.viewer?.blockedBy ||
post.author.viewer?.blockingByList,
)
const replyDisabled = post.viewer?.replyDisabled
const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
const onPressToggleLike = async () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return
}
try {
setHasLikeIconBeenToggled(true)
if (!post.viewer?.like) {
playHaptic('Light')
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
feedContext,
reqId,
})
captureAction(ProgressGuideAction.Like)
await queueLike()
} else {
await queueUnlike()
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
throw e
}
}
}
const onRepost = async () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return
}
try {
if (!post.viewer?.repost) {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionRepost',
feedContext,
reqId,
})
await queueRepost()
} else {
await queueUnrepost()
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
throw e
}
}
}
const onQuote = () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return
}
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionQuote',
feedContext,
reqId,
})
openComposer({
quote: post,
onPost: onPostReply,
})
}
const onShare = () => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionShare',
feedContext,
reqId,
})
}
return (
<View style={[a.flex_row, a.justify_between, a.align_center, style]}>
<View
style={[
big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
replyDisabled ? {opacity: 0.5} : undefined,
]}>
<PostControlButton
testID="replyBtn"
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',
}),
)}
big={big}>
<PostControlButtonIcon icon={Bubble} />
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
<PostControlButtonText>
{formatCount(i18n, post.replyCount)}
</PostControlButtonText>
)}
</PostControlButton>
</View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<RepostButton
isReposted={!!post.viewer?.repost}
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
onRepost={onRepost}
onQuote={onQuote}
big={big}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/>
</View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<PostControlButton
testID="likeBtn"
big={big}
onPress={() => requireAuth(() => onPressToggleLike())}
label={
post.viewer?.like
? _(
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({
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',
}),
)
}>
<AnimatedLikeIcon
isLiked={Boolean(post.viewer?.like)}
big={big}
hasBeenToggled={hasLikeIconBeenToggled}
/>
<CountWheel
likeCount={post.likeCount ?? 0}
big={big}
isLiked={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled}
/>
</PostControlButton>
</View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<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}
timestamp={post.indexedAt}
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
/>
</View>
</View>
)
}
PostControls = memo(PostControls)
export {PostControls}