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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user