Add iOS peek long-press context menu for image embeds
Introduces expo-bluesky-context-menu, a compositional Root/Trigger/Menu/MenuItem wrapper around UIContextMenuInteraction. The preview ViewController is sized to the image's true aspect ratio so tall/panorama previews don't stretch mid-lift, and menu icons are rasterized from the app's SVG icon set on the native side. The preview prop is a discriminated union (image today; video and externalCard reserved) so the same module can back those embeds in follow-ups. https://claude.ai/code/session_015REmux3R9uuEMMJUHxTyQT
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import {type ReactNode} from 'react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {IS_IOS} from '#/env'
|
||||
import * as ContextMenu from '../../../../modules/expo-bluesky-context-menu'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
aspectRatio,
|
||||
borderRadius,
|
||||
onPreviewPress,
|
||||
children,
|
||||
}: {
|
||||
fullsizeUri: string
|
||||
/** width / height; defaults to 1 if missing. */
|
||||
aspectRatio: number | undefined
|
||||
borderRadius?: number
|
||||
onPreviewPress?: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const saveImage = useSaveImageToMediaLibrary()
|
||||
|
||||
if (!IS_IOS) {
|
||||
return children
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
void saveImage(fullsizeUri)
|
||||
}
|
||||
const handleShare = () => {
|
||||
void shareImageModal({uri: fullsizeUri})
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger
|
||||
preview={{
|
||||
type: 'image',
|
||||
uri: fullsizeUri,
|
||||
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
|
||||
}}
|
||||
borderRadius={borderRadius}
|
||||
onPreviewPress={onPreviewPress}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Menu>
|
||||
<ContextMenu.MenuItem id="save" onSelect={handleSave}>
|
||||
<ContextMenu.MenuItemIcon icon={DownloadIcon} />
|
||||
<ContextMenu.MenuItemText>
|
||||
{_(msg`Save image`)}
|
||||
</ContextMenu.MenuItemText>
|
||||
</ContextMenu.MenuItem>
|
||||
<ContextMenu.MenuItem id="share" onSelect={handleShare}>
|
||||
<ContextMenu.MenuItemIcon icon={ShareIcon} />
|
||||
<ContextMenu.MenuItemText>{_(msg`Share`)}</ContextMenu.MenuItemText>
|
||||
</ContextMenu.MenuItem>
|
||||
</ContextMenu.Menu>
|
||||
</ContextMenu.Root>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {Gallery} from '#/components/images/Gallery'
|
||||
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
|
||||
import {useLightboxControls} from '#/components/Lightbox/state'
|
||||
import {type Dimensions} from '#/components/Lightbox/types'
|
||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {type EmbedType} from '#/types/bsky/post'
|
||||
@@ -59,24 +60,49 @@ export function ImageEmbed({
|
||||
|
||||
if (images.length === 1) {
|
||||
const image = images[0]
|
||||
const aspect =
|
||||
image.aspectRatio && image.aspectRatio.height > 0
|
||||
? image.aspectRatio.width / image.aspectRatio.height
|
||||
: undefined
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<AutoSizedImage
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={
|
||||
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
<ImageContextMenu
|
||||
fullsizeUri={image.fullsize}
|
||||
aspectRatio={aspect}
|
||||
borderRadius={tokens.borderRadius.md}
|
||||
onPreviewPress={() =>
|
||||
openLightbox({
|
||||
images: items.map(item => ({
|
||||
...item,
|
||||
thumbRect: null,
|
||||
thumbRef: null,
|
||||
thumbDimensions: null,
|
||||
thumbBorderRadius: tokens.borderRadius.md,
|
||||
type: 'image',
|
||||
})),
|
||||
index: 0,
|
||||
})
|
||||
}>
|
||||
<AutoSizedImage
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) =>
|
||||
onPress(0, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={
|
||||
rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
</ImageContextMenu>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user