about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-08 09:10:59 -0800
committerGitHub <noreply@github.com>2023-11-08 09:10:59 -0800
commite75b2d508baf9b19e7340657ac2951e9f057b735 (patch)
tree2c9647d9dc3d47261e4e838313c4b815f622fb6a /src
parent74f8390f1d879350ebb6516fade2b1d83d1601e7 (diff)
downloadvoidsky-e75b2d508baf9b19e7340657ac2951e9f057b735.tar.zst
Move invite-state to new persistence + context and replace the notifications with just showing uses in the modal (#1840)
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx5
-rw-r--r--src/App.web.tsx5
-rw-r--r--src/state/invites.tsx56
-rw-r--r--src/state/models/feeds/notifications.ts2
-rw-r--r--src/state/models/invited-users.ts88
-rw-r--r--src/state/models/me.ts1
-rw-r--r--src/state/models/root-store.ts6
-rw-r--r--src/state/persisted/legacy.ts6
-rw-r--r--src/state/persisted/schema.ts6
-rw-r--r--src/view/com/modals/InviteCodes.tsx105
-rw-r--r--src/view/com/notifications/InvitedUsers.tsx114
-rw-r--r--src/view/screens/Notifications.tsx2
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}