Merge remote-tracking branch 'origin/main' into app-2308
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
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 AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
import {
|
||||||
|
AppBskyEmbedGallery,
|
||||||
|
type AppBskyEmbedImages,
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {shareImageModal} from '#/lib/media/manip'
|
import {shareImageModal} from '#/lib/media/manip'
|
||||||
@@ -47,6 +51,34 @@ export function Embed({
|
|||||||
)}
|
)}
|
||||||
</Outer>
|
</Outer>
|
||||||
)
|
)
|
||||||
|
} else if (e.type === 'gallery') {
|
||||||
|
// Notification/DM preview is a narrow inline strip; cap at 4 tiles so
|
||||||
|
// a 10-image gallery doesn't blow out the row width. Single pass instead
|
||||||
|
// of filter().slice().map() so we stop at 4 viewable items rather than
|
||||||
|
// walking every item in a 10-image gallery.
|
||||||
|
const tiles: React.ReactNode[] = []
|
||||||
|
for (const item of e.view.items) {
|
||||||
|
if (tiles.length >= 4) break
|
||||||
|
if (!AppBskyEmbedGallery.isViewImage(item)) continue
|
||||||
|
if (peekable) {
|
||||||
|
const image: AppBskyEmbedImages.ViewImage = {
|
||||||
|
thumb: item.thumbnail,
|
||||||
|
fullsize: item.fullsize,
|
||||||
|
alt: item.alt,
|
||||||
|
aspectRatio: item.aspectRatio,
|
||||||
|
}
|
||||||
|
tiles.push(<PeekableImageItem key={item.thumbnail} image={image} />)
|
||||||
|
} else {
|
||||||
|
tiles.push(
|
||||||
|
<ImageItem
|
||||||
|
key={item.thumbnail}
|
||||||
|
thumbnail={item.thumbnail}
|
||||||
|
alt={item.alt}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <Outer style={style}>{tiles}</Outer>
|
||||||
} else if (e.type === 'link') {
|
} else if (e.type === 'link') {
|
||||||
if (!e.view.external.thumb) return null
|
if (!e.view.external.thumb) return null
|
||||||
if (!isGifEmbed(e.view.external.uri)) return null
|
if (!isGifEmbed(e.view.external.uri)) return null
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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'
|
||||||
|
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
|
||||||
|
|
||||||
import {atoms as a, tokens} from '#/alf'
|
import {atoms as a, tokens} from '#/alf'
|
||||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||||
@@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics'
|
|||||||
import {type EmbedType} from '#/types/bsky/post'
|
import {type EmbedType} from '#/types/bsky/post'
|
||||||
import {type CommonProps} from './types'
|
import {type CommonProps} from './types'
|
||||||
|
|
||||||
|
const MAX_GRID_IMAGES = 4
|
||||||
|
|
||||||
export function ImageEmbed({
|
export function ImageEmbed({
|
||||||
embed,
|
embed,
|
||||||
...rest
|
...rest
|
||||||
}: CommonProps & {
|
}: CommonProps & {
|
||||||
embed: EmbedType<'images'>
|
embed: EmbedType<'images'> | EmbedType<'gallery'>
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {openLightbox} = useLightboxControls()
|
const {openLightbox} = useLightboxControls()
|
||||||
const {images} = embed.view
|
const images: AppBskyEmbedImages.ViewImage[] =
|
||||||
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
embed.type === 'gallery'
|
||||||
|
? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
|
||||||
|
thumb: item.thumbnail,
|
||||||
|
fullsize: item.fullsize,
|
||||||
|
alt: item.alt,
|
||||||
|
aspectRatio: item.aspectRatio,
|
||||||
|
}))
|
||||||
|
: embed.view.images
|
||||||
|
const useExpandedLayout =
|
||||||
|
embed.type === 'gallery'
|
||||||
|
? images.length > MAX_GRID_IMAGES
|
||||||
|
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||||
|
|
||||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
// 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.
|
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||||
@@ -109,7 +123,7 @@ export function ImageEmbed({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (galleryEnabled) {
|
if (useExpandedLayout) {
|
||||||
return (
|
return (
|
||||||
<View style={[a.mt_sm, rest.style]}>
|
<View style={[a.mt_sm, rest.style]}>
|
||||||
<Gallery
|
<Gallery
|
||||||
@@ -130,6 +144,7 @@ export function ImageEmbed({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
viewContext={rest.viewContext}
|
viewContext={rest.viewContext}
|
||||||
|
isWithinQuote={rest.isWithinQuote}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
|
|||||||
|
|
||||||
switch (embed.type) {
|
switch (embed.type) {
|
||||||
case 'images':
|
case 'images':
|
||||||
|
case 'gallery':
|
||||||
case 'link':
|
case 'link':
|
||||||
case 'video':
|
case 'video':
|
||||||
case 'gallery': {
|
case 'gallery': {
|
||||||
@@ -91,7 +92,8 @@ function MediaEmbed({
|
|||||||
embed: TEmbed
|
embed: TEmbed
|
||||||
}) {
|
}) {
|
||||||
switch (embed.type) {
|
switch (embed.type) {
|
||||||
case 'images': {
|
case 'images':
|
||||||
|
case 'gallery': {
|
||||||
return (
|
return (
|
||||||
<ContentHider
|
<ContentHider
|
||||||
modui={rest.moderation?.ui('contentMedia')}
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AppBskyEmbedGallery,
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
type AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
@@ -27,9 +28,6 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
additionalCauses?: ModerationCause[] | AppModerationCause[]
|
additionalCauses?: ModerationCause[] | AppModerationCause[]
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// don't ever check gates like this, except this one time
|
|
||||||
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
post.record,
|
post.record,
|
||||||
@@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gate only controls whether legacy image embeds opt into the new
|
||||||
|
// expanded gallery layout. Gallery embeds always render expanded by item
|
||||||
|
// count, so their offset must apply regardless of the gate.
|
||||||
|
const isPostGalleryEmbedEnabled = features.isOn(
|
||||||
|
Features.PostGalleryEmbedEnable,
|
||||||
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* First check if we even have images
|
* First check if we even have images
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
embed,
|
embed,
|
||||||
AppBskyEmbedImages.isMain,
|
AppBskyEmbedImages.isMain,
|
||||||
)
|
)
|
||||||
|
const isGalleryEmbed =
|
||||||
|
embed &&
|
||||||
|
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
|
||||||
|
embed,
|
||||||
|
AppBskyEmbedGallery.isMain,
|
||||||
|
)
|
||||||
const isRecordWithMedia =
|
const isRecordWithMedia =
|
||||||
embed &&
|
embed &&
|
||||||
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
|
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
|
||||||
@@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
)
|
)
|
||||||
let hasImages = false
|
let hasImages = false
|
||||||
if (isImageEmbed) {
|
if (isImageEmbed) {
|
||||||
|
if (!isPostGalleryEmbedEnabled) return
|
||||||
// one image, not a gallery
|
// one image, not a gallery
|
||||||
if (embed.images.length === 1) return
|
if (embed.images.length === 1) return
|
||||||
hasImages = true
|
hasImages = true
|
||||||
}
|
}
|
||||||
|
if (isGalleryEmbed) {
|
||||||
|
// single (or empty) gallery - no offset needed
|
||||||
|
if (embed.items.length <= 1) return
|
||||||
|
hasImages = true
|
||||||
|
}
|
||||||
if (isRecordWithMedia) {
|
if (isRecordWithMedia) {
|
||||||
if (
|
if (
|
||||||
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
|
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
|
||||||
@@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
AppBskyEmbedImages.isMain,
|
AppBskyEmbedImages.isMain,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
if (!isPostGalleryEmbedEnabled) return
|
||||||
// one image, not a gallery
|
// one image, not a gallery
|
||||||
if (embed.media.images.length === 1) return
|
if (embed.media.images.length === 1) return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
|
||||||
|
embed.media,
|
||||||
|
AppBskyEmbedGallery.isMain,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// single (or empty) gallery - no offset needed
|
||||||
|
if (embed.media.items.length <= 1) return
|
||||||
|
}
|
||||||
hasImages = true
|
hasImages = true
|
||||||
}
|
}
|
||||||
if (!hasImages) return
|
if (!hasImages) return
|
||||||
|
|||||||
@@ -19,21 +19,28 @@ interface ImageLayoutGridProps {
|
|||||||
onPressIn?: (index: number) => void
|
onPressIn?: (index: number) => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
viewContext?: PostEmbedViewContext
|
viewContext?: PostEmbedViewContext
|
||||||
|
isWithinQuote?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
|
export function ImageLayoutGrid({
|
||||||
|
style,
|
||||||
|
isWithinQuote: isWithinQuoteProp,
|
||||||
|
...props
|
||||||
|
}: ImageLayoutGridProps) {
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const gap =
|
const isWithinQuote =
|
||||||
|
isWithinQuoteProp ??
|
||||||
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
? gtMobile
|
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
|
||||||
? a.gap_xs
|
|
||||||
: a.gap_2xs
|
|
||||||
: a.gap_xs
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
|
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
|
||||||
<ImageLayoutGridInner {...props} gap={gap} />
|
<ImageLayoutGridInner
|
||||||
|
{...props}
|
||||||
|
gap={gap}
|
||||||
|
isWithinQuote={isWithinQuote}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps {
|
|||||||
onLongPress?: (index: number) => void
|
onLongPress?: (index: number) => void
|
||||||
onPressIn?: (index: number) => void
|
onPressIn?: (index: number) => void
|
||||||
viewContext?: PostEmbedViewContext
|
viewContext?: PostEmbedViewContext
|
||||||
|
isWithinQuote?: boolean
|
||||||
gap: {gap: number}
|
gap: {gap: number}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Props {
|
|||||||
onPressIn?: EventFunction
|
onPressIn?: EventFunction
|
||||||
imageStyle?: StyleProp<ImageStyle>
|
imageStyle?: StyleProp<ImageStyle>
|
||||||
viewContext?: PostEmbedViewContext
|
viewContext?: PostEmbedViewContext
|
||||||
|
isWithinQuote?: boolean
|
||||||
insetBorderStyle?: StyleProp<ViewStyle>
|
insetBorderStyle?: StyleProp<ViewStyle>
|
||||||
containerRefs: AnimatedRef<any>[]
|
containerRefs: AnimatedRef<any>[]
|
||||||
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
|
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
|
||||||
@@ -42,6 +43,7 @@ export function GalleryItem({
|
|||||||
onPressIn,
|
onPressIn,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
viewContext,
|
viewContext,
|
||||||
|
isWithinQuote,
|
||||||
insetBorderStyle,
|
insetBorderStyle,
|
||||||
containerRefs,
|
containerRefs,
|
||||||
thumbDimsRef,
|
thumbDimsRef,
|
||||||
@@ -52,6 +54,7 @@ export function GalleryItem({
|
|||||||
const image = images[index]
|
const image = images[index]
|
||||||
const hasAlt = !!image.alt
|
const hasAlt = !!image.alt
|
||||||
const hideBadges =
|
const hideBadges =
|
||||||
|
isWithinQuote ??
|
||||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
|
||||||
const aspect =
|
const aspect =
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ export function parseReportSubject(
|
|||||||
reply: !!record.reply,
|
reply: !!record.reply,
|
||||||
image:
|
image:
|
||||||
embed.type === 'images' ||
|
embed.type === 'images' ||
|
||||||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
|
embed.type === 'gallery' ||
|
||||||
|
(embed.type === 'post_with_media' &&
|
||||||
|
(embed.media.type === 'images' ||
|
||||||
|
embed.media.type === 'gallery')),
|
||||||
video:
|
video:
|
||||||
embed.type === 'video' ||
|
embed.type === 'video' ||
|
||||||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
|
(embed.type === 'post_with_media' && embed.media.type === 'video'),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type $Typed,
|
type $Typed,
|
||||||
type AppBskyEmbedExternal,
|
type AppBskyEmbedExternal,
|
||||||
|
type AppBskyEmbedGallery,
|
||||||
type AppBskyEmbedImages,
|
type AppBskyEmbedImages,
|
||||||
type AppBskyEmbedRecord,
|
type AppBskyEmbedRecord,
|
||||||
type AppBskyEmbedRecordWithMedia,
|
type AppBskyEmbedRecordWithMedia,
|
||||||
@@ -254,6 +255,7 @@ async function resolveEmbed(
|
|||||||
onStateChange: ((state: string) => void) | undefined,
|
onStateChange: ((state: string) => void) | undefined,
|
||||||
): Promise<
|
): Promise<
|
||||||
| $Typed<AppBskyEmbedImages.Main>
|
| $Typed<AppBskyEmbedImages.Main>
|
||||||
|
| $Typed<AppBskyEmbedGallery.Main>
|
||||||
| $Typed<AppBskyEmbedVideo.Main>
|
| $Typed<AppBskyEmbedVideo.Main>
|
||||||
| $Typed<AppBskyEmbedExternal.Main>
|
| $Typed<AppBskyEmbedExternal.Main>
|
||||||
| $Typed<AppBskyEmbedRecord.Main>
|
| $Typed<AppBskyEmbedRecord.Main>
|
||||||
@@ -313,6 +315,7 @@ async function resolveMedia(
|
|||||||
): Promise<
|
): Promise<
|
||||||
| $Typed<AppBskyEmbedExternal.Main>
|
| $Typed<AppBskyEmbedExternal.Main>
|
||||||
| $Typed<AppBskyEmbedImages.Main>
|
| $Typed<AppBskyEmbedImages.Main>
|
||||||
|
| $Typed<AppBskyEmbedGallery.Main>
|
||||||
| $Typed<AppBskyEmbedVideo.Main>
|
| $Typed<AppBskyEmbedVideo.Main>
|
||||||
| undefined
|
| undefined
|
||||||
> {
|
> {
|
||||||
@@ -343,6 +346,34 @@ async function resolveMedia(
|
|||||||
images,
|
images,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (embedDraft.media?.type === 'gallery') {
|
||||||
|
const imagesDraft = embedDraft.media.images
|
||||||
|
logger.debug(`Uploading images`, {
|
||||||
|
count: imagesDraft.length,
|
||||||
|
})
|
||||||
|
onStateChange?.(t`Uploading images...`)
|
||||||
|
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
|
||||||
|
imagesDraft.map(async (image, i) => {
|
||||||
|
logger.debug(`Compressing image #${i}`)
|
||||||
|
const {path, width, height, mime} = await compressImage(
|
||||||
|
image,
|
||||||
|
IMAGE_SIZE_CONFIG_POSTS,
|
||||||
|
)
|
||||||
|
logger.debug(`Uploading image #${i}`)
|
||||||
|
const res = await uploadBlob(agent, path, mime)
|
||||||
|
return {
|
||||||
|
$type: 'app.bsky.embed.gallery#image' as const,
|
||||||
|
image: res.data.blob,
|
||||||
|
alt: image.alt,
|
||||||
|
aspectRatio: {width, height},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
$type: 'app.bsky.embed.gallery',
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
embedDraft.media?.type === 'video' &&
|
embedDraft.media?.type === 'video' &&
|
||||||
embedDraft.media.video.status === 'done'
|
embedDraft.media.video.status === 'done'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||||
import {type LayoutChangeEvent, View} from 'react-native'
|
import {type LayoutChangeEvent, View} from 'react-native'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {moderateProfile} from '@atproto/api'
|
import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
ScrollEdgeEffect,
|
ScrollEdgeEffect,
|
||||||
ScrollEdgeEffectProvider,
|
ScrollEdgeEffectProvider,
|
||||||
@@ -29,6 +29,7 @@ import {ConvoStatus} from '#/state/messages/convo/types'
|
|||||||
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useConvoQuery} from '#/state/queries/messages/conversation'
|
import {useConvoQuery} from '#/state/queries/messages/conversation'
|
||||||
|
import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
import {MessagesList} from '#/screens/Messages/components/MessagesList'
|
||||||
import {atoms as a, web} from '#/alf'
|
import {atoms as a, web} from '#/alf'
|
||||||
@@ -51,6 +52,7 @@ import {IS_INTERNAL, IS_LIQUID_GLASS} from '#/env'
|
|||||||
import {ChatDisabled} from './components/ChatDisabled'
|
import {ChatDisabled} from './components/ChatDisabled'
|
||||||
import {ChatEnded} from './components/ChatEnded'
|
import {ChatEnded} from './components/ChatEnded'
|
||||||
import {ChatLocked} from './components/ChatLocked'
|
import {ChatLocked} from './components/ChatLocked'
|
||||||
|
import {RequestStatus} from './components/RequestStatus'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -180,6 +182,12 @@ function InnerReady({
|
|||||||
const {needsEmailVerification} = useEmail()
|
const {needsEmailVerification} = useEmail()
|
||||||
const emailDialogControl = useEmailDialogControl()
|
const emailDialogControl = useEmailDialogControl()
|
||||||
|
|
||||||
|
const unreadRequestCount =
|
||||||
|
convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind)
|
||||||
|
? (convo.view.kind.unreadJoinRequestCount ?? 0)
|
||||||
|
: 0
|
||||||
|
const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be non-reactive, otherwise the update to open the global dialog will
|
* Must be non-reactive, otherwise the update to open the global dialog will
|
||||||
* cause a re-render loop.
|
* cause a re-render loop.
|
||||||
@@ -264,8 +272,25 @@ function InnerReady({
|
|||||||
{header}
|
{header}
|
||||||
</ScrollEdgeEffect>
|
</ScrollEdgeEffect>
|
||||||
) : (
|
) : (
|
||||||
header
|
<View onLayout={onHeaderLayout}>{header}</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? (
|
||||||
|
<RequestStatus
|
||||||
|
top={headerHeight}
|
||||||
|
count={unreadRequestCount}
|
||||||
|
onDismiss={() => {
|
||||||
|
markJoinRequestsRead()
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
markJoinRequestsRead()
|
||||||
|
navigation.navigate('MessagesJoinRequests', {
|
||||||
|
conversation: convo.view.id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<MessagesList
|
<MessagesList
|
||||||
hasScrolled={hasScrolled}
|
hasScrolled={hasScrolled}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
precacheConvoQuery,
|
precacheConvoQuery,
|
||||||
useMarkAsReadMutation,
|
useMarkAsReadMutation,
|
||||||
} from '#/state/queries/messages/conversation'
|
} from '#/state/queries/messages/conversation'
|
||||||
|
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
||||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||||
@@ -214,15 +215,15 @@ function GroupChatItem({
|
|||||||
primaryProfileModeration={moderation}
|
primaryProfileModeration={moderation}
|
||||||
isBlockedAccount={false}
|
isBlockedAccount={false}
|
||||||
isDeletedAccount={false}
|
isDeletedAccount={false}
|
||||||
subtitle={
|
requestInfo={
|
||||||
convo.details.joinRequestCount
|
convo.details.unreadJoinRequestCount
|
||||||
? convo.details.joinRequestCount > 20
|
? convo.details.unreadJoinRequestCount > JOIN_REQUESTS_THRESHOLD
|
||||||
? l({
|
? l({
|
||||||
message: '20+ new join requests',
|
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
||||||
context:
|
context:
|
||||||
'Displayed when there are more than 20 requests to join a group chat',
|
'Displayed when there are more than 20 requests to join a group chat',
|
||||||
})
|
})
|
||||||
: plural(convo.details.joinRequestCount, {
|
: plural(convo.details.unreadJoinRequestCount, {
|
||||||
one: '# new join request',
|
one: '# new join request',
|
||||||
other: '# new join requests',
|
other: '# new join requests',
|
||||||
})
|
})
|
||||||
@@ -241,6 +242,7 @@ function BaseChatItem({
|
|||||||
avatar,
|
avatar,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
requestInfo,
|
||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
isDeletedAccount,
|
isDeletedAccount,
|
||||||
isBlockedAccount,
|
isBlockedAccount,
|
||||||
@@ -256,6 +258,7 @@ function BaseChatItem({
|
|||||||
avatar: React.ReactNode
|
avatar: React.ReactNode
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
requestInfo?: string
|
||||||
accessibilityHint: string
|
accessibilityHint: string
|
||||||
isDeletedAccount: boolean
|
isDeletedAccount: boolean
|
||||||
isBlockedAccount: boolean
|
isBlockedAccount: boolean
|
||||||
@@ -280,8 +283,10 @@ function BaseChatItem({
|
|||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const hasUnread =
|
const hasUnread =
|
||||||
convo.view.unreadCount > 0 &&
|
|
||||||
!isDeletedAccount &&
|
!isDeletedAccount &&
|
||||||
|
(convo.view.unreadCount > 0 ||
|
||||||
|
(convo.kind === 'group' &&
|
||||||
|
(convo.details.unreadJoinRequestCount ?? 0) > 0)) &&
|
||||||
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
|
(convo.kind !== 'group' || convo.details.lockStatus === 'unlocked')
|
||||||
|
|
||||||
const blockInfo = useMemo(() => {
|
const blockInfo = useMemo(() => {
|
||||||
@@ -607,6 +612,19 @@ function BaseChatItem({
|
|||||||
|
|
||||||
{postAlerts}
|
{postAlerts}
|
||||||
|
|
||||||
|
{requestInfo && (
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
||||||
|
isDimStyle && t.atoms.text_contrast_medium,
|
||||||
|
a.pb_2xs,
|
||||||
|
]}
|
||||||
|
emoji>
|
||||||
|
{requestInfo}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={[a.flex_row, a.align_center]}>
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
{LastMessageIcon && (
|
{LastMessageIcon && (
|
||||||
<LastMessageIcon
|
<LastMessageIcon
|
||||||
@@ -623,8 +641,6 @@ function BaseChatItem({
|
|||||||
emoji
|
emoji
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
style={[
|
style={[
|
||||||
a.text_sm,
|
|
||||||
a.leading_snug,
|
|
||||||
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
hasUnread ? a.font_medium : t.atoms.text_contrast_high,
|
||||||
isDimStyle && t.atoms.text_contrast_medium,
|
isDimStyle && t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {Pressable} from 'react-native'
|
||||||
|
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||||
|
import {plural} from '@lingui/core/macro'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
|
import {HITSLOP_10} from '#/lib/constants'
|
||||||
|
import {JOIN_REQUESTS_THRESHOLD} from '#/state/queries/messages/list-join-requests'
|
||||||
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {GlassView} from '#/components/GlassView'
|
||||||
|
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||||
|
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {IS_LIQUID_GLASS} from '#/env'
|
||||||
|
|
||||||
|
export function RequestStatus({
|
||||||
|
top,
|
||||||
|
count,
|
||||||
|
onDismiss,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
top: number
|
||||||
|
count: number
|
||||||
|
onDismiss: () => void
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(200).delay(200)}
|
||||||
|
exiting={FadeOut.duration(200)}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.z_50,
|
||||||
|
{
|
||||||
|
top: top + (IS_LIQUID_GLASS ? tokens.space.sm : tokens.space.xl),
|
||||||
|
left: tokens.space.xl,
|
||||||
|
right: tokens.space.xl,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<GlassView
|
||||||
|
style={[a.flex_1, a.rounded_full, a.flex_row, a.align_center]}
|
||||||
|
isInteractive
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor={t.palette.primary_50}
|
||||||
|
fallbackStyle={{
|
||||||
|
backgroundColor: t.palette.primary_50,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: t.palette.primary_100,
|
||||||
|
}}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`View incoming requests`}
|
||||||
|
accessibilityHint={l`View incoming requests to join this group chat`}
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.p_lg]}
|
||||||
|
onPress={onPress}>
|
||||||
|
<EnvelopeIcon size="md" fill={t.palette.primary_500} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.ml_sm,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_semi_bold,
|
||||||
|
{color: t.palette.primary_500},
|
||||||
|
]}>
|
||||||
|
{count > JOIN_REQUESTS_THRESHOLD
|
||||||
|
? l({
|
||||||
|
message: `${JOIN_REQUESTS_THRESHOLD}+ new join requests`,
|
||||||
|
comment:
|
||||||
|
'Displayed when the number of requests is greater than 20',
|
||||||
|
})
|
||||||
|
: plural(count, {
|
||||||
|
one: '# new join request',
|
||||||
|
other: '# new join requests',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={l`Close banner`}
|
||||||
|
accessibilityHint={l`Close the incoming requests banner`}
|
||||||
|
hitSlop={HITSLOP_10}
|
||||||
|
onPress={onDismiss}
|
||||||
|
style={[a.p_lg]}>
|
||||||
|
<CloseIcon size="md" fill={t.palette.primary_500} />
|
||||||
|
</Pressable>
|
||||||
|
</GlassView>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ export function usePrefetchJoinLinkPreviews() {
|
|||||||
return queryClient.prefetchQuery({
|
return queryClient.prefetchQuery({
|
||||||
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
|
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
|
||||||
queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}),
|
queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}),
|
||||||
staleTime: STALE.MINUTES.ONE,
|
staleTime: STALE.SECONDS.FIFTEEN,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export function useGetJoinLinkPreview() {
|
|||||||
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
|
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
|
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
|
||||||
staleTime: STALE.MINUTES.ONE,
|
staleTime: STALE.SECONDS.FIFTEEN,
|
||||||
})
|
})
|
||||||
return data.joinLinkPreviews[0]
|
return data.joinLinkPreviews[0]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {createQueryKey} from '#/state/queries/util'
|
|||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {STALE} from '..'
|
import {STALE} from '..'
|
||||||
|
|
||||||
|
export const JOIN_REQUESTS_THRESHOLD = 20
|
||||||
|
|
||||||
const listJoinRequestsQueryKeyRoot = 'list-join-requests'
|
const listJoinRequestsQueryKeyRoot = 'list-join-requests'
|
||||||
|
|
||||||
export const createListJoinRequestsQueryKey = (args: {convoId: string}) =>
|
export const createListJoinRequestsQueryKey = (args: {convoId: string}) =>
|
||||||
@@ -53,7 +55,7 @@ export function useListJoinRequestsQuery({
|
|||||||
queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}),
|
queryKey: createListJoinRequestsQueryKey({convoId: convoId ?? ''}),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const {data} = await agent.chat.bsky.group.listJoinRequests(
|
const {data} = await agent.chat.bsky.group.listJoinRequests(
|
||||||
{convoId: convoId!, cursor: pageParam, limit: 20},
|
{convoId: convoId!, cursor: pageParam, limit: JOIN_REQUESTS_THRESHOLD},
|
||||||
{headers: DM_SERVICE_HEADERS},
|
{headers: DM_SERVICE_HEADERS},
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {RQKEY as CONVO_KEY} from './conversation'
|
||||||
|
import {
|
||||||
|
type ConvoListQueryData,
|
||||||
|
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
|
||||||
|
} from './list-conversations'
|
||||||
|
|
||||||
|
export function useMarkJoinRequestsRead(convoId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!convoId) throw new Error('No convoId provided')
|
||||||
|
await agent.chat.bsky.group.updateJoinRequestsRead(
|
||||||
|
{convoId},
|
||||||
|
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onMutate: () => {
|
||||||
|
if (!convoId) return
|
||||||
|
|
||||||
|
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
|
||||||
|
CONVO_KEY(convoId),
|
||||||
|
)
|
||||||
|
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView | undefined>(
|
||||||
|
CONVO_KEY(convoId),
|
||||||
|
old => {
|
||||||
|
if (!old || !ChatBskyConvoDefs.isGroupConvo(old.kind)) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
kind: {...old.kind, unreadJoinRequestCount: 0},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const prevListEntries = queryClient.getQueriesData<ConvoListQueryData>({
|
||||||
|
queryKey: [CONVO_LIST_ROOT_KEY],
|
||||||
|
})
|
||||||
|
queryClient.setQueriesData<ConvoListQueryData>(
|
||||||
|
{queryKey: [CONVO_LIST_ROOT_KEY]},
|
||||||
|
old => {
|
||||||
|
if (!old) return old
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map(page => ({
|
||||||
|
...page,
|
||||||
|
convos: page.convos.map(convo => {
|
||||||
|
if (
|
||||||
|
convo.id !== convoId ||
|
||||||
|
!ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||||
|
) {
|
||||||
|
return convo
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...convo,
|
||||||
|
kind: {...convo.kind, unreadJoinRequestCount: 0},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {prevConvo, prevListEntries}
|
||||||
|
},
|
||||||
|
onError: (error, _, context) => {
|
||||||
|
logger.error('Failed to mark join requests as read', {safeMessage: error})
|
||||||
|
if (!convoId) return
|
||||||
|
if (context?.prevConvo) {
|
||||||
|
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
|
||||||
|
}
|
||||||
|
for (const [key, data] of context?.prevListEntries ?? []) {
|
||||||
|
queryClient.setQueryData(key, data)
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({queryKey: CONVO_KEY(convoId)})
|
||||||
|
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ export type Embed =
|
|||||||
type: 'images'
|
type: 'images'
|
||||||
view: $Typed<AppBskyEmbedImages.View>
|
view: $Typed<AppBskyEmbedImages.View>
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'gallery'
|
||||||
|
view: $Typed<AppBskyEmbedGallery.View>
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'link'
|
type: 'link'
|
||||||
view: $Typed<AppBskyEmbedExternal.View>
|
view: $Typed<AppBskyEmbedExternal.View>
|
||||||
@@ -127,6 +131,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
|
|||||||
type: 'images',
|
type: 'images',
|
||||||
view: embed,
|
view: embed,
|
||||||
}
|
}
|
||||||
|
} else if (AppBskyEmbedGallery.isView(embed)) {
|
||||||
|
return {
|
||||||
|
type: 'gallery',
|
||||||
|
view: embed,
|
||||||
|
}
|
||||||
} else if (AppBskyEmbedExternal.isView(embed)) {
|
} else if (AppBskyEmbedExternal.isView(embed)) {
|
||||||
return {
|
return {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ import {
|
|||||||
composerReducer,
|
composerReducer,
|
||||||
createComposerState,
|
createComposerState,
|
||||||
type EmbedDraft,
|
type EmbedDraft,
|
||||||
MAX_IMAGES,
|
MAX_GALLERY_IMAGES,
|
||||||
type PostAction,
|
type PostAction,
|
||||||
type PostDraft,
|
type PostDraft,
|
||||||
type ThreadDraft,
|
type ThreadDraft,
|
||||||
@@ -178,6 +178,65 @@ type CancelRef = {
|
|||||||
onPressCancel: () => void
|
onPressCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyGalleryCap(
|
||||||
|
currentCount: number,
|
||||||
|
incoming: ComposerImage[],
|
||||||
|
):
|
||||||
|
| {status: 'full'}
|
||||||
|
| {status: 'partial'; accepted: ComposerImage[]; dropped: number}
|
||||||
|
| {status: 'ok'; accepted: ComposerImage[]} {
|
||||||
|
const remaining = MAX_GALLERY_IMAGES - currentCount
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return {status: 'full'}
|
||||||
|
}
|
||||||
|
if (incoming.length > remaining) {
|
||||||
|
return {
|
||||||
|
status: 'partial',
|
||||||
|
accepted: incoming.slice(0, remaining),
|
||||||
|
dropped: incoming.length - remaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {status: 'ok', accepted: incoming}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAddImagesWithCap(
|
||||||
|
currentCount: number,
|
||||||
|
dispatchPostAction: (action: PostAction) => void,
|
||||||
|
) {
|
||||||
|
const {t: l} = useLingui()
|
||||||
|
return useCallback(
|
||||||
|
(next: ComposerImage[]) => {
|
||||||
|
const result = applyGalleryCap(currentCount, next)
|
||||||
|
if (result.status === 'full') {
|
||||||
|
Toast.show(
|
||||||
|
l({
|
||||||
|
message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`,
|
||||||
|
comment:
|
||||||
|
'Toast shown when the user tries to add more images but the post gallery is already at the cap',
|
||||||
|
}),
|
||||||
|
{type: 'warning'},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status === 'partial') {
|
||||||
|
Toast.show(
|
||||||
|
l({
|
||||||
|
message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`,
|
||||||
|
comment:
|
||||||
|
'Toast shown when adding images would exceed the post gallery cap; only the first N are kept',
|
||||||
|
}),
|
||||||
|
{type: 'warning'},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dispatchPostAction({
|
||||||
|
type: 'embed_add_images',
|
||||||
|
images: result.accepted,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[currentCount, dispatchPostAction, l],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type Props = ComposerOpts
|
type Props = ComposerOpts
|
||||||
export const ComposePost = ({
|
export const ComposePost = ({
|
||||||
replyTo,
|
replyTo,
|
||||||
@@ -611,7 +670,11 @@ export const ComposePost = ({
|
|||||||
ax.metric('draft:save', {
|
ax.metric('draft:save', {
|
||||||
isNewDraft,
|
isNewDraft,
|
||||||
hasText: posts.some(p => p.richtext.text.trim().length > 0),
|
hasText: posts.some(p => p.richtext.text.trim().length > 0),
|
||||||
hasImages: posts.some(p => p.embed.media?.type === 'images'),
|
hasImages: posts.some(
|
||||||
|
p =>
|
||||||
|
p.embed.media?.type === 'images' ||
|
||||||
|
p.embed.media?.type === 'gallery',
|
||||||
|
),
|
||||||
hasVideo: posts.some(p => p.embed.media?.type === 'video'),
|
hasVideo: posts.some(p => p.embed.media?.type === 'video'),
|
||||||
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
|
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
|
||||||
hasQuote: posts.some(p => !!p.embed.quote),
|
hasQuote: posts.some(p => !!p.embed.quote),
|
||||||
@@ -780,7 +843,10 @@ export const ComposePost = ({
|
|||||||
for (let i = 0; i < thread.posts.length; i++) {
|
for (let i = 0; i < thread.posts.length; i++) {
|
||||||
const media = thread.posts[i].embed.media
|
const media = thread.posts[i].embed.media
|
||||||
if (media) {
|
if (media) {
|
||||||
if (media.type === 'images' && media.images.some(img => !img.alt)) {
|
if (
|
||||||
|
(media.type === 'images' || media.type === 'gallery') &&
|
||||||
|
media.images.some(img => !img.alt)
|
||||||
|
) {
|
||||||
return l`One or more images is missing alt text.`
|
return l`One or more images is missing alt text.`
|
||||||
}
|
}
|
||||||
if (media.type === 'gif' && !media.alt) {
|
if (media.type === 'gif' && !media.alt) {
|
||||||
@@ -931,7 +997,9 @@ export const ComposePost = ({
|
|||||||
logger.error(e, {
|
logger.error(e, {
|
||||||
message: `Composer: create post failed`,
|
message: `Composer: create post failed`,
|
||||||
hasImages: filteredThread.posts.some(
|
hasImages: filteredThread.posts.some(
|
||||||
p => p.embed.media?.type === 'images',
|
p =>
|
||||||
|
p.embed.media?.type === 'images' ||
|
||||||
|
p.embed.media?.type === 'gallery',
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -953,7 +1021,8 @@ export const ComposePost = ({
|
|||||||
for (let post of filteredThread.posts) {
|
for (let post of filteredThread.posts) {
|
||||||
ax.metric('post:create', {
|
ax.metric('post:create', {
|
||||||
imageCount:
|
imageCount:
|
||||||
post.embed.media?.type === 'images'
|
post.embed.media?.type === 'images' ||
|
||||||
|
post.embed.media?.type === 'gallery'
|
||||||
? post.embed.media.images.length
|
? post.embed.media.images.length
|
||||||
: 0,
|
: 0,
|
||||||
isReply: index > 0 || !!replyTo,
|
isReply: index > 0 || !!replyTo,
|
||||||
@@ -1395,15 +1464,11 @@ let ComposerPost = memo(function ComposerPost({
|
|||||||
[dispatch, post.id],
|
[dispatch, post.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onImageAdd = useCallback(
|
const postImagesCount =
|
||||||
(next: ComposerImage[]) => {
|
post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery'
|
||||||
dispatchPost({
|
? post.embed.media.images.length
|
||||||
type: 'embed_add_images',
|
: 0
|
||||||
images: next,
|
const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost)
|
||||||
})
|
|
||||||
},
|
|
||||||
[dispatchPost],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onNewLink = useCallback(
|
const onNewLink = useCallback(
|
||||||
(uri: string) => {
|
(uri: string) => {
|
||||||
@@ -1708,7 +1773,7 @@ function ComposerEmbeds({
|
|||||||
const video = embed.media?.type === 'video' ? embed.media.video : null
|
const video = embed.media?.type === 'video' ? embed.media.video : null
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{embed.media?.type === 'images' && (
|
{(embed.media?.type === 'images' || embed.media?.type === 'gallery') && (
|
||||||
<Gallery images={embed.media.images} dispatch={dispatch} />
|
<Gallery images={embed.media.images} dispatch={dispatch} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1819,7 +1884,11 @@ function ComposerPills({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const media = post.embed.media
|
const media = post.embed.media
|
||||||
const hasMedia = media?.type === 'images' || media?.type === 'video'
|
const hasMedia =
|
||||||
|
media?.type === 'images' ||
|
||||||
|
media?.type === 'gallery' ||
|
||||||
|
media?.type === 'gif' ||
|
||||||
|
media?.type === 'video'
|
||||||
const hasLink = !!post.embed.link
|
const hasLink = !!post.embed.link
|
||||||
|
|
||||||
// Don't render anything if no pills are going to be displayed
|
// Don't render anything if no pills are going to be displayed
|
||||||
@@ -1908,15 +1977,16 @@ function ComposerFooter({
|
|||||||
>(undefined)
|
>(undefined)
|
||||||
|
|
||||||
const media = post.embed.media
|
const media = post.embed.media
|
||||||
const images = media?.type === 'images' ? media.images : []
|
const images =
|
||||||
|
media?.type === 'images' || media?.type === 'gallery' ? media.images : []
|
||||||
const video = media?.type === 'video' ? media.video : null
|
const video = media?.type === 'video' ? media.video : null
|
||||||
const isMaxImages = images.length >= MAX_IMAGES
|
const isMaxImages = images.length >= MAX_GALLERY_IMAGES
|
||||||
const isMaxVideos = !!video
|
const isMaxVideos = !!video
|
||||||
|
|
||||||
let selectedAssetsCount = 0
|
let selectedAssetsCount = 0
|
||||||
let isMediaSelectionDisabled = false
|
let isMediaSelectionDisabled = false
|
||||||
|
|
||||||
if (media?.type === 'images') {
|
if (media?.type === 'images' || media?.type === 'gallery') {
|
||||||
isMediaSelectionDisabled = isMaxImages
|
isMediaSelectionDisabled = isMaxImages
|
||||||
selectedAssetsCount = images.length
|
selectedAssetsCount = images.length
|
||||||
} else if (media?.type === 'video') {
|
} else if (media?.type === 'video') {
|
||||||
@@ -1926,15 +1996,7 @@ function ComposerFooter({
|
|||||||
isMediaSelectionDisabled = !!media
|
isMediaSelectionDisabled = !!media
|
||||||
}
|
}
|
||||||
|
|
||||||
const onImageAdd = useCallback(
|
const onImageAdd = useAddImagesWithCap(images.length, dispatch)
|
||||||
(next: ComposerImage[]) => {
|
|
||||||
dispatch({
|
|
||||||
type: 'embed_add_images',
|
|
||||||
images: next,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[dispatch],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onSelectGif = useCallback(
|
const onSelectGif = useCallback(
|
||||||
(gif: Gif) => {
|
(gif: Gif) => {
|
||||||
@@ -2017,7 +2079,11 @@ function ComposerFooter({
|
|||||||
autoOpen={openGallery}
|
autoOpen={openGallery}
|
||||||
/>
|
/>
|
||||||
<OpenCameraBtn
|
<OpenCameraBtn
|
||||||
disabled={media?.type === 'images' ? isMaxImages : !!media}
|
disabled={
|
||||||
|
media?.type === 'images' || media?.type === 'gallery'
|
||||||
|
? isMaxImages
|
||||||
|
: !!media
|
||||||
|
}
|
||||||
onAdd={onImageAdd}
|
onAdd={onImageAdd}
|
||||||
/>
|
/>
|
||||||
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
|
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react'
|
|||||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {
|
import {
|
||||||
|
AppBskyEmbedGallery,
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
@@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
|||||||
const images = useMemo(() => {
|
const images = useMemo(() => {
|
||||||
if (AppBskyEmbedImages.isView(embed)) {
|
if (AppBskyEmbedImages.isView(embed)) {
|
||||||
return embed.images
|
return embed.images
|
||||||
} else if (
|
} else if (AppBskyEmbedGallery.isView(embed)) {
|
||||||
AppBskyEmbedRecordWithMedia.isView(embed) &&
|
return galleryItemsToImages(embed.items)
|
||||||
AppBskyEmbedImages.isView(embed.media)
|
} else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||||
) {
|
if (AppBskyEmbedImages.isView(embed.media)) {
|
||||||
return embed.media.images
|
return embed.media.images
|
||||||
|
} else if (AppBskyEmbedGallery.isView(embed.media)) {
|
||||||
|
return galleryItemsToImages(embed.media.items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [embed])
|
}, [embed])
|
||||||
|
|
||||||
@@ -129,6 +133,22 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function galleryItemsToImages(
|
||||||
|
items: AppBskyEmbedGallery.View['items'],
|
||||||
|
): AppBskyEmbedImages.ViewImage[] {
|
||||||
|
// The reply-to thumbnail only renders up to 4 tiles; slicing here keeps
|
||||||
|
// the existing layout switch valid for galleries up to 10 items.
|
||||||
|
return items
|
||||||
|
.filter(AppBskyEmbedGallery.isViewImage)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(item => ({
|
||||||
|
thumb: item.thumbnail,
|
||||||
|
fullsize: item.fullsize,
|
||||||
|
alt: item.alt,
|
||||||
|
aspectRatio: item.aspectRatio,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function ComposerReplyToImages({
|
function ComposerReplyToImages({
|
||||||
images,
|
images,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
import {openUnifiedPicker} from '#/lib/media/picker'
|
import {openUnifiedPicker} from '#/lib/media/picker'
|
||||||
import {extractDataUriMime} from '#/lib/media/util'
|
import {extractDataUriMime} from '#/lib/media/util'
|
||||||
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
|
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
@@ -393,7 +393,9 @@ export function SelectMediaButton({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const hasAutoOpened = useRef(false)
|
const hasAutoOpened = useRef(false)
|
||||||
|
|
||||||
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
|
// Picker uses the gallery cap; the reducer decides which embed variant
|
||||||
|
// to land in based on the final image count.
|
||||||
|
const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount
|
||||||
|
|
||||||
const processSelectedAssets = useCallback(
|
const processSelectedAssets = useCallback(
|
||||||
async (rawAssets: ImagePickerAsset[]) => {
|
async (rawAssets: ImagePickerAsset[]) => {
|
||||||
@@ -419,10 +421,10 @@ export function SelectMediaButton({
|
|||||||
),
|
),
|
||||||
[SelectedAssetError.MaxImages]: _(
|
[SelectedAssetError.MaxImages]: _(
|
||||||
msg({
|
msg({
|
||||||
message: `You can select up to ${plural(MAX_IMAGES, {
|
message: `You can select up to ${plural(MAX_GALLERY_IMAGES, {
|
||||||
other: '# images',
|
other: '# images',
|
||||||
})} in total.`,
|
})} in total.`,
|
||||||
comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`,
|
comment: `Error message for maximum number of images that can be selected to add to a post.`,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
[SelectedAssetError.MaxVideos]: _(
|
[SelectedAssetError.MaxVideos]: _(
|
||||||
@@ -507,10 +509,11 @@ export function SelectMediaButton({
|
|||||||
)}
|
)}
|
||||||
accessibilityHint={_(
|
accessibilityHint={_(
|
||||||
msg({
|
msg({
|
||||||
message: `Opens device gallery to select up to ${plural(MAX_IMAGES, {
|
message: `Opens device gallery to select up to ${plural(
|
||||||
other: '# images',
|
MAX_GALLERY_IMAGES,
|
||||||
})}, or a single video or GIF.`,
|
{other: '# images'},
|
||||||
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
|
)}, or a single video or GIF.`,
|
||||||
|
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post.`,
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
style={a.p_sm}
|
style={a.p_sm}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Type converters for Draft API - convert between ComposerState and server Draft types.
|
* Type converters for Draft API - convert between ComposerState and server Draft types.
|
||||||
*/
|
*/
|
||||||
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {resolveLink} from '#/lib/api/resolve'
|
import {resolveLink} from '#/lib/api/resolve'
|
||||||
@@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent'
|
|||||||
import {
|
import {
|
||||||
type ComposerState,
|
type ComposerState,
|
||||||
type EmbedDraft,
|
type EmbedDraft,
|
||||||
|
LEGACY_IMAGES_EMBED_MAX,
|
||||||
type PostDraft,
|
type PostDraft,
|
||||||
} from '#/view/com/composer/state/composer'
|
} from '#/view/com/composer/state/composer'
|
||||||
import {type VideoState} from '#/view/com/composer/state/video'
|
import {type VideoState} from '#/view/com/composer/state/video'
|
||||||
@@ -115,6 +116,16 @@ async function postDraftToServerPost(
|
|||||||
post.embed.media.images,
|
post.embed.media.images,
|
||||||
localRefPaths,
|
localRefPaths,
|
||||||
)
|
)
|
||||||
|
} else if (post.embed.media.type === 'gallery') {
|
||||||
|
draftPost.embedGallery = {
|
||||||
|
$type: 'app.bsky.draft.defs#draftEmbedGallery',
|
||||||
|
items: serializeImages(post.embed.media.images, localRefPaths).map(
|
||||||
|
img => ({
|
||||||
|
$type: 'app.bsky.draft.defs#draftEmbedImage' as const,
|
||||||
|
...img,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
} else if (post.embed.media.type === 'video') {
|
} else if (post.embed.media.type === 'video') {
|
||||||
const video = await serializeVideo(post.embed.media.video, localRefPaths)
|
const video = await serializeVideo(post.embed.media.video, localRefPaths)
|
||||||
if (video) {
|
if (video) {
|
||||||
@@ -269,6 +280,59 @@ function serializeGif(gifMedia: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore an array of draft image refs back to ComposerImages. Shared by
|
||||||
|
* both the `embedImages` and `embedGallery` paths in draftToComposerPosts.
|
||||||
|
*/
|
||||||
|
async function restoreDraftImages(
|
||||||
|
draftImages: AppBskyDraftDefs.DraftEmbedImage[],
|
||||||
|
loadedMedia: Map<string, string>,
|
||||||
|
): Promise<ComposerImage[]> {
|
||||||
|
const imagePromises = draftImages.map(async img => {
|
||||||
|
const path = loadedMedia.get(img.localRef.path)
|
||||||
|
if (!path) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = 0
|
||||||
|
let height = 0
|
||||||
|
try {
|
||||||
|
const dims = await getImageDim(path)
|
||||||
|
width = dims.width
|
||||||
|
height = dims.height
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to get image dimensions', {
|
||||||
|
path,
|
||||||
|
error: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('restoring image with localRefPath', {
|
||||||
|
localRefPath: img.localRef.path,
|
||||||
|
loadedPath: path,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
alt: img.alt || '',
|
||||||
|
// Preserve the original localRefPath for reuse when saving
|
||||||
|
localRefPath: img.localRef.path,
|
||||||
|
source: {
|
||||||
|
id: nanoid(),
|
||||||
|
path,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
},
|
||||||
|
} satisfies ComposerImage
|
||||||
|
})
|
||||||
|
|
||||||
|
return (await Promise.all(imagePromises)).filter(
|
||||||
|
(img): img is NonNullable<typeof img> => img !== null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert server DraftView to DraftSummary for list display.
|
* Convert server DraftView to DraftSummary for list display.
|
||||||
* Also checks which media files exist locally.
|
* Also checks which media files exist locally.
|
||||||
@@ -314,6 +378,24 @@ export function draftViewToSummary({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process gallery
|
||||||
|
if (post.embedGallery) {
|
||||||
|
for (const item of post.embedGallery.items) {
|
||||||
|
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||||
|
meta.mediaCount++
|
||||||
|
meta.hasMedia = true
|
||||||
|
const exists = storage.mediaExists(item.localRef.path)
|
||||||
|
if (!exists) {
|
||||||
|
meta.hasMissingMedia = true
|
||||||
|
}
|
||||||
|
images.push({
|
||||||
|
localPath: item.localRef.path,
|
||||||
|
altText: item.alt || '',
|
||||||
|
exists,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Process videos
|
// Process videos
|
||||||
if (post.embedVideos) {
|
if (post.embedVideos) {
|
||||||
for (const vid of post.embedVideos) {
|
for (const vid of post.embedVideos) {
|
||||||
@@ -431,54 +513,31 @@ export async function draftToComposerPosts(
|
|||||||
media: undefined,
|
media: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore images
|
// Restore images / gallery. Pick the variant from the restored count so
|
||||||
|
// we match the composer reducer's `imagesToMediaVariant` rule (<=4 stays
|
||||||
|
// legacy `images`, >4 promotes to `gallery`). This keeps restore robust
|
||||||
|
// to drafts whose server slot disagrees with their count - e.g. a draft
|
||||||
|
// saved in `embedImages` with 5 items would otherwise restore as a
|
||||||
|
// broken `images` variant the rest of the composer can't grow.
|
||||||
|
const restoredImages: ComposerImage[] = []
|
||||||
if (post.embedImages && post.embedImages.length > 0) {
|
if (post.embedImages && post.embedImages.length > 0) {
|
||||||
const imagePromises = post.embedImages.map(async img => {
|
restoredImages.push(
|
||||||
const path = loadedMedia.get(img.localRef.path)
|
...(await restoreDraftImages(post.embedImages, loadedMedia)),
|
||||||
if (!path) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let width = 0
|
|
||||||
let height = 0
|
|
||||||
try {
|
|
||||||
const dims = await getImageDim(path)
|
|
||||||
width = dims.width
|
|
||||||
height = dims.height
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to get image dimensions', {
|
|
||||||
path,
|
|
||||||
error: e,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('restoring image with localRefPath', {
|
|
||||||
localRefPath: img.localRef.path,
|
|
||||||
loadedPath: path,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
alt: img.alt || '',
|
|
||||||
// Preserve the original localRefPath for reuse when saving
|
|
||||||
localRefPath: img.localRef.path,
|
|
||||||
source: {
|
|
||||||
id: nanoid(),
|
|
||||||
path,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
},
|
|
||||||
} satisfies ComposerImage
|
|
||||||
})
|
|
||||||
|
|
||||||
const images = (await Promise.all(imagePromises)).filter(
|
|
||||||
(img): img is NonNullable<typeof img> => img !== null,
|
|
||||||
)
|
)
|
||||||
if (images.length > 0) {
|
|
||||||
embed.media = {type: 'images', images}
|
|
||||||
}
|
}
|
||||||
|
if (post.embedGallery && post.embedGallery.items.length > 0) {
|
||||||
|
const galleryImages = post.embedGallery.items.filter(
|
||||||
|
AppBskyDraftDefs.isDraftEmbedImage,
|
||||||
|
)
|
||||||
|
restoredImages.push(
|
||||||
|
...(await restoreDraftImages(galleryImages, loadedMedia)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (restoredImages.length > 0) {
|
||||||
|
embed.media =
|
||||||
|
restoredImages.length <= LEGACY_IMAGES_EMBED_MAX
|
||||||
|
? {type: 'images', images: restoredImages}
|
||||||
|
: {type: 'gallery', images: restoredImages}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore GIF from external embed
|
// Restore GIF from external embed
|
||||||
@@ -630,6 +689,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
|
|||||||
refs.add(img.localRef.path)
|
refs.add(img.localRef.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (post.embedGallery) {
|
||||||
|
for (const item of post.embedGallery.items) {
|
||||||
|
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||||
|
refs.add(item.localRef.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (post.embedVideos) {
|
if (post.embedVideos) {
|
||||||
for (const vid of post.embedVideos) {
|
for (const vid of post.embedVideos) {
|
||||||
refs.add(vid.localRef.path)
|
refs.add(vid.localRef.path)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
|
import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -74,6 +74,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Load gallery
|
||||||
|
if (post.embedGallery) {
|
||||||
|
for (const item of post.embedGallery.items) {
|
||||||
|
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||||
|
try {
|
||||||
|
const url = await storage.loadMediaFromLocal(item.localRef.path)
|
||||||
|
loadedMedia.set(item.localRef.path, url)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to load draft gallery image', {
|
||||||
|
path: item.localRef.path,
|
||||||
|
safeMessage: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Load videos
|
// Load videos
|
||||||
if (post.embedVideos) {
|
if (post.embedVideos) {
|
||||||
for (const vid of post.embedVideos) {
|
for (const vid of post.embedVideos) {
|
||||||
@@ -226,6 +241,12 @@ export function useDeleteDraftMutation() {
|
|||||||
await storage.deleteMediaFromLocal(img.localRef.path)
|
await storage.deleteMediaFromLocal(img.localRef.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (post.embedGallery) {
|
||||||
|
for (const item of post.embedGallery.items) {
|
||||||
|
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||||
|
await storage.deleteMediaFromLocal(item.localRef.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (post.embedVideos) {
|
if (post.embedVideos) {
|
||||||
for (const vid of post.embedVideos) {
|
for (const vid of post.embedVideos) {
|
||||||
await storage.deleteMediaFromLocal(vid.localRef.path)
|
await storage.deleteMediaFromLocal(vid.localRef.path)
|
||||||
|
|||||||
@@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
|||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
||||||
|
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
|
||||||
|
// n > 4 wraps to multiple rows via flexWrap on the gallery container.
|
||||||
|
const columns = Math.min(images.length, 4)
|
||||||
const side =
|
const side =
|
||||||
images.length === 1
|
images.length === 1
|
||||||
? 250
|
? 250
|
||||||
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
|
: (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns
|
||||||
images.length
|
|
||||||
|
|
||||||
const isOverflow = isMobile && images.length > 2
|
const isOverflow = isMobile && images.length > 2
|
||||||
|
|
||||||
@@ -273,6 +275,7 @@ const styles = StyleSheet.create({
|
|||||||
gallery: {
|
gallery: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
gap: IMAGE_GAP,
|
gap: IMAGE_GAP,
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
postUriToRelativePath,
|
postUriToRelativePath,
|
||||||
toBskyAppUrl,
|
toBskyAppUrl,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
|
import {logger} from '#/logger'
|
||||||
import {type ComposerImage, createInitialImages} from '#/state/gallery'
|
import {type ComposerImage, createInitialImages} from '#/state/gallery'
|
||||||
import {createPostgateRecord} from '#/state/queries/postgate/util'
|
import {createPostgateRecord} from '#/state/queries/postgate/util'
|
||||||
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
||||||
@@ -38,6 +39,11 @@ type ImagesMedia = {
|
|||||||
images: ComposerImage[]
|
images: ComposerImage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GalleryMedia = {
|
||||||
|
type: 'gallery'
|
||||||
|
images: ComposerImage[]
|
||||||
|
}
|
||||||
|
|
||||||
type VideoMedia = {
|
type VideoMedia = {
|
||||||
type: 'video'
|
type: 'video'
|
||||||
video: VideoState
|
video: VideoState
|
||||||
@@ -59,7 +65,7 @@ type Link = {
|
|||||||
export type EmbedDraft = {
|
export type EmbedDraft = {
|
||||||
// We'll always submit quote and actual media (images, video, gifs) chosen by the user.
|
// We'll always submit quote and actual media (images, video, gifs) chosen by the user.
|
||||||
quote: Link | undefined
|
quote: Link | undefined
|
||||||
media: ImagesMedia | VideoMedia | GifMedia | undefined
|
media: ImagesMedia | GalleryMedia | VideoMedia | GifMedia | undefined
|
||||||
// This field may end up ignored if we have more important things to display than a link card:
|
// This field may end up ignored if we have more important things to display than a link card:
|
||||||
link: Link | undefined
|
link: Link | undefined
|
||||||
}
|
}
|
||||||
@@ -154,7 +160,31 @@ export type ComposerAction =
|
|||||||
draftId: string
|
draftId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_IMAGES = 4
|
/**
|
||||||
|
* Threshold for picking between embed variants. <= this count uses the
|
||||||
|
* legacy `app.bsky.embed.images` shape; > this count promotes to
|
||||||
|
* `app.bsky.embed.gallery`. Named to flag that if/when we deprecate the
|
||||||
|
* legacy images embed entirely, this constant (and the variant split it
|
||||||
|
* gates) should go away.
|
||||||
|
*/
|
||||||
|
export const LEGACY_IMAGES_EMBED_MAX = 4
|
||||||
|
export const MAX_GALLERY_IMAGES = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the embed variant for a set of images. <=4 lands in the legacy
|
||||||
|
* `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`.
|
||||||
|
* Anything beyond the gallery cap is dropped by the hard slice; callers
|
||||||
|
* should already have enforced the cap upstream (picker, paste, etc),
|
||||||
|
* and the reducer logs a warning when the cap is exceeded so the UI
|
||||||
|
* layer can surface a toast.
|
||||||
|
*/
|
||||||
|
function imagesToMediaVariant(
|
||||||
|
images: ComposerImage[],
|
||||||
|
): ImagesMedia | GalleryMedia {
|
||||||
|
return images.length <= LEGACY_IMAGES_EMBED_MAX
|
||||||
|
? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)}
|
||||||
|
: {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)}
|
||||||
|
}
|
||||||
|
|
||||||
export function composerReducer(
|
export function composerReducer(
|
||||||
state: ComposerState,
|
state: ComposerState,
|
||||||
@@ -337,16 +367,28 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
}
|
}
|
||||||
const prevMedia = state.embed.media
|
const prevMedia = state.embed.media
|
||||||
let nextMedia = prevMedia
|
let nextMedia = prevMedia
|
||||||
|
const prevCount =
|
||||||
|
prevMedia?.type === 'images' || prevMedia?.type === 'gallery'
|
||||||
|
? prevMedia.images.length
|
||||||
|
: 0
|
||||||
|
const incomingCount = prevCount + action.images.length
|
||||||
|
if (incomingCount > MAX_GALLERY_IMAGES) {
|
||||||
|
// Defense in depth: callers (applyGalleryCap in Composer) should have
|
||||||
|
// already trimmed and surfaced a toast. The hard slice in
|
||||||
|
// imagesToMediaVariant still drops the excess so the cap holds.
|
||||||
|
logger.warn('composer: image add exceeds MAX_GALLERY_IMAGES', {
|
||||||
|
prevCount,
|
||||||
|
incomingCount,
|
||||||
|
dropped: incomingCount - MAX_GALLERY_IMAGES,
|
||||||
|
})
|
||||||
|
}
|
||||||
if (!prevMedia) {
|
if (!prevMedia) {
|
||||||
nextMedia = {
|
nextMedia = imagesToMediaVariant(action.images)
|
||||||
type: 'images',
|
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
|
||||||
images: action.images.slice(0, MAX_IMAGES),
|
nextMedia = imagesToMediaVariant([
|
||||||
}
|
...prevMedia.images,
|
||||||
} else if (prevMedia.type === 'images') {
|
...action.images,
|
||||||
nextMedia = {
|
])
|
||||||
...prevMedia,
|
|
||||||
images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -358,7 +400,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
}
|
}
|
||||||
case 'embed_update_image': {
|
case 'embed_update_image': {
|
||||||
const prevMedia = state.embed.media
|
const prevMedia = state.embed.media
|
||||||
if (prevMedia?.type === 'images') {
|
if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
|
||||||
const updatedImage = action.image
|
const updatedImage = action.image
|
||||||
const nextMedia = {
|
const nextMedia = {
|
||||||
...prevMedia,
|
...prevMedia,
|
||||||
@@ -382,19 +424,22 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
case 'embed_remove_image': {
|
case 'embed_remove_image': {
|
||||||
const prevMedia = state.embed.media
|
const prevMedia = state.embed.media
|
||||||
let nextLabels = state.labels
|
let nextLabels = state.labels
|
||||||
if (prevMedia?.type === 'images') {
|
if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
|
||||||
const removedImage = action.image
|
const removedImage = action.image
|
||||||
let nextMedia: ImagesMedia | undefined = {
|
const remainingImages = prevMedia.images.filter(img => {
|
||||||
...prevMedia,
|
|
||||||
images: prevMedia.images.filter(img => {
|
|
||||||
return img.source.id !== removedImage.source.id
|
return img.source.id !== removedImage.source.id
|
||||||
}),
|
})
|
||||||
}
|
let nextMedia: ImagesMedia | GalleryMedia | undefined
|
||||||
if (nextMedia.images.length === 0) {
|
if (remainingImages.length === 0) {
|
||||||
nextMedia = undefined
|
nextMedia = undefined
|
||||||
if (!state.embed.link) {
|
if (!state.embed.link) {
|
||||||
nextLabels = []
|
nextLabels = []
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Re-pick the variant so a gallery that shrinks to <=4 demotes
|
||||||
|
// back to the legacy `app.bsky.embed.images` shape - keeps old
|
||||||
|
// clients rendering it when possible.
|
||||||
|
nextMedia = imagesToMediaVariant(remainingImages)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -581,12 +626,9 @@ export function createComposerState({
|
|||||||
| AppBskyActorDefs.PostInteractionSettingsPref
|
| AppBskyActorDefs.PostInteractionSettingsPref
|
||||||
| undefined
|
| undefined
|
||||||
}): ComposerState {
|
}): ComposerState {
|
||||||
let media: ImagesMedia | undefined
|
let media: ImagesMedia | GalleryMedia | undefined
|
||||||
if (initImageUris?.length) {
|
if (initImageUris?.length) {
|
||||||
media = {
|
media = imagesToMediaVariant(createInitialImages(initImageUris))
|
||||||
type: 'images',
|
|
||||||
images: createInitialImages(initImageUris),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let quote: Link | undefined
|
let quote: Link | undefined
|
||||||
if (initQuoteUri) {
|
if (initQuoteUri) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
|
import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
|
||||||
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
|
||||||
import {MAX_IMAGES} from '#/view/com/composer/state/composer'
|
import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
@@ -64,7 +64,7 @@ export function ComposerPrompt() {
|
|||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionCountRemaining = MAX_IMAGES
|
const selectionCountRemaining = MAX_GALLERY_IMAGES
|
||||||
const {assets, canceled} = await sheetWrapper(
|
const {assets, canceled} = await sheetWrapper(
|
||||||
openUnifiedPicker({selectionCountRemaining}),
|
openUnifiedPicker({selectionCountRemaining}),
|
||||||
)
|
)
|
||||||
@@ -76,7 +76,7 @@ export function ComposerPrompt() {
|
|||||||
if (assets.length > 0) {
|
if (assets.length > 0) {
|
||||||
const imageUris = assets
|
const imageUris = assets
|
||||||
.filter(asset => asset.mimeType?.startsWith('image/'))
|
.filter(asset => asset.mimeType?.startsWith('image/'))
|
||||||
.slice(0, MAX_IMAGES)
|
.slice(0, MAX_GALLERY_IMAGES)
|
||||||
.map(asset => ({
|
.map(asset => ({
|
||||||
uri: asset.uri,
|
uri: asset.uri,
|
||||||
width: asset.width,
|
width: asset.width,
|
||||||
|
|||||||
Reference in New Issue
Block a user