diff --git a/assets/icons/arrowOutOfBoxModified_stroke2_corner2_rounded.svg b/assets/icons/arrowOutOfBoxModified_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..3311e68934
--- /dev/null
+++ b/assets/icons/arrowOutOfBoxModified_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/arrowShareRight_stroke2_corner2_rounded.svg b/assets/icons/arrowShareRight_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..40e2a5f253
--- /dev/null
+++ b/assets/icons/arrowShareRight_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/chainLink_stroke2_corner0_rounded.svg b/assets/icons/chainLink_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..c1626cc618
--- /dev/null
+++ b/assets/icons/chainLink_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 76fc74dc1d..c5ccfa5ece 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -244,6 +244,38 @@ export function ItemRadio({selected}: {selected: boolean}) {
)
}
+/**
+ * NATIVE ONLY - for adding non-pressable items to the menu
+ *
+ * @platform ios, android
+ */
+export function ContainerItem({
+ children,
+ style,
+}: {
+ children: React.ReactNode
+ style?: StyleProp
+}) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
+
export function LabelText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
@@ -272,13 +304,14 @@ export function Group({children, style}: GroupProps) {
style,
]}>
{flattenReactChildren(children).map((child, i) => {
- return React.isValidElement(child) && child.type === Item ? (
+ return React.isValidElement(child) &&
+ (child.type === Item || child.type === ContainerItem) ? (
{i > 0 ? (
) : null}
{React.cloneElement(child, {
- // @ts-ignore
+ // @ts-expect-error cloneElement is not aware of the types
style: {
borderRadius: 0,
borderWidth: 0,
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 27678bf2f2..7d6e50556c 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -390,3 +390,7 @@ export function Divider() {
/>
)
}
+
+export function ContainerItem() {
+ return null
+}
diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx
new file mode 100644
index 0000000000..796981f0c2
--- /dev/null
+++ b/src/components/PostControls/DiscoverDebug.tsx
@@ -0,0 +1,54 @@
+import {Pressable} from 'react-native'
+import * as Clipboard from 'expo-clipboard'
+import {t} from '@lingui/macro'
+
+import {IS_INTERNAL} from '#/lib/app-info'
+import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
+import {useGate} from '#/lib/statsig/statsig'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function DiscoverDebug({
+ feedContext,
+}: {
+ feedContext: string | undefined
+}) {
+ const {currentAccount} = useSession()
+ const {gtMobile} = useBreakpoints()
+ const gate = useGate()
+ const isDiscoverDebugUser =
+ IS_INTERNAL ||
+ DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
+ gate('debug_show_feedcontext')
+ const theme = useTheme()
+
+ return (
+ isDiscoverDebugUser &&
+ feedContext && (
+ {
+ e.stopPropagation()
+ Clipboard.setStringAsync(feedContext)
+ Toast.show(t`Copied to clipboard`, 'clipboard-check')
+ }}>
+
+ {feedContext}
+
+
+ )
+ )
+}
diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx
new file mode 100644
index 0000000000..1585d429d2
--- /dev/null
+++ b/src/components/PostControls/PostControlButton.tsx
@@ -0,0 +1,126 @@
+import {createContext, useContext, useMemo} from 'react'
+import {type GestureResponderEvent, type View} from 'react-native'
+
+import {POST_CTRL_HITSLOP} from '#/lib/constants'
+import {useHaptics} from '#/lib/haptics'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, type ButtonProps} from '#/components/Button'
+import {type Props as SVGIconProps} from '#/components/icons/common'
+import {Text, type TextProps} from '#/components/Typography'
+
+const PostControlContext = createContext<{
+ big?: boolean
+ active?: boolean
+ color?: {color: string}
+}>({})
+
+// Base button style, which the the other ones extend
+export function PostControlButton({
+ ref,
+ onPress,
+ onLongPress,
+ children,
+ big,
+ active,
+ activeColor,
+ ...props
+}: ButtonProps & {
+ ref?: React.Ref
+ 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 (
+
+ )
+}
+
+export function PostControlButtonIcon({
+ icon: Comp,
+}: {
+ icon: React.ComponentType
+}) {
+ const {big, color} = useContext(PostControlContext)
+
+ return
+}
+
+export function PostControlButtonText({style, ...props}: TextProps) {
+ const {big, active, color} = useContext(PostControlContext)
+
+ return (
+
+ )
+}
diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx
similarity index 84%
rename from src/view/com/util/forms/PostDropdownBtnMenuItems.tsx
rename to src/components/PostControls/PostMenu/PostMenuItems.tsx
index a5f41ea7a1..51991589fd 100644
--- a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx
+++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx
@@ -1,4 +1,4 @@
-import React, {memo} from 'react'
+import {memo, useMemo} from 'react'
import {
Platform,
type PressableProps,
@@ -13,7 +13,7 @@ import {
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -26,13 +26,11 @@ import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
-import {shareText, shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
@@ -52,20 +50,16 @@ import {
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {useSession} from '#/state/session'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
-import {useBreakpoints} from '#/alf'
+import * as Toast from '#/view/com/util/Toast'
import {useDialogControl} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
-import {EmbedDialog} from '#/components/dialogs/Embed'
import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
-import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
-import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
import {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
@@ -75,7 +69,6 @@ import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/E
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
-import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
@@ -90,17 +83,14 @@ import {
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
-import {useDevMode} from '#/storage/hooks/dev-mode'
import * as bsky from '#/types/bsky'
-import * as Toast from '../Toast'
-let PostDropdownMenuItems = ({
+let PostMenuItems = ({
post,
postFeedContext,
postReqId,
record,
richText,
- timestamp,
threadgateRecord,
onShowLess,
}: {
@@ -118,7 +108,6 @@ let PostDropdownMenuItems = ({
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
- const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
@@ -134,20 +123,16 @@ let PostDropdownMenuItems = ({
const reportDialogControl = useReportDialogControl()
const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl()
- const loggedOutWarningPromptControl = useDialogControl()
- const embedPostControl = useDialogControl()
- const sendViaChatControl = useDialogControl()
const postInteractionSettingsDialogControl = useDialogControl()
const quotePostDetachConfirmControl = useDialogControl()
const hideReplyConfirmControl = useDialogControl()
const {mutateAsync: toggleReplyVisibility} =
useToggleReplyVisibilityMutation()
- const [devModeEnabled] = useDevMode()
const postUri = post.uri
const postCid = post.cid
const postAuthor = useProfileShadow(post.author)
- const quoteEmbed = React.useMemo(() => {
+ const quoteEmbed = useMemo(() => {
if (!currentAccount || !post.embed) return
return getMaybeDetachedQuoteEmbed({
viewerDid: currentAccount.did,
@@ -181,7 +166,7 @@ let PostDropdownMenuItems = ({
rootPostUri: rootUri,
})
- const href = React.useMemo(() => {
+ const href = useMemo(() => {
const urip = new AtUri(postUri)
return makeProfileLink(postAuthor, 'post', urip.rkey)
}, [postUri, postAuthor])
@@ -273,14 +258,6 @@ let PostDropdownMenuItems = ({
label => label.val === '!no-unauthenticated',
)
- const showLoggedOutWarning =
- postAuthor.did !== currentAccount?.did && hideInPWI
-
- const onSharePost = () => {
- const url = toShareUrl(href)
- shareUrl(url)
- }
-
const onPressShowMore = () => {
feedFeedback.sendInteraction({
event: 'app.bsky.feed.defs#requestMore',
@@ -308,13 +285,6 @@ let PostDropdownMenuItems = ({
}
}
- const onSelectChatToShareTo = (conversation: string) => {
- navigation.navigate('MessagesConversation', {
- conversation,
- embed: postUri,
- })
- }
-
const onToggleQuotePostAttachment = async () => {
if (!quoteEmbed) return
@@ -341,7 +311,6 @@ let PostDropdownMenuItems = ({
}
const canHidePostForMe = !isAuthor && !isPostHidden
- const canEmbed = isWeb && gtMobile && !hideInPWI
const canHideReplyForEveryone =
!isAuthor && isRootPostAuthor && !isPostHidden && isReply
const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
@@ -417,14 +386,6 @@ let PostDropdownMenuItems = ({
}
}
- const onShareATURI = () => {
- shareText(postUri)
- }
-
- const onShareAuthorDID = () => {
- shareText(postAuthor.did)
- }
-
const onReportMisclassification = () => {
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
href,
@@ -482,44 +443,6 @@ let PostDropdownMenuItems = ({
>
)}
-
- {hasSession && (
- sendViaChatControl.open()}>
-
- Send via direct message
-
-
-
- )}
-
- {
- if (showLoggedOutWarning) {
- loggedOutWarningPromptControl.open()
- } else {
- onSharePost()
- }
- }}>
-
- {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
-
-
-
-
- {canEmbed && (
- embedPostControl.open()}>
- {_(msg`Embed post`)}
-
-
- )}
{hasSession && feedFeedback.enabled && (
@@ -550,11 +473,9 @@ let PostDropdownMenuItems = ({
DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && (
-
- {_(msg`Assign topic - help train Discover!`)}
-
+ {_(msg`Assign topic for algo`)}
)}
@@ -747,28 +668,6 @@ let PostDropdownMenuItems = ({
>
)}
-
- {devModeEnabled ? (
- <>
-
-
-
- {_(msg`Copy post at:// URI`)}
-
-
-
- {_(msg`Copy author DID`)}
-
-
-
- >
- ) : null}
>
)}
@@ -802,32 +701,6 @@ let PostDropdownMenuItems = ({
}}
/>
-
-
- {canEmbed && (
-
- )}
-
-
-
)
}
-PostDropdownMenuItems = memo(PostDropdownMenuItems)
-export {PostDropdownMenuItems}
+PostMenuItems = memo(PostMenuItems)
+export {PostMenuItems}
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/components/PostControls/PostMenu/index.tsx
similarity index 63%
rename from src/view/com/util/forms/PostDropdownBtn.tsx
rename to src/components/PostControls/PostMenu/index.tsx
index 57ee95e318..63aa460fbd 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/components/PostControls/PostMenu/index.tsx
@@ -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 '../EventStopper'
-import {PostDropdownMenuItems} from './PostDropdownBtnMenuItems'
+import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
+import {PostMenuItems} from './PostMenuItems'
-let PostDropdownBtn = ({
+let PostMenuButton = ({
testID,
post,
postFeedContext,
postReqId,
+ big,
record,
richText,
- style,
- hitSlop,
- size,
timestamp,
threadgateRecord,
onShowLess,
@@ -42,19 +33,15 @@ let PostDropdownBtn = ({
post: Shadow
postFeedContext: string | undefined
postReqId: string | undefined
+ big?: boolean
record: AppBskyFeedPost.Record
richText: RichTextAPI
- style?: StyleProp
- 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 = ({
- {({props, state}) => {
+ {({props}) => {
return (
-
-
-
+
+
+
)
}}
{hasBeenOpen && (
// Lazily initialized. Once mounted, they stay mounted.
- ({
- color: isReposted ? t.palette.positive_600 : t.palette.contrast_500,
- }),
- [t, isReposted],
- )
+
return (
<>
-
+
+ )}
+
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/components/PostControls/RepostButton.web.tsx
similarity index 51%
rename from src/view/com/util/post-ctrls/RepostButton.web.tsx
rename to src/components/PostControls/RepostButton.web.tsx
index 54119b532d..48720b753b 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/components/PostControls/RepostButton.web.tsx
@@ -1,18 +1,19 @@
-import React from 'react'
-import {Pressable, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequireAuth} from '#/state/session'
import {useSession} from '#/state/session'
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {formatCount} from '#/view/com/util/numeric/format'
+import {useTheme} from '#/alf'
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import * as Menu from '#/components/Menu'
-import {Text} from '#/components/Typography'
-import {EventStopper} from '../EventStopper'
-import {formatCount} from '../numeric/format'
+import {
+ PostControlButton,
+ PostControlButtonIcon,
+ PostControlButtonText,
+} from './PostControlButton'
interface Props {
isReposted: boolean
@@ -32,38 +33,30 @@ export const RepostButton = ({
embeddingDisabled,
}: Props) => {
const t = useTheme()
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const {hasSession} = useSession()
const requireAuth = useRequireAuth()
- const color = React.useMemo(
- () => ({
- color: isReposted ? t.palette.positive_600 : t.palette.contrast_500,
- }),
- [t, isReposted],
- )
-
return hasSession ? (
- {({props, state}) => {
+ {({props}) => {
return (
-
-
-
+
+
+ {typeof repostCount !== 'undefined' && repostCount > 0 && (
+
+ {formatCount(i18n, repostCount)}
+
+ )}
+
)
}}
@@ -97,51 +90,18 @@ export const RepostButton = ({
) : (
-
- )
-}
-
-const RepostInner = ({
- isReposted,
- color,
- repostCount,
- big,
-}: {
- isReposted: boolean
- color: {color: string}
- repostCount?: number
- big?: boolean
-}) => {
- const {i18n} = useLingui()
- return (
-
-
- {typeof repostCount !== 'undefined' && repostCount > 0 ? (
-
+ big={big}>
+
+ {typeof repostCount !== 'undefined' && repostCount > 0 && (
+
{formatCount(i18n, repostCount)}
-
- ) : undefined}
-
+
+ )}
+
)
}
diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx
new file mode 100644
index 0000000000..ca5d0029ec
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/RecentChats.tsx
@@ -0,0 +1,200 @@
+import {ScrollView, View} from 'react-native'
+import {moderateProfile, type ModerationOpts} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {type NavigationProp} from '#/lib/routes/types'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
+import {useSession} from '#/state/session'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useDialogContext} from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+import {useSimpleVerificationState} from '#/components/verification'
+import {VerificationCheck} from '#/components/verification/VerificationCheck'
+import type * as bsky from '#/types/bsky'
+
+export function RecentChats({postUri}: {postUri: string}) {
+ const control = useDialogContext()
+ const {_} = useLingui()
+ const {currentAccount} = useSession()
+ const {data} = useListConvosQuery({status: 'accepted'})
+ const convos = data?.pages[0]?.convos?.slice(0, 10)
+ const moderationOpts = useModerationOpts()
+ const navigation = useNavigation()
+
+ 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 (
+
+
+ {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 (
+ onSelectChat(convo.id)}
+ moderationOpts={moderationOpts}
+ />
+ )
+ })
+ ) : (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ {convos && convos.length === 0 && }
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function ConvoSkeleton() {
+ const t = useTheme()
+ return (
+
+
+
+
+ )
+}
+
+function NoConvos() {
+ const t = useTheme()
+
+ return (
+
+
+
+ Start a conversation, and it will appear here.
+
+
+ )
+}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
new file mode 100644
index 0000000000..94369fcff8
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
@@ -0,0 +1,197 @@
+import {memo, useMemo} from 'react'
+import * as ExpoClipboard from 'expo-clipboard'
+import {AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {type NavigationProp} from '#/lib/routes/types'
+import {shareText, shareUrl} from '#/lib/sharing'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {useDialogControl} from '#/components/Dialog'
+import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
+import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {useDevMode} from '#/storage/hooks/dev-mode'
+import {RecentChats} from './RecentChats'
+import {type ShareMenuItemsProps} from './ShareMenuItems.types'
+
+let ShareMenuItems = ({
+ post,
+ onShare: onShareProp,
+}: ShareMenuItemsProps): React.ReactNode => {
+ const {hasSession, currentAccount} = useSession()
+ const {_} = useLingui()
+ const navigation = useNavigation()
+ 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 (
+ <>
+
+ {hasSession && (
+
+
+
+
+ {
+ logger.metric('share:press:openDmSearch', {}, {statsig: true})
+ sendViaChatControl.open()
+ }}>
+
+ Send via direct message
+
+
+
+
+ )}
+
+
+ {
+ if (showLoggedOutWarning) {
+ pwiWarningShareControl.open()
+ } else {
+ onSharePost()
+ }
+ }}>
+
+ Share via...
+
+
+
+
+ {
+ if (showLoggedOutWarning) {
+ pwiWarningCopyControl.open()
+ } else {
+ onCopyLink()
+ }
+ }}>
+
+ Copy link to post
+
+
+
+
+
+ {devModeEnabled && (
+
+
+
+ Share post at:// URI
+
+
+
+
+
+ Share author DID
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ >
+ )
+}
+ShareMenuItems = memo(ShareMenuItems)
+export {ShareMenuItems}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx
new file mode 100644
index 0000000000..5bc2a8fb6e
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.types.tsx
@@ -0,0 +1,22 @@
+import {type PressableProps, type StyleProp, type ViewStyle} from 'react-native'
+import {
+ type AppBskyFeedDefs,
+ type AppBskyFeedPost,
+ type AppBskyFeedThreadgate,
+ type RichText as RichTextAPI,
+} from '@atproto/api'
+
+import {type Shadow} from '#/state/cache/post-shadow'
+
+export interface ShareMenuItemsProps {
+ testID: string
+ post: Shadow
+ record: AppBskyFeedPost.Record
+ richText: RichTextAPI
+ style?: StyleProp
+ hitSlop?: PressableProps['hitSlop']
+ size?: 'lg' | 'md' | 'sm'
+ timestamp: string
+ threadgateRecord?: AppBskyFeedThreadgate.Record
+ onShare: () => void
+}
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
new file mode 100644
index 0000000000..0da2596780
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
@@ -0,0 +1,192 @@
+import {memo, useMemo} from 'react'
+import {AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import type React from 'react'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {type NavigationProp} from '#/lib/routes/types'
+import {shareText, shareUrl} from '#/lib/sharing'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
+import {useBreakpoints} from '#/alf'
+import {useDialogControl} from '#/components/Dialog'
+import {EmbedDialog} from '#/components/dialogs/Embed'
+import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
+import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
+import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {useDevMode} from '#/storage/hooks/dev-mode'
+import {type ShareMenuItemsProps} from './ShareMenuItems.types'
+
+let ShareMenuItems = ({
+ post,
+ record,
+ timestamp,
+ onShare: onShareProp,
+}: ShareMenuItemsProps): React.ReactNode => {
+ const {hasSession, currentAccount} = useSession()
+ const {gtMobile} = useBreakpoints()
+ const {_} = useLingui()
+ const navigation = useNavigation()
+ 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 (
+ <>
+
+
+ {
+ if (showLoggedOutWarning) {
+ loggedOutWarningPromptControl.open()
+ } else {
+ onCopyLink()
+ }
+ }}>
+
+ Copy link to post
+
+
+
+
+ {hasSession && (
+ {
+ logger.metric('share:press:openDmSearch', {}, {statsig: true})
+ sendViaChatControl.open()
+ }}>
+
+ Send via direct message
+
+
+
+ )}
+
+ {canEmbed && (
+ {
+ logger.metric('share:press:embed', {}, {statsig: true})
+ embedPostControl.open()
+ }}>
+ {_(msg`Embed post`)}
+
+
+ )}
+
+
+ {devModeEnabled && (
+ <>
+
+
+
+
+ Copy post at:// URI
+
+
+
+
+
+ Copy author DID
+
+
+
+
+ >
+ )}
+
+
+
+
+ {canEmbed && (
+
+ )}
+
+
+ >
+ )
+}
+ShareMenuItems = memo(ShareMenuItems)
+export {ShareMenuItems}
diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx
new file mode 100644
index 0000000000..d4ea18bb0c
--- /dev/null
+++ b/src/components/PostControls/ShareMenu/index.tsx
@@ -0,0 +1,119 @@
+import {memo, useMemo, useState} from 'react'
+import {
+ type AppBskyFeedDefs,
+ type AppBskyFeedPost,
+ type AppBskyFeedThreadgate,
+ AtUri,
+ type RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type React from 'react'
+
+import {makeProfileLink} from '#/lib/routes/links'
+import {shareUrl} from '#/lib/sharing'
+import {useGate} from '#/lib/statsig/statsig'
+import {toShareUrl} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {type Shadow} from '#/state/cache/post-shadow'
+import {EventStopper} from '#/view/com/util/EventStopper'
+import {native} from '#/alf'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
+import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
+import {useMenuControl} from '#/components/Menu'
+import * as Menu from '#/components/Menu'
+import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
+import {ShareMenuItems} from './ShareMenuItems'
+
+let ShareMenuButton = ({
+ testID,
+ post,
+ big,
+ record,
+ richText,
+ timestamp,
+ threadgateRecord,
+ onShare,
+}: {
+ testID: string
+ post: Shadow
+ 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 (
+
+
+
+ {({props}) => {
+ return (
+
+
+
+ )
+ }}
+
+ {hasBeenOpen && (
+ // Lazily initialized. Once mounted, they stay mounted.
+
+ )}
+
+
+ )
+}
+
+ShareMenuButton = memo(ShareMenuButton)
+export {ShareMenuButton}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/components/PostControls/index.tsx
similarity index 51%
rename from src/view/com/util/post-ctrls/PostCtrls.tsx
rename to src/components/PostControls/index.tsx
index 3f82eb2944..7739da56be 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/components/PostControls/index.tsx
@@ -1,55 +1,43 @@
-import React, {memo} from 'react'
-import {
- Pressable,
- type PressableStateCallbackType,
- type StyleProp,
- View,
- type ViewStyle,
-} from 'react-native'
-import * as Clipboard from 'expo-clipboard'
+import {memo, useState} from 'react'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedPost,
type AppBskyFeedThreadgate,
- AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {IS_INTERNAL} from '#/lib/app-info'
-import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants'
import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
-import {makeProfileLink} from '#/lib/routes/links'
-import {shareUrl} from '#/lib/sharing'
-import {useGate} from '#/lib/statsig/statsig'
-import {toShareUrl} from '#/lib/strings/url-helpers'
import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
usePostLikeMutationQueue,
usePostRepostMutationQueue,
} from '#/state/queries/post'
-import {useRequireAuth, useSession} from '#/state/session'
+import {useRequireAuth} from '#/state/session'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
-import {atoms as a, useTheme} from '#/alf'
-import {useDialogControl} from '#/components/Dialog'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
+import {formatCount} from '#/view/com/util/numeric/format'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints} from '#/alf'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
-import * as Prompt from '#/components/Prompt'
-import {PostDropdownBtn} from '../forms/PostDropdownBtn'
-import {formatCount} from '../numeric/format'
-import {Text} from '../text/Text'
-import * as Toast from '../Toast'
+import {
+ PostControlButton,
+ PostControlButtonIcon,
+ PostControlButtonText,
+} from './PostControlButton'
+import {PostMenuButton} from './PostMenu'
import {RepostButton} from './RepostButton'
+import {ShareMenuButton} from './ShareMenu'
-let PostCtrls = ({
+let PostControls = ({
big,
post,
record,
@@ -76,25 +64,18 @@ let PostCtrls = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
}): React.ReactNode => {
- const t = useTheme()
const {_, i18n} = useLingui()
+ const {gtMobile} = useBreakpoints()
const {openComposer} = useOpenComposer()
- const {currentAccount} = useSession()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
post,
logContext,
)
const requireAuth = useRequireAuth()
- const loggedOutWarningPromptControl = useDialogControl()
const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics()
- const gate = useGate()
- const isDiscoverDebugUser =
- IS_INTERNAL ||
- DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
- gate('debug_show_feedcontext')
const isBlocked = Boolean(
post.author.viewer?.blocking ||
post.author.viewer?.blockedBy ||
@@ -102,22 +83,7 @@ let PostCtrls = ({
)
const replyDisabled = post.viewer?.replyDisabled
- const shouldShowLoggedOutWarning = React.useMemo(() => {
- return (
- post.author.did !== currentAccount?.did &&
- !!post.author.labels?.find(label => label.val === '!no-unauthenticated')
- )
- }, [currentAccount, post])
-
- const defaultCtrlColor = React.useMemo(
- () => ({
- color: t.palette.contrast_500,
- }),
- [t],
- ) as StyleProp
-
- const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] =
- React.useState(false)
+ const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
const onPressToggleLike = async () => {
if (isBlocked) {
@@ -200,10 +166,6 @@ let PostCtrls = ({
}
const onShare = () => {
- const urip = new AtUri(post.uri)
- const href = makeProfileLink(post.author, 'post', urip.rkey)
- const url = toShareUrl(href)
- shareUrl(url)
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionShare',
@@ -212,20 +174,6 @@ let PostCtrls = ({
})
}
- const btnStyle = React.useCallback(
- ({pressed, hovered}: PressableStateCallbackType) => [
- a.gap_xs,
- a.rounded_full,
- a.flex_row,
- a.justify_center,
- a.align_center,
- a.overflow_hidden,
- {padding: 5},
- (pressed || hovered) && t.atoms.bg_contrast_25,
- ],
- [t.atoms.bg_contrast_25],
- )
-
return (
- {
- if (!replyDisabled) {
- playHaptic('Light')
- requireAuth(() => onPressReply())
- }
- }}
- accessibilityRole="button"
- accessibilityLabel={_(
- msg`Reply (${plural(post.replyCount || 0, {
- one: '# reply',
- other: '# replies',
- })})`,
+ onPress={
+ !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined
+ }
+ label={_(
+ msg({
+ message: `Reply (${plural(post.replyCount || 0, {
+ one: '# reply',
+ other: '# replies',
+ })})`,
+ comment:
+ 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
+ }),
)}
- accessibilityHint=""
- hitSlop={POST_CTRL_HITSLOP}>
-
- {typeof post.replyCount !== 'undefined' && post.replyCount > 0 ? (
-
+ big={big}>
+
+ {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
+
{formatCount(i18n, post.replyCount)}
-
- ) : undefined}
-
+
+ )}
+
- requireAuth(() => onPressToggleLike())}
- accessibilityRole="button"
- accessibilityLabel={
+ label={
post.viewer?.like
? _(
- msg`Unlike (${plural(post.likeCount || 0, {
- one: '# like',
- other: '# likes',
- })})`,
+ msg({
+ message: `Unlike (${plural(post.likeCount || 0, {
+ one: '# like',
+ other: '# likes',
+ })})`,
+ comment:
+ 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
+ }),
)
: _(
- msg`Like (${plural(post.likeCount || 0, {
- one: '# like',
- other: '# likes',
- })})`,
+ msg({
+ message: `Like (${plural(post.likeCount || 0, {
+ one: '# like',
+ other: '# likes',
+ })})`,
+ comment:
+ 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
+ }),
)
- }
- accessibilityHint=""
- hitSlop={POST_CTRL_HITSLOP}>
+ }>
-
+
- {big && (
- <>
-
- {
- if (shouldShowLoggedOutWarning) {
- loggedOutWarningPromptControl.open()
- } else {
- onShare()
- }
- }}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Share`)}
- accessibilityHint=""
- hitSlop={POST_CTRL_HITSLOP}>
-
-
-
-
- >
- )}
-
+
+
+
+
+
- {isDiscoverDebugUser && feedContext && (
- {
- e.stopPropagation()
- Clipboard.setStringAsync(feedContext)
- Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
- }}>
-
- {feedContext}
-
-
- )}
)
}
-PostCtrls = memo(PostCtrls)
-export {PostCtrls}
+PostControls = memo(PostControls)
+export {PostControls}
diff --git a/src/components/icons/ArrowOutOfBox.tsx b/src/components/icons/ArrowOutOfBox.tsx
index 8b395016bd..23fee7de0c 100644
--- a/src/components/icons/ArrowOutOfBox.tsx
+++ b/src/components/icons/ArrowOutOfBox.tsx
@@ -3,3 +3,8 @@ import {createSinglePathSVG} from './TEMPLATE'
export const ArrowOutOfBox_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12.707 3.293a1 1 0 0 0-1.414 0l-4.5 4.5a1 1 0 0 0 1.414 1.414L11 6.414v8.836a1 1 0 1 0 2 0V6.414l2.793 2.793a1 1 0 1 0 1.414-1.414l-4.5-4.5ZM5 12.75a1 1 0 1 0-2 0V20a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-7.25a1 1 0 1 0-2 0V19H5v-6.25Z',
})
+
+export const ArrowOutOfBoxModified_Stroke2_Corner2_Rounded =
+ createSinglePathSVG({
+ path: 'M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25a1 1 0 1 1 2 0V18a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293l4.5 4.5a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z',
+ })
diff --git a/src/components/icons/ArrowShareRight.tsx b/src/components/icons/ArrowShareRight.tsx
new file mode 100644
index 0000000000..499034da71
--- /dev/null
+++ b/src/components/icons/ArrowShareRight.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowShareRight_Stroke2_Corner2_Rounded = createSinglePathSVG({
+ path: 'M11.839 4.744c0-1.488 1.724-2.277 2.846-1.364l.107.094 7.66 7.256.128.134c.558.652.558 1.62 0 2.272l-.128.135-7.66 7.255c-1.115 1.057-2.953.267-2.953-1.27v-2.748c-3.503.055-5.417.41-6.592.97-.997.474-1.525 1.122-2.084 2.14l-.243.46c-.558 1.088-2.09.583-2.08-.515l.015-.748c.111-3.68.777-6.5 2.546-8.415 1.83-1.98 4.63-2.771 8.438-2.884V4.744Zm2 3.256c0 .79-.604 1.41-1.341 1.494l-.149.01c-3.9.057-6.147.813-7.48 2.254-.963 1.043-1.562 2.566-1.842 4.79.38-.327.826-.622 1.361-.877 1.656-.788 4.08-1.14 7.938-1.169l.153.007c.754.071 1.36.704 1.36 1.491v2.675L20.884 12l-7.045-6.676V8Z',
+})
diff --git a/src/components/icons/ChainLink.tsx b/src/components/icons/ChainLink.tsx
new file mode 100644
index 0000000000..be19b556a4
--- /dev/null
+++ b/src/components/icons/ChainLink.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ChainLink_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M18.535 5.465a5.003 5.003 0 0 0-7.076 0l-.005.005-.752.742a1 1 0 1 1-1.404-1.424l.749-.74a7.003 7.003 0 0 1 9.904 9.905l-.002.003-.737.746a1 1 0 1 1-1.424-1.404l.747-.757a5.003 5.003 0 0 0 0-7.076ZM6.202 9.288a1 1 0 0 1 .01 1.414l-.747.757a5.003 5.003 0 1 0 7.076 7.076l.005-.005.752-.742a1 1 0 1 1 1.404 1.424l-.746.737-.003.002a7.003 7.003 0 0 1-9.904-9.904l.74-.75a1 1 0 0 1 1.413-.009Zm8.505.005a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414-1.414l4-4a1 1 0 0 1 1.414 0Z',
+})
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index d3334d82f6..c67bb60a3a 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,5 +1,6 @@
export type Gate =
// Keep this alphabetic please.
+ | 'alt_share_icon'
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'explore_show_suggested_feeds'
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index d64e44b40b..dfb8cd5416 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -395,4 +395,12 @@ export type MetricEvents = {
'live:card:openProfile': {subject: string}
'live:view:profile': {subject: string}
'live:view:post': {subject: string; feed?: string}
+
+ 'share:open': {context: 'feed' | 'thread'}
+ 'share:press:copyLink': {}
+ 'share:press:nativeShare': {}
+ 'share:press:openDmSearch': {}
+ 'share:press:dmSelected': {}
+ 'share:press:recentDm': {}
+ 'share:press:embed': {}
}
diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx
index fd1bdffa73..d1b7ab0dbd 100644
--- a/src/screens/Hashtag.tsx
+++ b/src/screens/Hashtag.tsx
@@ -1,14 +1,14 @@
import React from 'react'
-import {ListRenderItemInfo, View} from 'react-native'
-import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {type ListRenderItemInfo, View} from 'react-native'
+import {type PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {HITSLOP_10} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {type CommonNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -21,7 +21,7 @@ import {Post} from '#/view/com/post/Post'
import {List} from '#/view/com/util/List'
import {atoms as a, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import * as Layout from '#/components/Layout'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx
index e2ae3171c9..26fa08fdb6 100644
--- a/src/screens/Profile/components/ProfileFeedHeader.tsx
+++ b/src/screens/Profile/components/ProfileFeedHeader.tsx
@@ -29,7 +29,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {useRichText} from '#/components/hooks/useRichText'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx
index 9fae5d4d5f..c0d0341a62 100644
--- a/src/screens/StarterPack/StarterPackScreen.tsx
+++ b/src/screens/StarterPack/StarterPackScreen.tsx
@@ -5,25 +5,29 @@ import {
AppBskyGraphDefs,
AppBskyGraphStarterpack,
AtUri,
- ModerationOpts,
+ type ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {HITSLOP_20} from '#/lib/constants'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {makeProfileLink, makeStarterPackLink} from '#/lib/routes/links'
-import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {
+ type CommonNavigatorParams,
+ type NavigationProp,
+} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {getAllListMembers} from '#/state/queries/list-members'
@@ -46,7 +50,8 @@ import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
+import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
@@ -600,13 +605,24 @@ function OverflowMenu({
<>
- Share link
+ {isWeb ? (
+ Copy link
+ ) : (
+ Share via...
+ )}
-
+
diff --git a/src/screens/Topic.tsx b/src/screens/Topic.tsx
index 62726bcc6f..6cf7cf65b0 100644
--- a/src/screens/Topic.tsx
+++ b/src/screens/Topic.tsx
@@ -1,14 +1,14 @@
import React from 'react'
-import {ListRenderItemInfo, View} from 'react-native'
-import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {type ListRenderItemInfo, View} from 'react-native'
+import {type PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {HITSLOP_10} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {type CommonNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {cleanError} from '#/lib/strings/errors'
import {enforceLen} from '#/lib/strings/helpers'
@@ -20,7 +20,7 @@ import {Post} from '#/view/com/post/Post'
import {List} from '#/view/com/util/List'
import {atoms as a, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import * as Layout from '#/components/Layout'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx
index 0479617667..2a61db7158 100644
--- a/src/screens/VideoFeed/index.tsx
+++ b/src/screens/VideoFeed/index.tsx
@@ -82,7 +82,6 @@ import {useSetMinimalShellMode} from '#/state/shell'
import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {List} from '#/view/com/util/List'
-import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {Header} from '#/screens/VideoFeed/components/Header'
import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf'
@@ -97,6 +96,7 @@ import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import * as Hider from '#/components/moderation/Hider'
+import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
@@ -861,7 +861,7 @@ function Overlay({
)}
{record && (
-
-
)}
-
) : null}
-
-
+
+
)
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index f1fd237ec2..d18ba12c16 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -12,6 +12,7 @@ import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
import {type Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
import {
@@ -26,9 +27,11 @@ import {EventStopper} from '#/view/com/util/EventStopper'
import * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
-import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
-import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
-import {CircleX_Stroke2_Corner0_Rounded as CircleX} from '#/components/icons/CircleX'
+import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
+import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
+import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
+import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
@@ -236,7 +239,9 @@ let ProfileMenu = ({
{
if (showLoggedOutWarning) {
loggedOutWarningPromptControl.open()
@@ -245,9 +250,13 @@ let ProfileMenu = ({
}
}}>
- Share
+ {isWeb ? (
+ Copy link to profile
+ ) : (
+ Share via...
+ )}
-
+
Remove verification
-
+
) : (
Verify account
-
+
))}
{!isSelf && (
@@ -414,7 +423,7 @@ let ProfileMenu = ({
Copy at:// URI
-
+
Copy DID
-
+
>