Add GIF select to composer (#3600)
* create dialog with flatlist in it * use alf for composer photos/camera/gif buttons * add gif icons * focus textinput on gif dialog close * add giphy API + gif grid * web support * add consent confirmation * track gif select * desktop web consent styles * use InlineLinkText instead of Link * add error/loading state * hide sideborders on web * disable composer buttons where necessary * skip cardyb and set thumbnail directly * switch legacy analytics to statsig * remove autoplay prop * disable photo/gif buttons if external media is present * memoize listmaybeplaceholder * fix pagination * don't set `value` of TextInput, clear via ref * remove console.log * close modal if press escape * pass alt text in the description * Fix typo * Rm dialog --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>zio/stable
parent
2090738185
commit
ba1c4834ab
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 281 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V5h14v14H5Zm10.725-5.2c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Zm-3.367.872c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm-3.879.078C6.92 14.75 6 13.827 6 12.287v-.617c0-1.47.955-2.42 2.472-2.42.589 0 1.139.147 1.548.388.404.236.664.562.664.915 0 .373-.271.636-.656.636a.82.82 0 0 1-.41-.108 2.34 2.34 0 0 1-.271-.177c-.208-.148-.421-.3-.746-.3-.644 0-.95.38-.95 1.155v.52c0 .768.306 1.168.903 1.168.436 0 .735-.248.735-.61v-.061h-.146c-.412 0-.632-.194-.632-.551 0-.353.216-.535.632-.535h.806c.617 0 .884.256.884.834v.166c0 1.253-.92 2.06-2.354 2.06Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 1000 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3Zm1 14V6h16v12H4Zm2-5.713c0 1.54.92 2.463 2.48 2.463 1.434 0 2.353-.807 2.353-2.06v-.166c0-.578-.267-.834-.884-.834h-.806c-.416 0-.632.182-.632.535 0 .357.22.55.632.55h.146v.063c0 .36-.299.609-.735.609-.597 0-.904-.4-.904-1.168v-.52c0-.775.307-1.155.951-1.155.325 0 .538.152.746.3.089.064.176.127.272.177a.82.82 0 0 0 .409.108c.385 0 .656-.263.656-.636 0-.353-.26-.679-.664-.915-.409-.24-.96-.388-1.548-.388C6.955 9.25 6 10.2 6 11.67v.617Zm6.358 2.385c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm3.367-.872c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 993 B |
|
@ -4,6 +4,8 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import BottomSheet, {
|
import BottomSheet, {
|
||||||
BottomSheetBackdropProps,
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetFlatList,
|
||||||
|
BottomSheetFlatListMethods,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetScrollViewMethods,
|
BottomSheetScrollViewMethods,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
|
@ -11,10 +13,10 @@ import BottomSheet, {
|
||||||
useBottomSheet,
|
useBottomSheet,
|
||||||
WINDOW_HEIGHT,
|
WINDOW_HEIGHT,
|
||||||
} from '@discord/bottom-sheet/src'
|
} from '@discord/bottom-sheet/src'
|
||||||
|
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||||
import {isNative} from 'platform/detection'
|
|
||||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||||
import {Context} from '#/components/Dialog/context'
|
import {Context} from '#/components/Dialog/context'
|
||||||
import {
|
import {
|
||||||
|
@ -238,7 +240,7 @@ export const ScrollableInner = React.forwardRef<
|
||||||
},
|
},
|
||||||
flatten(style),
|
flatten(style),
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}
|
contentContainerStyle={a.pb_4xl}
|
||||||
ref={ref}>
|
ref={ref}>
|
||||||
{children}
|
{children}
|
||||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||||
|
@ -246,6 +248,34 @@ export const ScrollableInner = React.forwardRef<
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const InnerFlatList = React.forwardRef<
|
||||||
|
BottomSheetFlatListMethods,
|
||||||
|
BottomSheetFlatListProps<any>
|
||||||
|
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
|
return (
|
||||||
|
<BottomSheetFlatList
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
|
||||||
|
ListFooterComponent={
|
||||||
|
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||||
|
}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.p_xl,
|
||||||
|
a.pt_0,
|
||||||
|
a.h_full,
|
||||||
|
{
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
flatten(style),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export function Handle() {
|
export function Handle() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import React, {useImperativeHandle} from 'react'
|
import React, {useImperativeHandle} from 'react'
|
||||||
import {TouchableWithoutFeedback, View} from 'react-native'
|
import {
|
||||||
|
FlatList,
|
||||||
|
FlatListProps,
|
||||||
|
TouchableWithoutFeedback,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
|
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
@ -192,6 +197,17 @@ export function Inner({
|
||||||
|
|
||||||
export const ScrollableInner = Inner
|
export const ScrollableInner = Inner
|
||||||
|
|
||||||
|
export function InnerFlatList({
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: FlatListProps<any> & {label: string}) {
|
||||||
|
return (
|
||||||
|
<Inner label={label}>
|
||||||
|
<FlatList {...props} />
|
||||||
|
</Inner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Handle() {
|
export function Handle() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,10 +16,14 @@ export function Error({
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onGoBack: onGoBackProp,
|
||||||
|
sideBorders = true,
|
||||||
}: {
|
}: {
|
||||||
title?: string
|
title?: string
|
||||||
message?: string
|
message?: string
|
||||||
onRetry?: () => unknown
|
onRetry?: () => unknown
|
||||||
|
onGoBack?: () => unknown
|
||||||
|
sideBorders?: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
@ -28,6 +32,10 @@ export function Error({
|
||||||
|
|
||||||
const canGoBack = navigation.canGoBack()
|
const canGoBack = navigation.canGoBack()
|
||||||
const onGoBack = React.useCallback(() => {
|
const onGoBack = React.useCallback(() => {
|
||||||
|
if (onGoBackProp) {
|
||||||
|
onGoBackProp()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (canGoBack) {
|
if (canGoBack) {
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
} else {
|
} else {
|
||||||
|
@ -41,18 +49,19 @@ export function Error({
|
||||||
navigation.dispatch(StackActions.popToTop())
|
navigation.dispatch(StackActions.popToTop())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [navigation, canGoBack])
|
}, [navigation, canGoBack, onGoBackProp])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView
|
<CenteredView
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
a.gap_5xl,
|
||||||
|
!gtMobile && a.justify_between,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{paddingTop: 175, paddingBottom: 110},
|
{paddingTop: 175, paddingBottom: 110},
|
||||||
]}
|
]}
|
||||||
sideBorders>
|
sideBorders={sideBorders}>
|
||||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||||
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
|
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
|
||||||
<Text
|
<Text
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import React from 'react'
|
import React, {memo} from 'react'
|
||||||
import {View} from 'react-native'
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {CenteredView} from 'view/com/util/Views'
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {Error} from '#/components/Error'
|
import {Error} from '#/components/Error'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
@ -16,11 +16,13 @@ export function ListFooter({
|
||||||
error,
|
error,
|
||||||
onRetry,
|
onRetry,
|
||||||
height,
|
height,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
isFetchingNextPage?: boolean
|
isFetchingNextPage?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
onRetry?: () => Promise<unknown>
|
onRetry?: () => Promise<unknown>
|
||||||
height?: number
|
height?: number
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
|
@ -33,6 +35,7 @@ export function ListFooter({
|
||||||
a.pb_lg,
|
a.pb_lg,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{height: height ?? 180, paddingTop: 30},
|
{height: height ?? 180, paddingTop: 30},
|
||||||
|
flatten(style),
|
||||||
]}>
|
]}>
|
||||||
{isFetchingNextPage ? (
|
{isFetchingNextPage ? (
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
|
@ -120,7 +123,7 @@ export function ListHeaderDesktop({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListMaybePlaceholder({
|
let ListMaybePlaceholder = ({
|
||||||
isLoading,
|
isLoading,
|
||||||
noEmpty,
|
noEmpty,
|
||||||
isError,
|
isError,
|
||||||
|
@ -130,6 +133,8 @@ export function ListMaybePlaceholder({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
emptyType = 'page',
|
emptyType = 'page',
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onGoBack,
|
||||||
|
sideBorders,
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
noEmpty?: boolean
|
noEmpty?: boolean
|
||||||
|
@ -140,7 +145,9 @@ export function ListMaybePlaceholder({
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
emptyType?: 'page' | 'results'
|
emptyType?: 'page' | 'results'
|
||||||
onRetry?: () => Promise<unknown>
|
onRetry?: () => Promise<unknown>
|
||||||
}) {
|
onGoBack?: () => void
|
||||||
|
sideBorders?: boolean
|
||||||
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {gtMobile, gtTablet} = useBreakpoints()
|
const {gtMobile, gtTablet} = useBreakpoints()
|
||||||
|
@ -155,7 +162,7 @@ export function ListMaybePlaceholder({
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{paddingTop: 175, paddingBottom: 110},
|
{paddingTop: 175, paddingBottom: 110},
|
||||||
]}
|
]}
|
||||||
sideBorders={gtMobile}
|
sideBorders={sideBorders ?? gtMobile}
|
||||||
topBorder={!gtTablet}>
|
topBorder={!gtTablet}>
|
||||||
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
|
@ -170,6 +177,8 @@ export function ListMaybePlaceholder({
|
||||||
title={errorTitle ?? _(msg`Oops!`)}
|
title={errorTitle ?? _(msg`Oops!`)}
|
||||||
message={errorMessage ?? _(`Something went wrong!`)}
|
message={errorMessage ?? _(`Something went wrong!`)}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
sideBorders={sideBorders}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -188,9 +197,13 @@ export function ListMaybePlaceholder({
|
||||||
_(msg`We're sorry! We can't find the page you were looking for.`)
|
_(msg`We're sorry! We can't find the page you were looking for.`)
|
||||||
}
|
}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
sideBorders={sideBorders}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
ListMaybePlaceholder = memo(ListMaybePlaceholder)
|
||||||
|
export {ListMaybePlaceholder}
|
||||||
|
|
|
@ -0,0 +1,360 @@
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useDeferredValue,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {TextInput, View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {GIPHY_PRIVACY_POLICY} from '#/lib/constants'
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {
|
||||||
|
useExternalEmbedsPrefs,
|
||||||
|
useSetExternalEmbedPref,
|
||||||
|
} from '#/state/preferences'
|
||||||
|
import {Gif, useGifphySearch, useGiphyTrending} from '#/state/queries/giphy'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||||
|
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||||
|
import {Text} from '../Typography'
|
||||||
|
|
||||||
|
export function GifSelectDialog({
|
||||||
|
control,
|
||||||
|
onClose,
|
||||||
|
onSelectGif: onSelectGifProp,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onClose: () => void
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
const externalEmbedsPrefs = useExternalEmbedsPrefs()
|
||||||
|
const onSelectGif = useCallback(
|
||||||
|
(gif: Gif) => {
|
||||||
|
control.close(() => onSelectGifProp(gif))
|
||||||
|
},
|
||||||
|
[control, onSelectGifProp],
|
||||||
|
)
|
||||||
|
|
||||||
|
let content = null
|
||||||
|
let snapPoints
|
||||||
|
switch (externalEmbedsPrefs?.giphy) {
|
||||||
|
case 'show':
|
||||||
|
content = <GifList control={control} onSelectGif={onSelectGif} />
|
||||||
|
snapPoints = ['100%']
|
||||||
|
break
|
||||||
|
case 'hide':
|
||||||
|
default:
|
||||||
|
content = <GiphyConsentPrompt control={control} />
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
nativeOptions={{sheet: {snapPoints}}}
|
||||||
|
onClose={onClose}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
{content}
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GifList({
|
||||||
|
control,
|
||||||
|
onSelectGif,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const ref = useRef<TextInput>(null)
|
||||||
|
const [undeferredSearch, setSearch] = useState('')
|
||||||
|
const search = useDeferredValue(undeferredSearch)
|
||||||
|
|
||||||
|
const isSearching = search.length > 0
|
||||||
|
|
||||||
|
const trendingQuery = useGiphyTrending()
|
||||||
|
const searchQuery = useGifphySearch(search)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = isSearching ? searchQuery : trendingQuery
|
||||||
|
|
||||||
|
const flattenedData = useMemo(() => {
|
||||||
|
const uniquenessSet = new Set<string>()
|
||||||
|
|
||||||
|
function filter(gif: Gif) {
|
||||||
|
if (!gif) return false
|
||||||
|
if (uniquenessSet.has(gif.id)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
uniquenessSet.add(gif.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return data?.pages.flatMap(page => page.data.filter(filter)) || []
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({item}: {item: Gif}) => {
|
||||||
|
return <GifPreview gif={item} onSelectGif={onSelectGif} />
|
||||||
|
},
|
||||||
|
[onSelectGif],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onEndReached = React.useCallback(() => {
|
||||||
|
if (isFetchingNextPage || !hasNextPage || error) return
|
||||||
|
fetchNextPage()
|
||||||
|
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||||
|
|
||||||
|
const hasData = flattenedData.length > 0
|
||||||
|
|
||||||
|
const onGoBack = useCallback(() => {
|
||||||
|
if (isSearching) {
|
||||||
|
// clear the input and reset the state
|
||||||
|
ref.current?.clear()
|
||||||
|
setSearch('')
|
||||||
|
} else {
|
||||||
|
control.close()
|
||||||
|
}
|
||||||
|
}, [control, isSearching])
|
||||||
|
|
||||||
|
const listHeader = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.mb_lg,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
!gtMobile && isWeb && a.gap_md,
|
||||||
|
]}>
|
||||||
|
{/* cover top corners */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{top: 0, left: 0, right: 0, height: '50%'},
|
||||||
|
t.atoms.bg,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!gtMobile && isWeb && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
shape="round"
|
||||||
|
onPress={() => control.close()}
|
||||||
|
label={_(msg`Close GIF dialog`)}>
|
||||||
|
<ButtonIcon icon={Arrow} size="md" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Search} />
|
||||||
|
<TextField.Input
|
||||||
|
label={_(msg`Search GIFs`)}
|
||||||
|
placeholder={_(msg`Powered by GIPHY`)}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
returnKeyType="search"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
inputRef={ref}
|
||||||
|
maxLength={50}
|
||||||
|
onKeyPress={({nativeEvent}) => {
|
||||||
|
if (nativeEvent.key === 'Escape') {
|
||||||
|
control.close()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}, [gtMobile, t.atoms.bg, _, control])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{gtMobile && <Dialog.Close />}
|
||||||
|
<Dialog.InnerFlatList
|
||||||
|
key={gtMobile ? '3 cols' : '2 cols'}
|
||||||
|
data={flattenedData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
numColumns={gtMobile ? 3 : 2}
|
||||||
|
columnWrapperStyle={a.gap_sm}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
{listHeader}
|
||||||
|
{!hasData && (
|
||||||
|
<ListMaybePlaceholder
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={refetch}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
emptyType="results"
|
||||||
|
sideBorders={false}
|
||||||
|
errorTitle={_(msg`Failed to load GIFs`)}
|
||||||
|
errorMessage={_(msg`There was an issue connecting to GIPHY.`)}
|
||||||
|
emptyMessage={
|
||||||
|
isSearching
|
||||||
|
? _(msg`No search results found for "${search}".`)
|
||||||
|
: _(
|
||||||
|
msg`No trending GIFs found. There may be an issue with GIPHY.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={4}
|
||||||
|
keyExtractor={(item: Gif) => item.id}
|
||||||
|
// @ts-expect-error web only
|
||||||
|
style={isWeb && {minHeight: '100vh'}}
|
||||||
|
ListFooterComponent={
|
||||||
|
hasData ? (
|
||||||
|
<ListFooter
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
error={cleanError(error)}
|
||||||
|
onRetry={fetchNextPage}
|
||||||
|
style={{borderTopWidth: 0}}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GifPreview({
|
||||||
|
gif,
|
||||||
|
onSelectGif,
|
||||||
|
}: {
|
||||||
|
gif: Gif
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
const {gtTablet} = useBreakpoints()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const onPress = useCallback(() => {
|
||||||
|
logEvent('composer:gif:select', {})
|
||||||
|
onSelectGif(gif)
|
||||||
|
}, [onSelectGif, gif])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Select GIF "${gif.title}"`)}
|
||||||
|
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||||
|
onPress={onPress}>
|
||||||
|
{({pressed}) => (
|
||||||
|
<Image
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.mb_sm,
|
||||||
|
a.rounded_sm,
|
||||||
|
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}
|
||||||
|
source={{uri: gif.images.preview_gif.url}}
|
||||||
|
contentFit="cover"
|
||||||
|
accessibilityLabel={gif.title}
|
||||||
|
accessibilityHint=""
|
||||||
|
cachePolicy="none"
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GiphyConsentPrompt({control}: {control: Dialog.DialogControlProps}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const setExternalEmbedPref = useSetExternalEmbedPref()
|
||||||
|
|
||||||
|
const onShowPress = useCallback(() => {
|
||||||
|
setExternalEmbedPref('giphy', 'show')
|
||||||
|
}, [setExternalEmbedPref])
|
||||||
|
|
||||||
|
const onHidePress = useCallback(() => {
|
||||||
|
setExternalEmbedPref('giphy', 'hide')
|
||||||
|
control.close()
|
||||||
|
}, [control, setExternalEmbedPref])
|
||||||
|
|
||||||
|
const gtMobileWeb = gtMobile && isWeb
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Permission to use GIPHY`)}>
|
||||||
|
<View style={a.gap_sm}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>
|
||||||
|
<Trans>Permission to use GIPHY</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}>
|
||||||
|
<Text>
|
||||||
|
<Trans>
|
||||||
|
Bluesky uses GIPHY to provide the GIF selector feature.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={t.atoms.text_contrast_medium}>
|
||||||
|
<Trans>
|
||||||
|
GIPHY may collect information about you and your device. You can
|
||||||
|
find out more in their{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
to={GIPHY_PRIVACY_POLICY}
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
privacy policy
|
||||||
|
</InlineLinkText>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[a.gap_md, gtMobileWeb && a.flex_row_reverse]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Enable GIPHY`)}
|
||||||
|
onPress={onShowPress}
|
||||||
|
onAccessibilityEscape={control.close}
|
||||||
|
color="primary"
|
||||||
|
size={gtMobileWeb ? 'small' : 'medium'}
|
||||||
|
variant="solid">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Enable GIPHY</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
label={_(msg`No thanks`)}
|
||||||
|
onAccessibilityEscape={control.close}
|
||||||
|
onPress={onHidePress}
|
||||||
|
color="secondary"
|
||||||
|
size={gtMobileWeb ? 'small' : 'medium'}
|
||||||
|
variant="ghost">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>No thanks</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||||
export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
|
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
|
||||||
|
})
|
|
@ -0,0 +1,9 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Gif_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M3 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3Zm1 14V6h16v12H4Zm2-5.713c0 1.54.92 2.463 2.48 2.463 1.434 0 2.353-.807 2.353-2.06v-.166c0-.578-.267-.834-.884-.834h-.806c-.416 0-.632.182-.632.535 0 .357.22.55.632.55h.146v.063c0 .36-.299.609-.735.609-.597 0-.904-.4-.904-1.168v-.52c0-.775.307-1.155.951-1.155.325 0 .538.152.746.3.089.064.176.127.272.177a.82.82 0 0 0 .409.108c.385 0 .656-.263.656-.636 0-.353-.26-.679-.664-.915-.409-.24-.96-.388-1.548-.388C6.955 9.25 6 10.2 6 11.67v.617Zm6.358 2.385c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm3.367-.872c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GifSquare_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V5h14v14H5Zm10.725-5.2c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Zm-3.367.872c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm-3.879.078C6.92 14.75 6 13.827 6 12.287v-.617c0-1.47.955-2.42 2.472-2.42.589 0 1.139.147 1.548.388.404.236.664.562.664.915 0 .373-.271.636-.656.636a.82.82 0 0 1-.41-.108 2.34 2.34 0 0 1-.271-.177c-.208-.148-.421-.3-.746-.3-.644 0-.95.38-.95 1.155v.52c0 .768.306 1.168.903 1.168.436 0 .735-.248.735-.61v-.061h-.146c-.412 0-.632-.194-.632-.551 0-.353.216-.535.632-.535h.806c.617 0 .884.256.884.834v.166c0 1.253-.92 2.06-2.354 2.06Z',
|
||||||
|
})
|
|
@ -89,3 +89,12 @@ export const BSKY_FEED_OWNER_DIDS = [
|
||||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const GIPHY_API_URL = 'https://api.giphy.com'
|
||||||
|
export const GIPHY_API_KEY = Platform.select({
|
||||||
|
ios: 'ydVxhrQkwlcUjkVKx15mF6vyaNJbMeez',
|
||||||
|
android: 'Vwj3Ib7857dj3EcIg24Hiz1LbRVdGeYF',
|
||||||
|
default: 'vyL3hQQ8AipwcmIB8kFvg0NDs9faWg7G',
|
||||||
|
})
|
||||||
|
export const GIPHY_PRIVACY_POLICY =
|
||||||
|
'https://support.giphy.com/hc/en-us/articles/360032872931-GIPHY-Privacy-Policy'
|
||||||
|
|
|
@ -60,6 +60,8 @@ export type LogEvents = {
|
||||||
feedType: string
|
feedType: string
|
||||||
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
||||||
}
|
}
|
||||||
|
'composer:gif:open': {}
|
||||||
|
'composer:gif:select': {}
|
||||||
|
|
||||||
// Data events
|
// Data events
|
||||||
'account:create:begin': {}
|
'account:create:begin': {}
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
import {EmbedPlayerSource} from 'lib/strings/embed-player'
|
import {EmbedPlayerSource} from 'lib/strings/embed-player'
|
||||||
|
|
||||||
type StateContext = persisted.Schema['externalEmbeds']
|
type StateContext = persisted.Schema['externalEmbeds']
|
||||||
type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void
|
type SetContext = (
|
||||||
|
source: EmbedPlayerSource,
|
||||||
|
value: 'show' | 'hide' | undefined,
|
||||||
|
) => void
|
||||||
|
|
||||||
const stateContext = React.createContext<StateContext>(
|
const stateContext = React.createContext<StateContext>(
|
||||||
persisted.defaults.externalEmbeds,
|
persisted.defaults.externalEmbeds,
|
||||||
|
@ -14,7 +18,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [state, setState] = React.useState(persisted.get('externalEmbeds'))
|
const [state, setState] = React.useState(persisted.get('externalEmbeds'))
|
||||||
|
|
||||||
const setStateWrapped = React.useCallback(
|
const setStateWrapped = React.useCallback(
|
||||||
(source: EmbedPlayerSource, value: 'show' | 'hide') => {
|
(source: EmbedPlayerSource, value: 'show' | 'hide' | undefined) => {
|
||||||
setState(prev => {
|
setState(prev => {
|
||||||
persisted.write('externalEmbeds', {
|
persisted.write('externalEmbeds', {
|
||||||
...prev,
|
...prev,
|
||||||
|
|
|
@ -0,0 +1,280 @@
|
||||||
|
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {GIPHY_API_KEY, GIPHY_API_URL} from '#/lib/constants'
|
||||||
|
|
||||||
|
export const RQKEY_ROOT = 'giphy'
|
||||||
|
export const RQKEY_TRENDING = [RQKEY_ROOT, 'trending']
|
||||||
|
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
|
||||||
|
|
||||||
|
const getTrendingGifs = createGiphyApi<
|
||||||
|
{
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
rating?: string
|
||||||
|
random_id?: string
|
||||||
|
bundle?: string
|
||||||
|
},
|
||||||
|
{data: Gif[]; pagination: Pagination}
|
||||||
|
>('/v1/gifs/trending')
|
||||||
|
|
||||||
|
const searchGifs = createGiphyApi<
|
||||||
|
{
|
||||||
|
q: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
rating?: string
|
||||||
|
lang?: string
|
||||||
|
random_id?: string
|
||||||
|
bundle?: string
|
||||||
|
},
|
||||||
|
{data: Gif[]; pagination: Pagination}
|
||||||
|
>('/v1/gifs/search')
|
||||||
|
|
||||||
|
export function useGiphyTrending() {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: RQKEY_TRENDING,
|
||||||
|
queryFn: ({pageParam}) => getTrendingGifs({offset: pageParam}),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: lastPage =>
|
||||||
|
lastPage.pagination.offset + lastPage.pagination.count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGifphySearch(query: string) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: RQKEY_SEARCH(query),
|
||||||
|
queryFn: ({pageParam}) => searchGifs({q: query, offset: pageParam}),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: lastPage =>
|
||||||
|
lastPage.pagination.offset + lastPage.pagination.count,
|
||||||
|
enabled: !!query,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGiphyApi<Input extends object, Ouput>(
|
||||||
|
path: string,
|
||||||
|
): (input: Input) => Promise<
|
||||||
|
Ouput & {
|
||||||
|
meta: Meta
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
return async input => {
|
||||||
|
const url = new URL(path, GIPHY_API_URL)
|
||||||
|
url.searchParams.set('api_key', GIPHY_API_KEY)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(input)) {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch Giphy API')
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Gif = {
|
||||||
|
type: string
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
url: string
|
||||||
|
bitly_url: string
|
||||||
|
embed_url: string
|
||||||
|
username: string
|
||||||
|
source: string
|
||||||
|
rating: string
|
||||||
|
content_url: string
|
||||||
|
user: User
|
||||||
|
source_tld: string
|
||||||
|
source_post_url: string
|
||||||
|
update_datetime: string
|
||||||
|
create_datetime: string
|
||||||
|
import_datetime: string
|
||||||
|
trending_datetime: string
|
||||||
|
images: Images
|
||||||
|
title: string
|
||||||
|
alt_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Images = {
|
||||||
|
fixed_height: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
mp4: string
|
||||||
|
mp4_size: string
|
||||||
|
webp: string
|
||||||
|
webp_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_height_still: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_height_downsampled: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
webp: string
|
||||||
|
webp_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_width: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
mp4: string
|
||||||
|
mp4_size: string
|
||||||
|
webp: string
|
||||||
|
webp_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_width_still: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_width_downsampled: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
webp: string
|
||||||
|
webp_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_height_small: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
mp4: string
|
||||||
|
mp4_size: string
|
||||||
|
webp: string
|
||||||
|
webp_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_height_small_still: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_width_small: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
mp4: string
|
||||||
|
mp4_size: string
|
||||||
|
webp: string
|
||||||
|
webp_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed_width_small_still: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
downsized: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
downsized_still: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
downsized_large: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
downsized_medium: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
downsized_small: {
|
||||||
|
mp4: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
mp4_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
original: {
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
size: string
|
||||||
|
frames: string
|
||||||
|
mp4: string
|
||||||
|
mp4_size: string
|
||||||
|
webp: string
|
||||||
|
webp_size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
original_still: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
looping: {
|
||||||
|
mp4: string
|
||||||
|
}
|
||||||
|
|
||||||
|
preview: {
|
||||||
|
mp4: string
|
||||||
|
mp4_size: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
preview_gif: {
|
||||||
|
url: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
avatar_url: string
|
||||||
|
banner_url: string
|
||||||
|
profile_url: string
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meta = {
|
||||||
|
msg: string
|
||||||
|
status: number
|
||||||
|
response_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pagination = {
|
||||||
|
offset: number
|
||||||
|
total_count: number
|
||||||
|
count: number
|
||||||
|
}
|
|
@ -13,7 +13,6 @@ import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
LayoutAnimation,
|
LayoutAnimation,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
@ -27,6 +26,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
|
||||||
|
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
|
@ -37,6 +37,7 @@ import {
|
||||||
useLanguagePrefs,
|
useLanguagePrefs,
|
||||||
useLanguagePrefsApi,
|
useLanguagePrefsApi,
|
||||||
} from '#/state/preferences/languages'
|
} from '#/state/preferences/languages'
|
||||||
|
import {Gif} from '#/state/queries/giphy'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
import {getAgent, useSession} from '#/state/session'
|
import {getAgent, useSession} from '#/state/session'
|
||||||
|
@ -56,6 +57,9 @@ import {useDialogStateControlContext} from 'state/dialogs'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
|
import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
|
@ -66,6 +70,7 @@ import {ExternalEmbed} from './ExternalEmbed'
|
||||||
import {LabelsBtn} from './labels/LabelsBtn'
|
import {LabelsBtn} from './labels/LabelsBtn'
|
||||||
import {Gallery} from './photos/Gallery'
|
import {Gallery} from './photos/Gallery'
|
||||||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
||||||
|
import {SelectGifBtn} from './photos/SelectGifBtn'
|
||||||
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
||||||
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
||||||
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
||||||
|
@ -314,13 +319,33 @@ export const ComposePost = observer(function ComposePost({
|
||||||
? _(msg`Write your reply`)
|
? _(msg`Write your reply`)
|
||||||
: _(msg`What's up?`)
|
: _(msg`What's up?`)
|
||||||
|
|
||||||
const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
|
const canSelectImages = gallery.size < 4 && !extLink
|
||||||
const hasMedia = gallery.size > 0 || Boolean(extLink)
|
const hasMedia = gallery.size > 0 || Boolean(extLink)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openPicker?.(textInput.current?.getCursorPosition())
|
||||||
}, [openPicker])
|
}, [openPicker])
|
||||||
|
|
||||||
|
const focusTextInput = useCallback(() => {
|
||||||
|
textInput.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onSelectGif = useCallback(
|
||||||
|
(gif: Gif) =>
|
||||||
|
setExtLink({
|
||||||
|
uri: gif.url,
|
||||||
|
isLoading: true,
|
||||||
|
meta: {
|
||||||
|
url: gif.url,
|
||||||
|
image: gif.images.original_still.url,
|
||||||
|
likelyType: LikelyType.HTML,
|
||||||
|
title: `${gif.title} - Find & Share on GIPHY`,
|
||||||
|
description: `ALT: ${gif.alt_text}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[setExtLink],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
testID="composePostView"
|
testID="composePostView"
|
||||||
|
@ -473,25 +498,27 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
<View style={[pal.border, styles.bottomBar]}>
|
<View style={[pal.border, styles.bottomBar]}>
|
||||||
{canSelectImages ? (
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<>
|
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
<SelectPhotoBtn gallery={gallery} />
|
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
<OpenCameraBtn gallery={gallery} />
|
<SelectGifBtn
|
||||||
</>
|
onClose={focusTextInput}
|
||||||
) : null}
|
onSelectGif={onSelectGif}
|
||||||
{!isMobile ? (
|
disabled={hasMedia}
|
||||||
<Pressable
|
|
||||||
onPress={onEmojiButtonPress}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Open emoji picker`)}
|
|
||||||
accessibilityHint={_(msg`Open emoji picker`)}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={['far', 'face-smile']}
|
|
||||||
color={pal.colors.link}
|
|
||||||
size={22}
|
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
{!isMobile ? (
|
||||||
|
<Button
|
||||||
|
onPress={onEmojiButtonPress}
|
||||||
|
style={a.p_sm}
|
||||||
|
label={_(msg`Open emoji picker`)}
|
||||||
|
accessibilityHint={_(msg`Open emoji picker`)}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="primary">
|
||||||
|
<EmojiSmile size="lg" />
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
</View>
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
<SelectLangBtn />
|
<SelectLangBtn />
|
||||||
<CharProgress count={graphemeLength} />
|
<CharProgress count={graphemeLength} />
|
||||||
|
@ -586,7 +613,7 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
bottomBar: {
|
bottomBar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingVertical: 10,
|
paddingVertical: 4,
|
||||||
paddingLeft: 15,
|
paddingLeft: 15,
|
||||||
paddingRight: 20,
|
paddingRight: 20,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
@ -1,32 +1,31 @@
|
||||||
import React, {useCallback} from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {TouchableOpacity, StyleSheet} from 'react-native'
|
|
||||||
import * as MediaLibrary from 'expo-media-library'
|
import * as MediaLibrary from 'expo-media-library'
|
||||||
import {
|
|
||||||
FontAwesomeIcon,
|
|
||||||
FontAwesomeIconStyle,
|
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
|
||||||
import {openCamera} from 'lib/media/picker'
|
|
||||||
import {useCameraPermission} from 'lib/hooks/usePermissions'
|
|
||||||
import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
|
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
|
||||||
import {isMobileWeb, isNative} from 'platform/detection'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
|
import {POST_IMG_MAX} from '#/lib/constants'
|
||||||
|
import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
||||||
|
import {openCamera} from '#/lib/media/picker'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {isMobileWeb, isNative} from '#/platform/detection'
|
||||||
|
import {GalleryModel} from '#/state/models/media/gallery'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
gallery: GalleryModel
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenCameraBtn({gallery}: Props) {
|
export function OpenCameraBtn({gallery, disabled}: Props) {
|
||||||
const pal = usePalette('default')
|
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const [mediaPermissionRes, requestMediaPermission] =
|
const [mediaPermissionRes, requestMediaPermission] =
|
||||||
MediaLibrary.usePermissions()
|
MediaLibrary.usePermissions()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
const onPressTakePicture = useCallback(async () => {
|
const onPressTakePicture = useCallback(async () => {
|
||||||
track('Composer:CameraOpened')
|
track('Composer:CameraOpened')
|
||||||
|
@ -68,25 +67,17 @@ export function OpenCameraBtn({gallery}: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Button
|
||||||
testID="openCameraButton"
|
testID="openCameraButton"
|
||||||
onPress={onPressTakePicture}
|
onPress={onPressTakePicture}
|
||||||
style={styles.button}
|
label={_(msg`Camera`)}
|
||||||
hitSlop={HITSLOP_10}
|
accessibilityHint={_(msg`Opens camera on device`)}
|
||||||
accessibilityRole="button"
|
style={a.p_sm}
|
||||||
accessibilityLabel={_(msg`Camera`)}
|
variant="ghost"
|
||||||
accessibilityHint={_(msg`Opens camera on device`)}>
|
shape="round"
|
||||||
<FontAwesomeIcon
|
color="primary"
|
||||||
icon="camera"
|
disabled={disabled}>
|
||||||
style={pal.link as FontAwesomeIconStyle}
|
<Camera size="lg" style={disabled && t.atoms.text_contrast_low} />
|
||||||
size={24}
|
</Button>
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
paddingHorizontal: 15,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {Keyboard} from 'react-native'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {Gif} from '#/state/queries/giphy'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
|
||||||
|
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = useDialogControl()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const onPressSelectGif = useCallback(async () => {
|
||||||
|
logEvent('composer:gif:open', {})
|
||||||
|
Keyboard.dismiss()
|
||||||
|
control.open()
|
||||||
|
}, [control])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
testID="openGifBtn"
|
||||||
|
onPress={onPressSelectGif}
|
||||||
|
label={_(msg`Select GIF`)}
|
||||||
|
accessibilityHint={_(msg`Opens GIF select dialog`)}
|
||||||
|
style={a.p_sm}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="primary"
|
||||||
|
disabled={disabled}>
|
||||||
|
<GifIcon size="lg" style={disabled && t.atoms.text_contrast_low} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<GifSelectDialog
|
||||||
|
control={control}
|
||||||
|
onClose={onClose}
|
||||||
|
onSelectGif={onSelectGif}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,27 +1,26 @@
|
||||||
|
/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
|
||||||
import React, {useCallback} from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {TouchableOpacity, StyleSheet} from 'react-native'
|
|
||||||
import {
|
|
||||||
FontAwesomeIcon,
|
|
||||||
FontAwesomeIconStyle,
|
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
|
||||||
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
|
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
|
||||||
import {isNative} from 'platform/detection'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
|
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {GalleryModel} from '#/state/models/media/gallery'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
|
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
gallery: GalleryModel
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectPhotoBtn({gallery}: Props) {
|
export function SelectPhotoBtn({gallery, disabled}: Props) {
|
||||||
const pal = usePalette('default')
|
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
const onPressSelectPhotos = useCallback(async () => {
|
const onPressSelectPhotos = useCallback(async () => {
|
||||||
track('Composer:GalleryOpened')
|
track('Composer:GalleryOpened')
|
||||||
|
@ -34,25 +33,17 @@ export function SelectPhotoBtn({gallery}: Props) {
|
||||||
}, [track, requestPhotoAccessIfNeeded, gallery])
|
}, [track, requestPhotoAccessIfNeeded, gallery])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Button
|
||||||
testID="openGalleryBtn"
|
testID="openGalleryBtn"
|
||||||
onPress={onPressSelectPhotos}
|
onPress={onPressSelectPhotos}
|
||||||
style={styles.button}
|
label={_(msg`Gallery`)}
|
||||||
hitSlop={HITSLOP_10}
|
accessibilityHint={_(msg`Opens device photo gallery`)}
|
||||||
accessibilityRole="button"
|
style={a.p_sm}
|
||||||
accessibilityLabel={_(msg`Gallery`)}
|
variant="ghost"
|
||||||
accessibilityHint={_(msg`Opens device photo gallery`)}>
|
shape="round"
|
||||||
<FontAwesomeIcon
|
color="primary"
|
||||||
icon={['far', 'image']}
|
disabled={disabled}>
|
||||||
style={pal.link as FontAwesomeIconStyle}
|
<Image size="lg" style={disabled && t.atoms.text_contrast_low} />
|
||||||
size={24}
|
</Button>
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
paddingHorizontal: 15,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
ButtonText,
|
ButtonText,
|
||||||
ButtonVariant,
|
ButtonVariant,
|
||||||
} from '#/components/Button'
|
} from '#/components/Button'
|
||||||
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
|
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/Arrow'
|
||||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||||
import {H1} from '#/components/Typography'
|
import {H1} from '#/components/Typography'
|
||||||
|
|
|
@ -2,11 +2,11 @@ import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {H1} from '#/components/Typography'
|
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/Arrow'
|
||||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
|
||||||
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
|
|
||||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||||
|
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {H1} from '#/components/Typography'
|
||||||
|
|
||||||
export function Icons() {
|
export function Icons() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
Loading…
Reference in New Issue