Add user invite codes (#393)

* Add mobile UIs for invite codes

* Update invite code UIs for web

* Finish implementing invite code behaviors (including notifications of invited users)

* Bump deps

* Update web right nav to use real data; also fix lint
This commit is contained in:
Paul Frazee 2023-04-05 18:56:02 -05:00 committed by GitHub
parent 8e28d3c6be
commit ea04c2bd33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 932 additions and 246 deletions

View file

@ -0,0 +1,70 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {ComAtprotoServerDefs, AppBskyActorDefs} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {isObj, hasProp, isStrArray} from 'lib/type-guards'
export class InvitedUsers {
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}
}
hydrate(v: unknown) {
if (isObj(v) && hasProp(v, 'seenDids') && isStrArray(v.seenDids)) {
this.seenDids = v.seenDids
}
}
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.hydrateProfiles(this.profiles)
} catch (e) {
this.rootStore.log.error(
'Failed to fetch profiles for invited users',
e,
)
}
}
}
markSeen(did: string) {
this.seenDids.push(did)
this.profiles = this.profiles.filter(profile => profile.did !== did)
}
}

View file

@ -1,10 +1,13 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {ComAtprotoServerDefs} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {PostsFeedModel} from './feeds/posts'
import {NotificationsFeedModel} from './feeds/notifications'
import {MyFollowsCache} from './cache/my-follows'
import {isObj, hasProp} from 'lib/type-guards'
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
export class MeModel {
did: string = ''
handle: string = ''
@ -16,6 +19,12 @@ export class MeModel {
mainFeed: PostsFeedModel
notifications: NotificationsFeedModel
follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = []
lastProfileStateUpdate = Date.now()
get invitesAvailable() {
return this.invites.filter(isInviteAvailable).length
}
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
@ -39,6 +48,7 @@ export class MeModel {
this.displayName = ''
this.description = ''
this.avatar = ''
this.invites = []
}
serialize(): unknown {
@ -85,24 +95,7 @@ export class MeModel {
if (sess.hasSession) {
this.did = sess.currentSession?.did || ''
this.handle = sess.currentSession?.handle || ''
const profile = await this.rootStore.agent.getProfile({
actor: this.did,
})
runInAction(() => {
if (profile?.data) {
this.displayName = profile.data.displayName || ''
this.description = profile.data.description || ''
this.avatar = profile.data.avatar || ''
this.followsCount = profile.data.followsCount
this.followersCount = profile.data.followersCount
} else {
this.displayName = ''
this.description = ''
this.avatar = ''
this.followsCount = profile.data.followsCount
this.followersCount = undefined
}
})
await this.fetchProfile()
this.mainFeed.clear()
await Promise.all([
this.mainFeed.setup().catch(e => {
@ -113,8 +106,69 @@ export class MeModel {
}),
])
this.rootStore.emitSessionLoaded()
await this.fetchInviteCodes()
} else {
this.clear()
}
}
async updateIfNeeded() {
if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
this.rootStore.log.debug('Updating me profile information')
await this.fetchProfile()
await this.fetchInviteCodes()
}
await this.notifications.loadUnreadCount()
}
async fetchProfile() {
const profile = await this.rootStore.agent.getProfile({
actor: this.did,
})
runInAction(() => {
if (profile?.data) {
this.displayName = profile.data.displayName || ''
this.description = profile.data.description || ''
this.avatar = profile.data.avatar || ''
this.followsCount = profile.data.followsCount
this.followersCount = profile.data.followersCount
} else {
this.displayName = ''
this.description = ''
this.avatar = ''
this.followsCount = profile.data.followsCount
this.followersCount = undefined
}
})
}
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) {
this.rootStore.log.error('Failed to fetch user invite codes', e)
}
await this.rootStore.invitedUsers.fetch(this.invites)
}
}
}
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
return invite.available - invite.uses.length > 0 && !invite.disabled
}

View file

@ -16,6 +16,7 @@ import {ProfilesCache} from './cache/profiles-view'
import {LinkMetasCache} from './cache/link-metas'
import {NotificationsFeedItemModel} from './feeds/notifications'
import {MeModel} from './me'
import {InvitedUsers} from './invited-users'
import {PreferencesModel} from './ui/preferences'
import {resetToTab} from '../../Navigation'
import {ImageSizesCache} from './cache/image-sizes'
@ -36,6 +37,7 @@ export class RootStoreModel {
shell = new ShellUiModel(this)
preferences = new PreferencesModel()
me = new MeModel(this)
invitedUsers = new InvitedUsers(this)
profiles = new ProfilesCache(this)
linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache()
@ -61,6 +63,7 @@ export class RootStoreModel {
me: this.me.serialize(),
shell: this.shell.serialize(),
preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(),
}
}
@ -84,6 +87,9 @@ export class RootStoreModel {
if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences)
}
if (hasProp(v, 'invitedUsers')) {
this.invitedUsers.hydrate(v.invitedUsers)
}
}
}
@ -141,7 +147,7 @@ export class RootStoreModel {
return
}
try {
await this.me.notifications.loadUnreadCount()
await this.me.updateIfNeeded()
} catch (e: any) {
this.log.error('Failed to fetch latest state', e)
}

View file

@ -61,6 +61,10 @@ export interface WaitlistModal {
name: 'waitlist'
}
export interface InviteCodesModal {
name: 'invite-codes'
}
export type Modal =
| ConfirmModal
| EditProfileModal
@ -72,6 +76,7 @@ export type Modal =
| RepostModal
| ChangeHandleModal
| WaitlistModal
| InviteCodesModal
interface LightboxModel {}