Native `formSheet` for GIF select on iOS (#4328)
* native formsheet for gif select * trigger confirm discard if have gif * give modal a background color * fix web top bar - unrelated but I cba to make a separate PRzio/stable
parent
b02445883a
commit
da96fb1ef5
|
@ -0,0 +1,255 @@
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {Modal, ScrollView, TextInput, View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {
|
||||||
|
Gif,
|
||||||
|
useFeaturedGifsQuery,
|
||||||
|
useGifSearchQuery,
|
||||||
|
} from '#/state/queries/tenor'
|
||||||
|
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||||
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||||
|
import {FlatList_INTERNAL} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Button, ButtonText} from '../Button'
|
||||||
|
import {Handle} from '../Dialog'
|
||||||
|
import {useThrottledValue} from '../hooks/useThrottledValue'
|
||||||
|
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||||
|
import {GifPreview} from './GifSelect.shared'
|
||||||
|
|
||||||
|
export function GifSelectDialog({
|
||||||
|
controlRef,
|
||||||
|
onClose,
|
||||||
|
onSelectGif: onSelectGifProp,
|
||||||
|
}: {
|
||||||
|
controlRef: React.RefObject<{open: () => void}>
|
||||||
|
onClose: () => void
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
useImperativeHandle(controlRef, () => ({
|
||||||
|
open: () => setOpen(true),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpen(false)
|
||||||
|
onClose()
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const onSelectGif = useCallback(
|
||||||
|
(gif: Gif) => {
|
||||||
|
onSelectGifProp(gif)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
[onSelectGifProp, close],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderErrorBoundary = useCallback(
|
||||||
|
(error: any) => <ModalError details={String(error)} close={close} />,
|
||||||
|
[close],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="formSheet"
|
||||||
|
onRequestClose={close}
|
||||||
|
aria-modal
|
||||||
|
accessibilityViewIsModal>
|
||||||
|
<View style={[a.flex_1, t.atoms.bg]}>
|
||||||
|
<Handle />
|
||||||
|
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||||
|
<GifList onSelectGif={onSelectGif} close={close} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GifList({
|
||||||
|
onSelectGif,
|
||||||
|
}: {
|
||||||
|
close: () => void
|
||||||
|
onSelectGif: (gif: Gif) => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const textInputRef = useRef<TextInput>(null)
|
||||||
|
const listRef = useRef<FlatList_INTERNAL>(null)
|
||||||
|
const [undeferredSearch, setSearch] = useState('')
|
||||||
|
const search = useThrottledValue(undeferredSearch, 500)
|
||||||
|
|
||||||
|
const isSearching = search.length > 0
|
||||||
|
|
||||||
|
const trendingQuery = useFeaturedGifsQuery()
|
||||||
|
const searchQuery = useGifSearchQuery(search)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = isSearching ? searchQuery : trendingQuery
|
||||||
|
|
||||||
|
const flattenedData = useMemo(() => {
|
||||||
|
return data?.pages.flatMap(page => page.results) || []
|
||||||
|
}, [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
|
||||||
|
textInputRef.current?.clear()
|
||||||
|
setSearch('')
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}, [isSearching])
|
||||||
|
|
||||||
|
const listHeader = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
|
||||||
|
{/* cover top corners */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
{
|
||||||
|
borderBottomLeftRadius: 8,
|
||||||
|
borderBottomRightRadius: 8,
|
||||||
|
},
|
||||||
|
t.atoms.bg,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={Search} />
|
||||||
|
<TextField.Input
|
||||||
|
label={_(msg`Search GIFs`)}
|
||||||
|
placeholder={_(msg`Search Tenor`)}
|
||||||
|
onChangeText={text => {
|
||||||
|
setSearch(text)
|
||||||
|
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||||
|
}}
|
||||||
|
returnKeyType="search"
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
inputRef={textInputRef}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}, [t.atoms.bg, _])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList_INTERNAL
|
||||||
|
ref={listRef}
|
||||||
|
key={gtMobile ? '3 cols' : '2 cols'}
|
||||||
|
data={flattenedData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
numColumns={gtMobile ? 3 : 2}
|
||||||
|
columnWrapperStyle={a.gap_sm}
|
||||||
|
contentContainerStyle={a.px_lg}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
{listHeader}
|
||||||
|
{!hasData && (
|
||||||
|
<ListMaybePlaceholder
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={refetch}
|
||||||
|
onGoBack={onGoBack}
|
||||||
|
emptyType="results"
|
||||||
|
sideBorders={false}
|
||||||
|
topBorder={false}
|
||||||
|
errorTitle={_(msg`Failed to load GIFs`)}
|
||||||
|
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||||
|
emptyMessage={
|
||||||
|
isSearching
|
||||||
|
? _(msg`No search results found for "${search}".`)
|
||||||
|
: _(
|
||||||
|
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={4}
|
||||||
|
keyExtractor={(item: Gif) => item.id}
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
ListFooterComponent={
|
||||||
|
hasData ? (
|
||||||
|
<ListFooter
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
error={cleanError(error)}
|
||||||
|
onRetry={fetchNextPage}
|
||||||
|
style={{borderTopWidth: 0}}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalError({details, close}: {details?: string; close: () => void}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={[a.flex_1, a.gap_md]}
|
||||||
|
centerContent
|
||||||
|
contentContainerStyle={a.px_lg}>
|
||||||
|
<ErrorScreen
|
||||||
|
title={_(msg`Oh no!`)}
|
||||||
|
message={_(
|
||||||
|
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||||
|
)}
|
||||||
|
details={details}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close dialog`)}
|
||||||
|
onPress={close}
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
variant="solid">
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</ScrollView>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {Gif} from '#/state/queries/tenor'
|
||||||
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
|
import {Button} from '../Button'
|
||||||
|
|
||||||
|
export 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.media_formats.tinygif.url,
|
||||||
|
}}
|
||||||
|
contentFit="cover"
|
||||||
|
accessibilityLabel={gif.title}
|
||||||
|
accessibilityHint=""
|
||||||
|
cachePolicy="none"
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,11 +1,15 @@
|
||||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import {TextInput, View} from 'react-native'
|
import {TextInput, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
|
||||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {
|
import {
|
||||||
|
@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
|
||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||||
|
import {GifPreview} from './GifSelect.shared'
|
||||||
|
|
||||||
export function GifSelectDialog({
|
export function GifSelectDialog({
|
||||||
control,
|
controlRef,
|
||||||
onClose,
|
onClose,
|
||||||
onSelectGif: onSelectGifProp,
|
onSelectGif: onSelectGifProp,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
controlRef: React.RefObject<{open: () => void}>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelectGif: (gif: Gif) => void
|
onSelectGif: (gif: Gif) => void
|
||||||
}) {
|
}) {
|
||||||
|
const control = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
useImperativeHandle(controlRef, () => ({
|
||||||
|
open: () => control.open(),
|
||||||
|
}))
|
||||||
|
|
||||||
const onSelectGif = useCallback(
|
const onSelectGif = useCallback(
|
||||||
(gif: Gif) => {
|
(gif: Gif) => {
|
||||||
control.close(() => onSelectGifProp(gif))
|
control.close(() => onSelectGifProp(gif))
|
||||||
|
@ -233,50 +244,6 @@ function GifList({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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.media_formats.tinygif.url,
|
|
||||||
}}
|
|
||||||
contentFit="cover"
|
|
||||||
accessibilityLabel={gif.title}
|
|
||||||
accessibilityHint=""
|
|
||||||
cachePolicy="none"
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogError({details}: {details?: string}) {
|
function DialogError({details}: {details?: string}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
|
|
|
@ -173,7 +173,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
if (graphemeLength > 0 || !gallery.isEmpty) {
|
if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
|
||||||
closeAllDialogs()
|
closeAllDialogs()
|
||||||
if (Keyboard) {
|
if (Keyboard) {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
|
@ -183,6 +183,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
extGif,
|
||||||
graphemeLength,
|
graphemeLength,
|
||||||
gallery.isEmpty,
|
gallery.isEmpty,
|
||||||
closeAllDialogs,
|
closeAllDialogs,
|
||||||
|
@ -728,8 +729,6 @@ function useAnimatedBorders() {
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
topbar: {},
|
topbar: {},
|
||||||
topbarDesktop: {
|
topbarDesktop: {
|
||||||
paddingTop: 10,
|
|
||||||
paddingBottom: 10,
|
|
||||||
height: 50,
|
height: 50,
|
||||||
},
|
},
|
||||||
topbarInner: {
|
topbarInner: {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, {useCallback} from 'react'
|
import React, {useCallback, useRef} from 'react'
|
||||||
import {Keyboard} from 'react-native'
|
import {Keyboard} from 'react-native'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
@ -7,7 +7,6 @@ import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {Gif} from '#/state/queries/tenor'
|
import {Gif} from '#/state/queries/tenor'
|
||||||
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 {useDialogControl} from '#/components/Dialog'
|
|
||||||
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
|
import {GifSelectDialog} from '#/components/dialogs/GifSelect'
|
||||||
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
|
import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif'
|
||||||
|
|
||||||
|
@ -19,14 +18,14 @@ type Props = {
|
||||||
|
|
||||||
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const control = useDialogControl()
|
const ref = useRef<{open: () => void}>(null)
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const onPressSelectGif = useCallback(async () => {
|
const onPressSelectGif = useCallback(async () => {
|
||||||
logEvent('composer:gif:open', {})
|
logEvent('composer:gif:open', {})
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
control.open()
|
ref.current?.open()
|
||||||
}, [control])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -44,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<GifSelectDialog
|
<GifSelectDialog
|
||||||
control={control}
|
controlRef={ref}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSelectGif={onSelectGif}
|
onSelectGif={onSelectGif}
|
||||||
/>
|
/>
|
||||||
|
|
Loading…
Reference in New Issue