diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/components/Dialog/index.tsx | 34 | ||||
-rw-r--r-- | src/components/Dialog/index.web.tsx | 18 | ||||
-rw-r--r-- | src/components/Error.tsx | 15 | ||||
-rw-r--r-- | src/components/Lists.tsx | 25 | ||||
-rw-r--r-- | src/components/dialogs/GifSelect.tsx | 360 | ||||
-rw-r--r-- | src/components/icons/Arrow.tsx (renamed from src/components/icons/ArrowTopRight.tsx) | 4 | ||||
-rw-r--r-- | src/components/icons/Gif.tsx | 9 | ||||
-rw-r--r-- | src/lib/constants.ts | 9 | ||||
-rw-r--r-- | src/lib/statsig/events.ts | 2 | ||||
-rw-r--r-- | src/state/preferences/external-embeds-prefs.tsx | 8 | ||||
-rw-r--r-- | src/state/queries/giphy.ts | 280 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 71 | ||||
-rw-r--r-- | src/view/com/composer/photos/OpenCameraBtn.tsx | 59 | ||||
-rw-r--r-- | src/view/com/composer/photos/SelectGifBtn.tsx | 53 | ||||
-rw-r--r-- | src/view/com/composer/photos/SelectPhotoBtn.tsx | 55 | ||||
-rw-r--r-- | src/view/screens/Storybook/Buttons.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Storybook/Icons.tsx | 6 |
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() |