diff --git a/README.md b/README.md
index add0b6a8f..d6376fa88 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ alt="Get it on Obtainium" height="54" /> -->
- You can change "post" to any phrase you want in the UI
- Choose between sharing witchsky.app or bsky.app links
- Embed player works with [stream.place](https://stream.place/) links!
-- Open posts in PDSls and original bridged fedi instances
+- Open posts in PDSls, Skythread, and original bridged fedi instances
- You can delete and redraft (basically edit!) posts
- Better defaults (alt text required 😉 autoplay off 🫨)
- Read your chat threads with deleted/deactivated accounts
@@ -43,7 +43,7 @@ These are all available as options in the Runes sub-page of the app's settings.
- Menus
- Use handles in profile links instead of DIDs
- - Show "Open original post" and "Open post in PDSls" buttons
+ - Show the Open submenu for external post and profile links
- On non-bsky.social handles, show a link to that URL
- Only show URL on handles with working links
- Badges
diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json
index 685e7144c..27e123212 100644
--- a/oxlint-suppressions.json
+++ b/oxlint-suppressions.json
@@ -307,18 +307,10 @@
}
},
"src/components/PostControls/ShareMenu/ShareMenuItems.tsx": {
- "typescript/no-floating-promises": {
- "count": 3
- },
"typescript/no-misused-promises": {
"count": 1
}
},
- "src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx": {
- "typescript/no-floating-promises": {
- "count": 3
- }
- },
"src/components/PostControls/ShareMenu/index.tsx": {
"typescript/no-floating-promises": {
"count": 1
diff --git a/src/components/Menu/CheckboxItem.tsx b/src/components/Menu/CheckboxItem.tsx
new file mode 100644
index 000000000..732c4c1fe
--- /dev/null
+++ b/src/components/Menu/CheckboxItem.tsx
@@ -0,0 +1,34 @@
+import {useState} from 'react'
+
+import {atoms as a} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+
+export function CheckboxItemText({
+ children,
+ initialValue,
+ label,
+ onChange,
+}: React.PropsWithChildren<{
+ initialValue: boolean
+ label: string
+ onChange: (value: boolean) => void
+}>) {
+ const [selected, setSelected] = useState(initialValue)
+
+ return (
+ {
+ setSelected(value)
+ onChange(value)
+ }}
+ style={[a.flex_1, a.justify_between]}>
+
+ {children}
+
+
+
+ )
+}
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 126cb56c7..3eac8a8ea 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -1,4 +1,12 @@
-import {cloneElement, Fragment, isValidElement, useMemo} from 'react'
+import {
+ cloneElement,
+ Fragment,
+ isValidElement,
+ useCallback,
+ useId,
+ useMemo,
+ useState,
+} from 'react'
import {
Pressable,
type StyleProp,
@@ -14,6 +22,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon} from '#/components/icons/Chevron'
import {
Context,
ItemContext,
@@ -26,6 +35,7 @@ import {
type ItemIconProps,
type ItemProps,
type ItemTextProps,
+ type SubmenuProps,
type TriggerProps,
} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
@@ -56,11 +66,34 @@ export function Root({
dismissGuardRef?: React.MutableRefObject
}>) {
const defaultControl = Dialog.useDialogControl()
+ const [activeSubmenuId, setActiveSubmenuId] = useState(null)
+ const submenuRegistry = useMemo<
+ NonNullable['registry']
+ >(() => new Map(), [])
+ const openSubmenu = useCallback((id: string) => {
+ setActiveSubmenuId(id)
+ }, [])
+ const closeSubmenu = useCallback(() => {
+ setActiveSubmenuId(null)
+ }, [])
const context = useMemo(
() => ({
control: control || defaultControl,
+ nativeSubmenu: {
+ activeId: activeSubmenuId,
+ registry: submenuRegistry,
+ open: openSubmenu,
+ close: closeSubmenu,
+ },
}),
- [control, defaultControl],
+ [
+ control,
+ defaultControl,
+ activeSubmenuId,
+ submenuRegistry,
+ openSubmenu,
+ closeSubmenu,
+ ],
)
return {children}
@@ -116,6 +149,7 @@ export function Outer({
return (
- {children}
+
+ {children}
+
+
{IS_NATIVE && showCancel && }
@@ -134,6 +175,54 @@ export function Outer({
)
}
+function NativeSubmenuContent() {
+ const t = useTheme()
+ const {t: l} = useLingui()
+ const context = useMenuContext()
+ const activeId = context.nativeSubmenu?.activeId
+ const submenu = activeId
+ ? context.nativeSubmenu?.registry.get(activeId)
+ : undefined
+
+ if (!submenu) {
+ return null
+ }
+
+ return (
+
+ {submenu.label}
+
+
+
+
+ {l`Back`}
+
+
+
+ {submenu.children}
+
+ )
+}
+
export function Item({
children,
label,
@@ -205,6 +294,56 @@ export function Item({
)
}
+/**
+ * A nested menu. On native, submenus replace the contents of the current sheet
+ * and provide a back action; on web the Radix implementation presents a
+ * conventional hover/keyboard submenu.
+ */
+export function Submenu({children, label, trigger, style}: SubmenuProps) {
+ const t = useTheme()
+ const context = useMenuContext()
+ const id = useId()
+ context.nativeSubmenu?.registry.set(id, {label, children})
+ const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+ const {
+ state: pressed,
+ onIn: onPressIn,
+ onOut: onPressOut,
+ } = useInteractionState()
+
+ return (
+ <>
+ context.nativeSubmenu?.open(id)}
+ onPressIn={onPressIn}
+ onPressOut={onPressOut}
+ style={[
+ a.flex_row,
+ a.align_center,
+ a.gap_sm,
+ a.px_md,
+ a.rounded_md,
+ a.overflow_hidden,
+ a.border,
+ t.atoms.bg_contrast_25,
+ t.atoms.border_contrast_low,
+ {minHeight: 44, paddingVertical: 10},
+ style,
+ (focused || pressed) && t.atoms.bg_contrast_50,
+ ]}>
+
+ {trigger}
+
+
+ >
+ )
+}
+
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
const {disabled, destructive} = useMenuItemContext()
@@ -347,7 +486,9 @@ export function Group({children, style}: GroupProps) {
]}>
{flattenReactChildren(children).map((child, i) => {
return isValidElement(child) &&
- (child.type === Item || child.type === ContainerItem) ? (
+ (child.type === Item ||
+ child.type === ContainerItem ||
+ child.type === Submenu) ? (
{i > 0 ? (
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 9b92a9069..c0ecf63df 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -28,6 +28,7 @@ import {
type ItemProps,
type ItemTextProps,
type RadixPassThroughTriggerProps,
+ type SubmenuProps,
type TriggerProps,
} from '#/components/Menu/types'
import {Portal} from '#/components/Portal'
@@ -311,6 +312,74 @@ export function Item({
)
}
+/** A conventional hover- and keyboard-accessible web submenu. */
+export function Submenu({children, label, trigger, style}: SubmenuProps) {
+ const t = useTheme()
+ const {reduceMotionEnabled} = useA11y()
+ const {
+ state: hovered,
+ onIn: onMouseEnter,
+ onOut: onMouseLeave,
+ } = useInteractionState()
+ const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+ return (
+
+
+
+
+ {trigger}
+
+
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
const {disabled, destructive} = useMenuItemContext()
diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts
index c0f7e50a4..8cd8a902d 100644
--- a/src/components/Menu/types.ts
+++ b/src/components/Menu/types.ts
@@ -11,6 +11,18 @@ import {type Props as SVGIconProps} from '#/components/icons/common'
export type ContextType = {
control: Dialog.DialogOuterProps['control']
+ nativeSubmenu?: {
+ activeId: string | null
+ registry: Map<
+ string,
+ {
+ label: string
+ children: React.ReactNode
+ }
+ >
+ open: (id: string) => void
+ close: () => void
+ }
}
export type ItemContextType = {
@@ -112,3 +124,10 @@ export type ItemIconProps = React.PropsWithChildren<{
}>
export type GroupProps = React.PropsWithChildren
+
+export type SubmenuProps = React.PropsWithChildren<
+ ViewStyleProp & {
+ label: string
+ trigger: React.ReactNode
+ }
+>
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
index 231e337e3..b51bb705f 100644
--- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx
@@ -1,4 +1,4 @@
-import {memo, useMemo} from 'react'
+import {memo, useMemo, useRef} from 'react'
import * as ExpoClipboard from 'expo-clipboard'
import {AtUri} from '@atproto/api'
import {isIOS} from '@bsky.app/alf'
@@ -21,12 +21,15 @@ import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
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 {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
+import {BlueskyIcon} from '#/components/icons/providers/Bluesky'
+import {PDSlsIcon} from '#/components/icons/providers/PDSls'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as ExternalIcon} from '#/components/icons/SquareArrowTopRight'
import * as Menu from '#/components/Menu'
+import {CheckboxItemText} from '#/components/Menu/CheckboxItem'
import * as Toast from '#/components/Toast'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
@@ -48,6 +51,7 @@ let ShareMenuItems = ({
const aa = useAgeAssurance()
const openLink = useOpenLink()
const queryClient = useQueryClient()
+ const copyLinksRef = useRef(false)
const postUri = post.uri
const postAuthor = useProfileShadow(post.author)
@@ -63,17 +67,10 @@ let ShareMenuItems = ({
)
}, [postAuthor])
- const onSharePost = () => {
- ax.metric('share:press:nativeShare', {})
- const url = toShareUrl(href)
- shareUrl(url)
- onShareProp()
- }
-
const onSharePostBsky = () => {
ax.metric('share:press:nativeShare', {})
const url = toShareUrlBsky(href)
- shareUrl(url)
+ void shareUrl(url)
onShareProp()
}
@@ -120,11 +117,11 @@ let ShareMenuItems = ({
}
const onShareATURI = () => {
- shareText(postUri)
+ void shareText(postUri)
}
const onShareAuthorDID = () => {
- shareText(postAuthor.did)
+ void shareText(postAuthor.did)
}
const showExternalShareButtons = useShowExternalShareButtons()
@@ -140,7 +137,16 @@ let ShareMenuItems = ({
}
const onOpenPostInPdsls = () => {
- openLink(`https://pdsls.dev/${post.uri}`, true)
+ openLink(`https://pds.ls/${post.uri}`, true)
+ }
+
+ const onOpenPostInSkythread = () => {
+ openLink(
+ `https://skythread.mackuba.eu/?q=${encodeURIComponent(
+ toShareUrlBsky(href),
+ )}`,
+ true,
+ )
}
return (
@@ -169,55 +175,9 @@ let ShareMenuItems = ({
)}
- {showExternalShareButtons && (
-
- {isBridgedPost && (
-
-
- Open original post
-
-
-
- )}
-
-
-
- Open post in PDSls
-
-
-
-
- )}
-
-
- Share via...
-
-
-
-
-
-
- Share via bsky.app...
-
-
-
-
-
@@ -226,15 +186,89 @@ let ShareMenuItems = ({
-
-
- Copy via bsky.app
-
-
-
+
+
+ Share
+
+
+ >
+ }>
+
+ {
+ if (copyLinksRef.current) {
+ void onCopyLinkBsky()
+ } else {
+ onSharePostBsky()
+ }
+ }}>
+
+ Bluesky
+
+
+
+
+ {
+ copyLinksRef.current = value
+ }}>
+ Copy
+
+
+
+
+
+ {showExternalShareButtons && (
+
+
+ Open
+
+
+ >
+ }>
+
+ {isBridgedPost && (
+
+
+ Original post
+
+
+
+ )}
+
+
+ PDSls
+
+
+
+
+
+ Skythread
+
+
+
+
+
+ )}
{hideInPWI && (
diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
index da284987b..f2805c82f 100644
--- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
+++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx
@@ -18,9 +18,12 @@ 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 {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
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 {BlueskyIcon} from '#/components/icons/providers/Bluesky'
+import {PDSlsIcon} from '#/components/icons/providers/PDSls'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as ExternalIcon} from '#/components/icons/SquareArrowTopRight'
import * as Menu from '#/components/Menu'
import {useAgeAssurance} from '#/ageAssurance'
@@ -64,14 +67,14 @@ let ShareMenuItems = ({
const onCopyLink = () => {
ax.metric('share:press:copyLink', {})
const url = toShareUrl(href)
- shareUrl(url)
+ void shareUrl(url)
onShareProp()
}
const onCopyLinkBsky = () => {
ax.metric('share:press:copyLink', {})
const url = toShareUrlBsky(href)
- shareUrl(url)
+ void shareUrl(url)
onShareProp()
}
@@ -86,11 +89,11 @@ let ShareMenuItems = ({
const canEmbed = IS_WEB && gtMobile && !hideInPWI
const onShareATURI = () => {
- shareText(postUri)
+ void shareText(postUri)
}
const onShareAuthorDID = () => {
- shareText(postAuthor.did)
+ void shareText(postAuthor.did)
}
const showExternalShareButtons = useShowExternalShareButtons()
@@ -106,60 +109,99 @@ let ShareMenuItems = ({
}
const onOpenPostInPdsls = () => {
- openLink(`https://pdsls.dev/${post.uri}`, true)
+ openLink(`https://pds.ls/${post.uri}`, true)
}
- const copyLinkItem = (
-
-
-
- Copy link to post
-
-
-
-
-
- Copy via bsky.app
-
-
-
-
- )
+ const onOpenPostInSkythread = () => {
+ openLink(
+ `https://skythread.mackuba.eu/?q=${encodeURIComponent(
+ toShareUrlBsky(href),
+ )}`,
+ true,
+ )
+ }
return (
<>
- {copyLinkItem}
-
- {showExternalShareButtons && isBridgedPost && (
+
+ testID="postDropdownCopyLinkBtn"
+ label={_(msg`Copy link to post`)}
+ onPress={onCopyLink}>
- Open original post
+ Copy link to post
-
+
- )}
- {showExternalShareButtons && (
-
-
- Open post in PDSls
-
-
-
- )}
+
+
+ Share
+
+
+ >
+ }>
+
+
+ Bluesky
+
+
+
+
+
+ {showExternalShareButtons && (
+
+
+ Open
+
+
+ >
+ }>
+ {isBridgedPost && (
+
+
+ Original post
+
+
+
+ )}
+
+
+ PDSls
+
+
+
+
+
+ Skythread
+
+
+
+
+ )}
+
{hasSession && aa.state.access === aa.Access.Full && (
{hasSession && }
- {copyLinkItem}
Note: This post is only visible to logged-in users.
diff --git a/src/components/icons/providers/PDSls.tsx b/src/components/icons/providers/PDSls.tsx
new file mode 100644
index 000000000..c47121c85
--- /dev/null
+++ b/src/components/icons/providers/PDSls.tsx
@@ -0,0 +1,6 @@
+import {createSinglePathSVG} from '#/components/icons/TEMPLATE'
+
+export const PDSlsIcon = createSinglePathSVG({
+ path: 'M38.8392 1.51855C43.2582 1.51855 46.8405 5.18242 46.8405 9.70204C46.8405 11.5587 46.2345 13.2699 45.2157 14.6431L50.758 24.0001C51.3028 23.8802 51.8682 23.8165 52.4481 23.8165C56.8671 23.8165 60.4494 27.4804 60.4494 32C60.4494 36.5196 56.8671 40.1835 52.4481 40.1835C48.0291 40.1835 44.4468 36.5196 44.4468 32C44.4468 30.1472 45.0491 28.4385 46.064 27.0668L40.5174 17.7031C39.9761 17.8213 39.415 17.8855 38.8392 17.8855C38.2651 17.8855 37.7053 17.8228 37.1655 17.7053L31.5557 26.7987C32.5637 28.1677 33.1621 29.8706 33.1621 31.7164C33.162 33.6214 32.5231 35.3724 31.4557 36.763L37.11 46.3069C37.6669 46.1814 38.2454 46.1145 38.8392 46.1145C43.2582 46.1145 46.8405 49.7783 46.8405 54.298C46.8405 58.8176 43.2582 62.4814 38.8392 62.4814C34.4202 62.4814 30.8379 58.8176 30.8379 54.298C30.8379 52.4603 31.4302 50.7641 32.4301 49.3981L26.7106 39.7441C26.2091 39.8448 25.6911 39.8998 25.1608 39.8998C24.6178 39.8998 24.0877 39.8429 23.5751 39.7374L17.7696 49.1489C18.8844 50.5553 19.5532 52.3464 19.5532 54.298C19.5532 58.8176 15.9709 62.4814 11.552 62.4814C7.13296 62.4814 3.55066 58.8176 3.55066 54.298C3.55066 49.7783 7.13296 46.1145 11.552 46.1145C12.0383 46.1145 12.5142 46.1607 12.9767 46.2457L18.8442 36.7352C17.7897 35.3491 17.1596 33.6086 17.1595 31.7164C17.1595 27.1967 20.7418 23.5329 25.1608 23.5329C25.748 23.5329 26.3203 23.598 26.8715 23.7209L32.4671 14.6497C31.4455 13.2755 30.8379 11.5616 30.8379 9.70204C30.8379 5.18242 34.4202 1.51855 38.8392 1.51855Z',
+ viewBox: '0 0 64 64',
+})
diff --git a/src/screens/Settings/RunesSettings/MenusSettings.tsx b/src/screens/Settings/RunesSettings/MenusSettings.tsx
index bc65a25cf..f4ad521ac 100644
--- a/src/screens/Settings/RunesSettings/MenusSettings.tsx
+++ b/src/screens/Settings/RunesSettings/MenusSettings.tsx
@@ -4,6 +4,7 @@ import {
useSetShowExternalShareButtons,
useShowExternalShareButtons,
} from '#/state/preferences/external-share-buttons'
+import {RestartRequiredPrompt} from '#/state/preferences/restart-required-prompt'
import {
useSetShowLinkInHandle,
useShowLinkInHandle,
@@ -16,9 +17,8 @@ import {
useHandleInLinks,
useSetHandleInLinks,
} from '#/state/preferences/use-handle-in-links'
-import {RestartRequiredPrompt} from '#/state/preferences/restart-required-prompt'
-import * as Dialog from '#/components/Dialog'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
+import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
@@ -62,15 +62,13 @@ export function RunesMenusSettingsScreen() {
setShowExternalShareButtons(value)}>
-
- Show "Open original post" and "Open post in PDSls" buttons
-
+ Show the Open submenu for external links
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index a85aec44a..5e8e500f7 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -1,4 +1,5 @@
-import {memo, useCallback, useMemo, useState} from 'react'
+import {memo, useCallback, useMemo, useRef, useState} from 'react'
+import * as ExpoClipboard from 'expo-clipboard'
import {type AppBskyActorDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
@@ -19,6 +20,7 @@ import {
} from '#/state/preferences/deer-verification'
import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
import {useShowExternalShareButtons} from '#/state/preferences/external-share-buttons'
+import {useDeerVerificationProfileOverlay} from '#/state/queries/deer-verification'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {
RQKEY as profileQueryKey,
@@ -26,7 +28,6 @@ import {
useProfileFollowMutationQueue,
useProfileMuteMutationQueue,
} from '#/state/queries/profile'
-import {useDeerVerificationProfileOverlay} from '#/state/queries/deer-verification'
import {useSession} from '#/state/session'
import {EventStopper} from '#/view/com/util/EventStopper'
import {atoms as a, useTheme} from '#/alf'
@@ -35,8 +36,9 @@ import {useDialogControl} from '#/components/Dialog'
import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog'
import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog'
import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog'
-import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
+import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
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'
@@ -52,10 +54,12 @@ import {
PersonX_Stroke2_Corner0_Rounded as PersonX,
} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {BlueskyIcon} from '#/components/icons/providers/Bluesky'
+import {PDSlsIcon} from '#/components/icons/providers/PDSls'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
-import {SquareArrowTopRight_Stroke2_Corner0_Rounded as ExternalIcon} from '#/components/icons/SquareArrowTopRight'
import {StarterPack} from '#/components/icons/StarterPack'
import * as Menu from '#/components/Menu'
+import {CheckboxItemText} from '#/components/Menu/CheckboxItem'
import {BlockDialog} from '#/components/moderation/BlockDialog'
import {
ReportDialog,
@@ -67,7 +71,7 @@ import {useFullVerificationState} from '#/components/verification'
import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt'
import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt'
import {useAnalytics} from '#/analytics'
-import {IS_WEB} from '#/env'
+import {IS_IOS, IS_WEB} from '#/env'
import {useActorStatus, useLiveNowConfig} from '#/features/liveNow'
import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog'
import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog'
@@ -95,8 +99,8 @@ let ProfileMenu = ({
const isFollowingBlockedAccount = isFollowing && isBlocked
const isLabeler = !!profile.associated?.labeler
const [devModeEnabled] = useDevMode()
- const profileWithDeerVerification =
- useDeerVerificationProfileOverlay(profile)
+ const copyLinksRef = useRef(false)
+ const profileWithDeerVerification = useDeerVerificationProfileOverlay(profile)
const verification = useFullVerificationState({
profile: profileWithDeerVerification,
})
@@ -127,6 +131,7 @@ let ProfileMenu = ({
const goLiveDisabledDialogControl = useDialogControl()
const addToStarterPacksDialogControl = useDialogControl()
const addToListsDialogControl = useDialogControl()
+ const pendingShareAction = useRef<() => void>(() => {})
const showExternalShareButtons = useShowExternalShareButtons()
const openLink = useOpenLink()
@@ -149,14 +154,51 @@ let ProfileMenu = ({
addToStarterPacksDialogControl.open()
}, [addToStarterPacksDialogControl, ax])
- const onPressShare = useCallback(() => {
- void shareUrl(toShareUrl(makeProfileLink(profile)))
- }, [profile])
-
const onPressShareBsky = useCallback(() => {
- shareUrl(toShareUrlBsky(makeProfileLink(profile)))
+ void shareUrl(toShareUrlBsky(makeProfileLink(profile)))
}, [profile])
+ const onPressShareHandle = useCallback(() => {
+ void shareText(profile.handle)
+ }, [profile.handle])
+
+ const onPressCopy = useCallback(async () => {
+ const url = toShareUrl(makeProfileLink(profile))
+ if (IS_IOS) {
+ await ExpoClipboard.setUrlAsync(url)
+ } else {
+ await ExpoClipboard.setStringAsync(url)
+ }
+ Toast.show(l`Copied to clipboard`, {type: 'success'})
+ }, [l, profile])
+
+ const onPressCopyBsky = useCallback(async () => {
+ const url = toShareUrlBsky(makeProfileLink(profile))
+ if (IS_IOS) {
+ await ExpoClipboard.setUrlAsync(url)
+ } else {
+ await ExpoClipboard.setStringAsync(url)
+ }
+ Toast.show(l`Copied to clipboard`, {type: 'success'})
+ }, [l, profile])
+
+ const onPressCopyHandle = useCallback(async () => {
+ await ExpoClipboard.setStringAsync(profile.handle)
+ Toast.show(l`Copied to clipboard`, {type: 'success'})
+ }, [l, profile.handle])
+
+ const shareOrWarn = useCallback(
+ (action: () => void) => {
+ if (showLoggedOutWarning) {
+ pendingShareAction.current = action
+ loggedOutWarningPromptControl.open()
+ } else {
+ action()
+ }
+ },
+ [loggedOutWarningPromptControl, showLoggedOutWarning],
+ )
+
const onPressAddRemoveLists = useCallback(() => {
addToListsDialogControl.open()
}, [addToListsDialogControl])
@@ -223,8 +265,9 @@ let ProfileMenu = ({
const confirmFollowUnfollow = useConfirmFollowUnfollow()
const followPromptControl = Prompt.usePromptControl()
- const [confirmationAction, setConfirmationAction] =
- useState<'follow' | 'unfollow'>('follow')
+ const [confirmationAction, setConfirmationAction] = useState<
+ 'follow' | 'unfollow'
+ >('follow')
const executeFollow = useCallback(async () => {
try {
@@ -300,13 +343,13 @@ let ProfileMenu = ({
const onOpenProfileInPdsls = () => {
openLink(
- `https://pdsls.dev/at://${profile.did}/app.bsky.actor.profile/self`,
+ `https://pds.ls/at://${profile.did}/app.bsky.actor.profile/self`,
true,
)
}
const onOpenRepoInPdsls = () => {
- openLink(`https://pdsls.dev/at://${profile.did}`, true)
+ openLink(`https://pds.ls/at://${profile.did}`, true)
}
const verificationCreatePromptControl = Prompt.usePromptControl()
@@ -355,70 +398,110 @@ let ProfileMenu = ({
{
- if (showLoggedOutWarning) {
- loggedOutWarningPromptControl.open()
- } else {
- onPressShare()
- }
- }}>
-
- {IS_WEB ? (
- Copy link to profile
- ) : (
- Share via...
- )}
-
-
-
- {
- if (showLoggedOutWarning) {
- loggedOutWarningPromptControl.open()
- } else {
- onPressShareBsky()
- }
+ shareOrWarn(() => {
+ void onPressCopy()
+ })
}}>
- {IS_WEB ? (
- Copy via bsky.app
- ) : (
- Share via bsky.app...
- )}
+ Copy link to profile
-
+
- {showExternalShareButtons && (
- <>
+
+
+
+ Share
+
+
+ >
+ }>
+
+ testID="profileHeaderDropdownShareHandleBtn"
+ label={l`Handle`}
+ onPress={() => {
+ shareOrWarn(() => {
+ if (IS_WEB || copyLinksRef.current) {
+ void onPressCopyHandle()
+ } else {
+ onPressShareHandle()
+ }
+ })
+ }}>
- Open profile in PDSls
+ Handle
-
+
+ testID="profileHeaderDropdownShareBlueskyBtn"
+ label={l`Bluesky`}
+ onPress={() => {
+ shareOrWarn(() => {
+ if (!IS_WEB && copyLinksRef.current) {
+ void onPressCopyBsky()
+ } else {
+ onPressShareBsky()
+ }
+ })
+ }}>
- Open repo in PDSls
+ Bluesky
-
+
- >
+ {!IS_WEB && (
+
+ {
+ copyLinksRef.current = value
+ }}>
+ Copy
+
+
+ )}
+
+
+ {showExternalShareButtons && (
+
+
+ Open
+
+
+ >
+ }>
+
+
+
+ PDSls profile
+
+
+
+
+
+ PDSls repository
+
+
+
+
+
)}
pendingShareAction.current()}
confirmButtonCta={l`Share anyway`}
/>