ad7d49ef8f
* add expo-contacts * add expo-sms * update copy * add basic settings screen * state machine, flow screen * phone input screen * otp screen * tweak spacing * resend code logic * add layoutanimationconfig * check availablility in settings screen * get contacts * matches screen * search, temp setup for matches UI * add a bunch of number parsing logic with libphonenumber * cast to looser type * rename sync to find FCF (Find Contacts Flow) * update geolocation hook * nicer design for settings screen * add completed state * up border contrast * update expo deps * add pending spinner * add country whitelist * add empty state screen * drop add more functionality * upload -> import * fix typo * fix permission string * copy updates * rm envelope icon * update sms copy * add inviteinfo component * woke is back * [Contacts] NUXes (#9515) * add a bunch of number parsing logic with libphonenumber * add banner nux * add announcement nux * native only nux * rm shitty animation * move isNative check * [Contacts] Onboarding step (#9489) * add a bunch of number parsing logic with libphonenumber * restructure onboarding to better support dynamic screens * integrate existing flow into onboarding * add intro step * lift state up to allow going back freely * gate onboarding by geo if unsupported country * add done button to standalone flow * [Contacts] Add `contact-match` notification type to feed (#9519) * add a bunch of number parsing logic with libphonenumber * add contact-joined notif type * update string, api package * Update NotificationFeedItem.tsx * Update NotificationFeedItem.tsx * Delete SyncContactsFlow.web.tsx * fix follow back btn for this case * [Contacts] API integration (#9487) * api integration for flow * copy tweak * tweaks after running it * wire up status page * rename toast * use api lib * rm temp code * maybe fix otp error * clear code on error/resend * add 1s delay to verify success * update package versions * Delete SyncContactsFlow.web.tsx * try and fix yarn.lock lint * even woker * fix uppercase friends * surfdude feedback Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * rm log * allow resend on invalid code * Update GetContacts.tsx * overwrite country code if possible * fix Apple's Best Feature * devenv latest * disable bounces * interactive keyboard dismiss * copy changes * national format * allow resending immediately * move success state down a bit * Update FindContactsSettings.tsx * [Contacts] More onboarding changes (#9491) * integrate existing flow into onboarding * refreshed onboarding styles, rm stepper * center content on web * Add back dismiss button for internal onboarding * Import sort --------- Co-authored-by: Eric Bailey <git@esb.lol> * add matches query to shadow * add clockwise arrow * update status design, fix dismiss, fix queries * add metrics * add more comments * Update metrics.ts * refetch both queries on PTR * fix shadow state in matches page * reduce empty space at bottom * show contact info on matches * filter out contacts without numbers at an earlier stage * Error handling ✨ * get notifs working * filter out businesses * rm log * remove TODO from learn more links * try and exclude from web bundle --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
320 lines
8.7 KiB
TypeScript
320 lines
8.7 KiB
TypeScript
import {
|
|
useCallback,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import {type TextInput, View} from 'react-native'
|
|
import {useWindowDimensions} from 'react-native'
|
|
import {Image} from 'expo-image'
|
|
import {msg, Trans} from '@lingui/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
|
|
import {logEvent} from '#/lib/statsig/statsig'
|
|
import {cleanError} from '#/lib/strings/errors'
|
|
import {isWeb} from '#/platform/detection'
|
|
import {
|
|
type Gif,
|
|
tenorUrlToBskyGifUrl,
|
|
useFeaturedGifsQuery,
|
|
useGifSearchQuery,
|
|
} from '#/state/queries/tenor'
|
|
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
|
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
|
import {type ListMethods} from '#/view/com/util/List'
|
|
import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf'
|
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|
import * as Dialog from '#/components/Dialog'
|
|
import * as TextField from '#/components/forms/TextField'
|
|
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
|
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
|
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
|
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
|
|
|
export function GifSelectDialog({
|
|
controlRef,
|
|
onClose,
|
|
onSelectGif: onSelectGifProp,
|
|
}: {
|
|
controlRef: React.RefObject<{open: () => void} | null>
|
|
onClose?: () => void
|
|
onSelectGif: (gif: Gif) => void
|
|
}) {
|
|
const control = Dialog.useDialogControl()
|
|
|
|
useImperativeHandle(controlRef, () => ({
|
|
open: () => control.open(),
|
|
}))
|
|
|
|
const onSelectGif = useCallback(
|
|
(gif: Gif) => {
|
|
control.close(() => onSelectGifProp(gif))
|
|
},
|
|
[control, onSelectGifProp],
|
|
)
|
|
|
|
const renderErrorBoundary = useCallback(
|
|
(error: any) => <DialogError details={String(error)} />,
|
|
[],
|
|
)
|
|
|
|
return (
|
|
<Dialog.Outer
|
|
control={control}
|
|
onClose={onClose}
|
|
nativeOptions={{
|
|
bottomInset: 0,
|
|
// use system corner radius on iOS
|
|
...ios({cornerRadius: undefined}),
|
|
}}>
|
|
<Dialog.Handle />
|
|
<ErrorBoundary renderError={renderErrorBoundary}>
|
|
<GifList control={control} onSelectGif={onSelectGif} />
|
|
</ErrorBoundary>
|
|
</Dialog.Outer>
|
|
)
|
|
}
|
|
|
|
function GifList({
|
|
control,
|
|
onSelectGif,
|
|
}: {
|
|
control: Dialog.DialogControlProps
|
|
onSelectGif: (gif: Gif) => void
|
|
}) {
|
|
const {_} = useLingui()
|
|
const t = useTheme()
|
|
const {gtMobile} = useBreakpoints()
|
|
const textInputRef = useRef<TextInput>(null)
|
|
const listRef = useRef<ListMethods>(null)
|
|
const [undeferredSearch, setSearch] = useState('')
|
|
const search = useThrottledValue(undeferredSearch, 500)
|
|
const {height} = useWindowDimensions()
|
|
|
|
const isSearching = search.length > 0
|
|
|
|
const trendingQuery = useFeaturedGifsQuery()
|
|
const searchQuery = useGifSearchQuery(search)
|
|
|
|
const {
|
|
data,
|
|
fetchNextPage,
|
|
isFetchingNextPage,
|
|
hasNextPage,
|
|
error,
|
|
isPending,
|
|
isError,
|
|
refetch,
|
|
} = isSearching ? searchQuery : trendingQuery
|
|
|
|
const flattenedData = useMemo(() => {
|
|
return data?.pages.flatMap(page => page.results) || []
|
|
}, [data])
|
|
|
|
const renderItem = useCallback(
|
|
({item}: {item: Gif}) => {
|
|
return <GifPreview gif={item} onSelectGif={onSelectGif} />
|
|
},
|
|
[onSelectGif],
|
|
)
|
|
|
|
const onEndReached = 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
|
|
textInputRef.current?.clear()
|
|
setSearch('')
|
|
} else {
|
|
control.close()
|
|
}
|
|
}, [control, isSearching])
|
|
|
|
const listHeader = useMemo(() => {
|
|
return (
|
|
<View
|
|
style={[
|
|
native(a.pt_4xl),
|
|
a.relative,
|
|
a.mb_lg,
|
|
a.flex_row,
|
|
a.align_center,
|
|
!gtMobile && web(a.gap_md),
|
|
a.pb_sm,
|
|
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 style={[!gtMobile && isWeb && a.flex_1]}>
|
|
<TextField.Icon icon={Search} />
|
|
<TextField.Input
|
|
label={_(msg`Search GIFs`)}
|
|
placeholder={_(msg`Search Tenor`)}
|
|
onChangeText={text => {
|
|
setSearch(text)
|
|
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
|
}}
|
|
returnKeyType="search"
|
|
clearButtonMode="while-editing"
|
|
inputRef={textInputRef}
|
|
maxLength={50}
|
|
onKeyPress={({nativeEvent}) => {
|
|
if (nativeEvent.key === 'Escape') {
|
|
control.close()
|
|
}
|
|
}}
|
|
/>
|
|
</TextField.Root>
|
|
</View>
|
|
)
|
|
}, [gtMobile, t.atoms.bg, _, control])
|
|
|
|
return (
|
|
<>
|
|
{gtMobile && <Dialog.Close />}
|
|
<Dialog.InnerFlatList
|
|
ref={listRef}
|
|
key={gtMobile ? '3 cols' : '2 cols'}
|
|
data={flattenedData}
|
|
renderItem={renderItem}
|
|
numColumns={gtMobile ? 3 : 2}
|
|
columnWrapperStyle={[a.gap_sm]}
|
|
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
|
|
webInnerStyle={[web({minHeight: '80vh'})]}
|
|
webInnerContentContainerStyle={[web(a.pb_0)]}
|
|
ListHeaderComponent={
|
|
<>
|
|
{listHeader}
|
|
{!hasData && (
|
|
<ListMaybePlaceholder
|
|
isLoading={isPending}
|
|
isError={isError}
|
|
onRetry={refetch}
|
|
onGoBack={onGoBack}
|
|
emptyType="results"
|
|
sideBorders={false}
|
|
topBorder={false}
|
|
errorTitle={_(msg`Failed to load GIFs`)}
|
|
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
|
emptyMessage={
|
|
isSearching
|
|
? _(msg`No search results found for "${search}".`)
|
|
: _(
|
|
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
stickyHeaderIndices={[0]}
|
|
onEndReached={onEndReached}
|
|
onEndReachedThreshold={4}
|
|
keyExtractor={(item: Gif) => item.id}
|
|
keyboardDismissMode="on-drag"
|
|
ListFooterComponent={
|
|
hasData ? (
|
|
<ListFooter
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
error={cleanError(error)}
|
|
onRetry={fetchNextPage}
|
|
style={{borderTopWidth: 0}}
|
|
/>
|
|
) : null
|
|
}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function DialogError({details}: {details?: string}) {
|
|
const {_} = useLingui()
|
|
const control = Dialog.useDialogContext()
|
|
|
|
return (
|
|
<Dialog.ScrollableInner
|
|
style={a.gap_md}
|
|
label={_(msg`An error has occurred`)}>
|
|
<Dialog.Close />
|
|
<ErrorScreen
|
|
title={_(msg`Oh no!`)}
|
|
message={_(
|
|
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
|
)}
|
|
details={details}
|
|
/>
|
|
<Button
|
|
label={_(msg`Close dialog`)}
|
|
onPress={() => control.close()}
|
|
color="primary"
|
|
size="large"
|
|
variant="solid">
|
|
<ButtonText>
|
|
<Trans>Close</Trans>
|
|
</ButtonText>
|
|
</Button>
|
|
</Dialog.ScrollableInner>
|
|
)
|
|
}
|
|
|
|
export 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,
|
|
a.aspect_square,
|
|
{opacity: pressed ? 0.8 : 1},
|
|
t.atoms.bg_contrast_25,
|
|
]}
|
|
source={{
|
|
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
|
|
}}
|
|
contentFit="cover"
|
|
accessibilityLabel={gif.title}
|
|
accessibilityHint=""
|
|
cachePolicy="none"
|
|
accessibilityIgnoresInvertColors
|
|
/>
|
|
)}
|
|
</Button>
|
|
)
|
|
}
|