From 2d896983abb2de9f56b51244d76e20c996f2ffec Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 10 Jun 2026 22:35:16 +0300 Subject: [PATCH] Peek preview for external link embeds (#10704) Co-authored-by: Claude Opus 4.8 (1M context) --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/components/Link.tsx | 103 +++++++++++++++++- .../Post/Embed/ExternalEmbed/index.tsx | 3 + 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 2096871cca..510967c4bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2808a2e9d..4304f9cb14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 8795c8e899..258ba58ca4 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -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 = (