Move invite-state to new persistence + context and replace the notifications with just showing uses in the modal (#1840)

zio/stable
Paul Frazee 2023-11-08 09:10:59 -08:00 committed by GitHub
parent 74f8390f1d
commit e75b2d508b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 137 additions and 259 deletions

View File

@ -23,6 +23,7 @@ import {queryClient} from 'lib/react-query'
import {TestCtrls} from 'view/com/testing/TestCtrls' import {TestCtrls} from 'view/com/testing/TestCtrls'
import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@ -80,7 +81,9 @@ function App() {
return ( return (
<ShellStateProvider> <ShellStateProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
<InnerApp /> <InvitesStateProvider>
<InnerApp />
</InvitesStateProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
</ShellStateProvider> </ShellStateProvider>
) )

View File

@ -18,6 +18,7 @@ import {ThemeProvider} from 'lib/ThemeContext'
import {queryClient} from 'lib/react-query' import {queryClient} from 'lib/react-query'
import {Provider as ShellStateProvider} from 'state/shell' import {Provider as ShellStateProvider} from 'state/shell'
import {Provider as MutedThreadsProvider} from 'state/muted-threads' import {Provider as MutedThreadsProvider} from 'state/muted-threads'
import {Provider as InvitesStateProvider} from 'state/invites'
const InnerApp = observer(function AppImpl() { const InnerApp = observer(function AppImpl() {
const colorMode = useColorMode() const colorMode = useColorMode()
@ -70,7 +71,9 @@ function App() {
return ( return (
<ShellStateProvider> <ShellStateProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
<InnerApp /> <InvitesStateProvider>
<InnerApp />
</InvitesStateProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
</ShellStateProvider> </ShellStateProvider>
) )

View File

@ -0,0 +1,56 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['invites']
type ApiContext = {
setInviteCopied: (code: string) => void
}
const stateContext = React.createContext<StateContext>(
persisted.defaults.invites,
)
const apiContext = React.createContext<ApiContext>({
setInviteCopied(_: string) {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('invites'))
const api = React.useMemo(
() => ({
setInviteCopied(code: string) {
setState(state => {
state = {
...state,
copiedInvites: state.copiedInvites.includes(code)
? state.copiedInvites
: state.copiedInvites.concat([code]),
}
persisted.write('invites', state)
return state
})
},
}),
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('invites'))
})
}, [setState])
return (
<stateContext.Provider value={state}>
<apiContext.Provider value={api}>{children}</apiContext.Provider>
</stateContext.Provider>
)
}
export function useInvitesState() {
return React.useContext(stateContext)
}
export function useInvitesAPI() {
return React.useContext(apiContext)
}

View File

@ -304,7 +304,7 @@ export class NotificationsFeedModel {
} }
get unreadCountLabel(): string { get unreadCountLabel(): string {
const count = this.unreadCount + this.rootStore.invitedUsers.numNotifs const count = this.unreadCount
if (count >= MAX_VISIBLE_NOTIFS) { if (count >= MAX_VISIBLE_NOTIFS) {
return `${MAX_VISIBLE_NOTIFS}+` return `${MAX_VISIBLE_NOTIFS}+`
} }

View File

@ -1,88 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {ComAtprotoServerDefs, AppBskyActorDefs} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {isObj, hasProp, isStrArray} from 'lib/type-guards'
import {logger} from '#/logger'
export class InvitedUsers {
copiedInvites: string[] = []
seenDids: string[] = []
profiles: AppBskyActorDefs.ProfileViewDetailed[] = []
get numNotifs() {
return this.profiles.length
}
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{rootStore: false, serialize: false, hydrate: false},
{autoBind: true},
)
}
serialize() {
return {seenDids: this.seenDids, copiedInvites: this.copiedInvites}
}
hydrate(v: unknown) {
if (isObj(v) && hasProp(v, 'seenDids') && isStrArray(v.seenDids)) {
this.seenDids = v.seenDids
}
if (
isObj(v) &&
hasProp(v, 'copiedInvites') &&
isStrArray(v.copiedInvites)
) {
this.copiedInvites = v.copiedInvites
}
}
async fetch(invites: ComAtprotoServerDefs.InviteCode[]) {
// pull the dids of invited users not marked seen
const dids = []
for (const invite of invites) {
for (const use of invite.uses) {
if (!this.seenDids.includes(use.usedBy)) {
dids.push(use.usedBy)
}
}
}
// fetch their profiles
this.profiles = []
if (dids.length) {
try {
const res = await this.rootStore.agent.app.bsky.actor.getProfiles({
actors: dids,
})
runInAction(() => {
// save the ones following -- these are the ones we want to notify the user about
this.profiles = res.data.profiles.filter(
profile => !profile.viewer?.following,
)
})
this.rootStore.me.follows.hydrateMany(this.profiles)
} catch (e) {
logger.error('Failed to fetch profiles for invited users', {
error: e,
})
}
}
}
isInviteCopied(invite: string) {
return this.copiedInvites.includes(invite)
}
setInviteCopied(invite: string) {
if (!this.isInviteCopied(invite)) {
this.copiedInvites.push(invite)
}
}
markSeen(did: string) {
this.seenDids.push(did)
this.profiles = this.profiles.filter(profile => profile.did !== did)
}
}

View File

@ -193,7 +193,6 @@ export class MeModel {
error: e, error: e,
}) })
} }
await this.rootStore.invitedUsers.fetch(this.invites)
} }
} }

