about summary refs log tree commit diff
path: root/src/components/dialogs
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-06-04 04:05:46 +0300
committerGitHub <noreply@github.com>2024-06-04 02:05:46 +0100
commitda96fb1ef5a37018b6a238c3614e9b845d8e2686 (patch)
tree31022f17c172635beedade3597e48035993e6144 /src/components/dialogs
parentb02445883ab5abd7daa80c3a27cf06ffaf539ff3 (diff)
downloadvoidsky-da96fb1ef5a37018b6a238c3614e9b845d8e2686.tar.zst
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 PR
Diffstat (limited to 'src/components/dialogs')
-rw-r--r--src/components/dialogs/GifSelect.ios.tsx255
-rw-r--r--src/components/dialogs/GifSelect.shared.tsx53
-rw-r--r--src/components/dialogs/GifSelect.tsx65
3 files changed, 324 insertions, 49 deletions
diff --git a/src/components/dialogs/GifSelect.ios.tsx b/src/components/dialogs/GifSelect.ios.tsx
new file mode 100644
index 000000000..091a23e51
--- /dev/null
+++ b/src/components/dialogs/GifSelect.ios.tsx
@@ -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>
+  )
+}
diff --git a/src/components/dialogs/GifSelect.shared.tsx b/src/components/dialogs/GifSelect.shared.tsx
new file mode 100644
index 000000000..90b2abaa8
--- /dev/null
+++ b/src/components/dialogs/GifSelect.shared.tsx
@@ -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>
+  )
+}
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
index 4a3ce42aa..a64edcd6f 100644
--- a/src/components/dialogs/GifSelect.tsx
+++ b/src/components/dialogs/GifSelect.tsx
@@ -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 {Image} from 'expo-image'
 import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {isWeb} from '#/platform/detection'
 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 {Button, ButtonIcon, ButtonText} from '../Button'
 import {ListFooter, ListMaybePlaceholder} from '../Lists'
+import {GifPreview} from './GifSelect.shared'
 
 export function GifSelectDialog({
-  control,
+  controlRef,
   onClose,
   onSelectGif: onSelectGifProp,
 }: {
-  control: Dialog.DialogControlProps
+  controlRef: React.RefObject<{open: () => void}>
   onClose: () => void
   onSelectGif: (gif: Gif) => void
 }) {
+  const control = Dialog.useDialogControl()
+
+  useImperativeHandle(controlRef, () => ({
+    open: () => control.open(),
+  }))
+
   const onSelectGif = useCallback(
     (gif: 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}) {
   const {_} = useLingui()
   const control = Dialog.useDialogContext()