add empty state screen

This commit is contained in:
Samuel Newman
2025-12-08 19:41:13 +02:00
parent d33406f071
commit c6c5fbdc15
3 changed files with 165 additions and 29 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="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"/></svg>

After

Width:  |  Height:  |  Size: 1014 B

+159 -29
View File
@@ -1,10 +1,11 @@
import {useMemo, useRef, useState} from 'react' import {useMemo, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import * as Contacts from 'expo-contacts'
import * as SMS from 'expo-sms' import * as SMS from 'expo-sms'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {wait} from '#/lib/async/wait'
import {cleanError, isNetworkError} from '#/lib/strings/errors' 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 {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {Envelope_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' import {Envelope_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass' 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 {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
@@ -60,17 +62,16 @@ type Item =
type: 'search empty state' type: 'search empty state'
query: string query: string
} }
// we could use Contacts.presentAccessPickerAsync() if we get limited permissions? | {
// | { type: 'totally empty state'
// type: 'request more contacts' }
// }
export function ViewMatches({ export function ViewMatches({
state, state,
dispatch, dispatch,
}: { }: {
state: Extract<State, {step: '4: view matches'}> state: Extract<State, {step: '4: view matches'}>
dispatch: React.Dispatch<Action> dispatch: React.ActionDispatch<[Action]>
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const gutter = useGutters([0, 'wide']) const gutter = useGutters([0, 'wide'])
@@ -84,6 +85,7 @@ export function ViewMatches({
handles: ['pfrazee.com', 'internet.bsky.social', 'darrin.bsky.team'], handles: ['pfrazee.com', 'internet.bsky.social', 'darrin.bsky.team'],
}) })
state.matches = profiles?.profiles?.map(profile => ({profile})) ?? [] state.matches = profiles?.profiles?.map(profile => ({profile})) ?? []
// state.contacts = []
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const { const {
@@ -180,14 +182,21 @@ export function ViewMatches({
const profile = match.profile const profile = match.profile
all.push({type: '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'}) all.push({type: 'no matches header'})
} }
for (const contact of state.contacts) { for (const contact of state.contacts) {
all.push({type: 'contact', contact}) all.push({type: 'contact', contact})
} }
if (all.length === 0) {
all.push({type: 'totally empty state'})
}
} }
return all return all
@@ -276,6 +285,7 @@ export function ViewMatches({
return ( return (
<Header <Header
titleText={_(msg`You got here first`)} titleText={_(msg`You got here first`)}
largeTitle
subtitleText={_( subtitleText={_(
msg`Bluesky is more fun with friends. Do you want to invite some of yours?`, msg`Bluesky is more fun with friends. Do you want to invite some of yours?`,
)} )}
@@ -283,10 +293,15 @@ export function ViewMatches({
) )
case 'search empty state': case 'search empty state':
return <SearchEmptyState query={item.query} /> return <SearchEmptyState query={item.query} />
case 'totally empty state':
return <TotallyEmptyState dispatch={dispatch} />
} }
} }
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 ( return (
<View style={[a.h_full]}> <View style={[a.h_full]}>
@@ -295,25 +310,27 @@ export function ViewMatches({
<Layout.Header.Content /> <Layout.Header.Content />
<Layout.Header.Slot /> <Layout.Header.Slot />
</Layout.Header.Outer> </Layout.Header.Outer>
<View style={[gutter, a.mb_md]}> {!isTotallyEmpty && (
<SearchInput <View style={[gutter, a.mb_md]}>
placeholder={_(msg`Search contacts`)} <SearchInput
value={search} placeholder={_(msg`Search contacts`)}
onFocus={() => { value={search}
onFocus() onFocus={() => {
listRef.current?.scrollToOffset({offset: 0, animated: false}) onFocus()
}} listRef.current?.scrollToOffset({offset: 0, animated: false})
onBlur={() => { }}
onBlur() onBlur={() => {
listRef.current?.scrollToOffset({offset: 0, animated: false}) onBlur()
}} listRef.current?.scrollToOffset({offset: 0, animated: false})
onChangeText={text => { }}
setSearch(text) onChangeText={text => {
listRef.current?.scrollToOffset({offset: 0, animated: false}) setSearch(text)
}} listRef.current?.scrollToOffset({offset: 0, animated: false})
onClearText={() => setSearch('')} }}
/> onClearText={() => setSearch('')}
</View> />
</View>
)}
<List <List
ref={listRef} ref={listRef}
data={items} data={items}
@@ -458,11 +475,13 @@ function ContactItem({contact}: {contact: Contact}) {
function Header({ function Header({
titleText, titleText,
largeTitle,
subtitleText, subtitleText,
children, children,
hasContentAbove, hasContentAbove,
}: { }: {
titleText: React.ReactNode titleText: React.ReactNode
largeTitle?: boolean
subtitleText?: React.ReactNode subtitleText?: React.ReactNode
children?: React.ReactNode children?: React.ReactNode
hasContentAbove?: boolean hasContentAbove?: boolean
@@ -481,7 +500,9 @@ function Header({
: a.pt_md, : a.pt_md,
]}> ]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}> <View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text style={[a.text_3xl, a.font_bold]}>{titleText}</Text> <Text style={[largeTitle ? a.text_3xl : a.text_xl, a.font_bold]}>
{titleText}
</Text>
{children} {children}
</View> </View>
{subtitleText && ( {subtitleText && (
@@ -520,3 +541,112 @@ function SearchEmptyState({query}: {query: string}) {
</View> </View>
) )
} }
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 (
<View
style={[
a.flex_1,
a.flex_col,
a.align_center,
a.justify_center,
a.gap_xs,
{paddingTop: 140},
a.px_5xl,
]}>
<PersonXIcon width={64} style={[t.atoms.text_contrast_low]} />
<Text
style={[
a.text_xl,
a.font_bold,
a.leading_snug,
a.text_center,
a.mt_md,
]}>
<Trans>No contacts found</Trans>
</Text>
{isLimited && (
<>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.text_center,
]}>
<Trans>Would you like to try again?</Trans>
</Text>
<Button
label={_(msg`Try adding more contacts`)}
onPress={() => addMore()}
disabled={isPending}
color="secondary"
size="small"
style={[a.mt_md]}>
<ButtonText>
<Trans>Try adding more contacts</Trans>
</ButtonText>
</Button>
</>
)}
</View>
)
}
+5
View File
@@ -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', 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({ 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', 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',
}) })