Bluesky app fork with some witchin' additions 💫

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>

authored by samuel.fm

Dan Abramov and committed by
GitHub
ba1c4834 20907381

+912 -111
+1
assets/icons/arrowLeft_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/gifSquare_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+1
assets/icons/gif_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+32 -2
src/components/Dialog/index.tsx
··· 4 4 import {useSafeAreaInsets} from 'react-native-safe-area-context' 5 5 import BottomSheet, { 6 6 BottomSheetBackdropProps, 7 + BottomSheetFlatList, 8 + BottomSheetFlatListMethods, 7 9 BottomSheetScrollView, 8 10 BottomSheetScrollViewMethods, 9 11 BottomSheetTextInput, ··· 11 13 useBottomSheet, 12 14 WINDOW_HEIGHT, 13 15 } from '@discord/bottom-sheet/src' 16 + import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types' 14 17 15 18 import {logger} from '#/logger' 16 19 import {useDialogStateControlContext} from '#/state/dialogs' 17 - import {isNative} from 'platform/detection' 18 20 import {atoms as a, flatten, useTheme} from '#/alf' 19 21 import {Context} from '#/components/Dialog/context' 20 22 import { ··· 238 240 }, 239 241 flatten(style), 240 242 ]} 241 - contentContainerStyle={isNative ? a.pb_4xl : undefined} 243 + contentContainerStyle={a.pb_4xl} 242 244 ref={ref}> 243 245 {children} 244 246 <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> 245 247 </BottomSheetScrollView> 248 + ) 249 + }) 250 + 251 + export const InnerFlatList = React.forwardRef< 252 + BottomSheetFlatListMethods, 253 + BottomSheetFlatListProps<any> 254 + >(function InnerFlatList({style, contentContainerStyle, ...props}, ref) { 255 + const insets = useSafeAreaInsets() 256 + return ( 257 + <BottomSheetFlatList 258 + keyboardShouldPersistTaps="handled" 259 + contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]} 260 + ListFooterComponent={ 261 + <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> 262 + } 263 + ref={ref} 264 + {...props} 265 + style={[ 266 + a.flex_1, 267 + a.p_xl, 268 + a.pt_0, 269 + a.h_full, 270 + { 271 + marginTop: 40, 272 + }, 273 + flatten(style), 274 + ]} 275 + /> 246 276 ) 247 277 }) 248 278
+17 -1
src/components/Dialog/index.web.tsx
··· 1 1 import React, {useImperativeHandle} from 'react' 2 - import {TouchableWithoutFeedback, View} from 'react-native' 2 + import { 3 + FlatList, 4 + FlatListProps, 5 + TouchableWithoutFeedback, 6 + View, 7 + } from 'react-native' 3 8 import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated' 4 9 import {msg} from '@lingui/macro' 5 10 import {useLingui} from '@lingui/react' ··· 191 196 } 192 197 193 198 export const ScrollableInner = Inner 199 + 200 + export function InnerFlatList({ 201 + label, 202 + ...props 203 + }: FlatListProps<any> & {label: string}) { 204 + return ( 205 + <Inner label={label}> 206 + <FlatList {...props} /> 207 + </Inner> 208 + ) 209 + } 194 210 195 211 export function Handle() { 196 212 return null
+12 -3
src/components/Error.tsx
··· 16 16 title, 17 17 message, 18 18 onRetry, 19 + onGoBack: onGoBackProp, 20 + sideBorders = true, 19 21 }: { 20 22 title?: string 21 23 message?: string 22 24 onRetry?: () => unknown 25 + onGoBack?: () => unknown 26 + sideBorders?: boolean 23 27 }) { 24 28 const navigation = useNavigation<NavigationProp>() 25 29 const {_} = useLingui() ··· 28 32 29 33 const canGoBack = navigation.canGoBack() 30 34 const onGoBack = React.useCallback(() => { 35 + if (onGoBackProp) { 36 + onGoBackProp() 37 + return 38 + } 31 39 if (canGoBack) { 32 40 navigation.goBack() 33 41 } else { ··· 41 49 navigation.dispatch(StackActions.popToTop()) 42 50 } 43 51 } 44 - }, [navigation, canGoBack]) 52 + }, [navigation, canGoBack, onGoBackProp]) 45 53 46 54 return ( 47 55 <CenteredView 48 56 style={[ 49 57 a.flex_1, 50 58 a.align_center, 51 - !gtMobile ? a.justify_between : a.gap_5xl, 59 + a.gap_5xl, 60 + !gtMobile && a.justify_between, 52 61 t.atoms.border_contrast_low, 53 62 {paddingTop: 175, paddingBottom: 110}, 54 63 ]} 55 - sideBorders> 64 + sideBorders={sideBorders}> 56 65 <View style={[a.w_full, a.align_center, a.gap_lg]}> 57 66 <Text style={[a.font_bold, a.text_3xl]}>{title}</Text> 58 67 <Text
+19 -6
src/components/Lists.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 1 + import React, {memo} from 'react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {cleanError} from 'lib/strings/errors' 7 7 import {CenteredView} from 'view/com/util/Views' 8 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 8 + import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf' 9 9 import {Button, ButtonText} from '#/components/Button' 10 10 import {Error} from '#/components/Error' 11 11 import {Loader} from '#/components/Loader' ··· 16 16 error, 17 17 onRetry, 18 18 height, 19 + style, 19 20 }: { 20 21 isFetchingNextPage?: boolean 21 22 error?: string 22 23 onRetry?: () => Promise<unknown> 23 24 height?: number 25 + style?: StyleProp<ViewStyle> 24 26 }) { 25 27 const t = useTheme() 26 28 ··· 33 35 a.pb_lg, 34 36 t.atoms.border_contrast_low, 35 37 {height: height ?? 180, paddingTop: 30}, 38 + flatten(style), 36 39 ]}> 37 40 {isFetchingNextPage ? ( 38 41 <Loader size="xl" /> ··· 120 123 ) 121 124 } 122 125 123 - export function ListMaybePlaceholder({ 126 + let ListMaybePlaceholder = ({ 124 127 isLoading, 125 128 noEmpty, 126 129 isError, ··· 130 133 errorMessage, 131 134 emptyType = 'page', 132 135 onRetry, 136 + onGoBack, 137 + sideBorders, 133 138 }: { 134 139 isLoading: boolean 135 140 noEmpty?: boolean ··· 140 145 errorMessage?: string 141 146 emptyType?: 'page' | 'results' 142 147 onRetry?: () => Promise<unknown> 143 - }) { 148 + onGoBack?: () => void 149 + sideBorders?: boolean 150 + }): React.ReactNode => { 144 151 const t = useTheme() 145 152 const {_} = useLingui() 146 153 const {gtMobile, gtTablet} = useBreakpoints() ··· 155 162 t.atoms.border_contrast_low, 156 163 {paddingTop: 175, paddingBottom: 110}, 157 164 ]} 158 - sideBorders={gtMobile} 165 + sideBorders={sideBorders ?? gtMobile} 159 166 topBorder={!gtTablet}> 160 167 <View style={[a.w_full, a.align_center, {top: 100}]}> 161 168 <Loader size="xl" /> ··· 170 177 title={errorTitle ?? _(msg`Oops!`)} 171 178 message={errorMessage ?? _(`Something went wrong!`)} 172 179 onRetry={onRetry} 180 + onGoBack={onGoBack} 181 + sideBorders={sideBorders} 173 182 /> 174 183 ) 175 184 } ··· 188 197 _(msg`We're sorry! We can't find the page you were looking for.`) 189 198 } 190 199 onRetry={onRetry} 200 + onGoBack={onGoBack} 201 + sideBorders={sideBorders} 191 202 /> 192 203 ) 193 204 } 194 205 195 206 return null 196 207 } 208 + ListMaybePlaceholder = memo(ListMaybePlaceholder) 209 + export {ListMaybePlaceholder}
+360
src/components/dialogs/GifSelect.tsx
··· 1 + import React, { 2 + useCallback, 3 + useDeferredValue, 4 + useMemo, 5 + useRef, 6 + useState, 7 + } from 'react' 8 + import {TextInput, View} from 'react-native' 9 + import {Image} from 'expo-image' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 12 + 13 + import {GIPHY_PRIVACY_POLICY} from '#/lib/constants' 14 + import {logEvent} from '#/lib/statsig/statsig' 15 + import {cleanError} from '#/lib/strings/errors' 16 + import {isWeb} from '#/platform/detection' 17 + import { 18 + useExternalEmbedsPrefs, 19 + useSetExternalEmbedPref, 20 + } from '#/state/preferences' 21 + import {Gif, useGifphySearch, useGiphyTrending} from '#/state/queries/giphy' 22 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 23 + import * as Dialog from '#/components/Dialog' 24 + import * as TextField from '#/components/forms/TextField' 25 + import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 26 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 27 + import {InlineLinkText} from '#/components/Link' 28 + import {Button, ButtonIcon, ButtonText} from '../Button' 29 + import {ListFooter, ListMaybePlaceholder} from '../Lists' 30 + import {Text} from '../Typography' 31 + 32 + export function GifSelectDialog({ 33 + control, 34 + onClose, 35 + onSelectGif: onSelectGifProp, 36 + }: { 37 + control: Dialog.DialogControlProps 38 + onClose: () => void 39 + onSelectGif: (gif: Gif) => void 40 + }) { 41 + const externalEmbedsPrefs = useExternalEmbedsPrefs() 42 + const onSelectGif = useCallback( 43 + (gif: Gif) => { 44 + control.close(() => onSelectGifProp(gif)) 45 + }, 46 + [control, onSelectGifProp], 47 + ) 48 + 49 + let content = null 50 + let snapPoints 51 + switch (externalEmbedsPrefs?.giphy) { 52 + case 'show': 53 + content = <GifList control={control} onSelectGif={onSelectGif} /> 54 + snapPoints = ['100%'] 55 + break 56 + case 'hide': 57 + default: 58 + content = <GiphyConsentPrompt control={control} /> 59 + break 60 + } 61 + 62 + return ( 63 + <Dialog.Outer 64 + control={control} 65 + nativeOptions={{sheet: {snapPoints}}} 66 + onClose={onClose}> 67 + <Dialog.Handle /> 68 + {content} 69 + </Dialog.Outer> 70 + ) 71 + } 72 + 73 + function GifList({ 74 + control, 75 + onSelectGif, 76 + }: { 77 + control: Dialog.DialogControlProps 78 + onSelectGif: (gif: Gif) => void 79 + }) { 80 + const {_} = useLingui() 81 + const t = useTheme() 82 + const {gtMobile} = useBreakpoints() 83 + const ref = useRef<TextInput>(null) 84 + const [undeferredSearch, setSearch] = useState('') 85 + const search = useDeferredValue(undeferredSearch) 86 + 87 + const isSearching = search.length > 0 88 + 89 + const trendingQuery = useGiphyTrending() 90 + const searchQuery = useGifphySearch(search) 91 + 92 + const { 93 + data, 94 + fetchNextPage, 95 + isFetchingNextPage, 96 + hasNextPage, 97 + error, 98 + isLoading, 99 + isError, 100 + refetch, 101 + } = isSearching ? searchQuery : trendingQuery 102 + 103 + const flattenedData = useMemo(() => { 104 + const uniquenessSet = new Set<string>() 105 + 106 + function filter(gif: Gif) { 107 + if (!gif) return false 108 + if (uniquenessSet.has(gif.id)) { 109 + return false 110 + } 111 + uniquenessSet.add(gif.id) 112 + return true 113 + } 114 + return data?.pages.flatMap(page => page.data.filter(filter)) || [] 115 + }, [data]) 116 + 117 + const renderItem = useCallback( 118 + ({item}: {item: Gif}) => { 119 + return <GifPreview gif={item} onSelectGif={onSelectGif} /> 120 + }, 121 + [onSelectGif], 122 + ) 123 + 124 + const onEndReached = React.useCallback(() => { 125 + if (isFetchingNextPage || !hasNextPage || error) return 126 + fetchNextPage() 127 + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 128 + 129 + const hasData = flattenedData.length > 0 130 + 131 + const onGoBack = useCallback(() => { 132 + if (isSearching) { 133 + // clear the input and reset the state 134 + ref.current?.clear() 135 + setSearch('') 136 + } else { 137 + control.close() 138 + } 139 + }, [control, isSearching]) 140 + 141 + const listHeader = useMemo(() => { 142 + return ( 143 + <View 144 + style={[ 145 + a.relative, 146 + a.mb_lg, 147 + a.flex_row, 148 + a.align_center, 149 + !gtMobile && isWeb && a.gap_md, 150 + ]}> 151 + {/* cover top corners */} 152 + <View 153 + style={[ 154 + a.absolute, 155 + {top: 0, left: 0, right: 0, height: '50%'}, 156 + t.atoms.bg, 157 + ]} 158 + /> 159 + 160 + {!gtMobile && isWeb && ( 161 + <Button 162 + size="small" 163 + variant="ghost" 164 + color="secondary" 165 + shape="round" 166 + onPress={() => control.close()} 167 + label={_(msg`Close GIF dialog`)}> 168 + <ButtonIcon icon={Arrow} size="md" /> 169 + </Button> 170 + )} 171 + 172 + <TextField.Root> 173 + <TextField.Icon icon={Search} /> 174 + <TextField.Input 175 + label={_(msg`Search GIFs`)} 176 + placeholder={_(msg`Powered by GIPHY`)} 177 + onChangeText={setSearch} 178 + returnKeyType="search" 179 + clearButtonMode="while-editing" 180 + inputRef={ref} 181 + maxLength={50} 182 + onKeyPress={({nativeEvent}) => { 183 + if (nativeEvent.key === 'Escape') { 184 + control.close() 185 + } 186 + }} 187 + /> 188 + </TextField.Root> 189 + </View> 190 + ) 191 + }, [gtMobile, t.atoms.bg, _, control]) 192 + 193 + return ( 194 + <> 195 + {gtMobile && <Dialog.Close />} 196 + <Dialog.InnerFlatList 197 + key={gtMobile ? '3 cols' : '2 cols'} 198 + data={flattenedData} 199 + renderItem={renderItem} 200 + numColumns={gtMobile ? 3 : 2} 201 + columnWrapperStyle={a.gap_sm} 202 + ListHeaderComponent={ 203 + <> 204 + {listHeader} 205 + {!hasData && ( 206 + <ListMaybePlaceholder 207 + isLoading={isLoading} 208 + isError={isError} 209 + onRetry={refetch} 210 + onGoBack={onGoBack} 211 + emptyType="results" 212 + sideBorders={false} 213 + errorTitle={_(msg`Failed to load GIFs`)} 214 + errorMessage={_(msg`There was an issue connecting to GIPHY.`)} 215 + emptyMessage={ 216 + isSearching 217 + ? _(msg`No search results found for "${search}".`) 218 + : _( 219 + msg`No trending GIFs found. There may be an issue with GIPHY.`, 220 + ) 221 + } 222 + /> 223 + )} 224 + </> 225 + } 226 + stickyHeaderIndices={[0]} 227 + onEndReached={onEndReached} 228 + onEndReachedThreshold={4} 229 + keyExtractor={(item: Gif) => item.id} 230 + // @ts-expect-error web only 231 + style={isWeb && {minHeight: '100vh'}} 232 + ListFooterComponent={ 233 + hasData ? ( 234 + <ListFooter 235 + isFetchingNextPage={isFetchingNextPage} 236 + error={cleanError(error)} 237 + onRetry={fetchNextPage} 238 + style={{borderTopWidth: 0}} 239 + /> 240 + ) : null 241 + } 242 + /> 243 + </> 244 + ) 245 + } 246 + 247 + function GifPreview({ 248 + gif, 249 + onSelectGif, 250 + }: { 251 + gif: Gif 252 + onSelectGif: (gif: Gif) => void 253 + }) { 254 + const {gtTablet} = useBreakpoints() 255 + const {_} = useLingui() 256 + const t = useTheme() 257 + 258 + const onPress = useCallback(() => { 259 + logEvent('composer:gif:select', {}) 260 + onSelectGif(gif) 261 + }, [onSelectGif, gif]) 262 + 263 + return ( 264 + <Button 265 + label={_(msg`Select GIF "${gif.title}"`)} 266 + style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]} 267 + onPress={onPress}> 268 + {({pressed}) => ( 269 + <Image 270 + style={[ 271 + a.flex_1, 272 + a.mb_sm, 273 + a.rounded_sm, 274 + {aspectRatio: 1, opacity: pressed ? 0.8 : 1}, 275 + t.atoms.bg_contrast_25, 276 + ]} 277 + source={{uri: gif.images.preview_gif.url}} 278 + contentFit="cover" 279 + accessibilityLabel={gif.title} 280 + accessibilityHint="" 281 + cachePolicy="none" 282 + accessibilityIgnoresInvertColors 283 + /> 284 + )} 285 + </Button> 286 + ) 287 + } 288 + 289 + function GiphyConsentPrompt({control}: {control: Dialog.DialogControlProps}) { 290 + const {_} = useLingui() 291 + const t = useTheme() 292 + const {gtMobile} = useBreakpoints() 293 + const setExternalEmbedPref = useSetExternalEmbedPref() 294 + 295 + const onShowPress = useCallback(() => { 296 + setExternalEmbedPref('giphy', 'show') 297 + }, [setExternalEmbedPref]) 298 + 299 + const onHidePress = useCallback(() => { 300 + setExternalEmbedPref('giphy', 'hide') 301 + control.close() 302 + }, [control, setExternalEmbedPref]) 303 + 304 + const gtMobileWeb = gtMobile && isWeb 305 + 306 + return ( 307 + <Dialog.ScrollableInner label={_(msg`Permission to use GIPHY`)}> 308 + <View style={a.gap_sm}> 309 + <Text style={[a.text_2xl, a.font_bold]}> 310 + <Trans>Permission to use GIPHY</Trans> 311 + </Text> 312 + 313 + <View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}> 314 + <Text> 315 + <Trans> 316 + Bluesky uses GIPHY to provide the GIF selector feature. 317 + </Trans> 318 + </Text> 319 + 320 + <Text style={t.atoms.text_contrast_medium}> 321 + <Trans> 322 + GIPHY may collect information about you and your device. You can 323 + find out more in their{' '} 324 + <InlineLinkText 325 + to={GIPHY_PRIVACY_POLICY} 326 + onPress={() => control.close()}> 327 + privacy policy 328 + </InlineLinkText> 329 + . 330 + </Trans> 331 + </Text> 332 + </View> 333 + </View> 334 + <View style={[a.gap_md, gtMobileWeb && a.flex_row_reverse]}> 335 + <Button 336 + label={_(msg`Enable GIPHY`)} 337 + onPress={onShowPress} 338 + onAccessibilityEscape={control.close} 339 + color="primary" 340 + size={gtMobileWeb ? 'small' : 'medium'} 341 + variant="solid"> 342 + <ButtonText> 343 + <Trans>Enable GIPHY</Trans> 344 + </ButtonText> 345 + </Button> 346 + <Button 347 + label={_(msg`No thanks`)} 348 + onAccessibilityEscape={control.close} 349 + onPress={onHidePress} 350 + color="secondary" 351 + size={gtMobileWeb ? 'small' : 'medium'} 352 + variant="ghost"> 353 + <ButtonText> 354 + <Trans>No thanks</Trans> 355 + </ButtonText> 356 + </Button> 357 + </View> 358 + </Dialog.ScrollableInner> 359 + ) 360 + }
+9
src/components/icons/Arrow.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + }) 6 + 7 + export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + 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', 9 + })
-5
src/components/icons/ArrowTopRight.tsx
··· 1 - import {createSinglePathSVG} from './TEMPLATE' 2 - 3 - export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 - 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', 5 - })
+9
src/components/icons/Gif.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Gif_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + }) 6 + 7 + export const GifSquare_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + 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', 9 + })
+9
src/lib/constants.ts
··· 89 89 'did:plc:vpkhqolt662uhesyj6nxm7ys', 90 90 'did:plc:q6gjnaw2blty4crticxkmujt', 91 91 ] 92 + 93 + export const GIPHY_API_URL = 'https://api.giphy.com' 94 + export const GIPHY_API_KEY = Platform.select({ 95 + ios: 'ydVxhrQkwlcUjkVKx15mF6vyaNJbMeez', 96 + android: 'Vwj3Ib7857dj3EcIg24Hiz1LbRVdGeYF', 97 + default: 'vyL3hQQ8AipwcmIB8kFvg0NDs9faWg7G', 98 + }) 99 + export const GIPHY_PRIVACY_POLICY = 100 + 'https://support.giphy.com/hc/en-us/articles/360032872931-GIPHY-Privacy-Policy'
+2
src/lib/statsig/events.ts
··· 60 60 feedType: string 61 61 reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest' 62 62 } 63 + 'composer:gif:open': {} 64 + 'composer:gif:select': {} 63 65 64 66 // Data events 65 67 'account:create:begin': {}
+6 -2
src/state/preferences/external-embeds-prefs.tsx
··· 1 1 import React from 'react' 2 + 2 3 import * as persisted from '#/state/persisted' 3 4 import {EmbedPlayerSource} from 'lib/strings/embed-player' 4 5 5 6 type StateContext = persisted.Schema['externalEmbeds'] 6 - type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void 7 + type SetContext = ( 8 + source: EmbedPlayerSource, 9 + value: 'show' | 'hide' | undefined, 10 + ) => void 7 11 8 12 const stateContext = React.createContext<StateContext>( 9 13 persisted.defaults.externalEmbeds, ··· 14 18 const [state, setState] = React.useState(persisted.get('externalEmbeds')) 15 19 16 20 const setStateWrapped = React.useCallback( 17 - (source: EmbedPlayerSource, value: 'show' | 'hide') => { 21 + (source: EmbedPlayerSource, value: 'show' | 'hide' | undefined) => { 18 22 setState(prev => { 19 23 persisted.write('externalEmbeds', { 20 24 ...prev,
+280
src/state/queries/giphy.ts
··· 1 + import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query' 2 + 3 + import {GIPHY_API_KEY, GIPHY_API_URL} from '#/lib/constants' 4 + 5 + export const RQKEY_ROOT = 'giphy' 6 + export const RQKEY_TRENDING = [RQKEY_ROOT, 'trending'] 7 + export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query] 8 + 9 + const getTrendingGifs = createGiphyApi< 10 + { 11 + limit?: number 12 + offset?: number 13 + rating?: string 14 + random_id?: string 15 + bundle?: string 16 + }, 17 + {data: Gif[]; pagination: Pagination} 18 + >('/v1/gifs/trending') 19 + 20 + const searchGifs = createGiphyApi< 21 + { 22 + q: string 23 + limit?: number 24 + offset?: number 25 + rating?: string 26 + lang?: string 27 + random_id?: string 28 + bundle?: string 29 + }, 30 + {data: Gif[]; pagination: Pagination} 31 + >('/v1/gifs/search') 32 + 33 + export function useGiphyTrending() { 34 + return useInfiniteQuery({ 35 + queryKey: RQKEY_TRENDING, 36 + queryFn: ({pageParam}) => getTrendingGifs({offset: pageParam}), 37 + initialPageParam: 0, 38 + getNextPageParam: lastPage => 39 + lastPage.pagination.offset + lastPage.pagination.count, 40 + }) 41 + } 42 + 43 + export function useGifphySearch(query: string) { 44 + return useInfiniteQuery({ 45 + queryKey: RQKEY_SEARCH(query), 46 + queryFn: ({pageParam}) => searchGifs({q: query, offset: pageParam}), 47 + initialPageParam: 0, 48 + getNextPageParam: lastPage => 49 + lastPage.pagination.offset + lastPage.pagination.count, 50 + enabled: !!query, 51 + placeholderData: keepPreviousData, 52 + }) 53 + } 54 + 55 + function createGiphyApi<Input extends object, Ouput>( 56 + path: string, 57 + ): (input: Input) => Promise< 58 + Ouput & { 59 + meta: Meta 60 + } 61 + > { 62 + return async input => { 63 + const url = new URL(path, GIPHY_API_URL) 64 + url.searchParams.set('api_key', GIPHY_API_KEY) 65 + 66 + for (const [key, value] of Object.entries(input)) { 67 + url.searchParams.set(key, String(value)) 68 + } 69 + 70 + const res = await fetch(url.toString(), { 71 + method: 'GET', 72 + headers: { 73 + 'Content-Type': 'application/json', 74 + }, 75 + }) 76 + if (!res.ok) { 77 + throw new Error('Failed to fetch Giphy API') 78 + } 79 + return res.json() 80 + } 81 + } 82 + 83 + export type Gif = { 84 + type: string 85 + id: string 86 + slug: string 87 + url: string 88 + bitly_url: string 89 + embed_url: string 90 + username: string 91 + source: string 92 + rating: string 93 + content_url: string 94 + user: User 95 + source_tld: string 96 + source_post_url: string 97 + update_datetime: string 98 + create_datetime: string 99 + import_datetime: string 100 + trending_datetime: string 101 + images: Images 102 + title: string 103 + alt_text: string 104 + } 105 + 106 + type Images = { 107 + fixed_height: { 108 + url: string 109 + width: string 110 + height: string 111 + size: string 112 + mp4: string 113 + mp4_size: string 114 + webp: string 115 + webp_size: string 116 + } 117 + 118 + fixed_height_still: { 119 + url: string 120 + width: string 121 + height: string 122 + } 123 + 124 + fixed_height_downsampled: { 125 + url: string 126 + width: string 127 + height: string 128 + size: string 129 + webp: string 130 + webp_size: string 131 + } 132 + 133 + fixed_width: { 134 + url: string 135 + width: string 136 + height: string 137 + size: string 138 + mp4: string 139 + mp4_size: string 140 + webp: string 141 + webp_size: string 142 + } 143 + 144 + fixed_width_still: { 145 + url: string 146 + width: string 147 + height: string 148 + } 149 + 150 + fixed_width_downsampled: { 151 + url: string 152 + width: string 153 + height: string 154 + size: string 155 + webp: string 156 + webp_size: string 157 + } 158 + 159 + fixed_height_small: { 160 + url: string 161 + width: string 162 + height: string 163 + size: string 164 + mp4: string 165 + mp4_size: string 166 + webp: string 167 + webp_size: string 168 + } 169 + 170 + fixed_height_small_still: { 171 + url: string 172 + width: string 173 + height: string 174 + } 175 + 176 + fixed_width_small: { 177 + url: string 178 + width: string 179 + height: string 180 + size: string 181 + mp4: string 182 + mp4_size: string 183 + webp: string 184 + webp_size: string 185 + } 186 + 187 + fixed_width_small_still: { 188 + url: string 189 + width: string 190 + height: string 191 + } 192 + 193 + downsized: { 194 + url: string 195 + width: string 196 + height: string 197 + size: string 198 + } 199 + 200 + downsized_still: { 201 + url: string 202 + width: string 203 + height: string 204 + } 205 + 206 + downsized_large: { 207 + url: string 208 + width: string 209 + height: string 210 + size: string 211 + } 212 + 213 + downsized_medium: { 214 + url: string 215 + width: string 216 + height: string 217 + size: string 218 + } 219 + 220 + downsized_small: { 221 + mp4: string 222 + width: string 223 + height: string 224 + mp4_size: string 225 + } 226 + 227 + original: { 228 + width: string 229 + height: string 230 + size: string 231 + frames: string 232 + mp4: string 233 + mp4_size: string 234 + webp: string 235 + webp_size: string 236 + } 237 + 238 + original_still: { 239 + url: string 240 + width: string 241 + height: string 242 + } 243 + 244 + looping: { 245 + mp4: string 246 + } 247 + 248 + preview: { 249 + mp4: string 250 + mp4_size: string 251 + width: string 252 + height: string 253 + } 254 + 255 + preview_gif: { 256 + url: string 257 + width: string 258 + height: string 259 + } 260 + } 261 + 262 + type User = { 263 + avatar_url: string 264 + banner_url: string 265 + profile_url: string 266 + username: string 267 + display_name: string 268 + } 269 + 270 + type Meta = { 271 + msg: string 272 + status: number 273 + response_id: string 274 + } 275 + 276 + type Pagination = { 277 + offset: number 278 + total_count: number 279 + count: number 280 + }
+49 -22
src/view/com/composer/Composer.tsx
··· 13 13 KeyboardAvoidingView, 14 14 LayoutAnimation, 15 15 Platform, 16 - Pressable, 17 16 ScrollView, 18 17 StyleSheet, 19 18 TouchableOpacity, ··· 27 26 import {useLingui} from '@lingui/react' 28 27 import {observer} from 'mobx-react-lite' 29 28 29 + import {LikelyType} from '#/lib/link-meta/link-meta' 30 30 import {logEvent} from '#/lib/statsig/statsig' 31 31 import {logger} from '#/logger' 32 32 import {emitPostCreated} from '#/state/events' ··· 37 37 useLanguagePrefs, 38 38 useLanguagePrefsApi, 39 39 } from '#/state/preferences/languages' 40 + import {Gif} from '#/state/queries/giphy' 40 41 import {useProfileQuery} from '#/state/queries/profile' 41 42 import {ThreadgateSetting} from '#/state/queries/threadgate' 42 43 import {getAgent, useSession} from '#/state/session' ··· 56 57 import {GalleryModel} from 'state/models/media/gallery' 57 58 import {ComposerOpts} from 'state/shell/composer' 58 59 import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' 60 + import {atoms as a} from '#/alf' 61 + import {Button} from '#/components/Button' 62 + import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' 59 63 import * as Prompt from '#/components/Prompt' 60 64 import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed' 61 65 import {Text} from '../util/text/Text' ··· 66 70 import {LabelsBtn} from './labels/LabelsBtn' 67 71 import {Gallery} from './photos/Gallery' 68 72 import {OpenCameraBtn} from './photos/OpenCameraBtn' 73 + import {SelectGifBtn} from './photos/SelectGifBtn' 69 74 import {SelectPhotoBtn} from './photos/SelectPhotoBtn' 70 75 import {SelectLangBtn} from './select-language/SelectLangBtn' 71 76 import {SuggestedLanguage} from './select-language/SuggestedLanguage' ··· 314 319 ? _(msg`Write your reply`) 315 320 : _(msg`What's up?`) 316 321 317 - const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) 322 + const canSelectImages = gallery.size < 4 && !extLink 318 323 const hasMedia = gallery.size > 0 || Boolean(extLink) 319 324 320 325 const onEmojiButtonPress = useCallback(() => { 321 326 openPicker?.(textInput.current?.getCursorPosition()) 322 327 }, [openPicker]) 328 + 329 + const focusTextInput = useCallback(() => { 330 + textInput.current?.focus() 331 + }, []) 332 + 333 + const onSelectGif = useCallback( 334 + (gif: Gif) => 335 + setExtLink({ 336 + uri: gif.url, 337 + isLoading: true, 338 + meta: { 339 + url: gif.url, 340 + image: gif.images.original_still.url, 341 + likelyType: LikelyType.HTML, 342 + title: `${gif.title} - Find & Share on GIPHY`, 343 + description: `ALT: ${gif.alt_text}`, 344 + }, 345 + }), 346 + [setExtLink], 347 + ) 323 348 324 349 return ( 325 350 <KeyboardAvoidingView ··· 473 498 </ScrollView> 474 499 <SuggestedLanguage text={richtext.text} /> 475 500 <View style={[pal.border, styles.bottomBar]}> 476 - {canSelectImages ? ( 477 - <> 478 - <SelectPhotoBtn gallery={gallery} /> 479 - <OpenCameraBtn gallery={gallery} /> 480 - </> 481 - ) : null} 482 - {!isMobile ? ( 483 - <Pressable 484 - onPress={onEmojiButtonPress} 485 - accessibilityRole="button" 486 - accessibilityLabel={_(msg`Open emoji picker`)} 487 - accessibilityHint={_(msg`Open emoji picker`)}> 488 - <FontAwesomeIcon 489 - icon={['far', 'face-smile']} 490 - color={pal.colors.link} 491 - size={22} 492 - /> 493 - </Pressable> 494 - ) : null} 501 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 502 + <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> 503 + <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> 504 + <SelectGifBtn 505 + onClose={focusTextInput} 506 + onSelectGif={onSelectGif} 507 + disabled={hasMedia} 508 + /> 509 + {!isMobile ? ( 510 + <Button 511 + onPress={onEmojiButtonPress} 512 + style={a.p_sm} 513 + label={_(msg`Open emoji picker`)} 514 + accessibilityHint={_(msg`Open emoji picker`)} 515 + variant="ghost" 516 + shape="round" 517 + color="primary"> 518 + <EmojiSmile size="lg" /> 519 + </Button> 520 + ) : null} 521 + </View> 495 522 <View style={s.flex1} /> 496 523 <SelectLangBtn /> 497 524 <CharProgress count={graphemeLength} /> ··· 586 613 }, 587 614 bottomBar: { 588 615 flexDirection: 'row', 589 - paddingVertical: 10, 616 + paddingVertical: 4, 590 617 paddingLeft: 15, 591 618 paddingRight: 20, 592 619 alignItems: 'center',
+25 -34
src/view/com/composer/photos/OpenCameraBtn.tsx
··· 1 1 import React, {useCallback} from 'react' 2 - import {TouchableOpacity, StyleSheet} from 'react-native' 3 2 import * as MediaLibrary from 'expo-media-library' 4 - import { 5 - FontAwesomeIcon, 6 - FontAwesomeIconStyle, 7 - } from '@fortawesome/react-native-fontawesome' 8 - import {usePalette} from 'lib/hooks/usePalette' 9 - import {useAnalytics} from 'lib/analytics/analytics' 10 - import {openCamera} from 'lib/media/picker' 11 - import {useCameraPermission} from 'lib/hooks/usePermissions' 12 - import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants' 13 - import {GalleryModel} from 'state/models/media/gallery' 14 - import {isMobileWeb, isNative} from 'platform/detection' 15 - import {logger} from '#/logger' 3 + import {msg} from '@lingui/macro' 16 4 import {useLingui} from '@lingui/react' 17 - import {msg} from '@lingui/macro' 5 + 6 + import {useAnalytics} from '#/lib/analytics/analytics' 7 + import {POST_IMG_MAX} from '#/lib/constants' 8 + import {useCameraPermission} from '#/lib/hooks/usePermissions' 9 + import {openCamera} from '#/lib/media/picker' 10 + import {logger} from '#/logger' 11 + import {isMobileWeb, isNative} from '#/platform/detection' 12 + import {GalleryModel} from '#/state/models/media/gallery' 13 + import {atoms as a, useTheme} from '#/alf' 14 + import {Button} from '#/components/Button' 15 + import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' 18 16 19 17 type Props = { 20 18 gallery: GalleryModel 19 + disabled?: boolean 21 20 } 22 21 23 - export function OpenCameraBtn({gallery}: Props) { 24 - const pal = usePalette('default') 22 + export function OpenCameraBtn({gallery, disabled}: Props) { 25 23 const {track} = useAnalytics() 26 24 const {_} = useLingui() 27 25 const {requestCameraAccessIfNeeded} = useCameraPermission() 28 26 const [mediaPermissionRes, requestMediaPermission] = 29 27 MediaLibrary.usePermissions() 28 + const t = useTheme() 30 29 31 30 const onPressTakePicture = useCallback(async () => { 32 31 track('Composer:CameraOpened') ··· 68 67 } 69 68 70 69 return ( 71 - <TouchableOpacity 70 + <Button 72 71 testID="openCameraButton" 73 72 onPress={onPressTakePicture} 74 - style={styles.button} 75 - hitSlop={HITSLOP_10} 76 - accessibilityRole="button" 77 - accessibilityLabel={_(msg`Camera`)} 78 - accessibilityHint={_(msg`Opens camera on device`)}> 79 - <FontAwesomeIcon 80 - icon="camera" 81 - style={pal.link as FontAwesomeIconStyle} 82 - size={24} 83 - /> 84 - </TouchableOpacity> 73 + label={_(msg`Camera`)} 74 + accessibilityHint={_(msg`Opens camera on device`)} 75 + style={a.p_sm} 76 + variant="ghost" 77 + shape="round" 78 + color="primary" 79 + disabled={disabled}> 80 + <Camera size="lg" style={disabled && t.atoms.text_contrast_low} /> 81 + </Button> 85 82 ) 86 83 } 87 - 88 - const styles = StyleSheet.create({ 89 - button: { 90 - paddingHorizontal: 15, 91 - }, 92 - })
+53
src/view/com/composer/photos/SelectGifBtn.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {Keyboard} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {logEvent} from '#/lib/statsig/statsig' 7 + import {Gif} from '#/state/queries/giphy' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {Button} from '#/components/Button' 10 + import {useDialogControl} from '#/components/Dialog' 11 + import {GifSelectDialog} from '#/components/dialogs/GifSelect' 12 + import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' 13 + 14 + type Props = { 15 + onClose: () => void 16 + onSelectGif: (gif: Gif) => void 17 + disabled?: boolean 18 + } 19 + 20 + export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { 21 + const {_} = useLingui() 22 + const control = useDialogControl() 23 + const t = useTheme() 24 + 25 + const onPressSelectGif = useCallback(async () => { 26 + logEvent('composer:gif:open', {}) 27 + Keyboard.dismiss() 28 + control.open() 29 + }, [control]) 30 + 31 + return ( 32 + <> 33 + <Button 34 + testID="openGifBtn" 35 + onPress={onPressSelectGif} 36 + label={_(msg`Select GIF`)} 37 + accessibilityHint={_(msg`Opens GIF select dialog`)} 38 + style={a.p_sm} 39 + variant="ghost" 40 + shape="round" 41 + color="primary" 42 + disabled={disabled}> 43 + <GifIcon size="lg" style={disabled && t.atoms.text_contrast_low} /> 44 + </Button> 45 + 46 + <GifSelectDialog 47 + control={control} 48 + onClose={onClose} 49 + onSelectGif={onSelectGif} 50 + /> 51 + </> 52 + ) 53 + }
+23 -32
src/view/com/composer/photos/SelectPhotoBtn.tsx
··· 1 + /* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */ 1 2 import React, {useCallback} from 'react' 2 - import {TouchableOpacity, StyleSheet} from 'react-native' 3 - import { 4 - FontAwesomeIcon, 5 - FontAwesomeIconStyle, 6 - } from '@fortawesome/react-native-fontawesome' 7 - import {usePalette} from 'lib/hooks/usePalette' 8 - import {useAnalytics} from 'lib/analytics/analytics' 9 - import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' 10 - import {GalleryModel} from 'state/models/media/gallery' 11 - import {HITSLOP_10} from 'lib/constants' 12 - import {isNative} from 'platform/detection' 3 + import {msg} from '@lingui/macro' 13 4 import {useLingui} from '@lingui/react' 14 - import {msg} from '@lingui/macro' 5 + 6 + import {useAnalytics} from '#/lib/analytics/analytics' 7 + import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' 8 + import {isNative} from '#/platform/detection' 9 + import {GalleryModel} from '#/state/models/media/gallery' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Button} from '#/components/Button' 12 + import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' 15 13 16 14 type Props = { 17 15 gallery: GalleryModel 16 + disabled?: boolean 18 17 } 19 18 20 - export function SelectPhotoBtn({gallery}: Props) { 21 - const pal = usePalette('default') 19 + export function SelectPhotoBtn({gallery, disabled}: Props) { 22 20 const {track} = useAnalytics() 23 21 const {_} = useLingui() 24 22 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 23 + const t = useTheme() 25 24 26 25 const onPressSelectPhotos = useCallback(async () => { 27 26 track('Composer:GalleryOpened') ··· 34 33 }, [track, requestPhotoAccessIfNeeded, gallery]) 35 34 36 35 return ( 37 - <TouchableOpacity 36 + <Button 38 37 testID="openGalleryBtn" 39 38 onPress={onPressSelectPhotos} 40 - style={styles.button} 41 - hitSlop={HITSLOP_10} 42 - accessibilityRole="button" 43 - accessibilityLabel={_(msg`Gallery`)} 44 - accessibilityHint={_(msg`Opens device photo gallery`)}> 45 - <FontAwesomeIcon 46 - icon={['far', 'image']} 47 - style={pal.link as FontAwesomeIconStyle} 48 - size={24} 49 - /> 50 - </TouchableOpacity> 39 + label={_(msg`Gallery`)} 40 + accessibilityHint={_(msg`Opens device photo gallery`)} 41 + style={a.p_sm} 42 + variant="ghost" 43 + shape="round" 44 + color="primary" 45 + disabled={disabled}> 46 + <Image size="lg" style={disabled && t.atoms.text_contrast_low} /> 47 + </Button> 51 48 ) 52 49 } 53 - 54 - const styles = StyleSheet.create({ 55 - button: { 56 - paddingHorizontal: 15, 57 - }, 58 - })
+1 -1
src/view/screens/Storybook/Buttons.tsx
··· 9 9 ButtonText, 10 10 ButtonVariant, 11 11 } from '#/components/Button' 12 - import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' 12 + import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/Arrow' 13 13 import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 14 14 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 15 15 import {H1} from '#/components/Typography'
+3 -3
src/view/screens/Storybook/Icons.tsx
··· 2 2 import {View} from 'react-native' 3 3 4 4 import {atoms as a, useTheme} from '#/alf' 5 - import {H1} from '#/components/Typography' 6 - import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 7 - import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' 5 + import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/Arrow' 8 6 import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 7 + import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 9 8 import {Loader} from '#/components/Loader' 9 + import {H1} from '#/components/Typography' 10 10 11 11 export function Icons() { 12 12 const t = useTheme()