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 {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({
@@ -71,15 +72,18 @@ function GifPickerBody({
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 typed text
// - 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 =
GIF_CATEGORIES.find(c => c.id === activeCategory)?.searchterm ?? ''
const effectiveSearch = search.length > 0 ? search : activeCategorySearchterm
const isRecentsActive = activeCategory === 'recents' && rawSearch.length === 0
const {
data,
fetchNextPage,
@@ -90,18 +94,20 @@ function GifPickerBody({
isError,
isSearching,
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 onEndReached = () => {
if (isRecentsActive) return
if (isFetchingNextPage || !hasNextPage || error) return
void fetchNextPage()
}
const onGoBack = () => {
if (isSearching) {
if (isSearching || activeCategory !== 'trending') {
textInputRef.current?.clear()
setRawSearch('')
setActiveCategory('trending')
@@ -120,6 +126,11 @@ function GifPickerBody({
listRef.current?.scrollToOffset({offset: 0, animated: false})
}
const handleSelectGif = (gif: Gif) => {
addRecent(gif)
onSelectGif(gif)
}
const showPills = rawSearch.length === 0
const header = (
@@ -134,12 +145,13 @@ function GifPickerBody({
<GifCategoryPills
activeId={activeCategory}
onSelect={onSelectCategory}
hasRecents={hasRecents}
/>
)}
{!hasData && (
<GifPickerPlaceholder
isLoading={isPending}
isError={isError}
isLoading={!isRecentsActive && isPending}
isError={!isRecentsActive && isError}
isSearching={isSearching}
query={effectiveSearch}
onRetry={refetch}
@@ -157,11 +169,11 @@ function GifPickerBody({
items={items}
header={header}
hasData={hasData}
isFetchingNextPage={isFetchingNextPage}
error={error}
isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
error={isRecentsActive ? null : error}
fetchNextPage={fetchNextPage}
onEndReached={onEndReached}
onSelectGif={onSelectGif}
onSelectGif={handleSelectGif}
/>
</>
)
@@ -10,10 +10,11 @@ export type GifCategory = {
id: string
emoji: string
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[] = [
{id: 'recents', emoji: '🕐', label: msg`Recents`, searchterm: null},
{id: 'trending', emoji: '🔥', label: msg`Trending`, searchterm: null},
{id: 'love', emoji: '❤️', label: msg`Love`, searchterm: 'love'},
{id: 'happy', emoji: '😄', label: msg`Happy`, searchterm: 'happy'},
@@ -27,9 +28,11 @@ export const GIF_CATEGORIES: readonly GifCategory[] = [
export function GifCategoryPills({
activeId,
onSelect,
hasRecents,
}: {
activeId: string
onSelect: (category: GifCategory) => void
hasRecents: boolean
}) {
// 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.
@@ -42,6 +45,7 @@ export function GifCategoryPills({
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.flex_row, a.gap_xs, a.px_xl]}>
{GIF_CATEGORIES.map(category => {
if (category.id === 'recents' && !hasRecents) return null
const isActive = category.id === activeId
const label = i18n._(category.label)
return (
@@ -52,10 +56,8 @@ export function GifCategoryPills({
size="small"
variant={isActive ? 'solid' : 'outline'}
color={isActive ? 'primary' : 'secondary'}
shape="default">
<ButtonText emoji>
{category.emoji} {label}
</ButtonText>
shape="round">
<ButtonText emoji>{category.emoji}</ButtonText>
</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
lastSelectedHomeFeed?: string
/**
* Recently selected GIFs in the GIF picker, stored as serialized Gif objects.
* Most recent first, capped at 20.
*/
recentGifs?: string[]
}