about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-04-19 03:42:26 +0100
committerGitHub <noreply@github.com>2024-04-19 03:42:26 +0100
commitba1c4834ab23726c065aff31ef09e3578210ff01 (patch)
tree7c3335e22daf3b21e2e315d170b0936e0e26b5e6 /src/components
parent20907381858b61fec61249c6ef836b9696e1ab05 (diff)
downloadvoidsky-ba1c4834ab23726c065aff31ef09e3578210ff01.tar.zst
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>
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Dialog/index.tsx34
-rw-r--r--src/components/Dialog/index.web.tsx18
-rw-r--r--src/components/Error.tsx15
-rw-r--r--src/components/Lists.tsx25
-rw-r--r--src/components/dialogs/GifSelect.tsx360
-rw-r--r--src/components/icons/Arrow.tsx (renamed from src/components/icons/ArrowTopRight.tsx)4
-rw-r--r--src/components/icons/Gif.tsx9
7 files changed, 453 insertions, 12 deletions
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 55798db7f..859e4965c 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -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()
 
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 1892d944e..d00d2d832 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -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
 }
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
index 91b33f48e..bf689fc07 100644
--- a/src/components/Error.tsx
+++ b/src/components/Error.tsx
@@ -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
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index 89913b12b..b5419697b 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -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}
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
new file mode 100644
index 000000000..92e21af47
--- /dev/null
+++ b/src/components/dialogs/GifSelect.tsx
@@ -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>
+  )
+}
diff --git a/src/components/icons/ArrowTopRight.tsx b/src/components/icons/Arrow.tsx
index 92ad30a12..eb753e549 100644
--- a/src/components/icons/ArrowTopRight.tsx
+++ b/src/components/icons/Arrow.tsx
@@ -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',
+})
diff --git a/src/components/icons/Gif.tsx b/src/components/icons/Gif.tsx
new file mode 100644
index 000000000..72aefe5c2
--- /dev/null
+++ b/src/components/icons/Gif.tsx
@@ -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',
+})