add empty state screen

This commit is contained in:
Samuel Newman
2025-12-08 19:41:13 +02:00
parent 28532f81b9
commit cf7e89c24c
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 {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<State, {step: '4: view matches'}>
dispatch: React.Dispatch<Action>
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 (
<Header
titleText={_(msg`You got here first`)}
largeTitle
subtitleText={_(
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':
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 (
<View style={[a.h_full]}>
@@ -295,25 +310,27 @@ export function ViewMatches({
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
<View style={[gutter, a.mb_md]}>
<SearchInput
placeholder={_(msg`Search contacts`)}
value={search}
onFocus={() => {
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('')}
/>
</View>
{!isTotallyEmpty && (
<View style={[gutter, a.mb_md]}>
<SearchInput
placeholder={_(msg`Search contacts`)}
value={search}
onFocus={() => {
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('')}
/>
</View>
)}
<List
ref={listRef}
data={items}
@@ -458,11 +475,13 @@ function ContactItem({contact}: {contact: Contact}) {
function Header({
titleText,
largeTitle,
subtitleText,
children,
hasContentAbove,
}: {
titleText: React.ReactNode
largeTitle?: boolean
subtitleText?: React.ReactNode
children?: React.ReactNode
hasContentAbove?: boolean
@@ -481,7 +500,9 @@ function Header({
: a.pt_md,
]}>
<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}
</View>
{subtitleText && (
@@ -520,3 +541,112 @@ function SearchEmptyState({query}: {query: string}) {
</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',
})
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',
})