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>
This commit is contained in:
Samuel Newman 2024-04-19 03:42:26 +01:00 committed by GitHub
parent 2090738185
commit ba1c4834ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 907 additions and 106 deletions

View file

@ -4,6 +4,8 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import BottomSheet, {
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetFlatListMethods,
BottomSheetScrollView,
BottomSheetScrollViewMethods,
BottomSheetTextInput,
@ -11,10 +13,10 @@ import BottomSheet, {
useBottomSheet,
WINDOW_HEIGHT,
} from '@discord/bottom-sheet/src'
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {isNative} from 'platform/detection'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Context} from '#/components/Dialog/context'
import {
@ -238,7 +240,7 @@ export const ScrollableInner = React.forwardRef<
},
flatten(style),
]}
contentContainerStyle={isNative ? a.pb_4xl : undefined}
contentContainerStyle={a.pb_4xl}
ref={ref}>
{children}
<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() {
const t = useTheme()

View file

@ -1,5 +1,10 @@
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 {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@ -192,6 +197,17 @@ export function Inner({
export const ScrollableInner = Inner
export function InnerFlatList({
label,
...props
}: FlatListProps<any> & {label: string}) {
return (
<Inner label={label}>
<FlatList {...props} />
</Inner>
)
}
export function Handle() {
return null
}

View file

@ -16,10 +16,14 @@ export function Error({
title,
message,
onRetry,
onGoBack: onGoBackProp,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
sideBorders?: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
@ -28,6 +32,10 @@ export function Error({
const canGoBack = navigation.canGoBack()
const onGoBack = React.useCallback(() => {
if (onGoBackProp) {
onGoBackProp()
return
}
if (canGoBack) {
navigation.goBack()
} else {
@ -41,18 +49,19 @@ export function Error({
navigation.dispatch(StackActions.popToTop())
}
}
}, [navigation, canGoBack])
}, [navigation, canGoBack, onGoBackProp])
return (
<CenteredView
style={[
a.flex_1,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
a.gap_5xl,
!gtMobile && a.justify_between,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders>
sideBorders={sideBorders}>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
<Text

View file

@ -1,11 +1,11 @@
import React from 'react'
import {View} from 'react-native'
import React, {memo} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from 'lib/strings/errors'
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 {Error} from '#/components/Error'
import {Loader} from '#/components/Loader'
@ -16,11 +16,13 @@ export function ListFooter({
error,
onRetry,
height,
style,
}: {
isFetchingNextPage?: boolean
error?: string
onRetry?: () => Promise<unknown>
height?: number
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
@ -33,6 +35,7 @@ export function ListFooter({
a.pb_lg,
t.atoms.border_contrast_low,
{height: height ?? 180, paddingTop: 30},
flatten(style),
]}>
{isFetchingNextPage ? (
<Loader size="xl" />
@ -120,7 +123,7 @@ export function ListHeaderDesktop({
)
}
export function ListMaybePlaceholder({
let ListMaybePlaceholder = ({
isLoading,
noEmpty,
isError,
@ -130,6 +133,8 @@ export function ListMaybePlaceholder({
errorMessage,
emptyType = 'page',
onRetry,
onGoBack,
sideBorders,
}: {
isLoading: boolean
noEmpty?: boolean
@ -140,7 +145,9 @@ export function ListMaybePlaceholder({
errorMessage?: string
emptyType?: 'page' | 'results'
onRetry?: () => Promise<unknown>
}) {
onGoBack?: () => void
sideBorders?: boolean
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints()
@ -155,7 +162,7 @@ export function ListMaybePlaceholder({
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders={gtMobile}
sideBorders={sideBorders ?? gtMobile}
topBorder={!gtTablet}>
<View style={[a.w_full, a.align_center, {top: 100}]}>
<Loader size="xl" />
@ -170,6 +177,8 @@ export function ListMaybePlaceholder({
title={errorTitle ?? _(msg`Oops!`)}
message={errorMessage ?? _(`Something went wrong!`)}
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.`)
}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
/>
)
}
return null
}
ListMaybePlaceholder = memo(ListMaybePlaceholder)
export {ListMaybePlaceholder}

View file

@ -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>
)
}

View file

@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
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',
})
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',
})

View file

@ -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',
})