[APP-2067] Rebuild GIF Dialog (#10261)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spence Pope
2026-05-05 11:06:18 -04:00
committed by GitHub
parent 3a997031e0
commit 48ea9e0b96
27 changed files with 921 additions and 616 deletions
@@ -0,0 +1,144 @@
import {View} from 'react-native'
import {type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji'
import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka'
import {Trending3_Stroke2_Corner1_Rounded as Trending} from '#/components/icons/Trending'
export type GifCategory = {
id: string
icon: React.ComponentType<SVGIconProps>
label: MessageDescriptor
searchterm: string | null // null = trending/recents (handled by consumer)
}
/*
* Category pill labels are icon-only buttons in the UI; the `label` field is
* what screen readers announce. Each is phrased "[topic] GIFs" so the
* announcement makes sense in isolation rather than just "Love" or "Happy".
*/
export const GIF_CATEGORIES: readonly GifCategory[] = [
{
id: 'recents',
icon: Clock,
label: msg({
message: 'Recent GIFs',
comment:
'Accessibility label for the icon-only pill that shows previously selected GIFs in the GIF picker.',
}),
searchterm: null,
},
{
id: 'trending',
icon: Trending,
label: msg({
message: 'Trending GIFs',
comment:
'Accessibility label for the icon-only pill that shows currently trending/featured GIFs in the GIF picker.',
}),
searchterm: null,
},
{
id: 'love',
icon: Heart,
label: msg({
message: 'Love GIFs',
comment:
'Accessibility label for the icon-only pill that filters the GIF picker to GIFs about love/affection.',
}),
searchterm: 'love',
},
{
id: 'happy',
icon: EmojiSmile,
label: msg({
message: 'Happy GIFs',
comment:
'Accessibility label for the icon-only pill that filters the GIF picker to happy/joyful GIFs.',
}),
searchterm: 'happy',
},
{
id: 'sad',
icon: EmojiSad,
label: msg({
message: 'Sad GIFs',
comment:
'Accessibility label for the icon-only pill that filters the GIF picker to sad/crying GIFs.',
}),
searchterm: 'cry',
},
{
id: 'party',
icon: Celebrate,
label: msg({
message: 'Party GIFs',
comment:
'Accessibility label for the icon-only pill that filters the GIF picker to celebration/party GIFs.',
}),
searchterm: 'congratulations',
},
{
id: 'yes',
icon: Shaka,
label: msg({
message: 'Yes GIFs',
comment:
'Accessibility label for the icon-only pill that filters the GIF picker to affirmation/agreement GIFs.',
}),
searchterm: 'yes',
},
] as const
export function GifCategoryPills({
activeId,
onSelect,
hasRecents,
}: {
activeId: string
onSelect: (category: GifCategory) => void
hasRecents: boolean
}) {
const {i18n} = useLingui()
const t = useTheme()
return (
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_xs,
a.pb_md,
t.atoms.bg,
]}>
{GIF_CATEGORIES.map(category => {
if (category.id === 'recents' && !hasRecents) return null
const isActive = category.id === activeId
return (
<Button
key={category.id}
label={i18n._(category.label)}
aria-current={isActive ? 'true' : undefined}
onPress={() => onSelect(category)}
size="small"
color={isActive ? 'secondary_inverted' : 'secondary'}
shape="round">
<ButtonIcon icon={category.icon} size="md" />
</Button>
)
})}
</View>
)
}
@@ -0,0 +1,52 @@
import {Trans, useLingui} from '@lingui/react/macro'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
export function GifPickerErrorBoundary({details}: {details?: string}) {
const {t: l} = useLingui()
const control = Dialog.useDialogContext()
return (
<Dialog.ScrollableInner
style={a.gap_md}
label={l({
message: 'An error has occurred',
comment:
'Accessibility label for the dialog shown when the GIF picker hits an unexpected runtime error and falls back to its error boundary.',
})}>
<Dialog.Close />
<ErrorScreen
title={l({
message: 'Oh no!',
comment:
'Title of the error screen shown when the GIF picker crashes unexpectedly.',
})}
message={l({
message:
'There was an unexpected issue in the application. Please let us know if this happened to you!',
comment:
'Body of the error screen shown when the GIF picker crashes unexpectedly. Encourages the user to report the issue.',
})}
details={details}
/>
<Button
label={l({
message: 'Close dialog',
comment:
'Accessibility label for the button that dismisses the GIF picker error dialog.',
})}
onPress={() => control.close()}
color="primary"
size="large">
<ButtonText>
<Trans comment="Visible label of the button that dismisses the GIF picker error dialog.">
Close
</Trans>
</ButtonText>
</Button>
</Dialog.ScrollableInner>
)
}
@@ -0,0 +1,122 @@
import {forwardRef} from 'react'
import {Platform, useWindowDimensions, View} from 'react-native'
import {cleanError} from '#/lib/strings/errors'
import {type ListMethods} from '#/view/com/util/List'
import {atoms as a, native, useBreakpoints, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {ListFooter} from '#/components/Lists'
import {GifPickerItem} from '#/features/gifPicker/components/GifPickerItem'
import {type Gif} from '#/features/gifPicker/types'
type Props = {
items: Gif[]
header: React.ReactNode
hasData: boolean
isFetchingNextPage: boolean
error: unknown
fetchNextPage: () => Promise<unknown>
onEndReached: () => void
onSelectGif: (gif: Gif) => void
}
export const GifPickerGrid = forwardRef<ListMethods, Props>(
function GifPickerGrid(
{
items,
header,
hasData,
isFetchingNextPage,
error,
fetchNextPage,
onEndReached,
onSelectGif,
},
ref,
) {
const {gtMobile} = useBreakpoints()
const {height} = useWindowDimensions()
const numColumns = gtMobile ? 3 : 2
const columns = distributeIntoColumns(items, numColumns)
/**
* The grid is a single FlatList row because the tiles are distributed
* into columns up front for masonry. `onEndReached` still fires against
* the outer FlatList's scroll position, so pagination behaves the same
* as a conventional grid.
*/
const data = hasData ? [columns] : []
return (
<Dialog.InnerFlatList
ref={ref}
key={String(numColumns)}
data={data}
renderItem={({item}: {item: Gif[][]}) => (
<View style={[a.flex_row, a.gap_sm]}>
{item.map((column, i) => (
<View key={i} style={[a.flex_1, a.gap_sm, {minWidth: 0}]}>
{column.map(gif => (
<GifPickerItem
key={gif.id}
gif={gif}
onSelectGif={onSelectGif}
/>
))}
</View>
))}
</View>
)}
keyExtractor={(_item, index) => `masonry-${index}`}
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
webInnerStyle={[web({minHeight: '80vh'})]}
webInnerContentContainerStyle={[web(a.pb_0)]}
ListHeaderComponent={<>{header}</>}
stickyHeaderIndices={[0]}
onEndReached={onEndReached}
onEndReachedThreshold={1}
// On web, "on-drag" blurs the focused input on ANY scroll event,
// including programmatic scrolls (e.g., content shrinking when search
// results swap in). That breaks search-while-scrolled — the blur fires
// mid-typing and subsequent keystrokes go nowhere.
keyboardDismissMode={Platform.OS === 'web' ? 'none' : 'on-drag'}
ListFooterComponent={
hasData ? (
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderTopWidth: 0}}
/>
) : null
}
/>
)
},
)
/**
* Walks `items` in order and pushes each one into the currently shortest
* column, tracking accumulated height-per-unit-width from each GIF's
* intrinsic aspect ratio. Preserves ordering top-to-bottom within each
* column, which keeps pagination behavior intuitive as new pages stream in.
*/
function distributeIntoColumns(items: Gif[], numColumns: number): Gif[][] {
const columns: Gif[][] = Array.from({length: numColumns}, () => [])
const heights = new Array(numColumns).fill(0)
for (const item of items) {
const [w, h] = item.media_formats.tinygif.dims
const ratio = w > 0 && h > 0 ? h / w : 1
let shortest = 0
for (let i = 1; i < numColumns; i++) {
if (heights[i] < heights[shortest]) shortest = i
}
columns[shortest].push(item)
heights[shortest] += ratio
}
return columns
}
@@ -0,0 +1,78 @@
import {type Ref} from 'react'
import {type TextInput, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
export function GifPickerHeader({
inputRef,
onChangeText,
onClear,
onEscape,
canClear,
}: {
inputRef: Ref<TextInput>
onChangeText: (text: string) => void
onClear: () => void
onEscape: () => void
canClear: boolean
}) {
const {t: l} = useLingui()
const t = useTheme()
return (
<View
style={[
native(a.pt_4xl),
a.relative,
a.pb_md,
a.flex_row,
a.align_center,
t.atoms.bg,
]}>
<TextField.Root style={a.flex_1}>
<TextField.Icon icon={Search} />
<TextField.Input
label={l({
message: 'Search GIFs',
comment:
'Accessibility label for the GIF search input inside the GIF picker dialog.',
})}
placeholder={l({
message: 'Search KLIPY',
comment:
'Placeholder text inside the GIF search input. KLIPY is the third-party GIF provider; keep the brand name as-is.',
})}
onChangeText={onChangeText}
returnKeyType="search"
inputRef={inputRef}
maxLength={50}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
onEscape()
}
}}
/>
{canClear && (
<Button
size="tiny"
color="secondary"
shape="round"
style={a.z_30}
onPress={onClear}
label={l({
message: 'Clear GIF search',
comment:
'Accessibility label for the X button inside the search input that clears the typed query and returns to the trending feed.',
})}>
<ButtonIcon icon={X} size="sm" />
</Button>
)}
</TextField.Root>
</View>
)
}
@@ -0,0 +1,60 @@
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useAnalytics} from '#/analytics'
import {type Gif} from '#/features/gifPicker/types'
import {gifPreviewUrl} from '#/features/gifPicker/utils'
export function GifPickerItem({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const ax = useAnalytics()
const {t: l} = useLingui()
const t = useTheme()
const [width, height] = gif.media_formats.tinygif.dims
const aspectRatio = width > 0 && height > 0 ? width / height : 1
const onPress = () => {
ax.metric('composer:gif:select', {})
onSelectGif(gif)
}
return (
<Button
label={l({
message: `Select GIF "${gif.title}"`,
comment:
'Accessibility label for an individual GIF tile in the picker grid. The placeholder is the GIF’s title from the provider.',
})}
onPress={onPress}
style={a.w_full}>
{({pressed}) => (
<Image
style={[
a.w_full,
a.rounded_sm,
t.atoms.bg_contrast_25,
{
aspectRatio,
opacity: pressed ? 0.85 : 1,
transform: [{scale: pressed ? 0.97 : 1}],
},
]}
source={{uri: gifPreviewUrl(gif.media_formats.tinygif.url)}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
@@ -0,0 +1,65 @@
import {useLingui} from '@lingui/react/macro'
import {ListMaybePlaceholder} from '#/components/Lists'
export function GifPickerPlaceholder({
isLoading,
isError,
isSearching,
isRecentsEmpty,
query,
onRetry,
onGoBack,
}: {
isLoading: boolean
isError: boolean
isSearching: boolean
isRecentsEmpty: boolean
query: string
onRetry: () => Promise<unknown>
onGoBack: () => void
}) {
const {t: l} = useLingui()
const emptyMessage = isSearching
? l({
message: `No GIFs found for "${query}".`,
comment:
'Empty-state message shown in the GIF picker when a search returns zero results. Placeholder is the user’s search query.',
})
: isRecentsEmpty
? l({
message: 'No recent GIFs yet. Pick one to see it here.',
comment:
'Empty-state message shown in the GIF picker’s Recents tab before the user has selected any GIFs.',
})
: l({
message: 'No GIFs to show right now. Try again in a moment.',
comment:
'Empty-state message shown when the trending/featured GIF feed returns no results (rare, usually a transient provider issue).',
})
return (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={isError ? onRetry : undefined}
onGoBack={onGoBack}
emptyType="results"
sideBorders={false}
topBorder={false}
errorTitle={l({
message: 'Couldn’t load GIFs',
comment:
'Title of the error screen shown when the GIF provider request fails.',
})}
errorMessage={l({
message:
'There was a problem loading GIFs. Check your connection and try again.',
comment:
'Body message of the error screen shown when the GIF provider request fails. Encourages the user to retry.',
})}
emptyMessage={emptyMessage}
/>
)
}