pr fixes
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(yarn typecheck *)",
|
||||||
|
"Bash(yarn lint *)",
|
||||||
|
"Bash(yarn test *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
parseStarterPackUri,
|
parseStarterPackUri,
|
||||||
} from '#/lib/strings/starter-pack'
|
} from '#/lib/strings/starter-pack'
|
||||||
import {messages} from '#/locale/locales/en/messages'
|
import {messages} from '#/locale/locales/en/messages'
|
||||||
import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy'
|
import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
|
||||||
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
|
|
||||||
import {cleanError} from '../../src/lib/strings/errors'
|
import {cleanError} from '../../src/lib/strings/errors'
|
||||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||||
import {enforceLen} from '../../src/lib/strings/helpers'
|
import {enforceLen} from '../../src/lib/strings/helpers'
|
||||||
@@ -1072,21 +1071,6 @@ describe('createStarterPackGooglePlayUri', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('tenorUrlToBskyGifUrl', () => {
|
|
||||||
const inputs = [
|
|
||||||
'https://media.tenor.com/someID_AAAAC/someName.gif',
|
|
||||||
'https://media.tenor.com/someID/someName.gif',
|
|
||||||
]
|
|
||||||
|
|
||||||
it.each(inputs)(
|
|
||||||
'returns url with t.gifs.bsky.app as hostname for input url',
|
|
||||||
input => {
|
|
||||||
const out = tenorUrlToBskyGifUrl(input)
|
|
||||||
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('klipyUrlToBskyGifUrl', () => {
|
describe('klipyUrlToBskyGifUrl', () => {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
|
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ function GifPickerBody({
|
|||||||
const items = isRecentsActive ? getRecents() : networkItems
|
const items = isRecentsActive ? getRecents() : networkItems
|
||||||
const hasData = items.length > 0
|
const hasData = items.length > 0
|
||||||
|
|
||||||
|
// Remount the grid when the data source changes so FlatList doesn't carry
|
||||||
|
// stale virtualized cells between e.g. a search and the recents list.
|
||||||
|
const viewKey = isRecentsActive ? 'recents' : `network:${effectiveSearch}`
|
||||||
|
|
||||||
const onEndReached = () => {
|
const onEndReached = () => {
|
||||||
if (isRecentsActive) return
|
if (isRecentsActive) return
|
||||||
if (isFetchingNextPage || !hasNextPage || error) return
|
if (isFetchingNextPage || !hasNextPage || error) return
|
||||||
@@ -172,6 +176,7 @@ function GifPickerBody({
|
|||||||
hasData={hasData}
|
hasData={hasData}
|
||||||
isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
|
isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
|
||||||
error={isRecentsActive ? null : error}
|
error={isRecentsActive ? null : error}
|
||||||
|
viewKey={viewKey}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
onSelectGif={handleSelectGif}
|
onSelectGif={handleSelectGif}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {i18n, type MessageDescriptor} from '@lingui/core'
|
import {type MessageDescriptor} from '@lingui/core'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, useSharedButtonTextStyles} from '#/components/Button'
|
import {Button, ButtonIcon} 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 {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'
|
||||||
@@ -16,8 +16,6 @@ import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2
|
|||||||
import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka'
|
import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka'
|
||||||
import {Trending3_Stroke2_Corner1_Rounded as Trending} from '#/components/icons/Trending'
|
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
|
||||||
icon: React.ComponentType<SVGIconProps>
|
icon: React.ComponentType<SVGIconProps>
|
||||||
@@ -49,10 +47,7 @@ export function GifCategoryPills({
|
|||||||
onSelect: (category: GifCategory) => void
|
onSelect: (category: GifCategory) => void
|
||||||
hasRecents: boolean
|
hasRecents: boolean
|
||||||
}) {
|
}) {
|
||||||
// Subscribe to locale changes so i18n._() returns the current translation.
|
const {i18n} = useLingui()
|
||||||
// The lingui-msg-rule lint rule forbids _() with variables, so we use
|
|
||||||
// i18n._() directly to translate the MessageDescriptor from GIF_CATEGORIES.
|
|
||||||
useLingui()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -73,24 +68,12 @@ export function GifCategoryPills({
|
|||||||
aria-current={isActive ? 'true' : undefined}
|
aria-current={isActive ? 'true' : undefined}
|
||||||
onPress={() => onSelect(category)}
|
onPress={() => onSelect(category)}
|
||||||
size="small"
|
size="small"
|
||||||
variant={isActive ? 'solid' : 'ghost'}
|
color={isActive ? 'secondary_inverted' : 'secondary'}
|
||||||
color="secondary"
|
|
||||||
shape="round">
|
shape="round">
|
||||||
<PillIcon icon={category.icon} />
|
<ButtonIcon icon={category.icon} size="md" />
|
||||||
</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,6 +1,4 @@
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
@@ -8,27 +6,22 @@ import {Button, ButtonText} from '#/components/Button'
|
|||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
export function GifPickerErrorBoundary({details}: {details?: string}) {
|
export function GifPickerErrorBoundary({details}: {details?: string}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}>
|
||||||
style={a.gap_md}
|
|
||||||
label={_(msg`An error has occurred`)}>
|
|
||||||
<Dialog.Close />
|
<Dialog.Close />
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
title={_(msg`Oh no!`)}
|
title={l`Oh no!`}
|
||||||
message={_(
|
message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`}
|
||||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
|
||||||
)}
|
|
||||||
details={details}
|
details={details}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Close dialog`)}
|
label={l`Close dialog`}
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
color="primary"
|
color="primary"
|
||||||
size="large"
|
size="large">
|
||||||
variant="solid">
|
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Close</Trans>
|
<Trans>Close</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type Props = {
|
|||||||
hasData: boolean
|
hasData: boolean
|
||||||
isFetchingNextPage: boolean
|
isFetchingNextPage: boolean
|
||||||
error: unknown
|
error: unknown
|
||||||
|
/**
|
||||||
|
* Identifies the current data source (e.g. "recents", "trending", a search
|
||||||
|
* term). Used to remount the underlying FlatList when switching sources so
|
||||||
|
* virtualized cells from the previous view can't bleed into the next one.
|
||||||
|
*/
|
||||||
|
viewKey: string
|
||||||
fetchNextPage: () => Promise<unknown>
|
fetchNextPage: () => Promise<unknown>
|
||||||
onEndReached: () => void
|
onEndReached: () => void
|
||||||
onSelectGif: (gif: Gif) => void
|
onSelectGif: (gif: Gif) => void
|
||||||
@@ -28,6 +34,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
|
|||||||
hasData,
|
hasData,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
error,
|
error,
|
||||||
|
viewKey,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
onEndReached,
|
onEndReached,
|
||||||
onSelectGif,
|
onSelectGif,
|
||||||
@@ -51,7 +58,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
|
|||||||
return (
|
return (
|
||||||
<Dialog.InnerFlatList
|
<Dialog.InnerFlatList
|
||||||
ref={ref}
|
ref={ref}
|
||||||
key={String(numColumns)}
|
key={`${numColumns}-${viewKey}`}
|
||||||
data={data}
|
data={data}
|
||||||
renderItem={({item}: {item: Gif[][]}) => (
|
renderItem={({item}: {item: Gif[][]}) => (
|
||||||
<View style={[a.flex_row, a.gap_sm]}>
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {type Ref} from 'react'
|
import {type Ref} from 'react'
|
||||||
import {type TextInput, View} from 'react-native'
|
import {type TextInput, View} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
@@ -21,7 +20,7 @@ export function GifPickerHeader({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onEscape: () => void
|
onEscape: () => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
@@ -39,11 +38,10 @@ export function GifPickerHeader({
|
|||||||
{!gtMobile && IS_WEB && (
|
{!gtMobile && IS_WEB && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
label={_(msg`Close GIF dialog`)}>
|
label={l`Close GIF dialog`}>
|
||||||
<ButtonIcon icon={Arrow} size="md" />
|
<ButtonIcon icon={Arrow} size="md" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -51,8 +49,8 @@ export function GifPickerHeader({
|
|||||||
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
|
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
|
||||||
<TextField.Icon icon={Search} />
|
<TextField.Icon icon={Search} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
label={_(msg`Search GIFs`)}
|
label={l`Search GIFs`}
|
||||||
placeholder={_(msg`Search GIFs`)}
|
placeholder={l`Search GIFs`}
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
returnKeyType="search"
|
returnKeyType="search"
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
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'
|
||||||
import {type Gif} from '#/features/gifPicker/types'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
import {gifPreviewUrl} from '#/features/gifPicker/utils'
|
||||||
|
|
||||||
export function GifPickerItem({
|
export function GifPickerItem({
|
||||||
gif,
|
gif,
|
||||||
@@ -16,7 +15,7 @@ export function GifPickerItem({
|
|||||||
onSelectGif: (gif: Gif) => void
|
onSelectGif: (gif: Gif) => void
|
||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const [width, height] = gif.media_formats.tinygif.dims
|
const [width, height] = gif.media_formats.tinygif.dims
|
||||||
@@ -29,7 +28,7 @@ export function GifPickerItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Select GIF "${gif.title}"`)}
|
label={l`Select GIF "${gif.title}"`}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={a.w_full}>
|
style={a.w_full}>
|
||||||
{({pressed}) => (
|
{({pressed}) => (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {ListMaybePlaceholder} from '#/components/Lists'
|
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||||
|
|
||||||
@@ -20,13 +19,13 @@ export function GifPickerPlaceholder({
|
|||||||
onRetry: () => Promise<unknown>
|
onRetry: () => Promise<unknown>
|
||||||
onGoBack: () => void
|
onGoBack: () => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
const emptyMessage = isSearching
|
const emptyMessage = isSearching
|
||||||
? _(msg`No GIFs found for "${query}".`)
|
? l`No GIFs found for "${query}".`
|
||||||
: isRecentsEmpty
|
: isRecentsEmpty
|
||||||
? _(msg`No recent GIFs yet. Pick one to see it here.`)
|
? l`No recent GIFs yet. Pick one to see it here.`
|
||||||
: _(msg`No GIFs to show right now. Try again in a moment.`)
|
: l`No GIFs to show right now. Try again in a moment.`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListMaybePlaceholder
|
<ListMaybePlaceholder
|
||||||
@@ -37,10 +36,8 @@ export function GifPickerPlaceholder({
|
|||||||
emptyType="results"
|
emptyType="results"
|
||||||
sideBorders={false}
|
sideBorders={false}
|
||||||
topBorder={false}
|
topBorder={false}
|
||||||
errorTitle={_(msg`Couldn't load GIFs`)}
|
errorTitle={l`Couldn't load GIFs`}
|
||||||
errorMessage={_(
|
errorMessage={l`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={emptyMessage}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,48 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
|
useFeaturedGifsQuery,
|
||||||
useGifSearchQuery as useKlipyGifSearchQuery,
|
useGifSearchQuery,
|
||||||
} from '#/state/queries/klipy'
|
} from '#/features/gifPicker/queries'
|
||||||
import {
|
|
||||||
useTenorFeaturedGifsQuery,
|
|
||||||
useTenorGifSearchQuery,
|
|
||||||
} from '#/state/queries/tenor'
|
|
||||||
import {useAnalytics} from '#/analytics'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single entry point for the GIF picker's data layer. Wraps the Klipy/Tenor
|
* Single entry point for the GIF picker's data layer. Routes between the
|
||||||
* feature-flag split and the featured-vs-search branching so the UI only ever
|
* featured and search endpoints so the UI only ever consumes one query result.
|
||||||
* consumes one query result.
|
|
||||||
*
|
|
||||||
* The Tenor path is kept alive until the Klipy rollout is complete.
|
|
||||||
*/
|
*/
|
||||||
export function useGifPickerData(
|
export function useGifPickerData(
|
||||||
query: string,
|
query: string,
|
||||||
{enabled = true}: {enabled?: boolean} = {},
|
{enabled = true}: {enabled?: boolean} = {},
|
||||||
) {
|
) {
|
||||||
const ax = useAnalytics()
|
|
||||||
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
|
||||||
const isSearching = query.length > 0
|
const isSearching = query.length > 0
|
||||||
|
|
||||||
const klipyFeatured = useKlipyFeaturedGifsQuery({
|
const featured = useFeaturedGifsQuery({enabled: enabled && !isSearching})
|
||||||
enabled: enabled && useKlipy && !isSearching,
|
const search = useGifSearchQuery(query, {enabled: enabled && isSearching})
|
||||||
})
|
|
||||||
const klipySearch = useKlipyGifSearchQuery(query, {
|
|
||||||
enabled: enabled && useKlipy && isSearching,
|
|
||||||
})
|
|
||||||
const tenorFeatured = useTenorFeaturedGifsQuery({
|
|
||||||
enabled: enabled && !useKlipy && !isSearching,
|
|
||||||
})
|
|
||||||
const tenorSearch = useTenorGifSearchQuery(query, {
|
|
||||||
enabled: enabled && !useKlipy && isSearching,
|
|
||||||
})
|
|
||||||
|
|
||||||
const active = useKlipy
|
const active = isSearching ? search : featured
|
||||||
? isSearching
|
|
||||||
? klipySearch
|
|
||||||
: klipyFeatured
|
|
||||||
: isSearching
|
|
||||||
? tenorSearch
|
|
||||||
: tenorFeatured
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...active,
|
...active,
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {getLocales} from 'expo-localization'
|
|||||||
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
|
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
|
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {type Gif} from '#/state/queries/gif'
|
|
||||||
|
|
||||||
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']
|
||||||
@@ -88,21 +87,3 @@ function createKlipyApi<Input extends object>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrites a KLIPY static CDN URL through the bsky proxy
|
|
||||||
* (k.gifs.bsky.app). Mirrors `tenorUrlToBskyGifUrl`, but uses a
|
|
||||||
* separate hostname from Tenor's t.gifs.bsky.app so the two
|
|
||||||
* upstreams can be routed independently.
|
|
||||||
*/
|
|
||||||
export function klipyUrlToBskyGifUrl(klipyUrl: string) {
|
|
||||||
let url
|
|
||||||
try {
|
|
||||||
url = new URL(klipyUrl)
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('invalid url passed to klipyUrlToBskyGifUrl()')
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
url.hostname = 'k.gifs.bsky.app'
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
@@ -1 +1,33 @@
|
|||||||
export {type Gif} from '#/state/queries/gif'
|
/**
|
||||||
|
* 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
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites a KLIPY static CDN URL through the bsky proxy
|
||||||
|
* (k.gifs.bsky.app) so downstream consumers can route requests
|
||||||
|
* through Bluesky-owned infrastructure.
|
||||||
|
*/
|
||||||
|
export function klipyUrlToBskyGifUrl(klipyUrl: string) {
|
||||||
|
let url
|
||||||
|
try {
|
||||||
|
url = new URL(klipyUrl)
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('invalid url passed to klipyUrlToBskyGifUrl()')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
url.hostname = 'k.gifs.bsky.app'
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
@@ -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/gif'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {createGIFDescription} from '../gif-alt-text'
|
import {createGIFDescription} from '../gif-alt-text'
|
||||||
|
|
||||||
type ResolvedExternalLink = {
|
type ResolvedExternalLink = {
|
||||||
|
|||||||
@@ -173,11 +173,6 @@ export const KNOWN_SHUTDOWN_FEEDS = [
|
|||||||
|
|
||||||
export const GIF_SERVICE = 'https://gifs.bsky.app'
|
export const GIF_SERVICE = 'https://gifs.bsky.app'
|
||||||
|
|
||||||
export const GIF_SEARCH = (params: string) =>
|
|
||||||
`${GIF_SERVICE}/tenor/v2/search?${params}`
|
|
||||||
export const GIF_FEATURED = (params: string) =>
|
|
||||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
|
||||||
|
|
||||||
export const GIF_KLIPY_SEARCH = (params: string) =>
|
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) =>
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -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 './gif'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
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,105 +0,0 @@
|
|||||||
import {Platform} from 'react-native'
|
|
||||||
import {getLocales} from 'expo-localization'
|
|
||||||
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']
|
|
||||||
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
|
|
||||||
|
|
||||||
const getTrendingGifs = createTenorApi(GIF_FEATURED)
|
|
||||||
|
|
||||||
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
|
|
||||||
|
|
||||||
export function useTenorFeaturedGifsQuery(options?: {enabled?: boolean}) {
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: RQKEY_FEATURED,
|
|
||||||
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
|
||||||
getNextPageParam: lastPage => lastPage.next,
|
|
||||||
enabled: options?.enabled,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTenorGifSearchQuery(
|
|
||||||
query: string,
|
|
||||||
options?: {enabled?: boolean},
|
|
||||||
) {
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: RQKEY_SEARCH(query),
|
|
||||||
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
|
||||||
getNextPageParam: lastPage => lastPage.next,
|
|
||||||
enabled: !!query && options?.enabled !== false,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTenorApi<Input extends object>(
|
|
||||||
urlFn: (params: string) => string,
|
|
||||||
): (input: Input & {pos?: string}) => Promise<{
|
|
||||||
next: string
|
|
||||||
results: Gif[]
|
|
||||||
}> {
|
|
||||||
return async input => {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
|
|
||||||
// set client key based on platform
|
|
||||||
params.set(
|
|
||||||
'client_key',
|
|
||||||
Platform.select({
|
|
||||||
ios: 'bluesky-ios',
|
|
||||||
android: 'bluesky-android',
|
|
||||||
default: 'bluesky-web',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
|
|
||||||
params.set('limit', '30')
|
|
||||||
|
|
||||||
params.set('contentfilter', 'high')
|
|
||||||
|
|
||||||
params.set(
|
|
||||||
'media_filter',
|
|
||||||
(['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
|
|
||||||
)
|
|
||||||
|
|
||||||
const locale = getLocales?.()?.[0]
|
|
||||||
|
|
||||||
if (locale) {
|
|
||||||
params.set('locale', locale.languageTag.replace('-', '_'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(input)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
params.set(key, String(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(urlFn(params.toString()), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Failed to fetch Tenor API')
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tenorUrlToBskyGifUrl(tenorUrl: string) {
|
|
||||||
let url
|
|
||||||
try {
|
|
||||||
url = new URL(tenorUrl)
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('invalid url passed to tenorUrlToBskyGifUrl()')
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
url.hostname = 't.gifs.bsky.app'
|
|
||||||
return url.href
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
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 Gif} from '#/features/gifPicker/types'
|
||||||
import {type Geolocation} from '#/geolocation/types'
|
import {type Geolocation} from '#/geolocation/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ 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 {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
@@ -135,6 +134,7 @@ import * as Toast from '#/components/Toast'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
import {
|
import {
|
||||||
draftToComposerPosts,
|
draftToComposerPosts,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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,
|
||||||
@@ -15,6 +14,7 @@ import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
|
|||||||
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
|
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
|
||||||
import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
|
import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
export const ExternalEmbedGif = ({
|
export const ExternalEmbedGif = ({
|
||||||
onRemove,
|
onRemove,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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 {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'
|
||||||
@@ -24,6 +23,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico
|
|||||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
|
import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
|
||||||
export function GifAltTextDialog({
|
export function GifAltTextDialog({
|
||||||
gif,
|
gif,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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/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 {
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
import {type VideoState} from '#/view/com/composer/state/video'
|
import {type VideoState} from '#/view/com/composer/state/video'
|
||||||
import {type AnalyticsContextType} from '#/analytics'
|
import {type AnalyticsContextType} from '#/analytics'
|
||||||
import {getDeviceId} from '#/analytics/identifiers'
|
import {getDeviceId} from '#/analytics/identifiers'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {logger} from './logger'
|
import {logger} from './logger'
|
||||||
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
import {type DraftPostDisplay, type DraftSummary} from './schema'
|
||||||
import * as storage from './storage'
|
import * as storage from './storage'
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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 {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'
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
type LinkFacetMatch,
|
type LinkFacetMatch,
|
||||||
suggestLinkCardUri,
|
suggestLinkCardUri,
|
||||||
} from '#/view/com/composer/text-input/text-input-util'
|
} from '#/view/com/composer/text-input/text-input-util'
|
||||||
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
import {
|
import {
|
||||||
createVideoState,
|
createVideoState,
|
||||||
type VideoAction,
|
type VideoAction,
|
||||||
|
|||||||
Reference in New Issue
Block a user