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