Refactor invites modal (#1930)

* Refactor invites modal

* Replace in drawer

* Delete stuff from me model
zio/stable
Eric Bailey 2023-11-16 10:40:31 -06:00 committed by GitHub
parent 8a1fd160e6
commit e6efeea7c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 103 additions and 76 deletions

View File

@ -1,8 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import {ComAtprotoServerListAppPasswords} from '@atproto/api'
ComAtprotoServerDefs,
ComAtprotoServerListAppPasswords,
} from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
import {logger} from '#/logger' import {logger} from '#/logger'
@ -17,14 +14,9 @@ export class MeModel {
avatar: string = '' avatar: string = ''
followsCount: number | undefined followsCount: number | undefined
followersCount: number | undefined followersCount: number | undefined
invites: ComAtprotoServerDefs.InviteCode[] = []
appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = []
lastProfileStateUpdate = Date.now() lastProfileStateUpdate = Date.now()
get invitesAvailable() {
return this.invites.filter(isInviteAvailable).length
}
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
this, this,
@ -41,7 +33,6 @@ export class MeModel {
this.displayName = '' this.displayName = ''
this.description = '' this.description = ''
this.avatar = '' this.avatar = ''
this.invites = []
this.appPasswords = [] this.appPasswords = []
} }
@ -90,7 +81,6 @@ export class MeModel {
this.did = sess.currentSession?.did || '' this.did = sess.currentSession?.did || ''
await this.fetchProfile() await this.fetchProfile()
this.rootStore.emitSessionLoaded() this.rootStore.emitSessionLoaded()
await this.fetchInviteCodes()
await this.fetchAppPasswords() await this.fetchAppPasswords()
} else { } else {
this.clear() this.clear()
@ -102,7 +92,6 @@ export class MeModel {
logger.debug('Updating me profile information') logger.debug('Updating me profile information')
this.lastProfileStateUpdate = Date.now() this.lastProfileStateUpdate = Date.now()
await this.fetchProfile() await this.fetchProfile()
await this.fetchInviteCodes()
await this.fetchAppPasswords() await this.fetchAppPasswords()
} }
} }
@ -129,33 +118,6 @@ export class MeModel {
}) })
} }
async fetchInviteCodes() {
if (this.rootStore.session) {
try {
const res =
await this.rootStore.agent.com.atproto.server.getAccountInviteCodes(
{},
)
runInAction(() => {
this.invites = res.data.codes
this.invites.sort((a, b) => {
if (!isInviteAvailable(a)) {
return 1
}
if (!isInviteAvailable(b)) {
return -1
}
return 0
})
})
} catch (e) {
logger.error('Failed to fetch user invite codes', {
error: e,
})
}
}
}
async fetchAppPasswords() { async fetchAppPasswords() {
if (this.rootStore.session) { if (this.rootStore.session) {
try { try {
@ -208,7 +170,3 @@ export class MeModel {
} }
} }
} }
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled
}

View File

@ -0,0 +1,36 @@
import {ComAtprotoServerDefs} from '@atproto/api'
import {useQuery} from '@tanstack/react-query'
import {useSession} from '#/state/session'
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled
}
export type InviteCodesQueryResponse = Exclude<
ReturnType<typeof useInviteCodesQuery>['data'],
undefined
>
export function useInviteCodesQuery() {
const {agent} = useSession()
return useQuery({
queryKey: ['inviteCodes'],
queryFn: async () => {
const res = await agent.com.atproto.server.getAccountInviteCodes({})
if (!res.data?.codes) {
throw new Error(`useInviteCodesQuery: no codes returned`)
}
const available = res.data.codes.filter(isInviteAvailable)
const used = res.data.codes.filter(code => !isInviteAvailable(code))
return {
all: [...available, ...used],
available,
used,
}
},
})
}

View File

