Add iOS peek long-press context menu for image embeds (#10300)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -76,7 +76,7 @@
|
|||||||
},
|
},
|
||||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
|
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
},
|
},
|
||||||
"src/components/images/AutoSizedImage.tsx": {
|
"src/components/images/AutoSizedImage.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/images/Gallery/index.tsx": {
|
"src/components/images/Gallery/index.tsx": {
|
||||||
|
|||||||
+19
-14
@@ -41,11 +41,14 @@ class BottomSheetView(
|
|||||||
private val screenHeight: Float =
|
private val screenHeight: Float =
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||||
context.resources.displayMetrics.heightPixels.toFloat()
|
context.resources.displayMetrics.heightPixels
|
||||||
|
.toFloat()
|
||||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||||
wm.currentWindowMetrics.bounds.height().toFloat()
|
wm.currentWindowMetrics.bounds
|
||||||
|
.height()
|
||||||
|
.toFloat()
|
||||||
} else {
|
} else {
|
||||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||||
// which includes system bars (heightPixels may exclude them)
|
// which includes system bars (heightPixels may exclude them)
|
||||||
@@ -166,6 +169,7 @@ class BottomSheetView(
|
|||||||
when {
|
when {
|
||||||
// Full height sheets
|
// Full height sheets
|
||||||
contentHeight >= screenHeight -> 0.99f
|
contentHeight >= screenHeight -> 0.99f
|
||||||
|
|
||||||
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,8 +275,9 @@ class BottomSheetView(
|
|||||||
}
|
}
|
||||||
// Apply deferred layout update after gesture completes
|
// Apply deferred layout update after gesture completes
|
||||||
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
|
||||||
newState != BottomSheetBehavior.STATE_SETTLING &&
|
newState != BottomSheetBehavior.STATE_SETTLING &&
|
||||||
pendingLayoutUpdate) {
|
pendingLayoutUpdate
|
||||||
|
) {
|
||||||
pendingLayoutUpdate = false
|
pendingLayoutUpdate = false
|
||||||
updateLayout()
|
updateLayout()
|
||||||
}
|
}
|
||||||
@@ -292,7 +297,6 @@ class BottomSheetView(
|
|||||||
if (!fullHeight) {
|
if (!fullHeight) {
|
||||||
this.startObservingContentHeight()
|
this.startObservingContentHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateLayout() {
|
fun updateLayout() {
|
||||||
@@ -365,17 +369,18 @@ class BottomSheetView(
|
|||||||
|
|
||||||
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
val innerViewGroup = this.innerView as? ViewGroup ?: return
|
||||||
|
|
||||||
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
val listener =
|
||||||
val newHeight = bottom - top
|
OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
|
||||||
val oldHeight = oldBottom - oldTop
|
val newHeight = bottom - top
|
||||||
if (newHeight != oldHeight) {
|
val oldHeight = oldBottom - oldTop
|
||||||
val contentHeight = getContentHeight()
|
if (newHeight != oldHeight) {
|
||||||
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
val contentHeight = getContentHeight()
|
||||||
lastObservedContentHeight = contentHeight
|
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
|
||||||
updateLayout()
|
lastObservedContentHeight = contentHeight
|
||||||
|
updateLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val children = mutableListOf<View>()
|
val children = mutableListOf<View>()
|
||||||
for (i in 0 until innerViewGroup.childCount) {
|
for (i in 0 until innerViewGroup.childCount) {
|
||||||
|
|||||||
@@ -102,6 +102,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/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
+16
@@ -268,6 +268,9 @@ importers:
|
|||||||
'@bsky.app/expo-translate-text':
|
'@bsky.app/expo-translate-text':
|
||||||
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':
|
||||||
|
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)
|
||||||
'@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)
|
||||||
@@ -1649,6 +1652,13 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
'@bsky.app/peek-menu@0.2.4':
|
||||||
|
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
react: '*'
|
||||||
|
react-native: '*'
|
||||||
|
|
||||||
'@bsky.app/react-native-mmkv@2.12.5':
|
'@bsky.app/react-native-mmkv@2.12.5':
|
||||||
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
|
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -10461,6 +10471,12 @@ 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)':
|
||||||
|
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
|
||||||
|
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
|
||||||
'@bsky.app/react-native-mmkv@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)':
|
'@bsky.app/react-native-mmkv@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)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {shareImageModal} from '#/lib/media/manip'
|
||||||
|
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
|
||||||
import {isGifEmbed} from '#/lib/strings/embed-player'
|
import {isGifEmbed} from '#/lib/strings/embed-player'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
|
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import * as PeekMenu from '#/components/PeekMenu'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
@@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky'
|
|||||||
export function Embed({
|
export function Embed({
|
||||||
embed,
|
embed,
|
||||||
style,
|
style,
|
||||||
|
peekable = false,
|
||||||
}: {
|
}: {
|
||||||
embed: AppBskyFeedDefs.PostView['embed']
|
embed: AppBskyFeedDefs.PostView['embed']
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
peekable?: boolean
|
||||||
}) {
|
}) {
|
||||||
const e = bsky.post.parseEmbed(embed)
|
const e = bsky.post.parseEmbed(embed)
|
||||||
|
|
||||||
@@ -27,13 +34,17 @@ export function Embed({
|
|||||||
if (e.type === 'images') {
|
if (e.type === 'images') {
|
||||||
return (
|
return (
|
||||||
<Outer style={style}>
|
<Outer style={style}>
|
||||||
{e.view.images.map(image => (
|
{e.view.images.map(image =>
|
||||||
<ImageItem
|
peekable ? (
|
||||||
key={image.thumb}
|
<PeekableImageItem key={image.thumb} image={image} />
|
||||||
thumbnail={image.thumb}
|
) : (
|
||||||
alt={image.alt}
|
<ImageItem
|
||||||
/>
|
key={image.thumb}
|
||||||
))}
|
thumbnail={image.thumb}
|
||||||
|
alt={image.alt}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</Outer>
|
</Outer>
|
||||||
)
|
)
|
||||||
} else if (e.type === 'link') {
|
} else if (e.type === 'link') {
|
||||||
@@ -156,6 +167,45 @@ export function VideoItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PeekableImageItem({image}: {image: AppBskyEmbedImages.ViewImage}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const saveImage = useSaveImageToMediaLibrary()
|
||||||
|
|
||||||
|
const aspect =
|
||||||
|
image.aspectRatio && image.aspectRatio.height > 0
|
||||||
|
? image.aspectRatio.width / image.aspectRatio.height
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PeekMenu.Root style={[a.flex_1, {maxWidth: 100}]}>
|
||||||
|
<PeekMenu.Trigger
|
||||||
|
preview={{
|
||||||
|
type: 'image',
|
||||||
|
uri: image.fullsize,
|
||||||
|
thumbUri: image.thumb,
|
||||||
|
aspectRatio: aspect && aspect > 0 ? aspect : 1,
|
||||||
|
}}
|
||||||
|
borderRadius={tokens.borderRadius.xs}>
|
||||||
|
<ImageItem thumbnail={image.thumb} alt={image.alt} />
|
||||||
|
</PeekMenu.Trigger>
|
||||||
|
<PeekMenu.Menu>
|
||||||
|
<PeekMenu.MenuItem
|
||||||
|
id="save"
|
||||||
|
onSelect={() => void saveImage(image.fullsize)}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
<PeekMenu.MenuItem
|
||||||
|
id="share"
|
||||||
|
onSelect={() => void shareImageModal({uri: image.fullsize})}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
</PeekMenu.Menu>
|
||||||
|
</PeekMenu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
altContainer: {
|
altContainer: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from '@bsky.app/peek-menu'
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {type ReactNode} from 'react'
|
||||||
|
import {type StyleProp, type ViewStyle} from 'react-native'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {shareImageModal} from '#/lib/media/manip'
|
||||||
|
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
|
||||||
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
|
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
|
||||||
|
import * as PeekMenu from '#/components/PeekMenu'
|
||||||
|
import {IS_IOS} from '#/env'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an image embed with the iOS peek-and-menu interaction. On non-iOS
|
||||||
|
* platforms this renders children unchanged.
|
||||||
|
*
|
||||||
|
* The aspect ratio is consumed by the native side to size the preview
|
||||||
|
* viewController correctly — which is what makes the lift animation clean
|
||||||
|
* for portrait/panorama images.
|
||||||
|
*/
|
||||||
|
export function ImageContextMenu({
|
||||||
|
fullsizeUri,
|
||||||
|
thumbUri,
|
||||||
|
aspectRatio,
|
||||||
|
borderRadius,
|
||||||
|
onPreviewPress,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
fullsizeUri: string
|
||||||
|
/** Thumbnail URL. Used as an instant placeholder in the native preview
|
||||||
|
* while the fullsize loads, so there's no black flash on first peek. */
|
||||||
|
thumbUri?: string
|
||||||
|
/** width / height; defaults to 1 if missing. */
|
||||||
|
aspectRatio: number | undefined
|
||||||
|
borderRadius?: number
|
||||||
|
onPreviewPress?: () => void
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
const saveImage = useSaveImageToMediaLibrary()
|
||||||
|
|
||||||
|
if (!IS_IOS) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
void saveImage(fullsizeUri)
|
||||||
|
}
|
||||||
|
const handleShare = () => {
|
||||||
|
void shareImageModal({uri: fullsizeUri})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PeekMenu.Root style={style}>
|
||||||
|
<PeekMenu.Trigger
|
||||||
|
preview={{
|
||||||
|
type: 'image',
|
||||||
|
uri: fullsizeUri,
|
||||||
|
thumbUri,
|
||||||
|
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
|
||||||
|
}}
|
||||||
|
borderRadius={borderRadius}
|
||||||
|
onPreviewPress={onPreviewPress}>
|
||||||
|
{children}
|
||||||
|
</PeekMenu.Trigger>
|
||||||
|
<PeekMenu.Menu>
|
||||||
|
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
<PeekMenu.MenuItem id="share" onSelect={handleShare}>
|
||||||
|
<PeekMenu.MenuItemIcon icon={ShareIcon} />
|
||||||
|
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
|
||||||
|
</PeekMenu.MenuItem>
|
||||||
|
</PeekMenu.Menu>
|
||||||
|
</PeekMenu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {useRef} from 'react'
|
||||||
import {InteractionManager, View} from 'react-native'
|
import {InteractionManager, View} from 'react-native'
|
||||||
import {type AnimatedRef} from 'react-native-reanimated'
|
import {type AnimatedRef} from 'react-native-reanimated'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
@@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery'
|
|||||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
import {useLightboxControls} from '#/components/Lightbox/state'
|
||||||
import {type Dimensions} from '#/components/Lightbox/types'
|
import {type Dimensions} from '#/components/Lightbox/types'
|
||||||
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {type EmbedType} from '#/types/bsky/post'
|
import {type EmbedType} from '#/types/bsky/post'
|
||||||
@@ -24,6 +26,11 @@ export function ImageEmbed({
|
|||||||
const {images} = embed.view
|
const {images} = embed.view
|
||||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||||
|
|
||||||
|
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||||
|
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||||
|
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
|
||||||
|
const singleDimsRef = useRef<Dimensions | null>(null)
|
||||||
|
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
const items = images.map(img => ({
|
const items = images.map(img => ({
|
||||||
uri: img.fullsize,
|
uri: img.fullsize,
|
||||||
@@ -59,24 +66,49 @@ export function ImageEmbed({
|
|||||||
|
|
||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
const image = images[0]
|
const image = images[0]
|
||||||
|
const aspect =
|
||||||
|
image.aspectRatio && image.aspectRatio.height > 0
|
||||||
|
? image.aspectRatio.width / image.aspectRatio.height
|
||||||
|
: undefined
|
||||||
|
const openFromSingle = () => {
|
||||||
|
if (singleContainerRef.current) {
|
||||||
|
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<View style={[a.mt_sm, rest.style]}>
|
<View style={[a.mt_sm, rest.style]}>
|
||||||
<AutoSizedImage
|
<ImageContextMenu
|
||||||
crop={
|
fullsizeUri={image.fullsize}
|
||||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
thumbUri={image.thumb}
|
||||||
? 'none'
|
aspectRatio={aspect}
|
||||||
: rest.viewContext ===
|
borderRadius={tokens.borderRadius.md}
|
||||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
onPreviewPress={openFromSingle}>
|
||||||
? 'square'
|
<AutoSizedImage
|
||||||
: 'constrained'
|
crop={
|
||||||
}
|
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||||
image={image}
|
? 'none'
|
||||||
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
|
: rest.viewContext ===
|
||||||
onPressIn={() => onPressIn(0)}
|
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
hideBadge={
|
? 'square'
|
||||||
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
: 'constrained'
|
||||||
}
|
}
|
||||||
/>
|
image={image}
|
||||||
|
onContainerRef={ref => {
|
||||||
|
singleContainerRef.current = ref
|
||||||
|
}}
|
||||||
|
onDimsChange={dims => {
|
||||||
|
singleDimsRef.current = dims
|
||||||
|
}}
|
||||||
|
onPress={(containerRef, dims) =>
|
||||||
|
onPress(0, [containerRef], [dims])
|
||||||
|
}
|
||||||
|
onPressIn={() => onPressIn(0)}
|
||||||
|
hideBadge={
|
||||||
|
rest.viewContext ===
|
||||||
|
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ImageContextMenu>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import {forwardRef} from 'react'
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ForwardRefExoticComponent,
|
||||||
|
type RefAttributes,
|
||||||
|
} from 'react'
|
||||||
import Svg, {Path} from 'react-native-svg'
|
import Svg, {Path} from 'react-native-svg'
|
||||||
|
|
||||||
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
import {type Props, useCommonSVGProps} from '#/components/icons/common'
|
||||||
|
|
||||||
|
export type IconWithSvgMeta = ForwardRefExoticComponent<
|
||||||
|
Props & RefAttributes<Svg>
|
||||||
|
> & {
|
||||||
|
svgPaths: string[]
|
||||||
|
svgViewBox: string
|
||||||
|
svgStrokeWidth: number
|
||||||
|
}
|
||||||
|
|
||||||
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
|
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
|
||||||
function LogoImpl(props: Props, ref) {
|
function LogoImpl(props: Props, ref) {
|
||||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||||
@@ -41,7 +53,7 @@ export function createSinglePathSVG({
|
|||||||
strokeLinecap?: 'butt' | 'round' | 'square'
|
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||||
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
strokeLinejoin?: 'miter' | 'round' | 'bevel'
|
||||||
}) {
|
}) {
|
||||||
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
||||||
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||||
|
|
||||||
const hasStroke = strokeWidth > 0
|
const hasStroke = strokeWidth > 0
|
||||||
@@ -68,7 +80,11 @@ export function createSinglePathSVG({
|
|||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
)
|
)
|
||||||
})
|
}) as IconWithSvgMeta
|
||||||
|
Icon.svgPaths = [path]
|
||||||
|
Icon.svgViewBox = viewBox || '0 0 24 24'
|
||||||
|
Icon.svgStrokeWidth = strokeWidth
|
||||||
|
return Icon
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMultiPathSVG({
|
export function createMultiPathSVG({
|
||||||
@@ -78,7 +94,7 @@ export function createMultiPathSVG({
|
|||||||
paths: string[]
|
paths: string[]
|
||||||
viewBox?: string
|
viewBox?: string
|
||||||
}) {
|
}) {
|
||||||
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
||||||
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,5 +118,9 @@ export function createMultiPathSVG({
|
|||||||
))}
|
))}
|
||||||
</Svg>
|
</Svg>
|
||||||
)
|
)
|
||||||
})
|
}) as IconWithSvgMeta
|
||||||
|
Icon.svgPaths = paths
|
||||||
|
Icon.svgViewBox = viewBox || '0 0 24 24'
|
||||||
|
Icon.svgStrokeWidth = 0
|
||||||
|
return Icon
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useMemo, useRef} from 'react'
|
import {useEffect, useMemo, useRef} from 'react'
|
||||||
import {type DimensionValue, Pressable, View} from 'react-native'
|
import {type DimensionValue, Pressable, View} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
type AnimatedRef,
|
type AnimatedRef,
|
||||||
@@ -69,6 +69,8 @@ export function AutoSizedImage({
|
|||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
|
onContainerRef,
|
||||||
|
onDimsChange,
|
||||||
}: {
|
}: {
|
||||||
image: AppBskyEmbedImages.ViewImage
|
image: AppBskyEmbedImages.ViewImage
|
||||||
crop?: 'none' | 'square' | 'constrained'
|
crop?: 'none' | 'square' | 'constrained'
|
||||||
@@ -79,6 +81,11 @@ export function AutoSizedImage({
|
|||||||
) => void
|
) => void
|
||||||
onLongPress?: () => void
|
onLongPress?: () => void
|
||||||
onPressIn?: () => void
|
onPressIn?: () => void
|
||||||
|
/** Fires once with the internal container ref so a parent can drive its
|
||||||
|
* own lightbox-return animation without waiting for an `onPress`. */
|
||||||
|
onContainerRef?: (ref: AnimatedRef<any>) => void
|
||||||
|
/** Fires when the underlying image reports its natural dimensions. */
|
||||||
|
onDimsChange?: (dims: Dimensions) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -86,6 +93,10 @@ export function AutoSizedImage({
|
|||||||
const containerRef = useAnimatedRef()
|
const containerRef = useAnimatedRef()
|
||||||
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
|
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onContainerRef?.(containerRef)
|
||||||
|
}, [containerRef, onContainerRef])
|
||||||
|
|
||||||
let aspectRatio: number | undefined
|
let aspectRatio: number | undefined
|
||||||
const dims = image.aspectRatio
|
const dims = image.aspectRatio
|
||||||
if (dims) {
|
if (dims) {
|
||||||
@@ -122,10 +133,12 @@ export function AutoSizedImage({
|
|||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
onLoad={e => {
|
onLoad={e => {
|
||||||
if (!isContain) {
|
if (!isContain) {
|
||||||
fetchedDimsRef.current = {
|
const dims = {
|
||||||
width: e.source.width,
|
width: e.source.width,
|
||||||
height: e.source.height,
|
height: e.source.height,
|
||||||
}
|
}
|
||||||
|
fetchedDimsRef.current = dims
|
||||||
|
onDimsChange?.(dims)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {mergeRefs} from '#/lib/merge-refs'
|
|||||||
import {useA11y} from '#/state/a11y'
|
import {useA11y} from '#/state/a11y'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
||||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +36,7 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle
|
|||||||
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
|
||||||
import {getAspectRatio} from '#/components/images/Gallery/utils'
|
import {getAspectRatio} from '#/components/images/Gallery/utils'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
@@ -264,6 +265,21 @@ export function Gallery({
|
|||||||
data={images}
|
data={images}
|
||||||
keyExtractor={(item, index) => item.thumb + index}
|
keyExtractor={(item, index) => item.thumb + index}
|
||||||
renderItem={({item, index}) => {
|
renderItem={({item, index}) => {
|
||||||
|
const openLightboxAtIndex = onPress
|
||||||
|
? () => {
|
||||||
|
ax.metric('post:gallery:openLightbox', {
|
||||||
|
fromImage: index + 1, // convert to 1-based index for easier analysis
|
||||||
|
totalImages: images.length,
|
||||||
|
})
|
||||||
|
const refs: AnimatedRef<any>[] = []
|
||||||
|
const dims: (Dimensions | null)[] = []
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
refs.push(containerRefsRef.current.get(i)!)
|
||||||
|
dims.push(thumbDimsRef.current.get(i) ?? null)
|
||||||
|
}
|
||||||
|
onPress(index, refs, dims)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
return (
|
return (
|
||||||
<GalleryImage
|
<GalleryImage
|
||||||
hideBadges={hideBadges}
|
hideBadges={hideBadges}
|
||||||
@@ -288,24 +304,9 @@ export function Gallery({
|
|||||||
onThumbDims={(i, dims) => {
|
onThumbDims={(i, dims) => {
|
||||||
thumbDimsRef.current.set(i, dims)
|
thumbDimsRef.current.set(i, dims)
|
||||||
}}
|
}}
|
||||||
onPress={
|
onPress={openLightboxAtIndex}
|
||||||
onPress
|
|
||||||
? () => {
|
|
||||||
ax.metric('post:gallery:openLightbox', {
|
|
||||||
fromImage: index + 1, // convert to 1-based index for easier analysis
|
|
||||||
totalImages: images.length,
|
|
||||||
})
|
|
||||||
const refs: AnimatedRef<any>[] = []
|
|
||||||
const dims: (Dimensions | null)[] = []
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
|
||||||
refs.push(containerRefsRef.current.get(i)!)
|
|
||||||
dims.push(thumbDimsRef.current.get(i) ?? null)
|
|
||||||
}
|
|
||||||
onPress(index, refs, dims)
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||||
|
onPreviewPress={openLightboxAtIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -378,6 +379,7 @@ function GalleryImage({
|
|||||||
onThumbDims,
|
onThumbDims,
|
||||||
onPress,
|
onPress,
|
||||||
onPressIn,
|
onPressIn,
|
||||||
|
onPreviewPress,
|
||||||
}: {
|
}: {
|
||||||
contentHeight: number
|
contentHeight: number
|
||||||
image: AppBskyEmbedImages.ViewImage
|
image: AppBskyEmbedImages.ViewImage
|
||||||
@@ -391,6 +393,7 @@ function GalleryImage({
|
|||||||
onThumbDims: (index: number, dims: Dimensions) => void
|
onThumbDims: (index: number, dims: Dimensions) => void
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
onPressIn?: () => void
|
onPressIn?: () => void
|
||||||
|
onPreviewPress?: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
@@ -416,124 +419,131 @@ function GalleryImage({
|
|||||||
collapsable={false}
|
collapsable={false}
|
||||||
aria-roledescription={l`slide`}
|
aria-roledescription={l`slide`}
|
||||||
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
|
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
|
||||||
<Pressable
|
<ImageContextMenu
|
||||||
ref={itemRef}
|
fullsizeUri={image.fullsize}
|
||||||
tabIndex={index === 0 ? 0 : -1}
|
thumbUri={image.thumb}
|
||||||
onPress={onPress}
|
aspectRatio={aspectRatio}
|
||||||
onPressIn={onPressIn}
|
borderRadius={tokens.borderRadius.md}
|
||||||
onFocus={() => setFocused(true)}
|
onPreviewPress={onPreviewPress}>
|
||||||
onBlur={() => setFocused(false)}
|
<Pressable
|
||||||
accessibilityRole="button"
|
ref={itemRef}
|
||||||
accessibilityLabel={image.alt || l`Image ${index + 1}`}
|
tabIndex={index === 0 ? 0 : -1}
|
||||||
accessibilityHint={l`Opens full image`}
|
onPress={onPress}
|
||||||
android_ripple={{
|
onPressIn={onPressIn}
|
||||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
onFocus={() => setFocused(true)}
|
||||||
foreground: true,
|
onBlur={() => setFocused(false)}
|
||||||
}}
|
accessibilityRole="button"
|
||||||
style={({pressed}) => [
|
accessibilityLabel={image.alt || l`Image ${index + 1}`}
|
||||||
a.rounded_md,
|
accessibilityHint={l`Opens full image`}
|
||||||
a.overflow_hidden,
|
android_ripple={{
|
||||||
t.atoms.bg_contrast_25,
|
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||||
web([
|
foreground: true,
|
||||||
{
|
|
||||||
cursor: 'inherit',
|
|
||||||
outline: 0,
|
|
||||||
border: 0,
|
|
||||||
},
|
|
||||||
a.transition_transform,
|
|
||||||
{transitionDuration: '200ms'},
|
|
||||||
pressed && {transform: [{scale: 0.99}]},
|
|
||||||
]),
|
|
||||||
]}>
|
|
||||||
<Image
|
|
||||||
source={{uri: image.thumb}}
|
|
||||||
contentFit="cover"
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={image.alt}
|
|
||||||
accessibilityHint=""
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
loading={index === 0 ? 'eager' : 'lazy'}
|
|
||||||
style={[dims]}
|
|
||||||
onLoad={e => {
|
|
||||||
const ar = getAspectRatio(e.source)
|
|
||||||
if (ar && ar !== aspectRatio) {
|
|
||||||
setAspectRatio(ar)
|
|
||||||
}
|
|
||||||
onThumbDims(index, {
|
|
||||||
width: e.source.width,
|
|
||||||
height: e.source.height,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
style={({pressed}) => [
|
||||||
|
a.rounded_md,
|
||||||
{(hasAlt || isCropped) && !hideBadges ? (
|
a.overflow_hidden,
|
||||||
<View
|
t.atoms.bg_contrast_25,
|
||||||
accessible={false}
|
web([
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.flex_row,
|
|
||||||
{
|
{
|
||||||
bottom: a.p_xs.padding,
|
cursor: 'inherit',
|
||||||
right: a.p_xs.padding,
|
outline: 0,
|
||||||
gap: 3,
|
border: 0,
|
||||||
},
|
},
|
||||||
largeAltBadge && {
|
a.transition_transform,
|
||||||
gap: 4,
|
{transitionDuration: '200ms'},
|
||||||
},
|
pressed && {transform: [{scale: 0.99}]},
|
||||||
]}>
|
]),
|
||||||
{isCropped && (
|
]}>
|
||||||
<View
|
<Image
|
||||||
style={[
|
source={{uri: image.thumb}}
|
||||||
a.rounded_sm,
|
contentFit="cover"
|
||||||
a.p_xs,
|
accessible={true}
|
||||||
t.atoms.bg_contrast_25,
|
accessibilityLabel={image.alt}
|
||||||
{
|
accessibilityHint=""
|
||||||
opacity: 0.8,
|
accessibilityIgnoresInvertColors
|
||||||
},
|
loading={index === 0 ? 'eager' : 'lazy'}
|
||||||
largeAltBadge && {
|
style={[dims]}
|
||||||
padding: 6,
|
onLoad={e => {
|
||||||
},
|
const ar = getAspectRatio(e.source)
|
||||||
]}>
|
if (ar && ar !== aspectRatio) {
|
||||||
<Fullscreen
|
setAspectRatio(ar)
|
||||||
fill={t.atoms.text_contrast_high.color}
|
}
|
||||||
width={largeAltBadge ? 18 : 12}
|
onThumbDims(index, {
|
||||||
/>
|
width: e.source.width,
|
||||||
</View>
|
height: e.source.height,
|
||||||
)}
|
})
|
||||||
{hasAlt && (
|
}}
|
||||||
<View
|
/>
|
||||||
style={[
|
|
||||||
a.justify_center,
|
|
||||||
a.rounded_sm,
|
|
||||||
a.p_xs,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
{
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
largeAltBadge && {
|
|
||||||
padding: 6,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.font_bold,
|
|
||||||
largeAltBadge ? a.text_xs : {fontSize: 8},
|
|
||||||
]}>
|
|
||||||
<Trans>ALT</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<MediaInsetBorder
|
{(hasAlt || isCropped) && !hideBadges ? (
|
||||||
style={
|
<View
|
||||||
focused && {
|
accessible={false}
|
||||||
borderWidth: 2,
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.flex_row,
|
||||||
|
{
|
||||||
|
bottom: a.p_xs.padding,
|
||||||
|
right: a.p_xs.padding,
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
largeAltBadge && {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{isCropped && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
largeAltBadge && {
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Fullscreen
|
||||||
|
fill={t.atoms.text_contrast_high.color}
|
||||||
|
width={largeAltBadge ? 18 : 12}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasAlt && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_xs,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
{
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
largeAltBadge && {
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.font_bold,
|
||||||
|
largeAltBadge ? a.text_xs : {fontSize: 8},
|
||||||
|
]}>
|
||||||
|
<Trans>ALT</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MediaInsetBorder
|
||||||
|
style={
|
||||||
|
focused && {
|
||||||
|
borderWidth: 2,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Pressable>
|
||||||
</Pressable>
|
</ImageContextMenu>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {type Dimensions} from '#/lib/media/types'
|
import {type Dimensions} from '#/lib/media/types'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
|
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -52,46 +53,63 @@ export function GalleryItem({
|
|||||||
const hasAlt = !!image.alt
|
const hasAlt = !!image.alt
|
||||||
const hideBadges =
|
const hideBadges =
|
||||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
|
||||||
|
const aspect =
|
||||||
|
image.aspectRatio && image.aspectRatio.height > 0
|
||||||
|
? image.aspectRatio.width / image.aspectRatio.height
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
// The tap handler and the peek-commit handler do the same thing: open the
|
||||||
|
// lightbox with this cell's ref + dims so the lightbox's return animation
|
||||||
|
// can target the original thumbnail.
|
||||||
|
const openLightboxAtIndex = onPress
|
||||||
|
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
|
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
|
||||||
<Pressable
|
<ImageContextMenu
|
||||||
onPress={
|
fullsizeUri={image.fullsize}
|
||||||
onPress
|
thumbUri={image.thumb}
|
||||||
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
|
aspectRatio={aspect}
|
||||||
: undefined
|
borderRadius={tokens.borderRadius.md}
|
||||||
}
|
onPreviewPress={openLightboxAtIndex}
|
||||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
style={a.flex_1}>
|
||||||
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
<Pressable
|
||||||
android_ripple={{
|
onPress={openLightboxAtIndex}
|
||||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||||
foreground: true,
|
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
||||||
}}
|
android_ripple={{
|
||||||
style={[
|
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||||
a.flex_1,
|
foreground: true,
|
||||||
a.overflow_hidden,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
imageStyle,
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={image.alt || _(msg`Image`)}
|
|
||||||
accessibilityHint="">
|
|
||||||
<Image
|
|
||||||
source={{uri: image.thumb}}
|
|
||||||
style={[a.flex_1]}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={image.alt}
|
|
||||||
accessibilityHint=""
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
onLoad={e => {
|
|
||||||
thumbDimsRef.current[index] = {
|
|
||||||
width: e.source.width,
|
|
||||||
height: e.source.height,
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
loading="lazy"
|
style={[
|
||||||
/>
|
a.flex_1,
|
||||||
<MediaInsetBorder style={insetBorderStyle} />
|
a.overflow_hidden,
|
||||||
</Pressable>
|
t.atoms.bg_contrast_25,
|
||||||
|
imageStyle,
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={image.alt || _(msg`Image`)}
|
||||||
|
accessibilityHint="">
|
||||||
|
<Image
|
||||||
|
source={{uri: image.thumb}}
|
||||||
|
style={[a.flex_1]}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityLabel={image.alt}
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
onLoad={e => {
|
||||||
|
thumbDimsRef.current[index] = {
|
||||||
|
width: e.source.width,
|
||||||
|
height: e.source.height,
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<MediaInsetBorder style={insetBorderStyle} />
|
||||||
|
</Pressable>
|
||||||
|
</ImageContextMenu>
|
||||||
{hasAlt && !hideBadges ? (
|
{hasAlt && !hideBadges ? (
|
||||||
<View
|
<View
|
||||||
accessible={false}
|
accessible={false}
|
||||||
|
|||||||
@@ -1093,6 +1093,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
|||||||
<MediaPreview.Embed
|
<MediaPreview.Embed
|
||||||
embed={post.embed}
|
embed={post.embed}
|
||||||
style={styles.additionalPostImages}
|
style={styles.additionalPostImages}
|
||||||
|
peekable
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user