diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..118d148f
--- /dev/null
+++ b/assets/icons/crop_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index aefd62b9..3db00aec 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
-import {PostEmbeds} from '#/view/com/util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
return (
-
+
)
}
diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx
new file mode 100644
index 00000000..4b3fc560
--- /dev/null
+++ b/src/components/icons/Crop.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
+})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 3b5ddb1d..8cd6e70b 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -43,7 +43,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '../util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
import {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
@@ -363,7 +363,11 @@ let PostThreadItemLoaded = ({
) : undefined}
{post.embed && (
-
+
)}
@@ -591,7 +595,11 @@ let PostThreadItemLoaded = ({
) : undefined}
{post.embed && (
-
+
)}
) : undefined}
{post.embed ? (
-
+
) : null}
) : null}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 61cb6f69..f4fb3a1b 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -1,106 +1,219 @@
import React from 'react'
-import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native'
+import {DimensionValue, Pressable, View} from 'react-native'
import {Image} from 'expo-image'
-import {clamp} from 'lib/numbers'
-import {Dimensions} from 'lib/media/types'
-import * as imageSizes from 'lib/media/image-sizes'
+import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-const MIN_ASPECT_RATIO = 0.33 // 1/3
-const MAX_ASPECT_RATIO = 10 // 10/1
+import * as imageSizes from '#/lib/media/image-sizes'
+import {Dimensions} from '#/lib/media/types'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {atoms as a, useTheme} from '#/alf'
+import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
+import {Text} from '#/components/Typography'
-interface Props {
- alt?: string
- uri: string
- dimensionsHint?: Dimensions
- onPress?: () => void
- onLongPress?: () => void
- onPressIn?: () => void
- style?: StyleProp
- children?: React.ReactNode
+export function useImageAspectRatio({
+ src,
+ dimensions,
+}: {
+ src: string
+ dimensions: Dimensions | undefined
+}) {
+ const [raw, setAspectRatio] = React.useState(
+ dimensions ? calc(dimensions) : 1,
+ )
+ const {isCropped, constrained, max} = React.useMemo(() => {
+ const a34 = 0.75 // max of 3:4 ratio in feeds
+ const constrained = Math.max(raw, a34)
+ const max = Math.max(raw, 0.25) // max of 1:4 in thread
+ const isCropped = raw < constrained
+ return {
+ isCropped,
+ constrained,
+ max,
+ }
+ }, [raw])
+
+ React.useEffect(() => {
+ let aborted = false
+ if (dimensions) return
+ imageSizes.fetch(src).then(newDim => {
+ if (aborted) return
+ setAspectRatio(calc(newDim))
+ })
+ return () => {
+ aborted = true
+ }
+ }, [dimensions, setAspectRatio, src])
+
+ return {
+ dimensions,
+ raw,
+ constrained,
+ max,
+ isCropped,
+ }
+}
+
+export function ConstrainedImage({
+ aspectRatio,
+ fullBleed,
+ children,
+}: {
+ aspectRatio: number
+ fullBleed?: boolean
+ children: React.ReactNode
+}) {
+ const t = useTheme()
+ /**
+ * Computed as a % value to apply as `paddingTop`
+ */
+ const outerAspectRatio = React.useMemo(() => {
+ // capped to square or shorter
+ const ratio = Math.min(1 / aspectRatio, 1)
+ return `${ratio * 100}%`
+ }, [aspectRatio])
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
}
export function AutoSizedImage({
- alt,
- uri,
- dimensionsHint,
+ image,
+ crop = 'constrained',
+ hideBadge,
onPress,
onLongPress,
onPressIn,
- style,
- children = null,
-}: Props) {
+}: {
+ image: AppBskyEmbedImages.ViewImage
+ crop?: 'none' | 'square' | 'constrained'
+ hideBadge?: boolean
+ onPress?: () => void
+ onLongPress?: () => void
+ onPressIn?: () => void
+}) {
+ const t = useTheme()
const {_} = useLingui()
- const [dim, setDim] = React.useState(
- dimensionsHint || imageSizes.get(uri),
- )
- const [aspectRatio, setAspectRatio] = React.useState(
- dim ? calc(dim) : 1,
- )
- React.useEffect(() => {
- let aborted = false
- if (dim) {
- return
- }
- imageSizes.fetch(uri).then(newDim => {
- if (aborted) {
- return
- }
- setDim(newDim)
- setAspectRatio(calc(newDim))
- })
- }, [dim, setDim, setAspectRatio, uri])
+ const largeAlt = useLargeAltBadgeEnabled()
+ const {
+ constrained,
+ max,
+ isCropped: rawIsCropped,
+ } = useImageAspectRatio({
+ src: image.thumb,
+ dimensions: image.aspectRatio,
+ })
+ const cropDisabled = crop === 'none'
+ const isCropped = rawIsCropped && !cropDisabled
+ const hasAlt = !!image.alt
- if (onPress || onLongPress || onPressIn) {
+ const contents = (
+ <>
+
+
+ {(hasAlt || isCropped) && !hideBadge ? (
+
+ {isCropped && (
+
+ )}
+ {hasAlt && (
+
+ ALT
+
+ )}
+
+ ) : null}
+ >
+ )
+
+ if (cropDisabled) {
return (
- // disable a11y rule because in this case we want the tags on the image (#1640)
- // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
-
- {children}
+ // alt here is what screen readers actually use
+ accessibilityLabel={image.alt}
+ accessibilityHint={_(msg`Tap to view full image`)}
+ style={[
+ a.w_full,
+ a.rounded_sm,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ {aspectRatio: max},
+ ]}>
+ {contents}
)
+ } else {
+ return (
+
+
+ {contents}
+
+
+ )
}
-
- return (
-
-
- {children}
-
- )
}
function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) {
return 1
}
- return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
+ return dim.width / dim.height
}
-
-const styles = StyleSheet.create({
- container: {
- overflow: 'hidden',
- },
- image: {
- width: '100%',
- },
-})
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 9bbb2ac1..839674c8 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,13 +1,14 @@
import React, {ComponentProps, FC} from 'react'
-import {Pressable, StyleSheet, Text, View} from 'react-native'
+import {Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {isWeb} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
-import {atoms as a} from '#/alf'
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
@@ -17,7 +18,8 @@ interface GalleryItemProps {
onPress?: EventFunction
onLongPress?: EventFunction
onPressIn?: EventFunction
- imageStyle: ComponentProps['style']
+ imageStyle?: ComponentProps['style']
+ viewContext?: PostEmbedViewContext
}
export const GalleryItem: FC = ({
@@ -27,57 +29,69 @@ export const GalleryItem: FC = ({
onPress,
onPressIn,
onLongPress,
+ viewContext,
}) => {
+ const t = useTheme()
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
+ const hasAlt = !!image.alt
+ const hideBadges =
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return (
onPress(index) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
- style={a.flex_1}
+ style={[
+ a.flex_1,
+ a.rounded_xs,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ imageStyle,
+ ]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
- {image.alt === '' ? null : (
-
+ {hasAlt && !hideBadges ? (
+
+ style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
ALT
- )}
+ ) : null}
)
}
-
-const styles = StyleSheet.create({
- altContainer: {
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
- borderRadius: 6,
- paddingHorizontal: 6,
- paddingVertical: 3,
- position: 'absolute',
- // Related to margin/gap hack. This keeps the alt label in the same position
- // on all platforms
- right: isWeb ? 8 : 5,
- bottom: isWeb ? 8 : 5,
- },
- alt: {
- color: 'white',
- fontSize: 7,
- fontWeight: 'bold',
- },
-})
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index ba6c04f5..45da7f07 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,8 +1,10 @@
import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api'
+
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useBreakpoints} from '#/alf'
import {GalleryItem} from './Gallery'
-import {isWeb} from 'platform/detection'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
@@ -10,13 +12,25 @@ interface ImageLayoutGridProps {
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
style?: StyleProp
+ viewContext?: PostEmbedViewContext
}
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
+ const {gtMobile} = useBreakpoints()
+ const gap =
+ props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ ? gtMobile
+ ? a.gap_xs
+ : a.gap_2xs
+ : gtMobile
+ ? a.gap_sm
+ : a.gap_xs
+ const count = props.images.length
+ const aspectRatio = count === 2 ? 2 : count === 3 ? 1.5 : 1
return (
-
-
+
+
)
@@ -27,36 +41,39 @@ interface ImageLayoutGridInnerProps {
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
+ viewContext?: PostEmbedViewContext
+ gap: {gap: number}
}
function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
+ const gap = props.gap
const count = props.images.length
switch (count) {
case 2:
return (
-
-
-
+
+
+
-
-
+
+
)
case 3:
return (
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
@@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
case 4:
return (
<>
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
>
@@ -88,39 +105,3 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
return null
}
}
-
-// On web we use margin to calculate gap, as aspectRatio does not properly size
-// all images on web. On native though we cannot rely on margin, since the
-// negative margin interferes with the swipe controls on pagers.
-// https://github.com/facebook/yoga/issues/1418
-// https://github.com/bluesky-social/social-app/issues/2601
-const IMAGE_GAP = 5
-
-const styles = StyleSheet.create({
- container: isWeb
- ? {
- marginHorizontal: -IMAGE_GAP / 2,
- marginVertical: -IMAGE_GAP / 2,
- }
- : {
- gap: IMAGE_GAP,
- },
- flexRow: {
- flexDirection: 'row',
- gap: isWeb ? undefined : IMAGE_GAP,
- },
- smallItem: {flex: 1, aspectRatio: 1},
- image: isWeb
- ? {
- margin: IMAGE_GAP / 2,
- }
- : {},
- threeSingle: {
- flex: 2,
- aspectRatio: isWeb ? 1 : undefined,
- },
- threeDouble: {
- flex: 1,
- gap: isWeb ? undefined : IMAGE_GAP,
- },
-})
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index c61cda68..53cc3b8a 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -33,7 +33,7 @@ import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links'
import {precacheProfile} from 'state/queries/profile'
import {ComposerOptsQuote} from 'state/shell/composer'
-import {atoms as a} from '#/alf'
+import {atoms as a, useBreakpoints} from '#/alf'
import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
@@ -41,17 +41,20 @@ import {Link} from '../Link'
import {PostMeta} from '../PostMeta'
import {Text} from '../text/Text'
import {PostEmbeds} from '.'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
export function MaybeQuoteEmbed({
embed,
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed: AppBskyEmbedRecord.View
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
@@ -67,6 +70,7 @@ export function MaybeQuoteEmbed({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@@ -113,12 +117,14 @@ function QuoteEmbedModerated({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
@@ -144,6 +150,7 @@ function QuoteEmbedModerated({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
}
@@ -154,18 +161,21 @@ export function QuoteEmbed({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
quote: ComposerOptsQuote
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const queryClient = useQueryClient()
const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}`
+ const {gtMobile} = useBreakpoints()
const richText = React.useMemo(
() =>
@@ -197,6 +207,7 @@ export function QuoteEmbed({
}
}
}, [quote.embeds, allowNestedQuotes])
+ const isImagesEmbed = AppBskyEmbedImages.isView(embed)
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, quote.author)
@@ -226,15 +237,43 @@ export function QuoteEmbed({
{moderation ? (
) : null}
- {richText ? (
-
- ) : null}
- {embed && }
+
+ {viewContext === QuoteEmbedViewContext.FeedEmbedRecordWithMedia &&
+ isImagesEmbed ? (
+
+ {embed && (
+
+
+
+ )}
+ {richText ? (
+
+
+
+ ) : null}
+
+ ) : (
+ <>
+ {richText ? (
+
+ ) : null}
+ {embed && }
+ >
+ )}
)
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index d9e075e7..b4a6cf82 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -3,7 +3,6 @@ import {
InteractionManager,
StyleProp,
StyleSheet,
- Text,
View,
ViewStyle,
} from 'react-native'
@@ -22,7 +21,6 @@ import {
} from '@atproto/api'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
-import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
@@ -34,8 +32,11 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
import {VideoEmbed} from './VideoEmbed'
+export * from './types'
+
type Embed =
| AppBskyEmbedRecord.View
| AppBskyEmbedImages.View
@@ -50,15 +51,16 @@ export function PostEmbeds({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed?: Embed
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: PostEmbedViewContext
}) {
const {openLightbox} = useLightboxControls()
- const largeAltBadge = useLargeAltBadgeEnabled()
// quote post with media
// =
@@ -69,8 +71,17 @@ export function PostEmbeds({
embed={embed.media}
moderation={moderation}
onOpen={onOpen}
+ viewContext={viewContext}
+ />
+
-
)
}
@@ -124,27 +135,26 @@ export function PostEmbeds({
}
if (images.length === 1) {
- const {alt, thumb, aspectRatio} = images[0]
+ const image = images[0]
return (
_openLightbox(0)}
onPressIn={() => onPressIn(0)}
- style={a.rounded_sm}>
- {alt === '' ? null : (
-
-
- ALT
-
-
- )}
-
+ hideBadge={
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ }
+ />
)
@@ -157,6 +167,7 @@ export function PostEmbeds({
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
+ viewContext={viewContext}
/>
diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts
new file mode 100644
index 00000000..08e90327
--- /dev/null
+++ b/src/view/com/util/post-embeds/types.ts
@@ -0,0 +1,9 @@
+export enum PostEmbedViewContext {
+ ThreadHighlighted = 'ThreadHighlighted',
+ Feed = 'Feed',
+ FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
+}
+
+export enum QuoteEmbedViewContext {
+ FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
+}