Peek preview for external link embeds (#10704)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-10 22:35:16 +03:00
committed by GitHub
parent b47948dca7
commit 2d896983ab
4 changed files with 108 additions and 10 deletions
+1 -1
View File
@@ -103,7 +103,7 @@
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.2.4",
"@bsky.app/peek-menu": "^0.3.0",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.8",
"@bsky.app/tapper": "^0.5.7",
+5 -5
View File
@@ -272,8 +272,8 @@ importers:
specifier: ^0.2.9
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/peek-menu':
specifier: ^0.2.4
version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
specifier: ^0.3.0
version: 0.3.0(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/react-native-mmkv':
specifier: 2.12.5
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -1659,8 +1659,8 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/peek-menu@0.2.4':
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
'@bsky.app/peek-menu@0.3.0':
resolution: {integrity: sha512-F51CT7xawnzoIOGA86E5VNYuWCItS1DnQr/J3eRE62E5+lbuCixSK/mQ4oqVsfQbKuYfAqMsBJjCgpCkdZTqTQ==}
peerDependencies:
expo: '*'
react: '*'
@@ -10479,7 +10479,7 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
'@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
'@bsky.app/peek-menu@0.3.0(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
+99 -4
View File
@@ -6,6 +6,7 @@ import {
type TargetedEvent,
} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {useLingui} from '@lingui/react/macro'
import {
type LinkProps as RNLinkProps,
StackActions,
@@ -26,11 +27,14 @@ import {
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {useInAppBrowser} from '#/state/preferences/in-app-browser'
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import * as PeekMenu from '#/components/PeekMenu'
import {Text, type TextProps} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {router} from '#/routes'
import {useGlobalDialogsControlContext} from './dialogs/Context'
@@ -86,6 +90,13 @@ type BaseLinkProps = {
*/
shouldProxy?: boolean
/**
* iOS only. Wraps the link in a peek menu: long-pressing previews the live
* page in an in-app browser (morphing into it on tap when the in-app browser
* preference is on), with a share action in the menu. No-op elsewhere.
*/
peek?: boolean
/**
* Web only
*/
@@ -275,11 +286,19 @@ export function useLink({
[outerOnLongPress, handleLongPress, shareOnLongPress],
)
// Opens the link through the normal external flow (consent dialog, in-app
// browser, or system browser per preference). Used by the peek menu when the
// in-app browser is off, so committing the peek behaves like a plain tap.
const openExternally = useCallback(() => {
void openLink(href, overridePresentation, shouldProxy)
}, [openLink, href, overridePresentation, shouldProxy])
return {
isExternal,
href,
onPress,
onLongPress,
openExternally,
}
}
@@ -305,9 +324,10 @@ export function Link({
download,
shouldProxy,
overridePresentation,
peek,
...rest
}: LinkProps) {
const {href, isExternal, onPress, onLongPress} = useLink({
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
to,
displayText: typeof children === 'string' ? children : '',
action,
@@ -317,7 +337,10 @@ export function Link({
overridePresentation,
})
return (
// Peek is iOS-only and only makes sense for external web links.
const peekEnabled = Boolean(peek && IS_IOS && isExternal)
const button = (
<Button
{...rest}
style={[a.justify_start, rest.style]}
@@ -325,7 +348,11 @@ export function Link({
accessibilityRole="link"
href={href}
onPress={download ? undefined : onPress}
onLongPress={onLongPress}
// When peeking, the native long-press drives the context menu. A
// simultaneous RN long-press recognizer fights it — cancelling the tap
// and breaking the lift animation — so leave long-press to native and
// surface sharing through the peek menu instead.
onLongPress={peekEnabled ? undefined : onLongPress}
{...web({
hrefAttrs: {
target: download ? undefined : isExternal ? 'blank' : undefined,
@@ -340,6 +367,74 @@ export function Link({
{children}
</Button>
)
if (peekEnabled) {
// Match the lift radius to whatever the consumer styled the link with, so
// the peek animation clips to the same corners as the rendered card.
const borderRadius = flatten(rest.style)?.borderRadius
return (
<LinkPeek
href={href}
onPreviewPress={openExternally}
shouldProxy={shouldProxy}
borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}>
{button}
</LinkPeek>
)
}
return button
}
/**
* iOS peek-menu wrapper for an external `Link`. Long-pressing previews the live
* page in an in-app browser; the in-app-browser preference decides whether
* tapping the peek morphs into the browser or hands off to the normal link flow
* via `onPreviewPress`. The menu carries a Share action.
*/
function LinkPeek({
href,
onPreviewPress,
shouldProxy,
borderRadius,
children,
}: {
href: string
onPreviewPress: () => void
shouldProxy?: boolean
borderRadius: number
children: React.ReactNode
}) {
const t = useTheme()
const {t: l} = useLingui()
const useInAppBrowserPref = useInAppBrowser()
return (
<PeekMenu.Root>
<PeekMenu.Trigger
preview={{
type: 'link',
url: shouldProxy ? createProxiedUrl(href) : href,
// Only morph natively when the user has explicitly opted in. When the
// preference is unset (undefined), defer to the JS flow so the consent
// dialog can show.
useInAppBrowser: useInAppBrowserPref === true,
browserToolbarColor: t.atoms.bg.backgroundColor,
browserControlsColor: t.palette.primary_500,
}}
borderRadius={borderRadius}
// Fires only when not morphing natively (in-app browser off/unset).
onPreviewPress={onPreviewPress}>
{children}
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem id="share" onSelect={() => void shareUrl(href)}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
export type InlineLinkProps = React.PropsWithChildren<
@@ -88,6 +88,8 @@ export const ExternalEmbed = ({
label={link.title || _(msg`Open link to ${niceUrl}`)}
to={link.uri}
shouldProxy={true}
peek
style={[a.rounded_md]}
onPress={onPress}
onLongPress={onShareExternal}>
{({hovered}) => (
@@ -99,6 +101,7 @@ export const ExternalEmbed = ({
a.overflow_hidden,
a.w_full,
a.border,
t.atoms.bg,
style,
hovered
? t.atoms.border_contrast_high