feat(gif): emoji-only pills, add recents category

- Remove text labels from pills, show only emojis (placeholder for
  future design icons)
- Add "Recents" pill (first in list, hidden when empty)
- Store recently selected GIFs in per-account MMKV storage
- Default selected pill remains "Trending"
- Shape changed to round for emoji-only pills

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vineyardbovines
2026-04-14 16:34:30 -04:00
parent 05955cb99d
commit 91b64a2844
4 changed files with 79 additions and 14 deletions
+21 -9
View File
@@ -16,6 +16,7 @@ import {GifPickerGrid} from '#/features/gifPicker/components/GifPickerGrid'
import {GifPickerHeader} from '#/features/gifPicker/components/GifPickerHeader' import {GifPickerHeader} from '#/features/gifPicker/components/GifPickerHeader'
import {GifPickerPlaceholder} from '#/features/gifPicker/components/GifPickerPlaceholder' import {GifPickerPlaceholder} from '#/features/gifPicker/components/GifPickerPlaceholder'
import {useGifPickerData} from '#/features/gifPicker/hooks/useGifPickerData' import {useGifPickerData} from '#/features/gifPicker/hooks/useGifPickerData'
import {useRecentGifs} from '#/features/gifPicker/hooks/useRecentGifs'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
export function GifPickerDialog({ export function GifPickerDialog({
@@ -71,15 +72,18 @@ function GifPickerBody({
const [rawSearch, setRawSearch] = useState('') const [rawSearch, setRawSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<string>('trending') const [activeCategory, setActiveCategory] = useState<string>('trending')
const search = useThrottledValue(rawSearch, 750) const search = useThrottledValue(rawSearch, 750)
const {getRecents, addRecent, hasRecents} = useRecentGifs()
// Determine the effective search query: // Determine the effective search query:
// - If user is typing, use the typed text // - If user is typing, use the typed text
// - If a non-trending category is active, use its searchterm // - If a non-trending category is active, use its searchterm
// - Otherwise (trending), empty string triggers the featured endpoint // - Otherwise (trending/recents), empty string triggers the featured endpoint
const activeCategorySearchterm = const activeCategorySearchterm =
GIF_CATEGORIES.find(c => c.id === activeCategory)?.searchterm ?? '' GIF_CATEGORIES.find(c => c.id === activeCategory)?.searchterm ?? ''
const effectiveSearch = search.length > 0 ? search : activeCategorySearchterm const effectiveSearch = search.length > 0 ? search : activeCategorySearchterm
const isRecentsActive = activeCategory === 'recents' && rawSearch.length === 0
const { const {
data, data,
fetchNextPage, fetchNextPage,
@@ -90,18 +94,20 @@ function GifPickerBody({
isError, isError,
isSearching, isSearching,
refetch, refetch,
} = useGifPickerData(effectiveSearch) } = useGifPickerData(effectiveSearch, {enabled: !isRecentsActive})
const items = data?.pages.flatMap(page => page.results) ?? [] const networkItems = data?.pages.flatMap(page => page.results) ?? []
const items = isRecentsActive ? getRecents() : networkItems
const hasData = items.length > 0 const hasData = items.length > 0
const onEndReached = () => { const onEndReached = () => {
if (isRecentsActive) return
if (isFetchingNextPage || !hasNextPage || error) return if (isFetchingNextPage || !hasNextPage || error) return
void fetchNextPage() void fetchNextPage()
} }
const onGoBack = () => { const onGoBack = () => {
if (isSearching) { if (isSearching || activeCategory !== 'trending') {
textInputRef.current?.clear() textInputRef.current?.clear()
setRawSearch('') setRawSearch('')
setActiveCategory('trending') setActiveCategory('trending')
@@ -120,6 +126,11 @@ function GifPickerBody({
listRef.current?.scrollToOffset({offset: 0, animated: false}) listRef.current?.scrollToOffset({offset: 0, animated: false})
} }
const handleSelectGif = (gif: Gif) => {
addRecent(gif)
onSelectGif(gif)
}
const showPills = rawSearch.length === 0 const showPills = rawSearch.length === 0
const header = ( const header = (
@@ -134,12 +145,13 @@ function GifPickerBody({
<GifCategoryPills <GifCategoryPills
activeId={activeCategory} activeId={activeCategory}
onSelect={onSelectCategory} onSelect={onSelectCategory}
hasRecents={hasRecents}
/> />
)} )}
{!hasData && ( {!hasData && (
<GifPickerPlaceholder <GifPickerPlaceholder
isLoading={isPending} isLoading={!isRecentsActive && isPending}
isError={isError} isError={!isRecentsActive && isError}
isSearching={isSearching} isSearching={isSearching}
query={effectiveSearch} query={effectiveSearch}
onRetry={refetch} onRetry={refetch}
@@ -157,11 +169,11 @@ function GifPickerBody({
items={items} items={items}
header={header} header={header}
hasData={hasData} hasData={hasData}
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
error={error} error={isRecentsActive ? null : error}
fetchNextPage={fetchNextPage} fetchNextPage={fetchNextPage}
onEndReached={onEndReached} onEndReached={onEndReached}
onSelectGif={onSelectGif} onSelectGif={handleSelectGif}
/> />
</> </>
) )
@@ -10,10 +10,11 @@ export type GifCategory = {
id: string id: string
emoji: string emoji: string
label: MessageDescriptor label: MessageDescriptor
searchterm: string | null // null = trending (uses featured endpoint) searchterm: string | null // null = trending/recents (handled by consumer)
} }
export const GIF_CATEGORIES: readonly GifCategory[] = [ export const GIF_CATEGORIES: readonly GifCategory[] = [
{id: 'recents', emoji: '🕐', label: msg`Recents`, searchterm: null},
{id: 'trending', emoji: '🔥', label: msg`Trending`, searchterm: null}, {id: 'trending', emoji: '🔥', label: msg`Trending`, searchterm: null},
{id: 'love', emoji: '❤️', label: msg`Love`, searchterm: 'love'}, {id: 'love', emoji: '❤️', label: msg`Love`, searchterm: 'love'},
{id: 'happy', emoji: '😄', label: msg`Happy`, searchterm: 'happy'}, {id: 'happy', emoji: '😄', label: msg`Happy`, searchterm: 'happy'},
@@ -27,9 +28,11 @@ export const GIF_CATEGORIES: readonly GifCategory[] = [
export function GifCategoryPills({ export function GifCategoryPills({
activeId, activeId,
onSelect, onSelect,
hasRecents,
}: { }: {
activeId: string activeId: string
onSelect: (category: GifCategory) => void onSelect: (category: GifCategory) => void
hasRecents: boolean
}) { }) {
// useLingui() is called to re-render when the locale changes, even though we // useLingui() is called to re-render when the locale changes, even though we
// translate via i18n._() below to satisfy the lingui-msg-rule lint constraint. // translate via i18n._() below to satisfy the lingui-msg-rule lint constraint.
@@ -42,6 +45,7 @@ export function GifCategoryPills({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.flex_row, a.gap_xs, a.px_xl]}> contentContainerStyle={[a.flex_row, a.gap_xs, a.px_xl]}>
{GIF_CATEGORIES.map(category => { {GIF_CATEGORIES.map(category => {
if (category.id === 'recents' && !hasRecents) return null
const isActive = category.id === activeId const isActive = category.id === activeId
const label = i18n._(category.label) const label = i18n._(category.label)
return ( return (
@@ -52,10 +56,8 @@ export function GifCategoryPills({
size="small" size="small"
variant={isActive ? 'solid' : 'outline'} variant={isActive ? 'solid' : 'outline'}
color={isActive ? 'primary' : 'secondary'} color={isActive ? 'primary' : 'secondary'}
shape="default"> shape="round">
<ButtonText emoji> <ButtonText emoji>{category.emoji}</ButtonText>
{category.emoji} {label}
</ButtonText>
</Button> </Button>
) )
})} })}
@@ -0,0 +1,45 @@
import {useSession} from '#/state/session'
import {type Gif} from '#/features/gifPicker/types'
import {account} from '#/storage'
const MAX_RECENT_GIFS = 20
export function useRecentGifs() {
const {currentAccount} = useSession()
const did = currentAccount?.did
const getRecents = (): Gif[] => {
if (!did) return []
const stored = account.get([did, 'recentGifs'])
if (!stored) return []
try {
return stored.map(s => JSON.parse(s) as Gif)
} catch {
return []
}
}
const addRecent = (gif: Gif) => {
if (!did) return
const stored = account.get([did, 'recentGifs']) ?? []
// Remove duplicate if already in recents
const filtered = stored.filter(s => {
try {
return (JSON.parse(s) as Gif).id !== gif.id
} catch {
return true
}
})
// Prepend and cap
const updated = [JSON.stringify(gif), ...filtered].slice(0, MAX_RECENT_GIFS)
account.set([did, 'recentGifs'], updated)
}
return {
getRecents,
addRecent,
hasRecents: did
? (account.get([did, 'recentGifs'])?.length ?? 0) > 0
: false,
}
}
+6
View File
@@ -79,4 +79,10 @@ export type Account = {
birthdateLastUpdatedAt?: string birthdateLastUpdatedAt?: string
lastSelectedHomeFeed?: string lastSelectedHomeFeed?: string
/**
* Recently selected GIFs in the GIF picker, stored as serialized Gif objects.
* Most recent first, capped at 20.
*/
recentGifs?: string[]
} }