diff --git a/assets/icons/arrowLeft_stroke2_corner0_rounded.svg b/assets/icons/arrowLeft_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..96b5c16f
--- /dev/null
+++ b/assets/icons/arrowLeft_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/gifSquare_stroke2_corner0_rounded.svg b/assets/icons/gifSquare_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..47b9df98
--- /dev/null
+++ b/assets/icons/gifSquare_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/gif_stroke2_corner0_rounded.svg b/assets/icons/gif_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..519acfd4
--- /dev/null
+++ b/assets/icons/gif_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 55798db7..859e4965 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}
@@ -246,6 +248,34 @@ export const ScrollableInner = React.forwardRef<
)
})
+export const InnerFlatList = React.forwardRef<
+ BottomSheetFlatListMethods,
+ BottomSheetFlatListProps
+>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
+ const insets = useSafeAreaInsets()
+ return (
+
+ }
+ 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 1892d944..d00d2d83 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 & {label: string}) {
+ return (
+
+
+
+ )
+}
+
export function Handle() {
return null
}
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
index 91b33f48..bf689fc0 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()
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 (
+ sideBorders={sideBorders}>
{title}
Promise
height?: number
+ style?: StyleProp
}) {
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 ? (
@@ -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
-}) {
+ 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}>
@@ -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 00000000..92e21af4
--- /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 =
+ snapPoints = ['100%']
+ break
+ case 'hide':
+ default:
+ content =
+ break
+ }
+
+ return (
+
+
+ {content}
+
+ )
+}
+
+function GifList({
+ control,
+ onSelectGif,
+}: {
+ control: Dialog.DialogControlProps
+ onSelectGif: (gif: Gif) => void
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {gtMobile} = useBreakpoints()
+ const ref = useRef(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()
+
+ 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
+ },
+ [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 (
+
+ {/* cover top corners */}
+
+
+ {!gtMobile && isWeb && (
+
+ )}
+
+
+
+ {
+ if (nativeEvent.key === 'Escape') {
+ control.close()
+ }
+ }}
+ />
+
+
+ )
+ }, [gtMobile, t.atoms.bg, _, control])
+
+ return (
+ <>
+ {gtMobile && }
+
+ {listHeader}
+ {!hasData && (
+
+ )}
+ >
+ }
+ stickyHeaderIndices={[0]}
+ onEndReached={onEndReached}
+ onEndReachedThreshold={4}
+ keyExtractor={(item: Gif) => item.id}
+ // @ts-expect-error web only
+ style={isWeb && {minHeight: '100vh'}}
+ ListFooterComponent={
+ hasData ? (
+
+ ) : 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 (
+
+ )
+}
+
+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 (
+
+
+
+ Permission to use GIPHY
+
+
+
+
+
+ Bluesky uses GIPHY to provide the GIF selector feature.
+
+
+
+
+
+ GIPHY may collect information about you and your device. You can
+ find out more in their{' '}
+ control.close()}>
+ privacy policy
+
+ .
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/icons/ArrowTopRight.tsx b/src/components/icons/Arrow.tsx
similarity index 53%
rename from src/components/icons/ArrowTopRight.tsx
rename to src/components/icons/Arrow.tsx
index 92ad30a1..eb753e54 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 00000000..72aefe5c
--- /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 bb49387c..b96529b1 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 1231c5de..4cc02a9b 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 0f6385fe..9ace5d94 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(
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 00000000..ca5ff65f
--- /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(
+ 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 f90bdbee..f0f630dd 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 (
- {canSelectImages ? (
- <>
-
-
- >
- ) : null}
- {!isMobile ? (
-
-
-
- ) : null}
+
+
+
+
+ {!isMobile ? (
+
+ ) : null}
+
@@ -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 4353704d..8f9152e3 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 (
-
-
-
+ label={_(msg`Camera`)}
+ accessibilityHint={_(msg`Opens camera on device`)}
+ style={a.p_sm}
+ variant="ghost"
+ shape="round"
+ color="primary"
+ disabled={disabled}>
+
+
)
}
-
-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 00000000..31310fdc
--- /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 (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index f7fa9502..747653fc 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 (
-
-
-
+ label={_(msg`Gallery`)}
+ accessibilityHint={_(msg`Opens device photo gallery`)}
+ style={a.p_sm}
+ variant="ghost"
+ shape="round"
+ color="primary"
+ disabled={disabled}>
+
+
)
}
-
-const styles = StyleSheet.create({
- button: {
- paddingHorizontal: 15,
- },
-})
diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx
index cae8ec31..b532b0dd 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 9d7dc0aa..bff1fdc9 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()