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