View File

@ -15,7 +15,6 @@ import {ProfilesCache} from './cache/profiles-view'
import {PostsCache} from './cache/posts' import {PostsCache} from './cache/posts'
import {LinkMetasCache} from './cache/link-metas' import {LinkMetasCache} from './cache/link-metas'
import {MeModel} from './me' import {MeModel} from './me'
import {InvitedUsers} from './invited-users'
import {PreferencesModel} from './ui/preferences' import {PreferencesModel} from './ui/preferences'
import {resetToTab} from '../../Navigation' import {resetToTab} from '../../Navigation'
import {ImageSizesCache} from './cache/image-sizes' import {ImageSizesCache} from './cache/image-sizes'
@ -42,7 +41,6 @@ export class RootStoreModel {
shell = new ShellUiModel(this) shell = new ShellUiModel(this)
preferences = new PreferencesModel(this) preferences = new PreferencesModel(this)
me = new MeModel(this) me = new MeModel(this)
invitedUsers = new InvitedUsers(this)
handleResolutions = new HandleResolutionsCache() handleResolutions = new HandleResolutionsCache()
profiles = new ProfilesCache(this) profiles = new ProfilesCache(this)
posts = new PostsCache(this) posts = new PostsCache(this)
@ -68,7 +66,6 @@ export class RootStoreModel {
session: this.session.serialize(), session: this.session.serialize(),
me: this.me.serialize(), me: this.me.serialize(),
preferences: this.preferences.serialize(), preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(),
} }
} }
@ -89,9 +86,6 @@ export class RootStoreModel {
if (hasProp(v, 'preferences')) { if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences) this.preferences.hydrate(v.preferences)
} }
if (hasProp(v, 'invitedUsers')) {
this.invitedUsers.hydrate(v.invitedUsers)
}
} }
} }

View File

