Peek preview for external link embeds (#10704)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user