about summary refs log tree commit diff
path: root/src
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
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')
-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
-rw-r--r--src/lib/constants.ts9
-rw-r--r--src/lib/statsig/events.ts2
-rw-r--r--src/state/preferences/external-embeds-prefs.tsx8
-rw-r--r--src/state/queries/giphy.ts280
-rw-r--r--src/view/com/composer/Composer.tsx71
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx59
-rw-r--r--src/view/com/composer/photos/SelectGifBtn.tsx53
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx55
-rw-r--r--src/view/screens/Storybook/Buttons.tsx2
-rw-r--r--src/view/screens/Storybook/Icons.tsx6
17 files changed, 904 insertions, 106 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',
+})
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index bb49387c4..b96529b1f 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -89,3 +89,12 @@ export const BSKY_FEED_OWNER_DIDS = [
   'did:plc:vpkhqolt662uhesyj6nxm7ys',
   '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'
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 1231c5de5..4cc02a9b6 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -60,6 +60,8 @@ export type LogEvents = {
     feedType: string
     reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
   }
+  'composer:gif:open': {}
+  'composer:gif:select': {}
 
   // Data events
   'account:create:begin': {}
diff --git a/src/state/preferences/external-embeds-prefs.tsx b/src/state/preferences/external-embeds-prefs.tsx
index 0f6385fe8..9ace5d940 100644
--- a/src/state/preferences/external-embeds-prefs.tsx
+++ b/src/state/preferences/external-embeds-prefs.tsx
@@ -1,9 +1,13 @@
 import React from 'react'
+
 import * as persisted from '#/state/persisted'
 import {EmbedPlayerSource} from 'lib/strings/embed-player'
 
 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>(
   persisted.defaults.externalEmbeds,
@@ -14,7 +18,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   const [state, setState] = React.useState(persisted.get('externalEmbeds'))
 
   const setStateWrapped = React.useCallback(
-    (source: EmbedPlayerSource, value: 'show' | 'hide') => {
+    (source: EmbedPlayerSource, value: 'show' | 'hide' | undefined) => {
       setState(prev => {
         persisted.write('externalEmbeds', {
           ...prev,
diff --git a/src/state/queries/giphy.ts b/src/state/queries/giphy.ts
new file mode 100644
index 000000000..ca5ff65f5
--- /dev/null
+++ b/src/state/queries/giphy.ts
@@ -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
+}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index f90bdbee2..f0f630dd4 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -13,7 +13,6 @@ import {
   KeyboardAvoidingView,
   LayoutAnimation,
   Platform,
-  Pressable,
   ScrollView,
   StyleSheet,
   TouchableOpacity,
@@ -27,6 +26,7 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {observer} from 'mobx-react-lite'
 
+import {LikelyType} from '#/lib/link-meta/link-meta'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {emitPostCreated} from '#/state/events'
@@ -37,6 +37,7 @@ import {
   useLanguagePrefs,
   useLanguagePrefsApi,
 } from '#/state/preferences/languages'
+import {Gif} from '#/state/queries/giphy'
 import {useProfileQuery} from '#/state/queries/profile'
 import {ThreadgateSetting} from '#/state/queries/threadgate'
 import {getAgent, useSession} from '#/state/session'
@@ -56,6 +57,9 @@ import {useDialogStateControlContext} from 'state/dialogs'
 import {GalleryModel} from 'state/models/media/gallery'
 import {ComposerOpts} from 'state/shell/composer'
 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 {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
 import {Text} from '../util/text/Text'
@@ -66,6 +70,7 @@ import {ExternalEmbed} from './ExternalEmbed'
 import {LabelsBtn} from './labels/LabelsBtn'
 import {Gallery} from './photos/Gallery'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
+import {SelectGifBtn} from './photos/SelectGifBtn'
 import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
 import {SuggestedLanguage} from './select-language/SuggestedLanguage'
@@ -314,13 +319,33 @@ export const ComposePost = observer(function ComposePost({
     ? _(msg`Write your reply`)
     : _(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 onEmojiButtonPress = useCallback(() => {
     openPicker?.(textInput.current?.getCursorPosition())
   }, [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 (
     <KeyboardAvoidingView
       testID="composePostView"
@@ -473,25 +498,27 @@ export const ComposePost = observer(function ComposePost({
         </ScrollView>
         <SuggestedLanguage text={richtext.text} />
         <View style={[pal.border, styles.bottomBar]}>
-          {canSelectImages ? (
-            <>
-              <SelectPhotoBtn gallery={gallery} />
-              <OpenCameraBtn gallery={gallery} />
-            </>
-          ) : null}
-          {!isMobile ? (
-            <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>
-          ) : null}
+          <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+            <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
+            <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
+            <SelectGifBtn
+              onClose={focusTextInput}
+              onSelectGif={onSelectGif}
+              disabled={hasMedia}
+            />
+            {!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}
+          </View>
           <View style={s.flex1} />
           <SelectLangBtn />
           <CharProgress count={graphemeLength} />
@@ -586,7 +613,7 @@ const styles = StyleSheet.create({
   },
   bottomBar: {
     flexDirection: 'row',
-    paddingVertical: 10,
+    paddingVertical: 4,
     paddingLeft: 15,
     paddingRight: 20,
     alignItems: 'center',
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 4353704d5..8f9152e34 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -1,32 +1,31 @@
 import React, {useCallback} from 'react'
-import {TouchableOpacity, StyleSheet} from 'react-native'
 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 {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 = {
   gallery: GalleryModel
+  disabled?: boolean
 }
 
-export function OpenCameraBtn({gallery}: Props) {
-  const pal = usePalette('default')
+export function OpenCameraBtn({gallery, disabled}: Props) {
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const [mediaPermissionRes, requestMediaPermission] =
     MediaLibrary.usePermissions()
+  const t = useTheme()
 
   const onPressTakePicture = useCallback(async () => {
     track('Composer:CameraOpened')
@@ -68,25 +67,17 @@ export function OpenCameraBtn({gallery}: Props) {
   }
 
   return (
-    <TouchableOpacity
+    <Button
       testID="openCameraButton"
       onPress={onPressTakePicture}
-      style={styles.button}
-      hitSlop={HITSLOP_10}
-      accessibilityRole="button"
-      accessibilityLabel={_(msg`Camera`)}
-      accessibilityHint={_(msg`Opens camera on device`)}>
-      <FontAwesomeIcon
-        icon="camera"
-        style={pal.link as FontAwesomeIconStyle}
-        size={24}
-      />
-    </TouchableOpacity>
+      label={_(msg`Camera`)}
+      accessibilityHint={_(msg`Opens camera on device`)}
+      style={a.p_sm}
+      variant="ghost"
+      shape="round"
+      color="primary"
+      disabled={disabled}>
+      <Camera size="lg" style={disabled && t.atoms.text_contrast_low} />
+    </Button>
   )
 }
-
-const styles = StyleSheet.create({
-  button: {
-    paddingHorizontal: 15,
-  },
-})
diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx
new file mode 100644
index 000000000..31310fdc1
--- /dev/null
+++ b/src/view/com/composer/photos/SelectGifBtn.tsx
@@ -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}
+      />
+    </>
+  )
+}
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index f7fa9502d..747653fc8 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -1,27 +1,26 @@
+/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
 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 {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 = {
   gallery: GalleryModel
+  disabled?: boolean
 }
 
-export function SelectPhotoBtn({gallery}: Props) {
-  const pal = usePalette('default')
+export function SelectPhotoBtn({gallery, disabled}: Props) {
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const t = useTheme()
 
   const onPressSelectPhotos = useCallback(async () => {
     track('Composer:GalleryOpened')
@@ -34,25 +33,17 @@ export function SelectPhotoBtn({gallery}: Props) {
   }, [track, requestPhotoAccessIfNeeded, gallery])
 
   return (
-    <TouchableOpacity
+    <Button
       testID="openGalleryBtn"
       onPress={onPressSelectPhotos}
-      style={styles.button}
-      hitSlop={HITSLOP_10}
-      accessibilityRole="button"
-      accessibilityLabel={_(msg`Gallery`)}
-      accessibilityHint={_(msg`Opens device photo gallery`)}>
-      <FontAwesomeIcon
-        icon={['far', 'image']}
-        style={pal.link as FontAwesomeIconStyle}
-        size={24}
-      />
-    </TouchableOpacity>
+      label={_(msg`Gallery`)}
+      accessibilityHint={_(msg`Opens device photo gallery`)}
+      style={a.p_sm}
+      variant="ghost"
+      shape="round"
+      color="primary"
+      disabled={disabled}>
+      <Image size="lg" style={disabled && t.atoms.text_contrast_low} />
+    </Button>
   )
 }
-
-const styles = StyleSheet.create({
-  button: {
-    paddingHorizontal: 15,
-  },
-})
diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx
index cae8ec314..b532b0dd1 100644
--- a/src/view/screens/Storybook/Buttons.tsx
+++ b/src/view/screens/Storybook/Buttons.tsx
@@ -9,7 +9,7 @@ import {
   ButtonText,
   ButtonVariant,
 } 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 {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
 import {H1} from '#/components/Typography'
diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx
index 9d7dc0aa8..bff1fdc9b 100644
--- a/src/view/screens/Storybook/Icons.tsx
+++ b/src/view/screens/Storybook/Icons.tsx
@@ -2,11 +2,11 @@ import React from 'react'
 import {View} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
-import {H1} from '#/components/Typography'
-import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
-import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
+import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/Arrow'
 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 {H1} from '#/components/Typography'
 
 export function Icons() {
   const t = useTheme()