[APP-2067] Rebuild GIF Dialog (#10261)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user