From ea04c2bd330dc5b46d6f9df0d7d4619bbd8f56d0 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 5 Apr 2023 18:56:02 -0500 Subject: [PATCH] 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 --- __e2e__/mock-server.ts | 3 +- __e2e__/tests/create-account.test.ts | 2 +- __e2e__/tests/invite-codes.test.ts | 64 ++++++ jest/test-pds.ts | 17 +- package.json | 4 +- src/lib/hooks/useCustomPalette.ts | 13 ++ src/state/models/invited-users.ts | 70 +++++++ src/state/models/me.ts | 90 +++++++-- src/state/models/root-store.ts | 8 +- src/state/models/ui/shell.ts | 5 + src/view/com/auth/create/Step2.tsx | 1 + src/view/com/modals/InviteCodes.tsx | 191 ++++++++++++++++++ src/view/com/modals/Modal.tsx | 4 + src/view/com/modals/Modal.web.tsx | 3 + src/view/com/notifications/InvitedUsers.tsx | 112 +++++++++++ src/view/com/profile/FollowButton.tsx | 14 +- src/view/com/profile/ProfileCard.tsx | 2 +- src/view/com/util/PostMeta.tsx | 4 +- src/view/com/util/forms/Button.tsx | 148 +++++++------- src/view/screens/Notifications.tsx | 2 + src/view/screens/Settings.tsx | 142 ++++++++----- src/view/shell/BottomBar.tsx | 4 +- src/view/shell/Drawer.tsx | 209 ++++++++++++-------- src/view/shell/desktop/LeftNav.tsx | 4 +- src/view/shell/desktop/RightNav.tsx | 46 ++++- yarn.lock | 16 +- 26 files changed, 932 insertions(+), 246 deletions(-) create mode 100644 __e2e__/tests/invite-codes.test.ts create mode 100644 src/lib/hooks/useCustomPalette.ts create mode 100644 src/state/models/invited-users.ts create mode 100644 src/view/com/modals/InviteCodes.tsx create mode 100644 src/view/com/notifications/InvitedUsers.tsx diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 7a2be606..7bcad47f 100644 --- a/__e2e__/mock-server.ts +++ b/__e2e__/mock-server.ts @@ -13,7 +13,8 @@ async function main() { console.log('Closing old server') await server?.close() console.log('Starting new server') - server = await createServer() + const inviteRequired = url?.query && 'invite' in url.query + server = await createServer({inviteRequired}) console.log('Listening at', server.pdsUrl) if (url?.query) { if ('users' in url.query) { diff --git a/__e2e__/tests/create-account.test.ts b/__e2e__/tests/create-account.test.ts index 7b2e00fb..38466ed8 100644 --- a/__e2e__/tests/create-account.test.ts +++ b/__e2e__/tests/create-account.test.ts @@ -5,7 +5,7 @@ import {openApp, createServer} from '../util' describe('Create account', () => { let service: string beforeAll(async () => { - service = await createServer('mock0') + service = await createServer('') await openApp({permissions: {notifications: 'YES'}}) }) diff --git a/__e2e__/tests/invite-codes.test.ts b/__e2e__/tests/invite-codes.test.ts new file mode 100644 index 00000000..e3bb5d7f --- /dev/null +++ b/__e2e__/tests/invite-codes.test.ts @@ -0,0 +1,64 @@ +/* eslint-env detox/detox */ + +import {openApp, login, createServer} from '../util' + +describe('invite-codes', () => { + let service: string + let inviteCode = '' + beforeAll(async () => { + service = await createServer('?users&invite') + await openApp({permissions: {notifications: 'YES'}}) + }) + + it('I can fetch invite codes', async () => { + await expect(element(by.id('signInButton'))).toBeVisible() + await login(service, 'alice', 'hunter2') + await element(by.id('viewHeaderDrawerBtn')).tap() + await expect(element(by.id('drawer'))).toBeVisible() + await element(by.id('menuItemInviteCodes')).tap() + await expect(element(by.id('inviteCodesModal'))).toBeVisible() + const attrs = await element(by.id('inviteCode-0-code')).getAttributes() + inviteCode = attrs.text + await element(by.id('closeBtn')).tap() + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('signOutBtn')).tap() + }) + + it('I can create a new account with the invite code', async () => { + await element(by.id('createAccountButton')).tap() + await device.takeScreenshot('1- opened create account screen') + await element(by.id('otherServerBtn')).tap() + await device.takeScreenshot('2- selected other server') + await element(by.id('customServerInput')).clearText() + await element(by.id('customServerInput')).typeText(service) + await device.takeScreenshot('3- input test server URL') + await element(by.id('nextBtn')).tap() + await element(by.id('inviteCodeInput')).typeText(inviteCode) + await element(by.id('emailInput')).typeText('example@test.com') + await element(by.id('passwordInput')).typeText('hunter2') + await element(by.id('is13Input')).tap() + await device.takeScreenshot('4- entered account details') + await element(by.id('nextBtn')).tap() + await element(by.id('handleInput')).typeText('e2e-test') + await device.takeScreenshot('4- entered handle') + await element(by.id('nextBtn')).tap() + await expect(element(by.id('homeScreen'))).toBeVisible() + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Settings')).tap() + await element(by.id('signOutBtn')).tap() + }) + + it('I get a notification for the new user', async () => { + await expect(element(by.id('signInButton'))).toBeVisible() + await login(service, 'alice', 'hunter2') + await element(by.id('viewHeaderDrawerBtn')).tap() + await element(by.id('menuItemButton-Notifications')).tap() + await expect(element(by.id('invitedUser'))).toBeVisible() + }) + + it('I can dismiss the new user notification', async () => { + await element(by.id('dismissBtn')).tap() + await expect(element(by.id('invitedUser'))).not.toBeVisible() + }) +}) diff --git a/jest/test-pds.ts b/jest/test-pds.ts index 1e87df81..85177558 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -27,7 +27,9 @@ export interface TestPDS { close: () => Promise } -export async function createServer(): Promise { +export async function createServer( + {inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false}, +): Promise { const repoSigningKey = await crypto.Secp256k1Keypair.create() const plcRotationKey = await crypto.Secp256k1Keypair.create() const port = await getPort() @@ -61,7 +63,7 @@ export async function createServer(): Promise { serverDid, recoveryKey, adminPassword: ADMIN_PASSWORD, - inviteRequired: false, + inviteRequired, didPlcUrl: plcUrl, jwtSecret: 'jwt-secret', availableUserDomains: ['.test'], @@ -76,6 +78,7 @@ export async function createServer(): Promise { blobstoreTmp: `${blobstoreLoc}/tmp`, maxSubscriptionBuffer: 200, repoBackfillLimitMs: HOUR, + userInviteInterval: 1, }) const db = @@ -131,8 +134,18 @@ class Mocker { async createUser(name: string) { const agent = new BskyAgent({service: this.agent.service}) + + const inviteRes = await agent.api.com.atproto.server.createInviteCode( + {useCount: 1}, + { + headers: {authorization: `Basic ${btoa(`admin:${ADMIN_PASSWORD}`)}`}, + encoding: 'application/json', + }, + ) + const email = `fake${Object.keys(this.users).length + 1}@fake.com` const res = await agent.createAccount({ + inviteCode: inviteRes.data.code, email, handle: name + '.test', password: 'hunter2', diff --git a/package.json b/package.json index 35efccc6..99f288b3 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "0.2.3", + "@atproto/api": "0.2.4", "@bam.tech/react-native-image-resizer": "^3.0.4", "@expo/webpack-config": "^18.0.1", "@fortawesome/fontawesome-svg-core": "^6.1.1", @@ -120,7 +120,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/pds": "^0.1.0", + "@atproto/pds": "^0.1.3", "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", diff --git a/src/lib/hooks/useCustomPalette.ts b/src/lib/hooks/useCustomPalette.ts new file mode 100644 index 00000000..4f8f5c83 --- /dev/null +++ b/src/lib/hooks/useCustomPalette.ts @@ -0,0 +1,13 @@ +import React from 'react' +import {useTheme} from 'lib/ThemeContext' +import {choose} from 'lib/functions' + +export function useCustomPalette({light, dark}: {light: T; dark: T}) { + const theme = useTheme() + return React.useMemo(() => { + return choose>(theme.colorScheme, { + dark, + light, + }) + }, [theme.colorScheme, dark, light]) +} diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts new file mode 100644 index 00000000..121161a3 --- /dev/null +++ b/src/state/models/invited-users.ts @@ -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) + } +} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 3adbc7c6..1dcccb6f 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -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 } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 0d893415..9207f27b 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -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) } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index b782dd2f..917e7a09 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -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 {} diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 8df997bd..cf941a94 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -35,6 +35,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { Invite code { + store.shell.closeModal() + }, [store]) + + if (store.me.invites.length === 0) { + return ( + + + + You don't have any invite codes yet! We'll send you some when you've + been on Bluesky for a little longer. + + + + +