[APP-2067] Rebuild GIF Dialog (#10261)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(yarn typecheck *)",
|
||||||
|
"Bash(yarn lint *)",
|
||||||
|
"Bash(yarn test *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
parseStarterPackUri,
|
parseStarterPackUri,
|
||||||
} from '#/lib/strings/starter-pack'
|
} from '#/lib/strings/starter-pack'
|
||||||
import {messages} from '#/locale/locales/en/messages'
|
import {messages} from '#/locale/locales/en/messages'
|
||||||
import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy'
|
import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
|
||||||
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
|
|
||||||
import {cleanError} from '../../src/lib/strings/errors'
|
import {cleanError} from '../../src/lib/strings/errors'
|
||||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||||
@@ -1072,21 +1071,6 @@ describe('createStarterPackGooglePlayUri', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('tenorUrlToBskyGifUrl', () => {
|
|
||||||
const inputs = [
|
|
||||||
'https://media.tenor.com/someID_AAAAC/someName.gif',
|
|
||||||
'https://media.tenor.com/someID/someName.gif',
|
|
||||||
]
|
|
||||||
|
|
||||||
it.each(inputs)(
|
|
||||||
'returns url with t.gifs.bsky.app as hostname for input url',
|
|
||||||
input => {
|
|
||||||
const out = tenorUrlToBskyGifUrl(input)
|
|
||||||
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('klipyUrlToBskyGifUrl', () => {
|
describe('klipyUrlToBskyGifUrl', () => {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
|
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const ThumbUp_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
viewBox: '0 0 21 19',
|
||||||
|
path: 'M9.61523 0C11.4562 0 12.8635 1.6429 12.5801 3.46191L12.1836 6H16.0635C18.4871 6.0002 20.3536 8.13859 20.0264 10.54L19.3447 15.54C19.0745 17.522 17.3811 19 15.3809 19H2C0.895431 19 0 18.1046 0 17V10C0 8.89543 0.895431 8 2 8H4.38184L8.10547 0.552734L8.17676 0.431641C8.36169 0.163675 8.66854 0 9 0H9.61523ZM6 9.23633V17H15.3809C16.3809 17 17.228 16.2614 17.3633 15.2705L18.0449 10.2705C18.2087 9.06976 17.2753 8.0002 16.0635 8H11.0166C10.7246 8 10.4468 7.87218 10.2568 7.65039C10.0669 7.42849 9.98332 7.13434 10.0283 6.8457L10.6035 3.1543C10.698 2.54844 10.2301 2.00093 9.61719 2L6 9.23633ZM2 17H4V10H2V17Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import {useEffect, 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} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||||
|
import {
|
||||||
|
GIF_CATEGORIES,
|
||||||
|
type GifCategory,
|
||||||
|
GifCategoryPills,
|
||||||
|
} from '#/features/gifPicker/components/GifCategoryPills'
|
||||||
|
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 {useRecentGifs} from '#/features/gifPicker/hooks/useRecentGifs'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
|
export function GifPickerDialog({
|
||||||
|
control,
|
||||||
|
onClose,
|
||||||
|
onSelectGif: onSelectGifProp,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onClose?: () => void
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
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 textInputRef = useRef<TextInput>(null)
|
||||||
|
const listRef = useRef<ListMethods>(null)
|
||||||
|
const [rawSearch, setRawSearch] = useState('')
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>('trending')
|
||||||
|
const search = useThrottledValue(rawSearch, 750)
|
||||||
|
const {getRecents, addRecent, hasRecents} = useRecentGifs()
|
||||||
|
|
||||||
|
// Determine the effective search query:
|
||||||
|
// - If user is typing, use the throttled text
|
||||||
|
// - If user clears the input, immediately drop the search (don't wait for
|
||||||
|
// the throttle to catch up — otherwise the previous query keeps driving
|
||||||
|
// the visible results until the next interval tick)
|
||||||
|
// - If a non-trending category is active, use its searchterm
|
||||||
|
// - Otherwise (trending/recents), empty string triggers the featured endpoint
|
||||||
|
const activeCategorySearchterm =
|
||||||
|
GIF_CATEGORIES.find(c => c.id === activeCategory)?.searchterm ?? ''
|
||||||
|
const effectiveSearch =
|
||||||
|
rawSearch.length > 0 && search.length > 0
|
||||||
|
? search
|
||||||
|
: activeCategorySearchterm
|
||||||
|
|
||||||
|
const isRecentsActive = activeCategory === 'recents' && rawSearch.length === 0
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
error,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
isSearching,
|
||||||
|
refetch,
|
||||||
|
} = useGifPickerData(effectiveSearch, {enabled: !isRecentsActive})
|
||||||
|
|
||||||
|
const networkItems = dedupeById(
|
||||||
|
data?.pages.flatMap(page => page.results) ?? [],
|
||||||
|
)
|
||||||
|
const items = isRecentsActive ? getRecents() : networkItems
|
||||||
|
const hasData = items.length > 0
|
||||||
|
|
||||||
|
const onEndReached = () => {
|
||||||
|
if (isRecentsActive) return
|
||||||
|
if (isFetchingNextPage || !hasNextPage || error) return
|
||||||
|
void fetchNextPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to top when the effective query/category changes, NOT on every
|
||||||
|
// keystroke. Calling scrollToOffset on the FlatList while its sticky header
|
||||||
|
// holds the focused input blurs that input on web.
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||||
|
}, [effectiveSearch, isRecentsActive])
|
||||||
|
|
||||||
|
const onClearSearch = () => {
|
||||||
|
textInputRef.current?.clear()
|
||||||
|
setRawSearch('')
|
||||||
|
setActiveCategory('trending')
|
||||||
|
textInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGoBack = () => {
|
||||||
|
if (isSearching || activeCategory !== 'trending') {
|
||||||
|
onClearSearch()
|
||||||
|
} else {
|
||||||
|
control.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeSearch = (text: string) => {
|
||||||
|
setRawSearch(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectCategory = (category: GifCategory) => {
|
||||||
|
setActiveCategory(category.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectGif = (gif: Gif) => {
|
||||||
|
addRecent(gif)
|
||||||
|
onSelectGif(gif)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPills = rawSearch.length === 0
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<>
|
||||||
|
<GifPickerHeader
|
||||||
|
inputRef={textInputRef}
|
||||||
|
onChangeText={onChangeSearch}
|
||||||
|
onClear={onClearSearch}
|
||||||
|
canClear={rawSearch.length > 0}
|
||||||
|
onEscape={() => control.close()}
|
||||||
|
/>
|
||||||
|
{showPills && (
|
||||||
|
<GifCategoryPills
|
||||||
|
activeId={activeCategory}
|
||||||
|
onSelect={onSelectCategory}
|
||||||
|
hasRecents={hasRecents}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hasData && (
|
||||||
|
<GifPickerPlaceholder
|
||||||
|
isLoading={!isRecentsActive && isPending}
|
||||||
|
isError={!isRecentsActive && isError}
|
||||||
|
isSearching={isSearching}
|
||||||
|
isRecentsEmpty={isRecentsActive}
|
||||||
|
query={effectiveSearch}
|
||||||
|
onRetry={refetch}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog.Close />
|
||||||
|
<GifPickerGrid
|
||||||
|
ref={listRef}
|
||||||
|
items={items}
|
||||||
|
header={header}
|
||||||
|
hasData={hasData}
|
||||||
|
isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
|
||||||
|
error={isRecentsActive ? null : error}
|
||||||
|
fetchNextPage={fetchNextPage}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onSelectGif={handleSelectGif}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeById(items: Gif[]): Gif[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: Gif[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
useFeaturedGifsQuery,
|
||||||
|
useGifSearchQuery,
|
||||||
|
} from '#/features/gifPicker/queries'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single entry point for the GIF picker's data layer. Routes between the
|
||||||
|
* featured and search endpoints so the UI only ever consumes one query result.
|
||||||
|
*/
|
||||||
|
export function useGifPickerData(
|
||||||
|
query: string,
|
||||||
|
{enabled = true}: {enabled?: boolean} = {},
|
||||||
|
) {
|
||||||
|
const isSearching = query.length > 0
|
||||||
|
|
||||||
|
const featured = useFeaturedGifsQuery({enabled: enabled && !isSearching})
|
||||||
|
const search = useGifSearchQuery(query, {enabled: enabled && isSearching})
|
||||||
|
|
||||||
|
const active = isSearching ? search : featured
|
||||||
|
|
||||||
|
return {
|
||||||
|
...active,
|
||||||
|
isSearching,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
import {account} from '#/storage'
|
||||||
|
|
||||||
|
const MAX_RECENT_GIFS = 20
|
||||||
|
|
||||||
|
function readValid(did: string): Gif[] {
|
||||||
|
const stored = account.get([did, 'recentGifs']) ?? []
|
||||||
|
// Earlier builds of this branch stored recents as JSON-serialized strings.
|
||||||
|
// Drop any malformed entries so a dev with stale local data doesn't crash.
|
||||||
|
if (stored.some(item => typeof item !== 'object' || item === null)) {
|
||||||
|
account.remove([did, 'recentGifs'])
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecentGifs() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const did = currentAccount?.did
|
||||||
|
|
||||||
|
const getRecents = (): Gif[] => {
|
||||||
|
if (!did) return []
|
||||||
|
return readValid(did)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRecent = (gif: Gif) => {
|
||||||
|
if (!did) return
|
||||||
|
const existing = readValid(did)
|
||||||
|
const deduped = existing.filter(g => g.id !== gif.id)
|
||||||
|
const updated = [gif, ...deduped].slice(0, MAX_RECENT_GIFS)
|
||||||
|
account.set([did, 'recentGifs'], updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getRecents,
|
||||||
|
addRecent,
|
||||||
|
hasRecents: did ? readValid(did).length > 0 : false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import {Platform} from 'react-native'
|
import {Platform} from 'react-native'
|
||||||
import {getLocales} from 'expo-localization'
|
import {getLocales} from 'expo-localization'
|
||||||
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
|
import {useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
|
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
|
|
||||||
export const RQKEY_ROOT = 'klipy-gif-service'
|
export const RQKEY_ROOT = 'klipy-gif-service'
|
||||||
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
||||||
@@ -33,7 +32,6 @@ export function useGifSearchQuery(
|
|||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: lastPage => lastPage.next,
|
getNextPageParam: lastPage => lastPage.next,
|
||||||
enabled: !!query && options?.enabled !== false,
|
enabled: !!query && options?.enabled !== false,
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,21 +86,3 @@ function createKlipyApi<Input extends object>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrites a KLIPY static CDN URL through the bsky proxy
|
|
||||||
* (k.gifs.bsky.app). Mirrors `tenorUrlToBskyGifUrl`, but uses a
|
|
||||||
* separate hostname from Tenor's t.gifs.bsky.app so the two
|
|
||||||
* upstreams can be routed independently.
|
|
||||||
*/
|
|
||||||
export function klipyUrlToBskyGifUrl(klipyUrl: string) {
|
|
||||||
let url
|
|
||||||
try {
|
|
||||||
url = new URL(klipyUrl)
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('invalid url passed to klipyUrlToBskyGifUrl()')
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
url.hostname = 'k.gifs.bsky.app'
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* GIF shape returned by the Bluesky GIF proxy. The field names follow the
|
||||||
|
* Tenor schema; Klipy responses are normalized to the same shape by the
|
||||||
|
* proxy so downstream code can be provider-agnostic.
|
||||||
|
*/
|
||||||
|
export type Gif = {
|
||||||
|
created: number
|
||||||
|
hasaudio: boolean
|
||||||
|
id: string
|
||||||
|
media_formats: Record<BaseContentFormats, MediaObject> &
|
||||||
|
Partial<Record<VideoContentFormats, MediaObject>>
|
||||||
|
tags: string[]
|
||||||
|
title: string
|
||||||
|
content_description: string
|
||||||
|
itemurl: string
|
||||||
|
hascaption: boolean
|
||||||
|
flags: string
|
||||||
|
bg_color?: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaObject = {
|
||||||
|
url: string
|
||||||
|
dims: [number, number]
|
||||||
|
duration: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseContentFormats = 'preview' | 'gif' | 'tinygif'
|
||||||
|
|
||||||
|
type VideoContentFormats = 'mp4' | 'webm'
|
||||||
|
|
||||||
|
export type ContentFormats = BaseContentFormats | VideoContentFormats
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {logger} from '#/logger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites a provider's CDN URL (Tenor or Klipy) to the corresponding
|
||||||
|
* bsky proxy hostname. Leaves unrecognized hosts untouched.
|
||||||
|
*/
|
||||||
|
export function gifPreviewUrl(gifUrl: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(gifUrl)
|
||||||
|
if (url.hostname === 'media.tenor.com') {
|
||||||
|
url.hostname = 't.gifs.bsky.app'
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
|
if (url.hostname === 'static.klipy.com') {
|
||||||
|
url.hostname = 'k.gifs.bsky.app'
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
|
return gifUrl
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('invalid url passed to gifPreviewUrl()')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites a KLIPY static CDN URL through the bsky proxy
|
||||||
|
* (k.gifs.bsky.app) so downstream consumers can route requests
|
||||||
|
* through Bluesky-owned infrastructure.
|
||||||
|
*/
|
||||||
|
export function klipyUrlToBskyGifUrl(klipyUrl: string) {
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = new URL(klipyUrl)
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('invalid url passed to klipyUrlToBskyGifUrl()')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
url.hostname = 'k.gifs.bsky.app'
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
type AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
type AppBskyGraphDefs,
|
type AppBskyGraphDefs,
|
||||||
|
type BskyAgent,
|
||||||
type ComAtprotoRepoStrongRef,
|
type ComAtprotoRepoStrongRef,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
import {type BskyAgent} from '@atproto/api'
|
|
||||||
|
|
||||||
import {POST_IMG_MAX} from '#/lib/constants'
|
import {POST_IMG_MAX} from '#/lib/constants'
|
||||||
import {getLinkMeta} from '#/lib/link-meta/link-meta'
|
import {getLinkMeta} from '#/lib/link-meta/link-meta'
|
||||||
@@ -15,18 +15,19 @@ import {
|
|||||||
parseStarterPackUri,
|
parseStarterPackUri,
|
||||||
} from '#/lib/strings/starter-pack'
|
} from '#/lib/strings/starter-pack'
|
||||||
import {
|
import {
|
||||||
|
convertBskyAppUrlIfNeeded,
|
||||||
isBskyCustomFeedUrl,
|
isBskyCustomFeedUrl,
|
||||||
isBskyListUrl,
|
isBskyListUrl,
|
||||||
isBskyPostUrl,
|
isBskyPostUrl,
|
||||||
isBskyStarterPackUrl,
|
isBskyStarterPackUrl,
|
||||||
isBskyStartUrl,
|
isBskyStartUrl,
|
||||||
isShortLink,
|
isShortLink,
|
||||||
|
makeRecordUri,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {type ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import {createComposerImage} from '#/state/gallery'
|
import {createComposerImage} from '#/state/gallery'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {createGIFDescription} from '../gif-alt-text'
|
import {createGIFDescription} from '../gif-alt-text'
|
||||||
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
|
|
||||||
|
|
||||||
type ResolvedExternalLink = {
|
type ResolvedExternalLink = {
|
||||||
type: 'external'
|
type: 'external'
|
||||||
|
|||||||
@@ -173,11 +173,6 @@ export const KNOWN_SHUTDOWN_FEEDS = [
|
|||||||
|
|
||||||
export const GIF_SERVICE = 'https://gifs.bsky.app'
|
export const GIF_SERVICE = 'https://gifs.bsky.app'
|
||||||
|
|
||||||
export const GIF_SEARCH = (params: string) =>
|
|
||||||
`${GIF_SERVICE}/tenor/v2/search?${params}`
|
|
||||||
export const GIF_FEATURED = (params: string) =>
|
|
||||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
|
||||||
|
|
||||||
export const GIF_KLIPY_SEARCH = (params: string) =>
|
export const GIF_KLIPY_SEARCH = (params: string) =>
|
||||||
`${GIF_SERVICE}/klipy/v2/search?${params}`
|
`${GIF_SERVICE}/klipy/v2/search?${params}`
|
||||||
export const GIF_KLIPY_FEATURED = (params: string) =>
|
export const GIF_KLIPY_FEATURED = (params: string) =>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {type QueryClient, useQuery} from '@tanstack/react-query'
|
|||||||
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
|
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
|
||||||
import {STALE} from '#/state/queries/index'
|
import {STALE} from '#/state/queries/index'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {type Gif} from './tenor'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
export const RQKEY_LINK_ROOT = 'resolve-link'
|
export const RQKEY_LINK_ROOT = 'resolve-link'
|
||||||
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
|
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import {Platform} from 'react-native'
|
|
||||||
import {getLocales} from 'expo-localization'
|
|
||||||
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
|
|
||||||
export const RQKEY_ROOT = 'gif-service'
|
|
||||||
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
|
||||||
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
|
|
||||||
|
|
||||||
const getTrendingGifs = createTenorApi(GIF_FEATURED)
|
|
||||||
|
|
||||||
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
|
|
||||||
|
|
||||||
export function useTenorFeaturedGifsQuery(options?: {enabled?: boolean}) {
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: RQKEY_FEATURED,
|
|
||||||
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
|
||||||
getNextPageParam: lastPage => lastPage.next,
|
|
||||||
enabled: options?.enabled,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTenorGifSearchQuery(
|
|
||||||
query: string,
|
|
||||||
options?: {enabled?: boolean},
|
|
||||||
) {
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: RQKEY_SEARCH(query),
|
|
||||||
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
|
||||||
getNextPageParam: lastPage => lastPage.next,
|
|
||||||
enabled: !!query && options?.enabled !== false,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTenorApi<Input extends object>(
|
|
||||||
urlFn: (params: string) => string,
|
|
||||||
): (input: Input & {pos?: string}) => Promise<{
|
|
||||||
next: string
|
|
||||||
results: Gif[]
|
|
||||||
}> {
|
|
||||||
return async input => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
|
|
||||||
// set client key based on platform
|
|
||||||
params.set(
|
|
||||||
'client_key',
|
|
||||||
Platform.select({
|
|
||||||
ios: 'bluesky-ios',
|
|
||||||
android: 'bluesky-android',
|
|
||||||
default: 'bluesky-web',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
|
|
||||||
params.set('limit', '30')
|
|
||||||
|
|
||||||
params.set('contentfilter', 'high')
|
|
||||||
|
|
||||||
params.set(
|
|
||||||
'media_filter',
|
|
||||||
(['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
|
|
||||||
)
|
|
||||||
|
|
||||||
const locale = getLocales?.()?.[0]
|
|
||||||
|
|
||||||
if (locale) {
|
|
||||||
params.set('locale', locale.languageTag.replace('-', '_'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(input)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
params.set(key, String(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(urlFn(params.toString()), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to fetch Tenor API')
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tenorUrlToBskyGifUrl(tenorUrl: string) {
|
|
||||||
let url
|
|
||||||
try {
|
|
||||||
url = new URL(tenorUrl)
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('invalid url passed to tenorUrlToBskyGifUrl()')
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
url.hostname = 't.gifs.bsky.app'
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the appropriate URL for a GIF preview image.
|
|
||||||
* Tenor URLs (media.tenor.com) are routed through t.gifs.bsky.app;
|
|
||||||
* KLIPY URLs (static.klipy.com) are routed through k.gifs.bsky.app.
|
|
||||||
*/
|
|
||||||
export function gifPreviewUrl(gifUrl: string) {
|
|
||||||
try {
|
|
||||||
const url = new URL(gifUrl)
|
|
||||||
if (url.hostname === 'media.tenor.com') {
|
|
||||||
url.hostname = 't.gifs.bsky.app'
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
if (url.hostname === 'static.klipy.com') {
|
|
||||||
url.hostname = 'k.gifs.bsky.app'
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
return gifUrl
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('invalid url passed to gifPreviewUrl()')
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Gif = {
|
|
||||||
/**
|
|
||||||
* A Unix timestamp that represents when this post was created.
|
|
||||||
*/
|
|
||||||
created: number
|
|
||||||
/**
|
|
||||||
* Returns true if this post contains audio.
|
|
||||||
* Note: Only video formats support audio. The GIF image file format can't contain audio information.
|
|
||||||
*/
|
|
||||||
hasaudio: boolean
|
|
||||||
/**
|
|
||||||
* Tenor result identifier
|
|
||||||
*/
|
|
||||||
id: string
|
|
||||||
/**
|
|
||||||
* A dictionary with a content format as the key and a Media Object as the value.
|
|
||||||
*/
|
|
||||||
media_formats: Record<BaseContentFormats, MediaObject> &
|
|
||||||
Partial<Record<VideoContentFormats, MediaObject>>
|
|
||||||
/**
|
|
||||||
* An array of tags for the post
|
|
||||||
*/
|
|
||||||
tags: string[]
|
|
||||||
/**
|
|
||||||
* The title of the post
|
|
||||||
*/
|
|
||||||
title: string
|
|
||||||
/**
|
|
||||||
* A textual description of the content.
|
|
||||||
* We recommend that you use content_description for user accessibility features.
|
|
||||||
*/
|
|
||||||
content_description: string
|
|
||||||
/**
|
|
||||||
* The full URL to view the post on tenor.com.
|
|
||||||
*/
|
|
||||||
itemurl: string
|
|
||||||
/**
|
|
||||||
* Returns true if this post contains captions.
|
|
||||||
*/
|
|
||||||
hascaption: boolean
|
|
||||||
/**
|
|
||||||
* Comma-separated list to signify whether the content is a sticker or static image, has audio, or is any combination of these. If sticker and static aren't present, then the content is a GIF. A blank flags field signifies a GIF without audio.
|
|
||||||
*/
|
|
||||||
flags: string
|
|
||||||
/**
|
|
||||||
* The most common background pixel color of the content
|
|
||||||
*/
|
|
||||||
bg_color?: string
|
|
||||||
/**
|
|
||||||
* A short URL to view the post on tenor.com.
|
|
||||||
*/
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaObject = {
|
|
||||||
/**
|
|
||||||
* A URL to the media source
|
|
||||||
*/
|
|
||||||
url: string
|
|
||||||
/**
|
|
||||||
* Width and height of the media in pixels
|
|
||||||
*/
|
|
||||||
dims: [number, number]
|
|
||||||
/**
|
|
||||||
* Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0.
|
|
||||||
*/
|
|
||||||
duration: number
|
|
||||||
/**
|
|
||||||
* Size of the file in bytes
|
|
||||||
*/
|
|
||||||
size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseContentFormats =
|
|
||||||
| 'preview'
|
|
||||||
| 'gif'
|
|
||||||
// | 'mediumgif'
|
|
||||||
| 'tinygif'
|
|
||||||
// | 'nanogif'
|
|
||||||
|
|
||||||
type VideoContentFormats =
|
|
||||||
| 'mp4'
|
|
||||||
// | 'loopedmp4'
|
|
||||||
// | 'tinymp4'
|
|
||||||
// | 'nanomp4'
|
|
||||||
| 'webm'
|
|
||||||
// | 'tinywebm'
|
|
||||||
// | 'nanowebm'
|
|
||||||
|
|
||||||
type ContentFormats = BaseContentFormats | VideoContentFormats
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {type Geolocation} from '#/geolocation/types'
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,4 +80,9 @@ export type Account = {
|
|||||||
birthdateLastUpdatedAt?: string
|
birthdateLastUpdatedAt?: string
|
||||||
|
|
||||||
lastSelectedHomeFeed?: string
|
lastSelectedHomeFeed?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
|
||||||
|
*/
|
||||||
|
recentGifs?: Gif[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ import {
|
|||||||
} from '#/state/preferences/languages'
|
} from '#/state/preferences/languages'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
||||||
@@ -135,6 +134,7 @@ import * as Toast from '#/components/Toast'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
import {
|
import {
|
||||||
draftToComposerPosts,
|
draftToComposerPosts,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useResolveGifQuery,
|
useResolveGifQuery,
|
||||||
useResolveLinkQuery,
|
useResolveLinkQuery,
|
||||||
} from '#/state/queries/resolve-link'
|
} from '#/state/queries/resolve-link'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
@@ -15,6 +14,7 @@ import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
|
|||||||
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
|
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
|
||||||
import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
|
import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
export const ExternalEmbedGif = ({
|
export const ExternalEmbedGif = ({
|
||||||
onRemove,
|
onRemove,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
parseEmbedPlayerFromUrl,
|
parseEmbedPlayerFromUrl,
|
||||||
} from '#/lib/strings/embed-player'
|
} from '#/lib/strings/embed-player'
|
||||||
import {useResolveGifQuery} from '#/state/queries/resolve-link'
|
import {useResolveGifQuery} from '#/state/queries/resolve-link'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
|
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
@@ -24,6 +23,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico
|
|||||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
|
import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
export function GifAltTextDialog({
|
export function GifAltTextDialog({
|
||||||
gif,
|
gif,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {getImageDim} from '#/lib/media/manip'
|
|||||||
import {mimeToExt} from '#/lib/media/video/util'
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
import {type ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
||||||
import {createPublicAgent} from '#/state/session/agent'
|
import {createPublicAgent} from '#/state/session/agent'
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
import {type VideoState} from '#/view/com/composer/state/video'
|
import {type VideoState} from '#/view/com/composer/state/video'
|
||||||
import {type AnalyticsContextType} from '#/analytics'
|
import {type AnalyticsContextType} from '#/analytics'
|
||||||
import {getDeviceId} from '#/analytics/identifiers'
|
import {getDeviceId} from '#/analytics/identifiers'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {logger} from './logger'
|
import {logger} from './logger'
|
||||||
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
||||||
import * as storage from './storage'
|
import * as storage from './storage'
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import {useCallback, useRef} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {Keyboard} from 'react-native'
|
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 * as Dialog from '#/components/Dialog'
|
||||||
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
|
||||||
@@ -19,22 +20,34 @@ type Props = {
|
|||||||
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const ref = useRef<{open: () => void}>(null)
|
const control = Dialog.useDialogControl()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const onPressSelectGif = useCallback(async () => {
|
const onPressSelectGif = useCallback(() => {
|
||||||
ax.metric('composer:gif:open', {})
|
ax.metric('composer:gif:open', {})
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
ref.current?.open()
|
control.open()
|
||||||
}, [ax])
|
}, [ax, control])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
testID="openGifBtn"
|
testID="openGifBtn"
|
||||||
onPress={onPressSelectGif}
|
onPress={onPressSelectGif}
|
||||||
label={_(msg`Select GIF`)}
|
label={_(
|
||||||
accessibilityHint={_(msg`Opens GIF select dialog`)}
|
msg({
|
||||||
|
message: 'Select GIF',
|
||||||
|
comment:
|
||||||
|
'Accessibility label for the button in the post composer that opens the GIF picker dialog.',
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
accessibilityHint={_(
|
||||||
|
msg({
|
||||||
|
message: 'Opens the GIF picker dialog',
|
||||||
|
comment:
|
||||||
|
'Accessibility hint announced after the GIF picker button label, describing what activating it will do.',
|
||||||
|
}),
|
||||||
|
)}
|
||||||
style={a.p_sm}
|
style={a.p_sm}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
shape="round"
|
shape="round"
|
||||||
@@ -43,8 +56,8 @@ 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}
|
control={control}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSelectGif={onSelectGif}
|
onSelectGif={onSelectGif}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
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 {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
||||||
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||||
import {type ComposerOpts} from '#/state/shell/composer'
|
import {type ComposerOpts} from '#/state/shell/composer'
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
type LinkFacetMatch,
|
type LinkFacetMatch,
|
||||||
suggestLinkCardUri,
|
suggestLinkCardUri,
|
||||||
} from '#/view/com/composer/text-input/text-input-util'
|
} from '#/view/com/composer/text-input/text-input-util'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {
|
import {
|
||||||
createVideoState,
|
createVideoState,
|
||||||
type VideoAction,
|
type VideoAction,
|
||||||
|
|||||||
Reference in New Issue
Block a user