design tweaks and categories
This commit is contained in:
@@ -153,6 +153,7 @@ function GifPickerBody({
|
|||||||
isLoading={!isRecentsActive && isPending}
|
isLoading={!isRecentsActive && isPending}
|
||||||
isError={!isRecentsActive && isError}
|
isError={!isRecentsActive && isError}
|
||||||
isSearching={isSearching}
|
isSearching={isSearching}
|
||||||
|
isRecentsEmpty={isRecentsActive}
|
||||||
query={effectiveSearch}
|
query={effectiveSearch}
|
||||||
onRetry={refetch}
|
onRetry={refetch}
|
||||||
onGoBack={onGoBack}
|
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 {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
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 {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 {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
|
||||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import {
|
import {
|
||||||
EmojiArc_Stroke2_Corner0_Rounded as EmojiArc,
|
|
||||||
EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyes,
|
|
||||||
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
|
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
|
||||||
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
|
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
|
||||||
} from '#/components/icons/Emoji'
|
} from '#/components/icons/Emoji'
|
||||||
import {Flame_Stroke2_Corner1_Rounded as Flame} from '#/components/icons/Flame'
|
import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
|
||||||
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled} 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 = {
|
export type GifCategory = {
|
||||||
id: string
|
id: string
|
||||||
@@ -27,14 +27,12 @@ export type GifCategory = {
|
|||||||
|
|
||||||
export const GIF_CATEGORIES: readonly GifCategory[] = [
|
export const GIF_CATEGORIES: readonly GifCategory[] = [
|
||||||
{id: 'recents', icon: Clock, label: msg`Recents`, searchterm: null},
|
{id: 'recents', icon: Clock, label: msg`Recents`, searchterm: null},
|
||||||
{id: 'trending', icon: Flame, label: msg`Trending`, searchterm: null},
|
{id: 'trending', icon: Trending, label: msg`Trending`, searchterm: null},
|
||||||
{id: 'love', icon: HeartFilled, label: msg`Love`, searchterm: 'love'},
|
{id: 'love', icon: Heart, label: msg`Love`, searchterm: 'love'},
|
||||||
{id: 'happy', icon: EmojiSmile, label: msg`Happy`, searchterm: 'happy'},
|
{id: 'happy', icon: EmojiSmile, label: msg`Happy`, searchterm: 'happy'},
|
||||||
{id: 'sad', icon: EmojiSad, label: msg`Sad`, searchterm: 'cry'},
|
{id: 'sad', icon: EmojiSad, label: msg`Sad`, searchterm: 'cry'},
|
||||||
{id: 'party', icon: Celebrate, label: msg`Party`, searchterm: 'congratulations'},
|
{id: 'party', icon: Celebrate, label: msg`Party`, searchterm: 'congratulations'},
|
||||||
{id: 'yes', icon: Check, label: msg`Yes`, searchterm: 'yes'},
|
{id: 'yes', icon: Shaka, label: msg`Yes`, searchterm: 'yes'},
|
||||||
{id: 'lol', icon: EmojiArc, label: msg`LOL`, searchterm: 'lol'},
|
|
||||||
{id: 'excited', icon: EmojiHeartEyes, label: msg`Excited`, searchterm: 'excited'},
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function GifCategoryPills({
|
export function GifCategoryPills({
|
||||||
@@ -46,8 +44,9 @@ export function GifCategoryPills({
|
|||||||
onSelect: (category: GifCategory) => void
|
onSelect: (category: GifCategory) => void
|
||||||
hasRecents: boolean
|
hasRecents: boolean
|
||||||
}) {
|
}) {
|
||||||
// useLingui() is called to re-render when the locale changes, even though we
|
// Subscribe to locale changes so i18n._() returns the current translation.
|
||||||
// translate via i18n._() below to satisfy the lingui-msg-rule lint constraint.
|
// The lingui-msg-rule lint rule forbids _() with variables, so we use
|
||||||
|
// i18n._() directly to translate the MessageDescriptor from GIF_CATEGORIES.
|
||||||
useLingui()
|
useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,26 +56,36 @@ export function GifCategoryPills({
|
|||||||
a.justify_between,
|
a.justify_between,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.gap_xs,
|
a.gap_xs,
|
||||||
a.px_xl,
|
a.mb_md,
|
||||||
a.mb_sm,
|
|
||||||
]}>
|
]}>
|
||||||
{GIF_CATEGORIES.map(category => {
|
{GIF_CATEGORIES.map(category => {
|
||||||
if (category.id === 'recents' && !hasRecents) return null
|
if (category.id === 'recents' && !hasRecents) return null
|
||||||
const isActive = category.id === activeId
|
const isActive = category.id === activeId
|
||||||
const label = i18n._(category.label)
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={category.id}
|
key={category.id}
|
||||||
label={label}
|
label={i18n._(category.label)}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
onPress={() => onSelect(category)}
|
onPress={() => onSelect(category)}
|
||||||
size="small"
|
size="small"
|
||||||
variant={isActive ? 'solid' : 'ghost'}
|
variant={isActive ? 'solid' : 'ghost'}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round">
|
shape="round">
|
||||||
<ButtonIcon icon={category.icon} />
|
<PillIcon icon={category.icon} />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</View>
|
</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 {useWindowDimensions, View} from 'react-native'
|
||||||
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
@@ -38,10 +38,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
|
|||||||
const {height} = useWindowDimensions()
|
const {height} = useWindowDimensions()
|
||||||
const numColumns = gtMobile ? 3 : 2
|
const numColumns = gtMobile ? 3 : 2
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = distributeIntoColumns(items, numColumns)
|
||||||
() => distributeIntoColumns(items, numColumns),
|
|
||||||
[items, numColumns],
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The grid is a single FlatList row because the tiles are distributed
|
* 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
|
* the outer FlatList's scroll position, so pagination behaves the same
|
||||||
* as a conventional grid.
|
* as a conventional grid.
|
||||||
*/
|
*/
|
||||||
const data = useMemo(() => (hasData ? [columns] : []), [hasData, columns])
|
const data = hasData ? [columns] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.InnerFlatList
|
<Dialog.InnerFlatList
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ export function GifPickerHeader({
|
|||||||
style={[
|
style={[
|
||||||
native(a.pt_4xl),
|
native(a.pt_4xl),
|
||||||
a.relative,
|
a.relative,
|
||||||
a.mb_lg,
|
a.mb_md,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
!gtMobile && web(a.gap_md),
|
!gtMobile && web(a.gap_md),
|
||||||
a.pb_sm,
|
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
]}>
|
]}>
|
||||||
{!gtMobile && IS_WEB && (
|
{!gtMobile && IS_WEB && (
|
||||||
@@ -66,8 +65,6 @@ export function GifPickerHeader({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
|
|
||||||
{/* future: tabs (Trending / Recents / Categories) render here */}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Image} from 'expo-image'
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function GifPickerPlaceholder({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
isSearching,
|
isSearching,
|
||||||
|
isRecentsEmpty,
|
||||||
query,
|
query,
|
||||||
onRetry,
|
onRetry,
|
||||||
onGoBack,
|
onGoBack,
|
||||||
@@ -14,12 +15,19 @@ export function GifPickerPlaceholder({
|
|||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
isError: boolean
|
isError: boolean
|
||||||
isSearching: boolean
|
isSearching: boolean
|
||||||
|
isRecentsEmpty: boolean
|
||||||
query: string
|
query: string
|
||||||
onRetry: () => Promise<unknown>
|
onRetry: () => Promise<unknown>
|
||||||
onGoBack: () => void
|
onGoBack: () => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
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 (
|
return (
|
||||||
<ListMaybePlaceholder
|
<ListMaybePlaceholder
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -33,11 +41,7 @@ export function GifPickerPlaceholder({
|
|||||||
errorMessage={_(
|
errorMessage={_(
|
||||||
msg`There was a problem loading GIFs. Check your connection and try again.`,
|
msg`There was a problem loading GIFs. Check your connection and try again.`,
|
||||||
)}
|
)}
|
||||||
emptyMessage={
|
emptyMessage={emptyMessage}
|
||||||
isSearching
|
|
||||||
? _(msg`No GIFs found for "${query}".`)
|
|
||||||
: _(msg`No GIFs to show right now. Try again in a moment.`)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
useTenorGifSearchQuery,
|
||||||
} from '#/state/queries/tenor'
|
} from '#/state/queries/tenor'
|
||||||
import {useAnalytics} from '#/analytics'
|
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
|
* 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
|
// TODO: revert — hardcoded for local Klipy testing
|
||||||
const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||||
const isSearching = query.length > 0
|
const isSearching = query.length > 0
|
||||||
const provider: GifPickerProvider = useKlipy ? 'klipy' : 'tenor'
|
|
||||||
|
|
||||||
const klipyFeatured = useKlipyFeaturedGifsQuery({
|
const klipyFeatured = useKlipyFeaturedGifsQuery({
|
||||||
enabled: enabled && useKlipy && !isSearching,
|
enabled: enabled && useKlipy && !isSearching,
|
||||||
@@ -49,7 +47,6 @@ export function useGifPickerData(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...active,
|
...active,
|
||||||
provider,
|
|
||||||
isSearching,
|
isSearching,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,42 +4,37 @@ import {account} from '#/storage'
|
|||||||
|
|
||||||
const MAX_RECENT_GIFS = 20
|
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() {
|
export function useRecentGifs() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const did = currentAccount?.did
|
const did = currentAccount?.did
|
||||||
|
|
||||||
const getRecents = (): Gif[] => {
|
const getRecents = (): Gif[] => {
|
||||||
if (!did) return []
|
if (!did) return []
|
||||||
const stored = account.get([did, 'recentGifs'])
|
return readValid(did)
|
||||||
if (!stored) return []
|
|
||||||
try {
|
|
||||||
return stored.map(s => JSON.parse(s) as Gif)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const addRecent = (gif: Gif) => {
|
const addRecent = (gif: Gif) => {
|
||||||
if (!did) return
|
if (!did) return
|
||||||
const stored = account.get([did, 'recentGifs']) ?? []
|
const existing = readValid(did)
|
||||||
// Remove duplicate if already in recents
|
const deduped = existing.filter(g => g.id !== gif.id)
|
||||||
const filtered = stored.filter(s => {
|
const updated = [gif, ...deduped].slice(0, MAX_RECENT_GIFS)
|
||||||
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)
|
account.set([did, 'recentGifs'], updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getRecents,
|
getRecents,
|
||||||
addRecent,
|
addRecent,
|
||||||
hasRecents: did
|
hasRecents: did ? readValid(did).length > 0 : false,
|
||||||
? (account.get([did, 'recentGifs'])?.length ?? 0) > 0
|
|
||||||
: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
export {type Gif} from '#/state/queries/tenor'
|
export {type Gif} from '#/state/queries/gif'
|
||||||
|
|
||||||
export type GifPickerProvider = 'klipy' | 'tenor'
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {type ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import {createComposerImage} 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'
|
import {createGIFDescription} from '../gif-alt-text'
|
||||||
|
|
||||||
type ResolvedExternalLink = {
|
type ResolvedExternalLink = {
|
||||||
|
|||||||
@@ -182,8 +182,6 @@ export const GIF_KLIPY_SEARCH = (params: string) =>
|
|||||||
`${GIF_SERVICE}/klipy/v2/search?${params}`
|
`${GIF_SERVICE}/klipy/v2/search?${params}`
|
||||||
export const GIF_KLIPY_FEATURED = (params: string) =>
|
export const GIF_KLIPY_FEATURED = (params: string) =>
|
||||||
`${GIF_SERVICE}/klipy/v2/featured?${params}`
|
`${GIF_SERVICE}/klipy/v2/featured?${params}`
|
||||||
export const GIF_KLIPY_AUTOCOMPLETE = (params: string) =>
|
|
||||||
`${GIF_SERVICE}/klipy/v2/autocomplete?${params}`
|
|
||||||
|
|
||||||
export const MAX_LABELERS = 20
|
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 {Platform} from 'react-native'
|
||||||
import {getLocales} from 'expo-localization'
|
import {getLocales} from 'expo-localization'
|
||||||
import {
|
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
|
||||||
keepPreviousData,
|
|
||||||
useInfiniteQuery,
|
|
||||||
useQuery,
|
|
||||||
} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {
|
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
|
||||||
GIF_KLIPY_AUTOCOMPLETE,
|
|
||||||
GIF_KLIPY_FEATURED,
|
|
||||||
GIF_KLIPY_SEARCH,
|
|
||||||
} from '#/lib/constants'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {STALE} from '#/state/queries'
|
import {type Gif} from '#/state/queries/gif'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
|
|
||||||
export const RQKEY_ROOT = 'klipy-gif-service'
|
export const RQKEY_ROOT = 'klipy-gif-service'
|
||||||
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
||||||
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
|
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 getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED)
|
||||||
const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH)
|
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}) {
|
export function useFeaturedGifsQuery(options?: {enabled?: boolean}) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: RQKEY_FEATURED,
|
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>(
|
function createKlipyApi<Input extends object>(
|
||||||
urlFn: (params: string) => string,
|
urlFn: (params: string) => string,
|
||||||
): (input: Input & {pos?: string}) => Promise<{
|
): (input: Input & {pos?: string}) => Promise<{
|
||||||
@@ -167,8 +106,3 @@ export function klipyUrlToBskyGifUrl(klipyUrl: string) {
|
|||||||
url.hostname = 'k.gifs.bsky.app'
|
url.hostname = 'k.gifs.bsky.app'
|
||||||
return url.href
|
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 {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
|
||||||
import {STALE} from '#/state/queries/index'
|
import {STALE} from '#/state/queries/index'
|
||||||
import {useAgent} from '#/state/session'
|
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_ROOT = 'resolve-link'
|
||||||
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
|
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 {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {type ContentFormats, type Gif} from '#/state/queries/gif'
|
||||||
|
|
||||||
export const RQKEY_ROOT = 'gif-service'
|
export const RQKEY_ROOT = 'gif-service'
|
||||||
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
||||||
@@ -102,117 +103,3 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
|
|||||||
url.hostname = 't.gifs.bsky.app'
|
url.hostname = 't.gifs.bsky.app'
|
||||||
return url.href
|
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 ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
|
||||||
import {type Geolocation} from '#/geolocation/types'
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
|
||||||
@@ -81,8 +82,7 @@ export type Account = {
|
|||||||
lastSelectedHomeFeed?: string
|
lastSelectedHomeFeed?: string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recently selected GIFs in the GIF picker, stored as serialized Gif objects.
|
* Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
|
||||||
* Most recent first, capped at 20.
|
|
||||||
*/
|
*/
|
||||||
recentGifs?: string[]
|
recentGifs?: Gif[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ import {
|
|||||||
useLanguagePrefs,
|
useLanguagePrefs,
|
||||||
useLanguagePrefsApi,
|
useLanguagePrefsApi,
|
||||||
} from '#/state/preferences/languages'
|
} from '#/state/preferences/languages'
|
||||||
|
import {type Gif} from '#/state/queries/gif'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {type ComposerOpts, type OnPostSuccessData} 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 {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {type Gif} from '#/state/queries/gif'
|
||||||
import {
|
import {
|
||||||
useResolveGifQuery,
|
useResolveGifQuery,
|
||||||
useResolveLinkQuery,
|
useResolveLinkQuery,
|
||||||
} from '#/state/queries/resolve-link'
|
} from '#/state/queries/resolve-link'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
type EmbedPlayerParams,
|
type EmbedPlayerParams,
|
||||||
parseEmbedPlayerFromUrl,
|
parseEmbedPlayerFromUrl,
|
||||||
} from '#/lib/strings/embed-player'
|
} from '#/lib/strings/embed-player'
|
||||||
|
import {type Gif} from '#/state/queries/gif'
|
||||||
import {useResolveGifQuery} from '#/state/queries/resolve-link'
|
import {useResolveGifQuery} from '#/state/queries/resolve-link'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
|
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {getImageDim} from '#/lib/media/manip'
|
|||||||
import {mimeToExt} from '#/lib/media/video/util'
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
import {type ComposerImage} from '#/state/gallery'
|
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 {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
|
||||||
import {createPublicAgent} from '#/state/session/agent'
|
import {createPublicAgent} from '#/state/session/agent'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
toBskyAppUrl,
|
toBskyAppUrl,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {type ComposerImage, createInitialImages} from '#/state/gallery'
|
import {type ComposerImage, createInitialImages} from '#/state/gallery'
|
||||||
|
import {type Gif} from '#/state/queries/gif'
|
||||||
import {createPostgateRecord} from '#/state/queries/postgate/util'
|
import {createPostgateRecord} from '#/state/queries/postgate/util'
|
||||||
import {type Gif} from '#/state/queries/tenor'
|
|
||||||
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
||||||
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||||
import {type ComposerOpts} from '#/state/shell/composer'
|
import {type ComposerOpts} from '#/state/shell/composer'
|
||||||
|
|||||||
Reference in New Issue
Block a user