diff --git a/assets/icons/personX_stroke2_corner0_rounded_large.svg b/assets/icons/personX_stroke2_corner0_rounded_large.svg new file mode 100644 index 0000000000..d77d3763b3 --- /dev/null +++ b/assets/icons/personX_stroke2_corner0_rounded_large.svg @@ -0,0 +1 @@ + diff --git a/src/components/contacts/screens/ViewMatches.tsx b/src/components/contacts/screens/ViewMatches.tsx index c56c6e3f1c..84aa082d84 100644 --- a/src/components/contacts/screens/ViewMatches.tsx +++ b/src/components/contacts/screens/ViewMatches.tsx @@ -1,10 +1,11 @@ import {useMemo, useRef, useState} from 'react' import {View} from 'react-native' +import * as Contacts from 'expo-contacts' import * as SMS from 'expo-sms' import {type ModerationOpts} from '@atproto/api' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useMutation, useQueryClient} from '@tanstack/react-query' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {wait} from '#/lib/async/wait' import {cleanError, isNetworkError} from '#/lib/strings/errors' @@ -26,6 +27,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {Envelope_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass' +import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as Layout from '#/components/Layout' @@ -60,17 +62,16 @@ type Item = type: 'search empty state' query: string } -// we could use Contacts.presentAccessPickerAsync() if we get limited permissions? -// | { -// type: 'request more contacts' -// } + | { + type: 'totally empty state' + } export function ViewMatches({ state, dispatch, }: { state: Extract - dispatch: React.Dispatch + dispatch: React.ActionDispatch<[Action]> }) { const {_} = useLingui() const gutter = useGutters([0, 'wide']) @@ -84,6 +85,7 @@ export function ViewMatches({ handles: ['pfrazee.com', 'internet.bsky.social', 'darrin.bsky.team'], }) state.matches = profiles?.profiles?.map(profile => ({profile})) ?? [] + // state.contacts = [] const [search, setSearch] = useState('') const { @@ -180,14 +182,21 @@ export function ViewMatches({ const profile = match.profile all.push({type: 'match', profile}) } - all.push({type: 'contacts header'}) - } else { + + if (state.contacts.length > 0) { + all.push({type: 'contacts header'}) + } + } else if (state.contacts.length > 0) { all.push({type: 'no matches header'}) } for (const contact of state.contacts) { all.push({type: 'contact', contact}) } + + if (all.length === 0) { + all.push({type: 'totally empty state'}) + } } return all @@ -276,6 +285,7 @@ export function ViewMatches({ return (
+ case 'totally empty state': + return } } - const isEmpty = items?.[0]?.type === 'search empty state' + const isSearchEmpty = items?.[0]?.type === 'search empty state' + const isTotallyEmpty = items?.[0]?.type === 'totally empty state' + + const isEmpty = isSearchEmpty || isTotallyEmpty return ( @@ -295,25 +310,27 @@ export function ViewMatches({ - - { - onFocus() - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - onBlur={() => { - onBlur() - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - onChangeText={text => { - setSearch(text) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - onClearText={() => setSearch('')} - /> - + {!isTotallyEmpty && ( + + { + onFocus() + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onBlur={() => { + onBlur() + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onChangeText={text => { + setSearch(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onClearText={() => setSearch('')} + /> + + )} - {titleText} + + {titleText} + {children} {subtitleText && ( @@ -520,3 +541,112 @@ function SearchEmptyState({query}: {query: string}) { ) } + +function TotallyEmptyState({ + dispatch, +}: { + dispatch: React.ActionDispatch<[Action]> +}) { + const t = useTheme() + const {_} = useLingui() + + const {data} = useQuery({ + queryKey: ['contacts-permissions'], + queryFn: async () => await Contacts.getPermissionsAsync(), + }) + + const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({ + mutationFn: async (_contacts: Contacts.ExistingContact[]) => { + await wait(2e3, () => {}) + }, + onSuccess: () => { + dispatch({ + type: 'SYNC_CONTACTS_SUCCESS', + payload: { + matches: [], + }, + }) + }, + }) + + const {mutate: addMore, isPending: isAddMorePending} = useMutation({ + mutationFn: async () => { + await Contacts.presentAccessPickerAsync() + + const contacts = await Contacts.getContactsAsync({ + fields: [ + Contacts.Fields.FirstName, + Contacts.Fields.LastName, + Contacts.Fields.PhoneNumbers, + Contacts.Fields.Image, + ], + }) + + return contacts.data + }, + onSuccess: contacts => { + dispatch({ + type: 'GET_CONTACTS_SUCCESS', + payload: {contacts}, + }) + uploadContacts(contacts) + }, + onError: err => { + logger.error('Add more contacts failed', {safeMessage: err}) + Toast.show(_(msg`Could not add more contacts. ${cleanError(err)}`)) + }, + }) + + const isPending = isAddMorePending || isUploadPending + + const isLimited = data?.accessPrivileges === 'limited' + + return ( + + + + No contacts found + + {isLimited && ( + <> + + Would you like to try again? + + + + )} + + ) +} diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx index 8428fcce1b..55324f10d6 100644 --- a/src/components/icons/Person.tsx +++ b/src/components/icons/Person.tsx @@ -16,6 +16,11 @@ export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z', }) +export const PersonX_Stroke2_Corner0_Rounded_Large = createSinglePathSVG({ + viewBox: '0 0 64 64', + path: 'M24.952 33.435c10.897 0 19.195 6.496 22.58 15.65.784 2.117.258 4.183-1 5.683-1.242 1.48-3.194 2.414-5.33 2.415h-32.5c-2.136 0-4.089-.935-5.331-2.415-1.258-1.5-1.783-3.566-1-5.682 3.386-9.155 11.683-15.651 22.58-15.651Zm0 2.298c-9.885 0-17.355 5.849-20.425 14.15-.47 1.271-.174 2.48.604 3.407.795.947 2.096 1.594 3.57 1.594h32.5c1.476 0 2.777-.647 3.571-1.594.778-.928 1.074-2.136.605-3.406-3.07-8.302-10.54-14.15-20.425-14.15Zm33.262-14.59a1.15 1.15 0 1 1 1.625 1.626l-5.688 5.687 5.686 5.688a1.15 1.15 0 1 1-1.625 1.625l-5.686-5.688-5.687 5.688a1.15 1.15 0 1 1-1.625-1.625l5.687-5.688-5.689-5.687a1.15 1.15 0 1 1 1.625-1.625l5.689 5.687 5.688-5.687ZM24.949 5.552c6.557 0 11.874 5.316 11.874 11.874 0 6.557-5.317 11.874-11.874 11.874s-11.874-5.317-11.874-11.874S18.39 5.55 24.949 5.55Zm0 2.299a9.575 9.575 0 0 0-9.575 9.575A9.575 9.575 0 0 0 24.949 27a9.575 9.575 0 0 0 9.575-9.575A9.575 9.575 0 0 0 24.95 7.85Z', +}) + export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z', })