@ -1,5 +1,10 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {
StyleSheet,
TouchableOpacity,
View,
ActivityIndicator,
} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {ComAtprotoServerDefs} from '@atproto/api' import {ComAtprotoServerDefs} from '@atproto/api'
import { import {
@ -10,23 +15,41 @@ import Clipboard from '@react-native-clipboard/clipboard'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button' import {Button} from '../util/forms/Button'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {ScrollView} from './util' 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 {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import {cleanError} from 'lib/strings/errors'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useInvitesState, useInvitesAPI} from '#/state/invites' import {useInvitesState, useInvitesAPI} from '#/state/invites'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {
useInviteCodesQuery,
InviteCodesQueryResponse,
} from '#/state/queries/invites'
export const snapPoints = ['70%'] export const snapPoints = ['70%']
export function Component({}: {}) { export function Component() {
const {isLoading, data: invites, error} = useInviteCodesQuery()
return error ? (
<ErrorMessage message={cleanError(error)} />
) : isLoading || !invites ? (
<View style={{padding: 18}}>
<ActivityIndicator />
</View>
) : (
<Inner invites={invites} />
)
}
export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
@ -34,7 +57,7 @@ export function Component({}: {}) {
closeModal() closeModal()
}, [closeModal]) }, [closeModal])
if (store.me.invites.length === 0) { if (invites.all.length === 0) {
return ( return (
<View style={[styles.container, pal.view]} testID="inviteCodesModal"> <View style={[styles.container, pal.view]} testID="inviteCodesModal">
<View style={[styles.empty, pal.viewLight]}> <View style={[styles.empty, pal.viewLight]}>
@ -74,12 +97,21 @@ export function Component({}: {}) {
</Trans> </Trans>
</Text> </Text>
<ScrollView style={[styles.scrollContainer, pal.border]}> <ScrollView style={[styles.scrollContainer, pal.border]}>
{store.me.invites.map((invite, i) => ( {invites.available.map((invite, i) => (
<InviteCode <InviteCode
testID={`inviteCode-${i}`} testID={`inviteCode-${i}`}
key={invite.code} key={invite.code}
invite={invite} invite={invite}
used={invite.available - invite.uses.length <= 0 || invite.disabled} invites={invites}
/>
))}
{invites.used.map((invite, i) => (
<InviteCode
used
testID={`inviteCode-${i}`}
key={invite.code}
invite={invite}
invites={invites}
/> />
))} ))}
</ScrollView> </ScrollView>
@ -101,14 +133,14 @@ const InviteCode = observer(function InviteCodeImpl({
testID, testID,
invite, invite,
used, used,
invites,
}: { }: {
testID: string testID: string
invite: ComAtprotoServerDefs.InviteCode invite: ComAtprotoServerDefs.InviteCode
used?: boolean used?: boolean
invites: InviteCodesQueryResponse
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores()
const {invitesAvailable} = store.me
const invitesState = useInvitesState() const invitesState = useInvitesState()
const {setInviteCopied} = useInvitesAPI() const {setInviteCopied} = useInvitesAPI()
@ -130,9 +162,9 @@ const InviteCode = observer(function InviteCodeImpl({
onPress={onPress} onPress={onPress}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
invitesAvailable === 1 invites.available.length === 1
? 'Invite codes: 1 available' ? 'Invite codes: 1 available'
: `Invite codes: ${invitesAvailable} available` : `Invite codes: ${invites.available.length} available`
} }
accessibilityHint="Opens list of invite codes"> accessibilityHint="Opens list of invite codes">
<Text <Text

View File

@ -60,6 +60,7 @@ import {
import {useSession, useSessionApi, SessionAccount} from '#/state/session' import {useSession, useSessionApi, SessionAccount} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useClearPreferencesMutation} from '#/state/queries/preferences' import {useClearPreferencesMutation} from '#/state/queries/preferences'
import {useInviteCodesQuery} from '#/state/queries/invites'
// TEMPORARY (APP-700) // TEMPORARY (APP-700)
// remove after backend testing finishes // remove after backend testing finishes
@ -155,6 +156,8 @@ export const SettingsScreen = withAuthRequired(
const {isSwitchingAccounts, accounts, currentAccount} = useSession() const {isSwitchingAccounts, accounts, currentAccount} = useSession()
const {clearCurrentAccount} = useSessionApi() const {clearCurrentAccount} = useSessionApi()
const {mutate: clearPreferences} = useClearPreferencesMutation() const {mutate: clearPreferences} = useClearPreferencesMutation()
const {data: invites} = useInviteCodesQuery()
const invitesAvailable = invites?.available?.length ?? 0
const primaryBg = useCustomPalette<ViewStyle>({ const primaryBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.blue0}, light: {backgroundColor: colors.blue0},
@ -362,6 +365,7 @@ export const SettingsScreen = withAuthRequired(
<Text type="xl-bold" style={[pal.text, styles.heading]}> <Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Invite a Friend</Trans> <Trans>Invite a Friend</Trans>
</Text> </Text>
<TouchableOpacity <TouchableOpacity
testID="inviteFriendBtn" testID="inviteFriendBtn"
style={[ style={[
@ -376,22 +380,20 @@ export const SettingsScreen = withAuthRequired(
<View <View
style={[ style={[
styles.iconContainer, styles.iconContainer,
store.me.invitesAvailable > 0 ? primaryBg : pal.btn, invitesAvailable > 0 ? primaryBg : pal.btn,
]}> ]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="ticket" icon="ticket"
style={ style={
(store.me.invitesAvailable > 0 (invitesAvailable > 0
? primaryText ? primaryText
: pal.text) as FontAwesomeIconStyle : pal.text) as FontAwesomeIconStyle
} }
/> />
</View> </View>
<Text <Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}>
type="lg" {formatCount(invitesAvailable)} invite{' '}
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> {pluralize(invitesAvailable, 'code')} available
{formatCount(store.me.invitesAvailable)} invite{' '}
{pluralize(store.me.invitesAvailable, 'code')} available
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>

View File

@ -17,7 +17,6 @@ import {
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
import {useStores} from 'state/index'
import { import {
HomeIcon, HomeIcon,
HomeIconSolid, HomeIconSolid,
@ -51,6 +50,7 @@ import {useSession, SessionAccount} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {emitSoftReset} from '#/state/events' import {emitSoftReset} from '#/state/events'
import {useInviteCodesQuery} from '#/state/queries/invites'
export function DrawerProfileCard({ export function DrawerProfileCard({
account, account,
@ -464,10 +464,10 @@ const InviteCodes = observer(function InviteCodesImpl({
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const {track} = useAnalytics() const {track} = useAnalytics()
const store = useStores()
const setDrawerOpen = useSetDrawerOpen() const setDrawerOpen = useSetDrawerOpen()
const pal = usePalette('default') const pal = usePalette('default')
const {invitesAvailable} = store.me const {data: invites} = useInviteCodesQuery()
const invitesAvailable = invites?.available?.length ?? 0
const {openModal} = useModalControls() const {openModal} = useModalControls()
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
track('Menu:ItemClicked', {url: '#invite-codes'}) track('Menu:ItemClicked', {url: '#invite-codes'})
@ -490,15 +490,15 @@ const InviteCodes = observer(function InviteCodesImpl({
icon="ticket" icon="ticket"
style={[ style={[
styles.inviteCodesIcon, styles.inviteCodesIcon,
store.me.invitesAvailable > 0 ? pal.link : pal.textLight, invitesAvailable > 0 ? pal.link : pal.textLight,
]} ]}
size={18} size={18}
/> />
<Text <Text
type="lg-medium" type="lg-medium"
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> style={invitesAvailable > 0 ? pal.link : pal.textLight}>
{formatCount(store.me.invitesAvailable)} invite{' '} {formatCount(invitesAvailable)} invite{' '}
{pluralize(store.me.invitesAvailable, 'code')} {pluralize(invitesAvailable, 'code')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) )

View File

@ -9,12 +9,12 @@ import {Text} from 'view/com/util/text/Text'
import {TextLink} from 'view/com/util/Link' import {TextLink} from 'view/com/util/Link'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {formatCount} from 'view/com/util/numeric/format' import {formatCount} from 'view/com/util/numeric/format'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useInviteCodesQuery} from '#/state/queries/invites'
export const DesktopRightNav = observer(function DesktopRightNavImpl() { export const DesktopRightNav = observer(function DesktopRightNavImpl() {
const pal = usePalette('default') const pal = usePalette('default')
@ -83,11 +83,10 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() {
}) })
const InviteCodes = observer(function InviteCodesImpl() { const InviteCodes = observer(function InviteCodesImpl() {
const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {data: invites} = useInviteCodesQuery()
const {invitesAvailable} = store.me const invitesAvailable = invites?.available?.length ?? 0
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
openModal({name: 'invite-codes'}) openModal({name: 'invite-codes'})
@ -107,15 +106,15 @@ const InviteCodes = observer(function InviteCodesImpl() {
icon="ticket" icon="ticket"
style={[ style={[
styles.inviteCodesIcon, styles.inviteCodesIcon,
store.me.invitesAvailable > 0 ? pal.link : pal.textLight, invitesAvailable > 0 ? pal.link : pal.textLight,
]} ]}
size={16} size={16}
/> />
<Text <Text
type="md-medium" type="md-medium"
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> style={invitesAvailable > 0 ? pal.link : pal.textLight}>
{formatCount(store.me.invitesAvailable)} invite{' '} {formatCount(invitesAvailable)} invite{' '}
{pluralize(store.me.invitesAvailable, 'code')} available {pluralize(invitesAvailable, 'code')} available
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) )