diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-11-08 09:10:59 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-08 09:10:59 -0800 |
commit | e75b2d508baf9b19e7340657ac2951e9f057b735 (patch) | |
tree | 2c9647d9dc3d47261e4e838313c4b815f622fb6a | |
parent | 74f8390f1d879350ebb6516fade2b1d83d1601e7 (diff) | |
download | voidsky-e75b2d508baf9b19e7340657ac2951e9f057b735.tar.zst |
Move invite-state to new persistence + context and replace the notifications with just showing uses in the modal (#1840)
-rw-r--r-- | src/App.native.tsx | 5 | ||||
-rw-r--r-- | src/App.web.tsx | 5 | ||||
-rw-r--r-- | src/state/invites.tsx | 56 | ||||
-rw-r--r-- | src/state/models/feeds/notifications.ts | 2 | ||||
-rw-r--r-- | src/state/models/invited-users.ts | 88 | ||||
-rw-r--r-- | src/state/models/me.ts | 1 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 6 | ||||
-rw-r--r-- | src/state/persisted/legacy.ts | 6 | ||||
-rw-r--r-- | src/state/persisted/schema.ts | 6 | ||||
-rw-r--r-- | src/view/com/modals/InviteCodes.tsx | 105 | ||||
-rw-r--r-- | src/view/com/notifications/InvitedUsers.tsx | 114 | ||||
-rw-r--r-- | src/view/screens/Notifications.tsx | 2 |
12 files changed, 137 insertions, 259 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 4500b5d07..865e6dc19 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -23,6 +23,7 @@ import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as MutedThreadsProvider} from 'state/muted-threads' +import {Provider as InvitesStateProvider} from 'state/invites' SplashScreen.preventAutoHideAsync() @@ -80,7 +81,9 @@ function App() { return ( <ShellStateProvider> <MutedThreadsProvider> - <InnerApp /> + <InvitesStateProvider> + <InnerApp /> + </InvitesStateProvider> </MutedThreadsProvider> </ShellStateProvider> ) diff --git a/src/App.web.tsx b/src/App.web.tsx index 9792274b0..cfc2a0028 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -18,6 +18,7 @@ import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' import {Provider as MutedThreadsProvider} from 'state/muted-threads' +import {Provider as InvitesStateProvider} from 'state/invites' const InnerApp = observer(function AppImpl() { const colorMode = useColorMode() @@ -70,7 +71,9 @@ function App() { return ( <ShellStateProvider> <MutedThreadsProvider> - <InnerApp /> + <InvitesStateProvider> + <InnerApp /> + </InvitesStateProvider> </MutedThreadsProvider> </ShellStateProvider> ) diff --git a/src/state/invites.tsx b/src/state/invites.tsx new file mode 100644 index 000000000..6a0d1b590 --- /dev/null +++ b/src/state/invites.tsx @@ -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) +} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 272d52881..5f34feb66 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -304,7 +304,7 @@ export class NotificationsFeedModel { } get unreadCountLabel(): string { - const count = this.unreadCount + this.rootStore.invitedUsers.numNotifs + const count = this.unreadCount if (count >= MAX_VISIBLE_NOTIFS) { return `${MAX_VISIBLE_NOTIFS}+` } diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts deleted file mode 100644 index 9ba65e19e..000000000 --- a/src/state/models/invited-users.ts +++ /dev/null @@ -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) - } -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index d12cb68c4..d3061f166 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -193,7 +193,6 @@ export class MeModel { error: e, }) } - await this.rootStore.invitedUsers.fetch(this.invites) } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index fadd279fc..d11e9a148 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -15,7 +15,6 @@ import {ProfilesCache} from './cache/profiles-view' import {PostsCache} from './cache/posts' import {LinkMetasCache} from './cache/link-metas' import {MeModel} from './me' -import {InvitedUsers} from './invited-users' import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' @@ -42,7 +41,6 @@ export class RootStoreModel { shell = new ShellUiModel(this) preferences = new PreferencesModel(this) me = new MeModel(this) - invitedUsers = new InvitedUsers(this) handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) posts = new PostsCache(this) @@ -68,7 +66,6 @@ export class RootStoreModel { session: this.session.serialize(), me: this.me.serialize(), preferences: this.preferences.serialize(), - invitedUsers: this.invitedUsers.serialize(), } } @@ -89,9 +86,6 @@ export class RootStoreModel { if (hasProp(v, 'preferences')) { this.preferences.hydrate(v.preferences) } - if (hasProp(v, 'invitedUsers')) { - this.invitedUsers.hydrate(v.invitedUsers) - } } } diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index 67eef81a0..3da509304 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -97,11 +97,9 @@ export function transform(legacy: LegacySchema): Schema { legacy.preferences.requireAltTextEnabled || defaults.requireAltTextEnabled, mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, - invitedUsers: { - seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids, + invites: { copiedInvites: - legacy.invitedUsers.copiedInvites || - defaults.invitedUsers.copiedInvites, + legacy.invitedUsers.copiedInvites || defaults.invites.copiedInvites, }, onboarding: { step: legacy.onboarding.step || defaults.onboarding.step, diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 708930610..9c52661e4 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -29,8 +29,7 @@ export const schema = z.object({ }), requireAltTextEnabled: z.boolean(), // should move to server mutedThreads: z.array(z.string()), // should move to server - invitedUsers: z.object({ - seenDids: z.array(z.string()), + invites: z.object({ copiedInvites: z.array(z.string()), }), onboarding: z.object({ @@ -58,8 +57,7 @@ export const defaults: Schema = { }, requireAltTextEnabled: false, mutedThreads: [], - invitedUsers: { - seenDids: [], + invites: { copiedInvites: [], }, onboarding: { diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 09cfd4de7..a8aa164c3 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -1,6 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {ComAtprotoServerDefs} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -14,6 +15,10 @@ import {ScrollView} from './util' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' 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%'] @@ -66,7 +71,7 @@ export function Component({}: {}) { <InviteCode testID={`inviteCode-${i}`} key={invite.code} - code={invite.code} + invite={invite} used={invite.available - invite.uses.length <= 0 || invite.disabled} /> ))} @@ -87,52 +92,81 @@ export function Component({}: {}) { const InviteCode = observer(function InviteCodeImpl({ testID, - code, + invite, used, }: { testID: string - code: string + invite: ComAtprotoServerDefs.InviteCode used?: boolean }) { const pal = usePalette('default') const store = useStores() const {invitesAvailable} = store.me + const invitesState = useInvitesState() + const {setInviteCopied} = useInvitesAPI() const onPress = React.useCallback(() => { - Clipboard.setString(code) + Clipboard.setString(invite.code) Toast.show('Copied to clipboard') - store.invitedUsers.setInviteCopied(code) - }, [store, code]) + setInviteCopied(invite.code) + }, [setInviteCopied, invite]) return ( - <TouchableOpacity - testID={testID} - style={[styles.inviteCode, pal.border]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <Text - testID={`${testID}-code`} - type={used ? 'md' : 'md-bold'} - style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> - {code} - </Text> - <View style={styles.flex1} /> - {!used && store.invitedUsers.isInviteCopied(code) && ( - <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> - )} - {!used && ( - <FontAwesomeIcon - icon={['far', 'clone']} - style={pal.text as FontAwesomeIconStyle} - /> - )} - </TouchableOpacity> + <View + style={[ + pal.border, + {borderBottomWidth: 1, paddingHorizontal: 20, paddingVertical: 14}, + ]}> + <TouchableOpacity + testID={testID} + style={[styles.inviteCode]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invitesAvailable === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invitesAvailable} available` + } + accessibilityHint="Opens list of invite codes"> + <Text + testID={`${testID}-code`} + type={used ? 'md' : 'md-bold'} + style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> + {invite.code} + </Text> + <View style={styles.flex1} /> + {!used && invitesState.copiedInvites.includes(invite.code) && ( + <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> + )} + {!used && ( + <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: { flexDirection: 'row', alignItems: 'center', - borderBottomWidth: 1, - paddingHorizontal: 20, - paddingVertical: 14, }, codeCopied: { marginRight: 8, diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx deleted file mode 100644 index aaf358b87..000000000 --- a/src/view/com/notifications/InvitedUsers.tsx +++ /dev/null @@ -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, - }, -}) diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index cd482bd1c..b03e73376 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -10,7 +10,6 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' import {TextLink} from 'view/com/util/Link' -import {InvitedUsers} from '../com/notifications/InvitedUsers' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {useStores} from 'state/index' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' @@ -145,7 +144,6 @@ export const NotificationsScreen = withAuthRequired( return ( <View testID="notificationsScreen" style={s.hContentRegion}> <ViewHeader title="Notifications" canGoBack={false} /> - <InvitedUsers /> <Feed view={store.me.notifications} onPressTryAgain={onPressTryAgain} |