Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Native `formSheet` for GIF select on iOS (#4328)

* native formsheet for gif select

* trigger confirm discard if have gif

* give modal a background color

* fix web top bar - unrelated but I cba to make a separate PR

authored by

Samuel Newman and committed by
GitHub
da96fb1e b0244588

+331 -58
+255
src/components/dialogs/GifSelect.ios.tsx
··· 1 + import React, { 2 + useCallback, 3 + useImperativeHandle, 4 + useMemo, 5 + useRef, 6 + useState, 7 + } from 'react' 8 + import {Modal, ScrollView, TextInput, View} from 'react-native' 9 + import {msg, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + 12 + import {cleanError} from '#/lib/strings/errors' 13 + import { 14 + Gif, 15 + useFeaturedGifsQuery, 16 + useGifSearchQuery, 17 + } from '#/state/queries/tenor' 18 + import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 19 + import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 20 + import {FlatList_INTERNAL} from '#/view/com/util/Views' 21 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 22 + import * as TextField from '#/components/forms/TextField' 23 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 24 + import {Button, ButtonText} from '../Button' 25 + import {Handle} from '../Dialog' 26 + import {useThrottledValue} from '../hooks/useThrottledValue' 27 + import {ListFooter, ListMaybePlaceholder} from '../Lists' 28 + import {GifPreview} from './GifSelect.shared' 29 + 30 + export function GifSelectDialog({ 31 + controlRef, 32 + onClose, 33 + onSelectGif: onSelectGifProp, 34 + }: { 35 + controlRef: React.RefObject<{open: () => void}> 36 + onClose: () => void 37 + onSelectGif: (gif: Gif) => void 38 + }) { 39 + const t = useTheme() 40 + const [open, setOpen] = useState(false) 41 + 42 + useImperativeHandle(controlRef, () => ({ 43 + open: () => setOpen(true), 44 + })) 45 + 46 + const close = useCallback(() => { 47 + setOpen(false) 48 + onClose() 49 + }, [onClose]) 50 + 51 + const onSelectGif = useCallback( 52 + (gif: Gif) => { 53 + onSelectGifProp(gif) 54 + close() 55 + }, 56 + [onSelectGifProp, close], 57 + ) 58 + 59 + const renderErrorBoundary = useCallback( 60 + (error: any) => <ModalError details={String(error)} close={close} />, 61 + [close], 62 + ) 63 + 64 + return ( 65 + <Modal 66 + visible={open} 67 + animationType="slide" 68 + presentationStyle="formSheet" 69 + onRequestClose={close} 70 + aria-modal 71 + accessibilityViewIsModal> 72 + <View style={[a.flex_1, t.atoms.bg]}> 73 + <Handle /> 74 + <ErrorBoundary renderError={renderErrorBoundary}> 75 + <GifList onSelectGif={onSelectGif} close={close} /> 76 + </ErrorBoundary> 77 + </View> 78 + </Modal> 79 + ) 80 + } 81 + 82 + function GifList({ 83 + onSelectGif, 84 + }: { 85 + close: () => void 86 + onSelectGif: (gif: Gif) => void 87 + }) { 88 + const {_} = useLingui() 89 + const t = useTheme() 90 + const {gtMobile} = useBreakpoints() 91 + const textInputRef = useRef<TextInput>(null) 92 + const listRef = useRef<FlatList_INTERNAL>(null) 93 + const [undeferredSearch, setSearch] = useState('') 94 + const search = useThrottledValue(undeferredSearch, 500) 95 + 96 + const isSearching = search.length > 0 97 + 98 + const trendingQuery = useFeaturedGifsQuery() 99 + const searchQuery = useGifSearchQuery(search) 100 + 101 + const { 102 + data, 103 + fetchNextPage, 104 + isFetchingNextPage, 105 + hasNextPage, 106 + error, 107 + isLoading, 108 + isError, 109 + refetch, 110 + } = isSearching ? searchQuery : trendingQuery 111 + 112 + const flattenedData = useMemo(() => { 113 + return data?.pages.flatMap(page => page.results) || [] 114 + }, [data]) 115 + 116 + const renderItem = useCallback( 117 + ({item}: {item: Gif}) => { 118 + return <GifPreview gif={item} onSelectGif={onSelectGif} /> 119 + }, 120 + [onSelectGif], 121 + ) 122 + 123 + const onEndReached = React.useCallback(() => { 124 + if (isFetchingNextPage || !hasNextPage || error) return 125 + fetchNextPage() 126 + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 127 + 128 + const hasData = flattenedData.length > 0 129 + 130 + const onGoBack = useCallback(() => { 131 + if (isSearching) { 132 + // clear the input and reset the state 133 + textInputRef.current?.clear() 134 + setSearch('') 135 + } else { 136 + close() 137 + } 138 + }, [isSearching]) 139 + 140 + const listHeader = useMemo(() => { 141 + return ( 142 + <View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}> 143 + {/* cover top corners */} 144 + <View 145 + style={[ 146 + a.absolute, 147 + a.inset_0, 148 + { 149 + borderBottomLeftRadius: 8, 150 + borderBottomRightRadius: 8, 151 + }, 152 + t.atoms.bg, 153 + ]} 154 + /> 155 + 156 + <TextField.Root> 157 + <TextField.Icon icon={Search} /> 158 + <TextField.Input 159 + label={_(msg`Search GIFs`)} 160 + placeholder={_(msg`Search Tenor`)} 161 + onChangeText={text => { 162 + setSearch(text) 163 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 164 + }} 165 + returnKeyType="search" 166 + clearButtonMode="while-editing" 167 + inputRef={textInputRef} 168 + maxLength={50} 169 + /> 170 + </TextField.Root> 171 + </View> 172 + ) 173 + }, [t.atoms.bg, _]) 174 + 175 + return ( 176 + <FlatList_INTERNAL 177 + ref={listRef} 178 + key={gtMobile ? '3 cols' : '2 cols'} 179 + data={flattenedData} 180 + renderItem={renderItem} 181 + numColumns={gtMobile ? 3 : 2} 182 + columnWrapperStyle={a.gap_sm} 183 + contentContainerStyle={a.px_lg} 184 + ListHeaderComponent={ 185 + <> 186 + {listHeader} 187 + {!hasData && ( 188 + <ListMaybePlaceholder 189 + isLoading={isLoading} 190 + isError={isError} 191 + onRetry={refetch} 192 + onGoBack={onGoBack} 193 + emptyType="results" 194 + sideBorders={false} 195 + topBorder={false} 196 + errorTitle={_(msg`Failed to load GIFs`)} 197 + errorMessage={_(msg`There was an issue connecting to Tenor.`)} 198 + emptyMessage={ 199 + isSearching 200 + ? _(msg`No search results found for "${search}".`) 201 + : _( 202 + msg`No featured GIFs found. There may be an issue with Tenor.`, 203 + ) 204 + } 205 + /> 206 + )} 207 + </> 208 + } 209 + stickyHeaderIndices={[0]} 210 + onEndReached={onEndReached} 211 + onEndReachedThreshold={4} 212 + keyExtractor={(item: Gif) => item.id} 213 + keyboardDismissMode="on-drag" 214 + ListFooterComponent={ 215 + hasData ? ( 216 + <ListFooter 217 + isFetchingNextPage={isFetchingNextPage} 218 + error={cleanError(error)} 219 + onRetry={fetchNextPage} 220 + style={{borderTopWidth: 0}} 221 + /> 222 + ) : null 223 + } 224 + /> 225 + ) 226 + } 227 + 228 + function ModalError({details, close}: {details?: string; close: () => void}) { 229 + const {_} = useLingui() 230 + 231 + return ( 232 + <ScrollView 233 + style={[a.flex_1, a.gap_md]} 234 + centerContent 235 + contentContainerStyle={a.px_lg}> 236 + <ErrorScreen 237 + title={_(msg`Oh no!`)} 238 + message={_( 239 + msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, 240 + )} 241 + details={details} 242 + /> 243 + <Button 244 + label={_(msg`Close dialog`)} 245 + onPress={close} 246 + color="primary" 247 + size="medium" 248 + variant="solid"> 249 + <ButtonText> 250 + <Trans>Close</Trans> 251 + </ButtonText> 252 + </Button> 253 + </ScrollView> 254 + ) 255 + }
+53
src/components/dialogs/GifSelect.shared.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {Image} from 'expo-image' 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/tenor' 8 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 9 + import {Button} from '../Button' 10 + 11 + export function GifPreview({ 12 + gif, 13 + onSelectGif, 14 + }: { 15 + gif: Gif 16 + onSelectGif: (gif: Gif) => void 17 + }) { 18 + const {gtTablet} = useBreakpoints() 19 + const {_} = useLingui() 20 + const t = useTheme() 21 + 22 + const onPress = useCallback(() => { 23 + logEvent('composer:gif:select', {}) 24 + onSelectGif(gif) 25 + }, [onSelectGif, gif]) 26 + 27 + return ( 28 + <Button 29 + label={_(msg`Select GIF "${gif.title}"`)} 30 + style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]} 31 + onPress={onPress}> 32 + {({pressed}) => ( 33 + <Image 34 + style={[ 35 + a.flex_1, 36 + a.mb_sm, 37 + a.rounded_sm, 38 + {aspectRatio: 1, opacity: pressed ? 0.8 : 1}, 39 + t.atoms.bg_contrast_25, 40 + ]} 41 + source={{ 42 + uri: gif.media_formats.tinygif.url, 43 + }} 44 + contentFit="cover" 45 + accessibilityLabel={gif.title} 46 + accessibilityHint="" 47 + cachePolicy="none" 48 + accessibilityIgnoresInvertColors 49 + /> 50 + )} 51 + </Button> 52 + ) 53 + }
+16 -49
src/components/dialogs/GifSelect.tsx
··· 1 - import React, {useCallback, useMemo, useRef, useState} from 'react' 1 + import React, { 2 + useCallback, 3 + useImperativeHandle, 4 + useMemo, 5 + useRef, 6 + useState, 7 + } from 'react' 2 8 import {TextInput, View} from 'react-native' 3 - import {Image} from 'expo-image' 4 9 import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' 5 10 import {msg, Trans} from '@lingui/macro' 6 11 import {useLingui} from '@lingui/react' 7 12 8 - import {logEvent} from '#/lib/statsig/statsig' 9 13 import {cleanError} from '#/lib/strings/errors' 10 14 import {isWeb} from '#/platform/detection' 11 15 import { ··· 23 27 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 24 28 import {Button, ButtonIcon, ButtonText} from '../Button' 25 29 import {ListFooter, ListMaybePlaceholder} from '../Lists' 30 + import {GifPreview} from './GifSelect.shared' 26 31 27 32 export function GifSelectDialog({ 28 - control, 33 + controlRef, 29 34 onClose, 30 35 onSelectGif: onSelectGifProp, 31 36 }: { 32 - control: Dialog.DialogControlProps 37 + controlRef: React.RefObject<{open: () => void}> 33 38 onClose: () => void 34 39 onSelectGif: (gif: Gif) => void 35 40 }) { 41 + const control = Dialog.useDialogControl() 42 + 43 + useImperativeHandle(controlRef, () => ({ 44 + open: () => control.open(), 45 + })) 46 + 36 47 const onSelectGif = useCallback( 37 48 (gif: Gif) => { 38 49 control.close(() => onSelectGifProp(gif)) ··· 230 241 } 231 242 /> 232 243 </> 233 - ) 234 - } 235 - 236 - function GifPreview({ 237 - gif, 238 - onSelectGif, 239 - }: { 240 - gif: Gif 241 - onSelectGif: (gif: Gif) => void 242 - }) { 243 - const {gtTablet} = useBreakpoints() 244 - const {_} = useLingui() 245 - const t = useTheme() 246 - 247 - const onPress = useCallback(() => { 248 - logEvent('composer:gif:select', {}) 249 - onSelectGif(gif) 250 - }, [onSelectGif, gif]) 251 - 252 - return ( 253 - <Button 254 - label={_(msg`Select GIF "${gif.title}"`)} 255 - style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]} 256 - onPress={onPress}> 257 - {({pressed}) => ( 258 - <Image 259 - style={[ 260 - a.flex_1, 261 - a.mb_sm, 262 - a.rounded_sm, 263 - {aspectRatio: 1, opacity: pressed ? 0.8 : 1}, 264 - t.atoms.bg_contrast_25, 265 - ]} 266 - source={{ 267 - uri: gif.media_formats.tinygif.url, 268 - }} 269 - contentFit="cover" 270 - accessibilityLabel={gif.title} 271 - accessibilityHint="" 272 - cachePolicy="none" 273 - accessibilityIgnoresInvertColors 274 - /> 275 - )} 276 - </Button> 277 244 ) 278 245 } 279 246
+2 -3
src/view/com/composer/Composer.tsx
··· 173 173 ) 174 174 175 175 const onPressCancel = useCallback(() => { 176 - if (graphemeLength > 0 || !gallery.isEmpty) { 176 + if (graphemeLength > 0 || !gallery.isEmpty || extGif) { 177 177 closeAllDialogs() 178 178 if (Keyboard) { 179 179 Keyboard.dismiss() ··· 183 183 onClose() 184 184 } 185 185 }, [ 186 + extGif, 186 187 graphemeLength, 187 188 gallery.isEmpty, 188 189 closeAllDialogs, ··· 728 729 const styles = StyleSheet.create({ 729 730 topbar: {}, 730 731 topbarDesktop: { 731 - paddingTop: 10, 732 - paddingBottom: 10, 733 732 height: 50, 734 733 }, 735 734 topbarInner: {
+5 -6
src/view/com/composer/photos/SelectGifBtn.tsx
··· 1 - import React, {useCallback} from 'react' 1 + import React, {useCallback, useRef} from 'react' 2 2 import {Keyboard} from 'react-native' 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' ··· 7 7 import {Gif} from '#/state/queries/tenor' 8 8 import {atoms as a, useTheme} from '#/alf' 9 9 import {Button} from '#/components/Button' 10 - import {useDialogControl} from '#/components/Dialog' 11 10 import {GifSelectDialog} from '#/components/dialogs/GifSelect' 12 11 import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' 13 12 ··· 19 18 20 19 export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { 21 20 const {_} = useLingui() 22 - const control = useDialogControl() 21 + const ref = useRef<{open: () => void}>(null) 23 22 const t = useTheme() 24 23 25 24 const onPressSelectGif = useCallback(async () => { 26 25 logEvent('composer:gif:open', {}) 27 26 Keyboard.dismiss() 28 - control.open() 29 - }, [control]) 27 + ref.current?.open() 28 + }, []) 30 29 31 30 return ( 32 31 <> ··· 44 43 </Button> 45 44 46 45 <GifSelectDialog 47 - control={control} 46 + controlRef={ref} 48 47 onClose={onClose} 49 48 onSelectGif={onSelectGif} 50 49 />