design tweaks and categories

This commit is contained in:
vineyardbovines
2026-04-20 12:10:59 -04:00
parent 2dc369a8fc
commit 128911f451
23 changed files with 130 additions and 431 deletions
@@ -153,6 +153,7 @@ function GifPickerBody({
isLoading={!isRecentsActive && isPending}
isError={!isRecentsActive && isError}
isSearching={isSearching}
isRecentsEmpty={isRecentsActive}
query={effectiveSearch}
onRetry={refetch}
onGoBack={onGoBack}
@@ -1,66 +0,0 @@
import {Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {Text} from '#/components/Typography'
const LISTBOX_ID = 'gif-autocomplete-listbox'
export function suggestionItemId(index: number) {
return `gif-autocomplete-option-${index}`
}
export {LISTBOX_ID as GIF_AUTOCOMPLETE_LISTBOX_ID}
export function GifAutocompleteSuggestions({
suggestions,
activeIndex,
onSelect,
}: {
suggestions: string[]
activeIndex: number
onSelect: (suggestion: string) => void
}) {
const {_} = useLingui()
const t = useTheme()
if (suggestions.length === 0) return null
return (
<View
role="list"
id={LISTBOX_ID}
aria-label={_(msg`Search suggestions`)}
style={[a.rounded_sm, a.overflow_hidden, a.mt_xs, a.mb_sm]}>
{suggestions.map((suggestion, index) => {
const isActive = index === activeIndex
return (
<Pressable
key={suggestion}
role="option"
id={suggestionItemId(index)}
aria-selected={isActive}
accessibilityLabel={suggestion}
accessibilityHint={_(msg`Search for ${suggestion}`)}
onPress={() => onSelect(suggestion)}
style={state => [
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
a.py_sm,
(isActive || ('hovered' in state && state.hovered)) &&
t.atoms.bg_contrast_25,
]}>
<SearchIcon size="sm" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_md, a.flex_1]} numberOfLines={1}>
{suggestion}
</Text>
</Pressable>
)
})}
</View>
)
}
@@ -4,19 +4,19 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Button, useSharedButtonTextStyles} from '#/components/Button'
import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {
EmojiArc_Stroke2_Corner0_Rounded as EmojiArc,
EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyes,
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji'
import {Flame_Stroke2_Corner1_Rounded as Flame} from '#/components/icons/Flame'
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled} from '#/components/icons/Heart2'
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'
const ICON_SIZE = 20
export type GifCategory = {
id: string
@@ -27,14 +27,12 @@ export type GifCategory = {
export const GIF_CATEGORIES: readonly GifCategory[] = [
{id: 'recents', icon: Clock, label: msg`Recents`, searchterm: null},
{id: 'trending', icon: Flame, label: msg`Trending`, searchterm: null},
{id: 'love', icon: HeartFilled, label: msg`Love`, searchterm: 'love'},
{id: 'trending', icon: Trending, label: msg`Trending`, searchterm: null},
{id: 'love', icon: Heart, label: msg`Love`, searchterm: 'love'},
{id: 'happy', icon: EmojiSmile, label: msg`Happy`, searchterm: 'happy'},
{id: 'sad', icon: EmojiSad, label: msg`Sad`, searchterm: 'cry'},
{id: 'party', icon: Celebrate, label: msg`Party`, searchterm: 'congratulations'},
{id: 'yes', icon: Check, label: msg`Yes`, searchterm: 'yes'},
{id: 'lol', icon: EmojiArc, label: msg`LOL`, searchterm: 'lol'},
{id: 'excited', icon: EmojiHeartEyes, label: msg`Excited`, searchterm: 'excited'},
{id: 'yes', icon: Shaka, label: msg`Yes`, searchterm: 'yes'},
] as const
export function GifCategoryPills({
@@ -46,8 +44,9 @@ export function GifCategoryPills({
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.
// Subscribe to locale changes so i18n._() returns the current translation.
// The lingui-msg-rule lint rule forbids _() with variables, so we use
// i18n._() directly to translate the MessageDescriptor from GIF_CATEGORIES.
useLingui()
return (
@@ -57,26 +56,36 @@ export function GifCategoryPills({
a.justify_between,
a.align_center,
a.gap_xs,
a.px_xl,
a.mb_sm,
a.mb_md,
]}>
{GIF_CATEGORIES.map(category => {
if (category.id === 'recents' && !hasRecents) return null
const isActive = category.id === activeId
const label = i18n._(category.label)
return (
<Button
key={category.id}
label={label}
label={i18n._(category.label)}
aria-current={isActive ? 'true' : undefined}
onPress={() => onSelect(category)}
size="small"
variant={isActive ? 'solid' : 'ghost'}
color="secondary"
shape="round">
<ButtonIcon icon={category.icon} />
<PillIcon icon={category.icon} />
</Button>
)
})}
</View>
)
}
function PillIcon({icon: Icon}: {icon: React.ComponentType<SVGIconProps>}) {
const textStyles = useSharedButtonTextStyles()
return (
<Icon
width={ICON_SIZE}
height={ICON_SIZE}
style={{color: textStyles.color}}
/>
)
}
@@ -1,4 +1,4 @@
import {forwardRef, useMemo} from 'react'
import {forwardRef} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {cleanError} from '#/lib/strings/errors'
@@ -38,10 +38,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
const {height} = useWindowDimensions()
const numColumns = gtMobile ? 3 : 2
const columns = useMemo(
() => distributeIntoColumns(items, numColumns),
[items, numColumns],
)
const columns = distributeIntoColumns(items, numColumns)
/**
* The grid is a single FlatList row because the tiles are distributed
@@ -49,7 +46,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
* the outer FlatList's scroll position, so pagination behaves the same
* as a conventional grid.
*/
const data = useMemo(() => (hasData ? [columns] : []), [hasData, columns])
const data = hasData ? [columns] : []
return (
<Dialog.InnerFlatList
@@ -30,11 +30,10 @@ export function GifPickerHeader({
style={[
native(a.pt_4xl),
a.relative,
a.mb_lg,
a.mb_md,
a.flex_row,
a.align_center,
!gtMobile && web(a.gap_md),
a.pb_sm,
t.atoms.bg,
]}>
{!gtMobile && IS_WEB && (
@@ -66,8 +65,6 @@ export function GifPickerHeader({
}}
/>
</TextField.Root>
{/* future: tabs (Trending / Recents / Categories) render here */}
</View>
)
}
@@ -2,7 +2,7 @@ import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {gifPreviewUrl} from '#/state/queries/tenor'
import {gifPreviewUrl} from '#/state/queries/gif'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useAnalytics} from '#/analytics'
@@ -7,6 +7,7 @@ export function GifPickerPlaceholder({
isLoading,
isError,
isSearching,
isRecentsEmpty,
query,
onRetry,
onGoBack,
@@ -14,12 +15,19 @@ export function GifPickerPlaceholder({
isLoading: boolean
isError: boolean
isSearching: boolean
isRecentsEmpty: boolean
query: string
onRetry: () => Promise<unknown>
onGoBack: () => void
}) {
const {_} = useLingui()
const emptyMessage = isSearching
? _(msg`No GIFs found for "${query}".`)
: isRecentsEmpty
? _(msg`No recent GIFs yet. Pick one to see it here.`)
: _(msg`No GIFs to show right now. Try again in a moment.`)
return (
<ListMaybePlaceholder
isLoading={isLoading}
@@ -33,11 +41,7 @@ export function GifPickerPlaceholder({
errorMessage={_(
msg`There was a problem loading GIFs. Check your connection and try again.`,
)}
emptyMessage={
isSearching
? _(msg`No GIFs found for "${query}".`)
: _(msg`No GIFs to show right now. Try again in a moment.`)
}
emptyMessage={emptyMessage}
/>
)
}
@@ -1,109 +0,0 @@
import {useRef, useState} from 'react'
import {useKlipyAutocompleteQuery} from '#/state/queries/klipy'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
export type GifAutocompleteState = {
/** The suggestion strings to display */
suggestions: string[]
/** Whether the suggestion list should be visible */
isVisible: boolean
/** Index of the keyboard-highlighted suggestion (web only), -1 = none */
activeIndex: number
/** Call when the user selects a suggestion */
selectSuggestion: (suggestion: string) => void
/** Call when the raw search text changes (from the input's onChangeText) */
handleTextChange: (text: string) => void
/** Call with the key event from the search input (web only) */
handleKeyDown: (key: string) => boolean
/** Call to dismiss suggestions (e.g. escape key) */
dismiss: () => void
}
export function useGifAutocomplete({
onSelectSuggestion,
}: {
onSelectSuggestion: (text: string) => void
}): GifAutocompleteState {
const ax = useAnalytics()
// TODO: revert — hardcoded for local Klipy testing
const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable)
const [rawText, setRawText] = useState('')
const [dismissed, setDismissed] = useState(false)
const justSelectedRef = useRef(false)
const autocompleteQuery = useThrottledValue(rawText, 200)
const {data: suggestions} = useKlipyAutocompleteQuery(autocompleteQuery, {
enabled: useKlipy && !justSelectedRef.current,
})
const [activeIndex, setActiveIndex] = useState(-1)
const isVisible =
rawText.length > 0 &&
!dismissed &&
!justSelectedRef.current &&
(suggestions?.length ?? 0) > 0
const handleTextChange = (text: string) => {
setRawText(text)
if (justSelectedRef.current) {
justSelectedRef.current = false
}
setDismissed(false)
setActiveIndex(-1)
}
const selectSuggestion = (suggestion: string) => {
justSelectedRef.current = true
setRawText(suggestion)
setActiveIndex(-1)
onSelectSuggestion(suggestion)
}
const dismiss = () => {
setDismissed(true)
setActiveIndex(-1)
}
const handleKeyDown = (key: string): boolean => {
if (!IS_WEB || !isVisible || !suggestions?.length) return false
switch (key) {
case 'ArrowDown': {
setActiveIndex(i => (i + 1) % suggestions.length)
return true
}
case 'ArrowUp': {
setActiveIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
return true
}
case 'Enter': {
if (activeIndex >= 0 && activeIndex < suggestions.length) {
selectSuggestion(suggestions[activeIndex])
return true
}
return false
}
case 'Escape': {
dismiss()
return true
}
default:
return false
}
}
return {
suggestions: suggestions ?? [],
isVisible,
activeIndex,
selectSuggestion,
handleTextChange,
handleKeyDown,
dismiss,
}
}
@@ -7,7 +7,6 @@ import {
useTenorGifSearchQuery,
} from '#/state/queries/tenor'
import {useAnalytics} from '#/analytics'
import {type GifPickerProvider} from '#/features/gifPicker/types'
/**
* Single entry point for the GIF picker's data layer. Wraps the Klipy/Tenor
@@ -24,7 +23,6 @@ export function useGifPickerData(
// TODO: revert — hardcoded for local Klipy testing
const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable)
const isSearching = query.length > 0
const provider: GifPickerProvider = useKlipy ? 'klipy' : 'tenor'
const klipyFeatured = useKlipyFeaturedGifsQuery({
enabled: enabled && useKlipy && !isSearching,
@@ -49,7 +47,6 @@ export function useGifPickerData(
return {
...active,
provider,
isSearching,
}
}
+16 -21
View File
@@ -4,42 +4,37 @@ 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 []
const stored = account.get([did, 'recentGifs'])
if (!stored) return []
try {
return stored.map(s => JSON.parse(s) as Gif)
} catch {
return []
}
return readValid(did)
}
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)
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
? (account.get([did, 'recentGifs'])?.length ?? 0) > 0
: false,
hasRecents: did ? readValid(did).length > 0 : false,
}
}
+1 -3
View File
@@ -1,3 +1 @@
export {type Gif} from '#/state/queries/tenor'
export type GifPickerProvider = 'klipy' | 'tenor'
export {type Gif} from '#/state/queries/gif'
+1 -1
View File
@@ -26,7 +26,7 @@ import {
} 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 '#/state/queries/gif'
import {createGIFDescription} from '../gif-alt-text'
type ResolvedExternalLink = {
-2
View File
@@ -182,8 +182,6 @@ export const GIF_KLIPY_SEARCH = (params: string) =>
`${GIF_SERVICE}/klipy/v2/search?${params}`
export const GIF_KLIPY_FEATURED = (params: string) =>
`${GIF_SERVICE}/klipy/v2/featured?${params}`
export const GIF_KLIPY_AUTOCOMPLETE = (params: string) =>
`${GIF_SERVICE}/klipy/v2/autocomplete?${params}`
export const MAX_LABELERS = 20
+57
View File
@@ -0,0 +1,57 @@
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 ''
}
}
/**
* 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<BaseContentFormats, MediaObject> &
Partial<Record<VideoContentFormats, MediaObject>>
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
+3 -69
View File
@@ -1,66 +1,18 @@
import {Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query'
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
import {
GIF_KLIPY_AUTOCOMPLETE,
GIF_KLIPY_FEATURED,
GIF_KLIPY_SEARCH,
} from '#/lib/constants'
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {type Gif} from '#/state/queries/tenor'
import {type Gif} from '#/state/queries/gif'
export const RQKEY_ROOT = 'klipy-gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
export const RQKEY_AUTOCOMPLETE = (query: string) => [
RQKEY_ROOT,
'autocomplete',
query,
]
const getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED)
const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH)
async function fetchKlipyAutocomplete(query: string): Promise<string[]> {
const params = new URLSearchParams()
params.set(
'client_key',
Platform.select({
ios: 'bluesky-ios',
android: 'bluesky-android',
default: 'bluesky-web',
}),
)
params.set('limit', '8')
const locale = getLocales?.()?.[0]
if (locale) {
params.set('locale', locale.languageTag.replace('-', '_'))
}
params.set('q', query)
const res = await fetch(GIF_KLIPY_AUTOCOMPLETE(params.toString()), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!res.ok) {
throw new Error('Failed to fetch KLIPY autocomplete API')
}
const body: KlipyAutocompleteResponse = await res.json()
return body.results
}
export function useFeaturedGifsQuery(options?: {enabled?: boolean}) {
return useInfiniteQuery({
queryKey: RQKEY_FEATURED,
@@ -85,19 +37,6 @@ export function useGifSearchQuery(
})
}
export function useKlipyAutocompleteQuery(
query: string,
options?: {enabled?: boolean},
) {
return useQuery({
queryKey: RQKEY_AUTOCOMPLETE(query),
queryFn: () => fetchKlipyAutocomplete(query),
enabled: query.length > 0 && options?.enabled !== false,
staleTime: STALE.HOURS.ONE,
placeholderData: keepPreviousData,
})
}
function createKlipyApi<Input extends object>(
urlFn: (params: string) => string,
): (input: Input & {pos?: string}) => Promise<{
@@ -167,8 +106,3 @@ export function klipyUrlToBskyGifUrl(klipyUrl: string) {
url.hostname = 'k.gifs.bsky.app'
return url.href
}
type KlipyAutocompleteResponse = {
locale: string
results: string[]
}
+1 -1
View File
@@ -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 './gif'
export const RQKEY_LINK_ROOT = 'resolve-link'
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
+1 -114
View File
@@ -4,6 +4,7 @@ import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
import {logger} from '#/logger'
import {type ContentFormats, type Gif} from '#/state/queries/gif'
export const RQKEY_ROOT = 'gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
@@ -102,117 +103,3 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
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<BaseContentFormats, MediaObject> &
Partial<Record<VideoContentFormats, MediaObject>>
/**
* 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
+3 -3
View File
@@ -1,3 +1,4 @@
import {type Gif} from '#/state/queries/gif'
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
import {type Geolocation} from '#/geolocation/types'
@@ -81,8 +82,7 @@ export type Account = {
lastSelectedHomeFeed?: string
/**
* Recently selected GIFs in the GIF picker, stored as serialized Gif objects.
* Most recent first, capped at 20.
* Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
*/
recentGifs?: string[]
recentGifs?: Gif[]
}
+1 -1
View File
@@ -93,9 +93,9 @@ import {
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {type Gif} from '#/state/queries/gif'
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'
+1 -1
View File
@@ -2,11 +2,11 @@ import {useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {cleanError} from '#/lib/strings/errors'
import {type Gif} from '#/state/queries/gif'
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'
+1 -1
View File
@@ -10,8 +10,8 @@ import {
type EmbedPlayerParams,
parseEmbedPlayerFromUrl,
} from '#/lib/strings/embed-player'
import {type Gif} from '#/state/queries/gif'
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'
+1 -1
View File
@@ -10,7 +10,7 @@ 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 {type Gif} from '#/state/queries/gif'
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
import {createPublicAgent} from '#/state/session/agent'
import {
+1 -1
View File
@@ -17,8 +17,8 @@ import {
toBskyAppUrl,
} from '#/lib/strings/url-helpers'
import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {type Gif} from '#/state/queries/gif'
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'