wip
This commit is contained in:
@@ -1,344 +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 {msg} from '@lingui/core/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} 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 {_} = 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 useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
|
||||||
|
|
||||||
const isSearching = search.length > 0
|
|
||||||
|
|
||||||
const klipyTrending = useKlipyFeaturedGifsQuery({enabled: useKlipy})
|
|
||||||
const klipySearch = useKlipyGifSearchQuery(search, {enabled: useKlipy})
|
|
||||||
const tenorTrending = useTenorFeaturedGifsQuery({enabled: !useKlipy})
|
|
||||||
const tenorSearch = useTenorGifSearchQuery(search, {enabled: !useKlipy})
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
fetchNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
error,
|
|
||||||
isPending,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
} = useKlipy
|
|
||||||
? 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={_(msg`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={_(msg`Search GIFs`)}
|
|
||||||
placeholder={useKlipy ? _(msg`Search KLIPY`) : _(msg`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, _, control, useKlipy])
|
|
||||||
|
|
||||||
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={_(msg`Failed to load GIFs`)}
|
|
||||||
errorMessage={
|
|
||||||
useKlipy
|
|
||||||
? _(msg`There was an issue connecting to KLIPY.`)
|
|
||||||
: _(msg`There was an issue connecting to Tenor.`)
|
|
||||||
}
|
|
||||||
emptyMessage={
|
|
||||||
isSearching
|
|
||||||
? _(msg`No search results found for "${search}".`)
|
|
||||||
: useKlipy
|
|
||||||
? _(
|
|
||||||
msg`No featured GIFs found. There may be an issue with KLIPY.`,
|
|
||||||
)
|
|
||||||
: _(
|
|
||||||
msg`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 {_} = useLingui()
|
|
||||||
const control = Dialog.useDialogContext()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.ScrollableInner
|
|
||||||
style={a.gap_md}
|
|
||||||
label={_(msg`An error has occurred`)}>
|
|
||||||
<Dialog.Close />
|
|
||||||
<ErrorScreen
|
|
||||||
title={_(msg`Oh no!`)}
|
|
||||||
message={_(
|
|
||||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
|
||||||
)}
|
|
||||||
details={details}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label={_(msg`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 {_} = useLingui()
|
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
const onPress = useCallback(() => {
|
|
||||||
ax.metric('composer:gif:select', {})
|
|
||||||
onSelectGif(gif)
|
|
||||||
}, [ax, onSelectGif, gif])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
label={_(msg`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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import {useImperativeHandle, useRef, useState} from 'react'
|
||||||
|
import {type TextInput} from 'react-native'
|
||||||
|
|
||||||
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
|
import {ios, useBreakpoints} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||||
|
import {GifPickerErrorBoundary} from '#/features/gifPicker/components/GifPickerErrorBoundary'
|
||||||
|
import {GifPickerGrid} from '#/features/gifPicker/components/GifPickerGrid'
|
||||||
|
import {GifPickerHeader} from '#/features/gifPicker/components/GifPickerHeader'
|
||||||
|
import {GifPickerPlaceholder} from '#/features/gifPicker/components/GifPickerPlaceholder'
|
||||||
|
import {useGifPickerData} from '#/features/gifPicker/hooks/useGifPickerData'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
|
export function GifPickerDialog({
|
||||||
|
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 = (gif: Gif) => {
|
||||||
|
control.close(() => onSelectGifProp(gif))
|
||||||
|
}
|
||||||
|
|
||||||
|
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={error => (
|
||||||
|
<GifPickerErrorBoundary details={String(error)} />
|
||||||
|
)}>
|
||||||
|
<GifPickerBody control={control} onSelectGif={onSelectGif} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GifPickerBody({
|
||||||
|
control,
|
||||||
|
onSelectGif,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const textInputRef = useRef<TextInput>(null)
|
||||||
|
const listRef = useRef<ListMethods>(null)
|
||||||
|
const [rawSearch, setRawSearch] = useState('')
|
||||||
|
const search = useThrottledValue(rawSearch, 500)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
error,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
isSearching,
|
||||||
|
refetch,
|
||||||
|
} = useGifPickerData(search)
|
||||||
|
|
||||||
|
const items = data?.pages.flatMap(page => page.results) ?? []
|
||||||
|
const hasData = items.length > 0
|
||||||
|
|
||||||
|
const onEndReached = () => {
|
||||||
|
if (isFetchingNextPage || !hasNextPage || error) return
|
||||||
|
void fetchNextPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGoBack = () => {
|
||||||
|
if (isSearching) {
|
||||||
|
textInputRef.current?.clear()
|
||||||
|
setRawSearch('')
|
||||||
|
} else {
|
||||||
|
control.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeSearch = (text: string) => {
|
||||||
|
setRawSearch(text)
|
||||||
|
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<>
|
||||||
|
<GifPickerHeader
|
||||||
|
inputRef={textInputRef}
|
||||||
|
onChangeText={onChangeSearch}
|
||||||
|
onClose={() => control.close()}
|
||||||
|
onEscape={() => control.close()}
|
||||||
|
/>
|
||||||
|
{!hasData && (
|
||||||
|
<GifPickerPlaceholder
|
||||||
|
isLoading={isPending}
|
||||||
|
isError={isError}
|
||||||
|
isSearching={isSearching}
|
||||||
|
query={search}
|
||||||
|
onRetry={refetch}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{gtMobile && <Dialog.Close />}
|
||||||
|
<GifPickerGrid
|
||||||
|
ref={listRef}
|
||||||
|
items={items}
|
||||||
|
header={header}
|
||||||
|
hasData={hasData}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
error={error}
|
||||||
|
fetchNextPage={fetchNextPage}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onSelectGif={onSelectGif}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# GIF Picker
|
||||||
|
|
||||||
|
Feature module for the GIF selection dialog used by the composer.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `GifPickerDialog.tsx` — entry component. Wraps `Dialog.Outer` with an error
|
||||||
|
boundary and exposes an imperative `open()` via `controlRef`.
|
||||||
|
- `components/GifPickerHeader.tsx` — sticky search row. Reserves a slot beneath
|
||||||
|
the search input where future tab bars (Trending / Recents / Categories) will
|
||||||
|
render.
|
||||||
|
- `components/GifPickerGrid.tsx` — column-distribution masonry grid built on
|
||||||
|
`Dialog.InnerFlatList`. Uses each GIF's intrinsic width/height instead of
|
||||||
|
forcing a square aspect ratio.
|
||||||
|
- `components/GifPickerItem.tsx` — single tile. Natural aspect ratio, press
|
||||||
|
feedback, fires the `composer:gif:select` analytics event.
|
||||||
|
- `components/GifPickerPlaceholder.tsx` — loading / empty / error states.
|
||||||
|
- `components/GifPickerErrorBoundary.tsx` — fallback rendered when the data
|
||||||
|
layer throws.
|
||||||
|
- `hooks/useGifPickerData.ts` — collapses the Klipy/Tenor provider flag and the
|
||||||
|
search-vs-featured branching into one hook so the UI never sees both paths.
|
||||||
|
|
||||||
|
## Provider switching
|
||||||
|
|
||||||
|
Provider selection is gated by the `KlipyGifProviderEnable` analytics feature
|
||||||
|
flag. Both provider modules in `src/state/queries/{klipy,tenor}.ts` are live
|
||||||
|
until the Klipy rollout (and the tango backend-proxy PR) is complete. Do not
|
||||||
|
remove the Tenor path without first confirming Klipy is globally enabled.
|
||||||
|
|
||||||
|
## Out of scope (future tickets)
|
||||||
|
|
||||||
|
- Autocomplete / autosuggest
|
||||||
|
- Recents
|
||||||
|
- Trending tags / categories browsing
|
||||||
|
- Favorites
|
||||||
|
- Alt-text-at-pick flow (alt text is still added via a separate dialog after
|
||||||
|
selection)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {msg} from '@lingui/core/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {Trans} 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 {_} = useLingui()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
style={a.gap_md}
|
||||||
|
label={_(msg`An error has occurred`)}>
|
||||||
|
<Dialog.Close />
|
||||||
|
<ErrorScreen
|
||||||
|
title={_(msg`Oh no!`)}
|
||||||
|
message={_(
|
||||||
|
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||||
|
)}
|
||||||
|
details={details}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close dialog`)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
variant="solid">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {forwardRef, useMemo} from 'react'
|
||||||
|
import {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 = useMemo(
|
||||||
|
() => distributeIntoColumns(items, numColumns),
|
||||||
|
[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 = useMemo(() => (hasData ? [columns] : []), [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={4}
|
||||||
|
keyboardDismissMode="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,73 @@
|
|||||||
|
import {type Ref} from 'react'
|
||||||
|
import {type TextInput, View} from 'react-native'
|
||||||
|
import {msg} from '@lingui/core/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||||
|
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||||
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
|
export function GifPickerHeader({
|
||||||
|
inputRef,
|
||||||
|
onChangeText,
|
||||||
|
onClose,
|
||||||
|
onEscape,
|
||||||
|
}: {
|
||||||
|
inputRef: Ref<TextInput>
|
||||||
|
onChangeText: (text: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
onEscape: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
|
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={onClose}
|
||||||
|
label={_(msg`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={_(msg`Search GIFs`)}
|
||||||
|
placeholder={_(msg`Search GIFs`)}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
returnKeyType="search"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
inputRef={inputRef}
|
||||||
|
maxLength={50}
|
||||||
|
onKeyPress={({nativeEvent}) => {
|
||||||
|
if (nativeEvent.key === 'Escape') {
|
||||||
|
onEscape()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
{/* future: tabs (Trending / Recents / Categories) render here */}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {msg} from '@lingui/core/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {gifPreviewUrl} from '#/state/queries/tenor'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
|
export function GifPickerItem({
|
||||||
|
gif,
|
||||||
|
onSelectGif,
|
||||||
|
}: {
|
||||||
|
gif: Gif
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const {_} = 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={_(msg`Select GIF "${gif.title}"`)}
|
||||||
|
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,43 @@
|
|||||||
|
import {msg} from '@lingui/core/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||||
|
|
||||||
|
export function GifPickerPlaceholder({
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
isSearching,
|
||||||
|
query,
|
||||||
|
onRetry,
|
||||||
|
onGoBack,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
isSearching: boolean
|
||||||
|
query: string
|
||||||
|
onRetry: () => Promise<unknown>
|
||||||
|
onGoBack: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListMaybePlaceholder
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={onRetry}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
emptyType="results"
|
||||||
|
sideBorders={false}
|
||||||
|
topBorder={false}
|
||||||
|
errorTitle={_(msg`Couldn't load GIFs`)}
|
||||||
|
errorMessage={_(
|
||||||
|
msg`There was a problem loading GIFs. Check your connection and try again.`,
|
||||||
|
)}
|
||||||
|
emptyMessage={
|
||||||
|
isSearching
|
||||||
|
? _(msg`No GIFs found for "${query}".`)
|
||||||
|
: _(msg`No GIFs to show right now. Try again in a moment.`)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
|
||||||
|
useGifSearchQuery as useKlipyGifSearchQuery,
|
||||||
|
} from '#/state/queries/klipy'
|
||||||
|
import {
|
||||||
|
useTenorFeaturedGifsQuery,
|
||||||
|
useTenorGifSearchQuery,
|
||||||
|
} from '#/state/queries/tenor'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {type GifPickerProvider} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single entry point for the GIF picker's data layer. Wraps the Klipy/Tenor
|
||||||
|
* feature-flag split and the featured-vs-search branching so the UI only ever
|
||||||
|
* consumes one query result.
|
||||||
|
*
|
||||||
|
* The Tenor path is kept alive until the Klipy rollout is complete.
|
||||||
|
*/
|
||||||
|
export function useGifPickerData(
|
||||||
|
query: string,
|
||||||
|
{enabled = true}: {enabled?: boolean} = {},
|
||||||
|
) {
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||||
|
const isSearching = query.length > 0
|
||||||
|
const provider: GifPickerProvider = useKlipy ? 'klipy' : 'tenor'
|
||||||
|
|
||||||
|
const klipyFeatured = useKlipyFeaturedGifsQuery({
|
||||||
|
enabled: enabled && useKlipy && !isSearching,
|
||||||
|
})
|
||||||
|
const klipySearch = useKlipyGifSearchQuery(query, {
|
||||||
|
enabled: enabled && useKlipy && isSearching,
|
||||||
|
})
|
||||||
|
const tenorFeatured = useTenorFeaturedGifsQuery({
|
||||||
|
enabled: enabled && !useKlipy && !isSearching,
|
||||||
|
})
|
||||||
|
const tenorSearch = useTenorGifSearchQuery(query, {
|
||||||
|
enabled: enabled && !useKlipy && isSearching,
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = useKlipy
|
||||||
|
? isSearching
|
||||||
|
? klipySearch
|
||||||
|
: klipyFeatured
|
||||||
|
: isSearching
|
||||||
|
? tenorSearch
|
||||||
|
: tenorFeatured
|
||||||
|
|
||||||
|
return {
|
||||||
|
...active,
|
||||||
|
provider,
|
||||||
|
isSearching,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export {type Gif} from '#/state/queries/tenor'
|
||||||
|
|
||||||
|
export type GifPickerProvider = 'klipy' | 'tenor'
|
||||||
@@ -3,12 +3,12 @@ import {Keyboard} from 'react-native'
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
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 {GifSelectDialog} from '#/components/dialogs/GifSelect'
|
|
||||||
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
|
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
|
import {GifPickerDialog} from '#/features/gifPicker/GifPickerDialog'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
@@ -43,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
|||||||
<GifIcon size="lg" style={disabled && t.atoms.text_contrast_low} />
|
<GifIcon size="lg" style={disabled && t.atoms.text_contrast_low} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<GifSelectDialog
|
<GifPickerDialog
|
||||||
controlRef={ref}
|
controlRef={ref}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSelectGif={onSelectGif}
|
onSelectGif={onSelectGif}
|
||||||
|
|||||||
Reference in New Issue
Block a user