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-image-crop-tool": "^0.5.1",
|
||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@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/react-native-mmkv": "2.12.5",
|
||||||
"@bsky.app/sift": "^0.3.8",
|
"@bsky.app/sift": "^0.3.8",
|
||||||
"@bsky.app/tapper": "^0.5.7",
|
"@bsky.app/tapper": "^0.5.7",
|
||||||
|
|||||||
Generated
+5
-5
@@ -272,8 +272,8 @@ importers:
|
|||||||
specifier: ^0.2.9
|
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)
|
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':
|
'@bsky.app/peek-menu':
|
||||||
specifier: ^0.2.4
|
specifier: ^0.3.0
|
||||||
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)
|
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':
|
'@bsky.app/react-native-mmkv':
|
||||||
specifier: 2.12.5
|
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)
|
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: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
'@bsky.app/peek-menu@0.2.4':
|
'@bsky.app/peek-menu@0.3.0':
|
||||||
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
|
resolution: {integrity: sha512-F51CT7xawnzoIOGA86E5VNYuWCItS1DnQr/J3eRE62E5+lbuCixSK/mQ4oqVsfQbKuYfAqMsBJjCgpCkdZTqTQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
react: '*'
|
react: '*'
|
||||||
@@ -10479,7 +10479,7 @@ snapshots:
|
|||||||
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-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:
|
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)
|
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
|
react: 19.1.0
|
||||||
|
|||||||
+99
-4
@@ -6,6 +6,7 @@ import {
|
|||||||
type TargetedEvent,
|
type TargetedEvent,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {
|
import {
|
||||||
type LinkProps as RNLinkProps,
|
type LinkProps as RNLinkProps,
|
||||||
StackActions,
|
StackActions,
|
||||||
@@ -26,11 +27,14 @@ import {
|
|||||||
linkRequiresWarning,
|
linkRequiresWarning,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {useModalControls} from '#/state/modals'
|
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 {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
|
||||||
import {Button, type ButtonProps} from '#/components/Button'
|
import {Button, type ButtonProps} from '#/components/Button'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
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 {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 {router} from '#/routes'
|
||||||
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
||||||
|
|
||||||
@@ -86,6 +90,13 @@ type BaseLinkProps = {
|
|||||||
*/
|
*/
|
||||||
shouldProxy?: boolean
|
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
|
* Web only
|
||||||
*/
|
*/
|
||||||
@@ -275,11 +286,19 @@ export function useLink({
|
|||||||
[outerOnLongPress, handleLongPress, shareOnLongPress],
|
[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 {
|
return {
|
||||||
isExternal,
|
isExternal,
|
||||||
href,
|
href,
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
|
openExternally,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,9 +324,10 @@ export function Link({
|
|||||||
download,
|
download,
|
||||||
shouldProxy,
|
shouldProxy,
|
||||||
overridePresentation,
|
overridePresentation,
|
||||||
|
peek,
|
||||||
...rest
|
...rest
|
||||||
}: LinkProps) {
|
}: LinkProps) {
|
||||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
|
||||||
to,
|
to,
|
||||||
displayText: typeof children === 'string' ? children : '',
|
displayText: typeof children === 'string' ? children : '',
|
||||||
action,
|
action,
|
||||||
@@ -317,7 +337,10 @@ export function Link({
|
|||||||
overridePresentation,
|
overridePresentation,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
// Peek is iOS-only and only makes sense for external web links.
|
||||||
|
const peekEnabled = Boolean(peek && IS_IOS && isExternal)
|
||||||
|
|
||||||
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
{...rest}
|
{...rest}
|
||||||
style={[a.justify_start, rest.style]}
|
style={[a.justify_start, rest.style]}
|
||||||
@@ -325,7 +348,11 @@ export function Link({
|
|||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
href={href}
|
href={href}
|
||||||
onPress={download ? undefined : onPress}
|
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({
|
{...web({
|
||||||
hrefAttrs: {
|
hrefAttrs: {
|
||||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||||
@@ -340,6 +367,74 @@ export function Link({
|
|||||||
{children}
|
{children}
|
||||||
</Button>
|
</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<
|
export type InlineLinkProps = React.PropsWithChildren<
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const ExternalEmbed = ({
|
|||||||
label={link.title || _(msg`Open link to ${niceUrl}`)}
|
label={link.title || _(msg`Open link to ${niceUrl}`)}
|
||||||
to={link.uri}
|
to={link.uri}
|
||||||
shouldProxy={true}
|
shouldProxy={true}
|
||||||
|
peek
|
||||||
|
style={[a.rounded_md]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onLongPress={onShareExternal}>
|
onLongPress={onShareExternal}>
|
||||||
{({hovered}) => (
|
{({hovered}) => (
|
||||||
@@ -99,6 +101,7 @@ export const ExternalEmbed = ({
|
|||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.border,
|
a.border,
|
||||||
|
t.atoms.bg,
|
||||||
style,
|
style,
|
||||||
hovered
|
hovered
|
||||||
? t.atoms.border_contrast_high
|
? t.atoms.border_contrast_high
|
||||||
|
|||||||
Reference in New Issue
Block a user