design tweaks and categories
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 +1 @@
|
||||
export {type Gif} from '#/state/queries/tenor'
|
||||
|
||||
export type GifPickerProvider = 'klipy' | 'tenor'
|
||||
export {type Gif} from '#/state/queries/gif'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user