add empty state screen
This commit is contained in:
@@ -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 |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user