[APP-2067] Rebuild GIF Dialog (#10261)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,334 +0,0 @@
|
||||
import {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type TextInput, View} from 'react-native'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
|
||||
useGifSearchQuery as useKlipyGifSearchQuery,
|
||||
} from '#/state/queries/klipy'
|
||||
import {
|
||||
type Gif,
|
||||
gifPreviewUrl,
|
||||
useTenorFeaturedGifsQuery,
|
||||
useTenorGifSearchQuery,
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export function GifSelectDialog({
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
controlRef: React.RefObject<{open: () => void} | null>
|
||||
onClose?: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
useImperativeHandle(controlRef, () => ({
|
||||
open: () => control.open(),
|
||||
}))
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
control.close(() => onSelectGifProp(gif))
|
||||
},
|
||||
[control, onSelectGifProp],
|
||||
)
|
||||
|
||||
const renderErrorBoundary = useCallback(
|
||||
(error: any) => <DialogError details={String(error)} />,
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{
|
||||
bottomInset: 0,
|
||||
// use system corner radius on iOS
|
||||
...ios({cornerRadius: undefined}),
|
||||
fullHeight: true,
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<GifList control={control} onSelectGif={onSelectGif} />
|
||||
</ErrorBoundary>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function GifList({
|
||||
control,
|
||||
onSelectGif,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const [undeferredSearch, setSearch] = useState('')
|
||||
const search = useThrottledValue(undeferredSearch, 500)
|
||||
const {height} = useWindowDimensions()
|
||||
const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||
|
||||
const isSearching = search.length > 0
|
||||
|
||||
const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled})
|
||||
const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled})
|
||||
const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled})
|
||||
const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled})
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = klipyEnabled
|
||||
? isSearching
|
||||
? klipySearch
|
||||
: klipyTrending
|
||||
: isSearching
|
||||
? tenorSearch
|
||||
: tenorTrending
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.results) || []
|
||||
}, [data])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: Gif}) => {
|
||||
return <GifPreview gif={item} onSelectGif={onSelectGif} />
|
||||
},
|
||||
[onSelectGif],
|
||||
)
|
||||
|
||||
const onEndReached = useCallback(() => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
fetchNextPage()
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
const hasData = flattenedData.length > 0
|
||||
|
||||
const onGoBack = useCallback(() => {
|
||||
if (isSearching) {
|
||||
// clear the input and reset the state
|
||||
textInputRef.current?.clear()
|
||||
setSearch('')
|
||||
} else {
|
||||
control.close()
|
||||
}
|
||||
}, [control, isSearching])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
native(a.pt_4xl),
|
||||
a.relative,
|
||||
a.mb_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
!gtMobile && web(a.gap_md),
|
||||
a.pb_sm,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
{!gtMobile && IS_WEB && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => control.close()}
|
||||
label={l`Close GIF dialog`}>
|
||||
<ButtonIcon icon={Arrow} size="md" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={l`Search GIFs`}
|
||||
placeholder={klipyEnabled ? l`Search KLIPY` : l`Search Tenor`}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
inputRef={textInputRef}
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [gtMobile, t.atoms.bg, l, control, klipyEnabled])
|
||||
|
||||
return (
|
||||
<>
|
||||
{gtMobile && <Dialog.Close />}
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
key={gtMobile ? '3 cols' : '2 cols'}
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
numColumns={gtMobile ? 3 : 2}
|
||||
columnWrapperStyle={[a.gap_sm]}
|
||||
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
|
||||
webInnerStyle={[web({minHeight: '80vh'})]}
|
||||
webInnerContentContainerStyle={[web(a.pb_0)]}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{!hasData && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isPending}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
errorTitle={l`Failed to load GIFs`}
|
||||
errorMessage={
|
||||
klipyEnabled
|
||||
? l`There was an issue connecting to KLIPY.`
|
||||
: l`There was an issue connecting to Tenor.`
|
||||
}
|
||||
emptyMessage={
|
||||
isSearching
|
||||
? l`No search results found for "${search}".`
|
||||
: klipyEnabled
|
||||
? l`No featured GIFs found. There may be an issue with KLIPY.`
|
||||
: l`No featured GIFs found. There may be an issue with Tenor.`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
keyboardDismissMode="on-drag"
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderTopWidth: 0}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogError({details}: {details?: string}) {
|
||||
const {t: l} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}>
|
||||
<Dialog.Close />
|
||||
<ErrorScreen
|
||||
title={l`Oh no!`}
|
||||
message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`}
|
||||
details={details}
|
||||
/>
|
||||
<Button
|
||||
label={l`Close dialog`}
|
||||
onPress={() => control.close()}
|
||||
color="primary"
|
||||
size="large"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
export function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
ax.metric('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [ax, onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={l`Select GIF "${gif.title}"`}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
a.aspect_square,
|
||||
{opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gifPreviewUrl(gif.media_formats.tinygif.url),
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user