diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..53998ac58c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(yarn typecheck *)", + "Bash(yarn lint *)", + "Bash(yarn test *)" + ] + } +} diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 38966c35cb..8a56423796 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -8,8 +8,7 @@ import { parseStarterPackUri, } from '#/lib/strings/starter-pack' import {messages} from '#/locale/locales/en/messages' -import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy' -import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor' +import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils' import {cleanError} from '../../src/lib/strings/errors' import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' 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', () => { const inputs = [ 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif', diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx deleted file mode 100644 index bc58cbf137..0000000000 --- a/src/components/dialogs/GifSelect.tsx +++ /dev/null @@ -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) => , - [], - ) - - return ( - - - - - - - ) -} - -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(null) - const listRef = useRef(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 - }, - [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 ( - - {!gtMobile && IS_WEB && ( - - )} - - - - { - 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() - } - }} - /> - - - ) - }, [gtMobile, t.atoms.bg, l, control, klipyEnabled]) - - return ( - <> - {gtMobile && } - - {listHeader} - {!hasData && ( - - )} - - } - stickyHeaderIndices={[0]} - onEndReached={onEndReached} - onEndReachedThreshold={4} - keyExtractor={(item: Gif) => item.id} - keyboardDismissMode="on-drag" - ListFooterComponent={ - hasData ? ( - - ) : null - } - /> - - ) -} - -function DialogError({details}: {details?: string}) { - const {t: l} = useLingui() - const control = Dialog.useDialogContext() - - return ( - - - - - - ) -} - -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 ( - - ) -} diff --git a/src/components/icons/Thumb.tsx b/src/components/icons/Thumb.tsx new file mode 100644 index 0000000000..b8247a4d24 --- /dev/null +++ b/src/components/icons/Thumb.tsx @@ -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', +}) diff --git a/src/features/gifPicker/GifPickerDialog.tsx b/src/features/gifPicker/GifPickerDialog.tsx new file mode 100644 index 0000000000..6f51364408 --- /dev/null +++ b/src/features/gifPicker/GifPickerDialog.tsx @@ -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 ( + + + ( + + )}> + + + + ) +} + +function GifPickerBody({ + control, + onSelectGif, +}: { + control: Dialog.DialogControlProps + onSelectGif: (gif: Gif) => void +}) { + const textInputRef = useRef(null) + const listRef = useRef(null) + const [rawSearch, setRawSearch] = useState('') + const [activeCategory, setActiveCategory] = useState('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 = ( + <> + 0} + onEscape={() => control.close()} + /> + {showPills && ( + + )} + {!hasData && ( + + )} + + ) + + return ( + <> + + + + ) +} + +function dedupeById(items: Gif[]): Gif[] { + const seen = new Set() + const out: Gif[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + out.push(item) + } + return out +} diff --git a/src/features/gifPicker/components/GifCategoryPills.tsx b/src/features/gifPicker/components/GifCategoryPills.tsx new file mode 100644 index 0000000000..44edd69323 --- /dev/null +++ b/src/features/gifPicker/components/GifCategoryPills.tsx @@ -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 + 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 ( + + {GIF_CATEGORIES.map(category => { + if (category.id === 'recents' && !hasRecents) return null + const isActive = category.id === activeId + return ( + + ) + })} + + ) +} diff --git a/src/features/gifPicker/components/GifPickerErrorBoundary.tsx b/src/features/gifPicker/components/GifPickerErrorBoundary.tsx new file mode 100644 index 0000000000..a57ad46551 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerErrorBoundary.tsx @@ -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 ( + + + + + + ) +} diff --git a/src/features/gifPicker/components/GifPickerGrid.tsx b/src/features/gifPicker/components/GifPickerGrid.tsx new file mode 100644 index 0000000000..a34aeacab0 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerGrid.tsx @@ -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 + onEndReached: () => void + onSelectGif: (gif: Gif) => void +} + +export const GifPickerGrid = forwardRef( + 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 ( + ( + + {item.map((column, i) => ( + + {column.map(gif => ( + + ))} + + ))} + + )} + 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 ? ( + + ) : 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 +} diff --git a/src/features/gifPicker/components/GifPickerHeader.tsx b/src/features/gifPicker/components/GifPickerHeader.tsx new file mode 100644 index 0000000000..ee50cdb8e4 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerHeader.tsx @@ -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 + onChangeText: (text: string) => void + onClear: () => void + onEscape: () => void + canClear: boolean +}) { + const {t: l} = useLingui() + const t = useTheme() + + return ( + + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + /> + {canClear && ( + + )} + + + ) +} diff --git a/src/features/gifPicker/components/GifPickerItem.tsx b/src/features/gifPicker/components/GifPickerItem.tsx new file mode 100644 index 0000000000..e307e064a3 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerItem.tsx @@ -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 ( + + ) +} diff --git a/src/features/gifPicker/components/GifPickerPlaceholder.tsx b/src/features/gifPicker/components/GifPickerPlaceholder.tsx new file mode 100644 index 0000000000..d498a8285e --- /dev/null +++ b/src/features/gifPicker/components/GifPickerPlaceholder.tsx @@ -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 + 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 ( + + ) +} diff --git a/src/features/gifPicker/hooks/useGifPickerData.ts b/src/features/gifPicker/hooks/useGifPickerData.ts new file mode 100644 index 0000000000..f598b5b719 --- /dev/null +++ b/src/features/gifPicker/hooks/useGifPickerData.ts @@ -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, + } +} diff --git a/src/features/gifPicker/hooks/useRecentGifs.ts b/src/features/gifPicker/hooks/useRecentGifs.ts new file mode 100644 index 0000000000..a2230005bd --- /dev/null +++ b/src/features/gifPicker/hooks/useRecentGifs.ts @@ -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, + } +} diff --git a/src/state/queries/klipy.ts b/src/features/gifPicker/queries.ts similarity index 77% rename from src/state/queries/klipy.ts rename to src/features/gifPicker/queries.ts index f87e031e31..b8055fc8eb 100644 --- a/src/state/queries/klipy.ts +++ b/src/features/gifPicker/queries.ts @@ -1,10 +1,9 @@ import {Platform} from 'react-native' 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 {logger} from '#/logger' -import {type Gif} from '#/state/queries/tenor' +import {type Gif} from '#/features/gifPicker/types' export const RQKEY_ROOT = 'klipy-gif-service' export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured'] @@ -33,7 +32,6 @@ export function useGifSearchQuery( initialPageParam: undefined as string | undefined, getNextPageParam: lastPage => lastPage.next, enabled: !!query && options?.enabled !== false, - placeholderData: keepPreviousData, }) } @@ -88,21 +86,3 @@ function createKlipyApi( } } } - -/** - * 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 -} diff --git a/src/features/gifPicker/types.ts b/src/features/gifPicker/types.ts new file mode 100644 index 0000000000..02c607dc5b --- /dev/null +++ b/src/features/gifPicker/types.ts @@ -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 & + Partial> + 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 diff --git a/src/features/gifPicker/utils.ts b/src/features/gifPicker/utils.ts new file mode 100644 index 0000000000..233a0e42e6 --- /dev/null +++ b/src/features/gifPicker/utils.ts @@ -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 +} diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index e6d21845fd..1b798bd85b 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -1,10 +1,10 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, + type BskyAgent, type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {type BskyAgent} from '@atproto/api' import {POST_IMG_MAX} from '#/lib/constants' import {getLinkMeta} from '#/lib/link-meta/link-meta' @@ -15,18 +15,19 @@ import { parseStarterPackUri, } from '#/lib/strings/starter-pack' import { + convertBskyAppUrlIfNeeded, isBskyCustomFeedUrl, isBskyListUrl, isBskyPostUrl, isBskyStarterPackUrl, isBskyStartUrl, isShortLink, + makeRecordUri, } from '#/lib/strings/url-helpers' import {type ComposerImage} 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 {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' type ResolvedExternalLink = { type: 'external' diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 8364cd2f6c..7a5b74843b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -173,11 +173,6 @@ export const KNOWN_SHUTDOWN_FEEDS = [ 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) => `${GIF_SERVICE}/klipy/v2/search?${params}` export const GIF_KLIPY_FEATURED = (params: string) => diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts index b0f694b464..a6b21e0bcc 100644 --- a/src/state/queries/resolve-link.ts +++ b/src/state/queries/resolve-link.ts @@ -4,7 +4,7 @@ import {type QueryClient, useQuery} from '@tanstack/react-query' import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve' import {STALE} from '#/state/queries/index' 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 = (url: string) => [RQKEY_LINK_ROOT, url] diff --git a/src/state/queries/tenor.ts b/src/state/queries/tenor.ts deleted file mode 100644 index 835f21f066..0000000000 --- a/src/state/queries/tenor.ts +++ /dev/null @@ -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( - 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 & - Partial> - /** - * 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 diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 2dd99ace2d..e6dc865450 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -1,4 +1,5 @@ import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config' +import {type Gif} from '#/features/gifPicker/types' import {type Geolocation} from '#/geolocation/types' /** @@ -79,4 +80,9 @@ export type Account = { birthdateLastUpdatedAt?: string lastSelectedHomeFeed?: string + + /** + * Recently selected GIFs in the GIF picker. Most recent first, capped at 20. + */ + recentGifs?: Gif[] } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index eb3f5f25cd..1d588c9b8b 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -95,7 +95,6 @@ import { } from '#/state/preferences/languages' import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' -import {type Gif} from '#/state/queries/tenor' import {useAgent, useSession} from '#/state/session' import {useComposerControls} 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 {useAnalytics} from '#/analytics' 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 { draftToComposerPosts, diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 0db82a03f8..19ac9b9dcc 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -6,7 +6,6 @@ import { useResolveGifQuery, useResolveLinkQuery, } from '#/state/queries/resolve-link' -import {type Gif} from '#/state/queries/tenor' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' @@ -15,6 +14,7 @@ import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed' import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' +import {type Gif} from '#/features/gifPicker/types' export const ExternalEmbedGif = ({ onRemove, diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index e32df485c3..275b4be89a 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -11,7 +11,6 @@ import { parseEmbedPlayerFromUrl, } from '#/lib/strings/embed-player' import {useResolveGifQuery} from '#/state/queries/resolve-link' -import {type Gif} from '#/state/queries/tenor' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {atoms as a, useTheme} from '#/alf' 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 {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif' import {Text} from '#/components/Typography' +import {type Gif} from '#/features/gifPicker/types' export function GifAltTextDialog({ gif, diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index b07c290733..1099c1a0fd 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -10,7 +10,6 @@ import {getImageDim} from '#/lib/media/manip' import {mimeToExt} from '#/lib/media/video/util' import {shortenLinks} from '#/lib/strings/rich-text-manip' import {type ComposerImage} from '#/state/gallery' -import {type Gif} from '#/state/queries/tenor' import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util' import {createPublicAgent} from '#/state/session/agent' import { @@ -21,6 +20,7 @@ import { import {type VideoState} from '#/view/com/composer/state/video' import {type AnalyticsContextType} from '#/analytics' import {getDeviceId} from '#/analytics/identifiers' +import {type Gif} from '#/features/gifPicker/types' import {logger} from './logger' import {type DraftPostDisplay, type DraftSummary} from './schema' import * as storage from './storage' diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index d7dfe0c079..151578bbed 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -1,14 +1,15 @@ -import {useCallback, useRef} from 'react' +import {useCallback} from 'react' import {Keyboard} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' -import {type Gif} from '#/state/queries/tenor' import {atoms as a, useTheme} from '#/alf' 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 {useAnalytics} from '#/analytics' +import {GifPickerDialog} from '#/features/gifPicker/GifPickerDialog' +import {type Gif} from '#/features/gifPicker/types' type Props = { onClose?: () => void @@ -19,22 +20,34 @@ type Props = { export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { const ax = useAnalytics() const {_} = useLingui() - const ref = useRef<{open: () => void}>(null) + const control = Dialog.useDialogControl() const t = useTheme() - const onPressSelectGif = useCallback(async () => { + const onPressSelectGif = useCallback(() => { ax.metric('composer:gif:open', {}) Keyboard.dismiss() - ref.current?.open() - }, [ax]) + control.open() + }, [ax, control]) return ( <> - diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 75ea8153cf..d2b9af7b8e 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -18,7 +18,6 @@ import { } from '#/lib/strings/url-helpers' import {type ComposerImage, createInitialImages} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' -import {type Gif} from '#/state/queries/tenor' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {type ComposerOpts} from '#/state/shell/composer' @@ -26,6 +25,7 @@ import { type LinkFacetMatch, suggestLinkCardUri, } from '#/view/com/composer/text-input/text-input-util' +import {type Gif} from '#/features/gifPicker/types' import { createVideoState, type VideoAction,