@ -97,11 +97,9 @@ export function transform(legacy: LegacySchema): Schema {
legacy.preferences.requireAltTextEnabled || legacy.preferences.requireAltTextEnabled ||
defaults.requireAltTextEnabled, defaults.requireAltTextEnabled,
mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads,
invitedUsers: { invites: {
seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids,
copiedInvites: copiedInvites:
legacy.invitedUsers.copiedInvites || legacy.invitedUsers.copiedInvites || defaults.invites.copiedInvites,
defaults.invitedUsers.copiedInvites,
}, },
onboarding: { onboarding: {
step: legacy.onboarding.step || defaults.onboarding.step, step: legacy.onboarding.step || defaults.onboarding.step,

View File

@ -29,8 +29,7 @@ export const schema = z.object({
}), }),
requireAltTextEnabled: z.boolean(), // should move to server requireAltTextEnabled: z.boolean(), // should move to server
mutedThreads: z.array(z.string()), // should move to server mutedThreads: z.array(z.string()), // should move to server
invitedUsers: z.object({ invites: z.object({
seenDids: z.array(z.string()),
copiedInvites: z.array(z.string()), copiedInvites: z.array(z.string()),
}), }),
onboarding: z.object({ onboarding: z.object({
@ -58,8 +57,7 @@ export const defaults: Schema = {
}, },
requireAltTextEnabled: false, requireAltTextEnabled: false,
mutedThreads: [], mutedThreads: [],
invitedUsers: { invites: {
seenDids: [],
copiedInvites: [], copiedInvites: [],
}, },
onboarding: { onboarding: {

View File

@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {ComAtprotoServerDefs} from '@atproto/api'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -14,6 +15,10 @@ import {ScrollView} from './util'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useInvitesState, useInvitesAPI} from '#/state/invites'
import {UserInfoText} from '../util/UserInfoText'
import {makeProfileLink} from '#/lib/routes/links'
import {Link} from '../util/Link'
export const snapPoints = ['70%'] export const snapPoints = ['70%']
@ -66,7 +71,7 @@ export function Component({}: {}) {
<InviteCode <InviteCode
testID={`inviteCode-${i}`} testID={`inviteCode-${i}`}
key={invite.code} key={invite.code}
code={invite.code} invite={invite}
used={invite.available - invite.uses.length <= 0 || invite.disabled} used={invite.available - invite.uses.length <= 0 || invite.disabled}
/> />
))} ))}
@ -87,52 +92,81 @@ export function Component({}: {}) {
const InviteCode = observer(function InviteCodeImpl({ const InviteCode = observer(function InviteCodeImpl({
testID, testID,
code, invite,
used, used,
}: { }: {
testID: string testID: string
code: string invite: ComAtprotoServerDefs.InviteCode
used?: boolean used?: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const {invitesAvailable} = store.me const {invitesAvailable} = store.me
const invitesState = useInvitesState()
const {setInviteCopied} = useInvitesAPI()
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
Clipboard.setString(code) Clipboard.setString(invite.code)
Toast.show('Copied to clipboard') Toast.show('Copied to clipboard')
store.invitedUsers.setInviteCopied(code) setInviteCopied(invite.code)
}, [store, code]) }, [setInviteCopied, invite])
return ( return (
<TouchableOpacity <View
testID={testID} style={[
style={[styles.inviteCode, pal.border]} pal.border,
onPress={onPress} {borderBottomWidth: 1, paddingHorizontal: 20, paddingVertical: 14},
accessibilityRole="button" ]}>
accessibilityLabel={ <TouchableOpacity
invitesAvailable === 1 testID={testID}
? 'Invite codes: 1 available' style={[styles.inviteCode]}
: `Invite codes: ${invitesAvailable} available` onPress={onPress}
} accessibilityRole="button"
accessibilityHint="Opens list of invite codes"> accessibilityLabel={
<Text invitesAvailable === 1
testID={`${testID}-code`} ? 'Invite codes: 1 available'
type={used ? 'md' : 'md-bold'} : `Invite codes: ${invitesAvailable} available`
style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> }
{code} accessibilityHint="Opens list of invite codes">
</Text> <Text
<View style={styles.flex1} /> testID={`${testID}-code`}
{!used && store.invitedUsers.isInviteCopied(code) && ( type={used ? 'md' : 'md-bold'}
<Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> style={used ? [pal.textLight, styles.strikeThrough] : pal.text}>
)} {invite.code}
{!used && ( </Text>
<FontAwesomeIcon <View style={styles.flex1} />
icon={['far', 'clone']} {!used && invitesState.copiedInvites.includes(invite.code) && (
style={pal.text as FontAwesomeIconStyle} <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text>
/> )}
)} {!used && (
</TouchableOpacity> <FontAwesomeIcon
icon={['far', 'clone']}
style={pal.text as FontAwesomeIconStyle}
/>
)}
</TouchableOpacity>
{invite.uses.length > 0 ? (
<View
style={{
flexDirection: 'column',
gap: 8,
paddingTop: 6,
}}>
<Text style={pal.text}>Used by:</Text>
{invite.uses.map(use => (
<Link
key={use.usedBy}
href={makeProfileLink({handle: use.usedBy, did: ''})}
style={{
flexDirection: 'row',
}}>
<Text style={pal.text}> </Text>
<UserInfoText did={use.usedBy} style={pal.link} />
</Link>
))}
</View>
) : null}
</View>
) )
}) })
@ -176,9 +210,6 @@ const styles = StyleSheet.create({
inviteCode: { inviteCode: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
borderBottomWidth: 1,
paddingHorizontal: 20,
paddingVertical: 14,
}, },
codeCopied: { codeCopied: {
marginRight: 8, marginRight: 8,

View File

@ -1,114 +0,0 @@
import React from 'react'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs} from '@atproto/api'
import {UserAvatar} from '../util/UserAvatar'
import {Text} from '../util/text/Text'
import {Link, TextLink} from '../util/Link'
import {Button} from '../util/forms/Button'
import {FollowButton} from '../profile/FollowButton'
import {CenteredView} from '../util/Views.web'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {makeProfileLink} from 'lib/routes/links'
export const InvitedUsers = observer(function InvitedUsersImpl() {
const store = useStores()
return (
<CenteredView>
{store.invitedUsers.profiles.map(profile => (
<InvitedUser key={profile.did} profile={profile} />
))}
</CenteredView>
)
})
function InvitedUser({
profile,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
}) {
const pal = usePalette('default')
const store = useStores()
const onPressDismiss = React.useCallback(() => {
store.invitedUsers.markSeen(profile.did)
}, [store, profile])
return (
<View
testID="invitedUser"
style={[
styles.layout,
{
backgroundColor: pal.colors.unreadNotifBg,
borderColor: pal.colors.unreadNotifBorder,
},
]}>
<View style={styles.layoutIcon}>
<FontAwesomeIcon
icon="user-plus"
size={24}
style={[styles.icon, s.blue3 as FontAwesomeIconStyle]}
/>
</View>
<View style={s.flex1}>
<Link href={makeProfileLink(profile)}>
<UserAvatar avatar={profile.avatar} size={35} />
</Link>
<Text style={[styles.desc, pal.text]}>
<TextLink
type="md-bold"
style={pal.text}
href={makeProfileLink(profile)}
text={sanitizeDisplayName(profile.displayName || profile.handle)}
/>{' '}
joined using your invite code!
</Text>
<View style={styles.btns}>
<FollowButton
unfollowedType="primary"
followedType="primary-light"
profile={profile}
/>
<Button
testID="dismissBtn"
type="primary-light"
label="Dismiss"
onPress={onPressDismiss}
/>
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
layout: {
flexDirection: 'row',
borderTopWidth: 1,
padding: 10,
},
layoutIcon: {
width: 70,
alignItems: 'flex-end',
paddingTop: 2,
},
icon: {
marginRight: 10,
marginTop: 4,
},
desc: {
paddingVertical: 6,
},
btns: {
flexDirection: 'row',
gap: 10,
},
})

View File

@ -10,7 +10,6 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed' import {Feed} from '../com/notifications/Feed'
import {TextLink} from 'view/com/util/Link' import {TextLink} from 'view/com/util/Link'
import {InvitedUsers} from '../com/notifications/InvitedUsers'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
@ -145,7 +144,6 @@ export const NotificationsScreen = withAuthRequired(
return ( return (
<View testID="notificationsScreen" style={s.hContentRegion}> <View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} /> <ViewHeader title="Notifications" canGoBack={false} />
<InvitedUsers />
<Feed <Feed
view={store.me.notifications} view={store.me.notifications}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}