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
zio/stable
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

@ -13,7 +13,8 @@ async function main() {
console.log('Closing old server') console.log('Closing old server')
await server?.close() await server?.close()
console.log('Starting new server') 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) console.log('Listening at', server.pdsUrl)
if (url?.query) { if (url?.query) {
if ('users' in url.query) { if ('users' in url.query) {

View File

@ -5,7 +5,7 @@ import {openApp, createServer} from '../util'
describe('Create account', () => { describe('Create account', () => {
let service: string let service: string
beforeAll(async () => { beforeAll(async () => {
service = await createServer('mock0') service = await createServer('')
await openApp({permissions: {notifications: 'YES'}}) await openApp({permissions: {notifications: 'YES'}})
}) })

View File

@ -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()
})
})

View File

@ -27,7 +27,9 @@ export interface TestPDS {
close: () => Promise<void> close: () => Promise<void>
} }
export async function createServer(): Promise<TestPDS> { export async function createServer(
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
): Promise<TestPDS> {
const repoSigningKey = await crypto.Secp256k1Keypair.create() const repoSigningKey = await crypto.Secp256k1Keypair.create()
const plcRotationKey = await crypto.Secp256k1Keypair.create() const plcRotationKey = await crypto.Secp256k1Keypair.create()
const port = await getPort() const port = await getPort()
@ -61,7 +63,7 @@ export async function createServer(): Promise<TestPDS> {
serverDid, serverDid,
recoveryKey, recoveryKey,
adminPassword: ADMIN_PASSWORD, adminPassword: ADMIN_PASSWORD,
inviteRequired: false, inviteRequired,
didPlcUrl: plcUrl, didPlcUrl: plcUrl,
jwtSecret: 'jwt-secret', jwtSecret: 'jwt-secret',
availableUserDomains: ['.test'], availableUserDomains: ['.test'],
@ -76,6 +78,7 @@ export async function createServer(): Promise<TestPDS> {
blobstoreTmp: `${blobstoreLoc}/tmp`, blobstoreTmp: `${blobstoreLoc}/tmp`,
maxSubscriptionBuffer: 200, maxSubscriptionBuffer: 200,
repoBackfillLimitMs: HOUR, repoBackfillLimitMs: HOUR,
userInviteInterval: 1,
}) })
const db = const db =
@ -131,8 +134,18 @@ class Mocker {
async createUser(name: string) { async createUser(name: string) {
const agent = new BskyAgent({service: this.agent.service}) 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 email = `fake${Object.keys(this.users).length + 1}@fake.com`
const res = await agent.createAccount({ const res = await agent.createAccount({
inviteCode: inviteRes.data.code,
email, email,
handle: name + '.test', handle: name + '.test',
password: 'hunter2', password: 'hunter2',

View File

@ -21,7 +21,7 @@
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.2.3", "@atproto/api": "0.2.4",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@expo/webpack-config": "^18.0.1", "@expo/webpack-config": "^18.0.1",
"@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/fontawesome-svg-core": "^6.1.1",
@ -120,7 +120,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/pds": "^0.1.0", "@atproto/pds": "^0.1.3",
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0", "@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",

View File

@ -0,0 +1,13 @@
import React from 'react'
import {useTheme} from 'lib/ThemeContext'
import {choose} from 'lib/functions'
export function useCustomPalette<T>({light, dark}: {light: T; dark: T}) {
const theme = useTheme()
return React.useMemo(() => {
return choose<T, Record<string, T>>(theme.colorScheme, {
dark,
light,
})
}, [theme.colorScheme, dark, light])
}

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 {makeAutoObservable, runInAction} from 'mobx'
import {ComAtprotoServerDefs} from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {PostsFeedModel} from './feeds/posts' import {PostsFeedModel} from './feeds/posts'
import {NotificationsFeedModel} from './feeds/notifications' import {NotificationsFeedModel} from './feeds/notifications'
import {MyFollowsCache} from './cache/my-follows' import {MyFollowsCache} from './cache/my-follows'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
export class MeModel { export class MeModel {
did: string = '' did: string = ''
handle: string = '' handle: string = ''
@ -16,6 +19,12 @@ export class MeModel {
mainFeed: PostsFeedModel mainFeed: PostsFeedModel
notifications: NotificationsFeedModel notifications: NotificationsFeedModel
follows: MyFollowsCache follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = []
lastProfileStateUpdate = Date.now()
get invitesAvailable() {
return this.invites.filter(isInviteAvailable).length
}
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@ -39,6 +48,7 @@ export class MeModel {
this.displayName = '' this.displayName = ''
this.description = '' this.description = ''
this.avatar = '' this.avatar = ''
this.invites = []
} }
serialize(): unknown { serialize(): unknown {
@ -85,24 +95,7 @@ export class MeModel {
if (sess.hasSession) { if (sess.hasSession) {
this.did = sess.currentSession?.did || '' this.did = sess.currentSession?.did || ''
this.handle = sess.currentSession?.handle || '' this.handle = sess.currentSession?.handle || ''
const profile = await this.rootStore.agent.getProfile({ await this.fetchProfile()
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
}
})
this.mainFeed.clear() this.mainFeed.clear()
await Promise.all([ await Promise.all([
this.mainFeed.setup().catch(e => { this.mainFeed.setup().catch(e => {
@ -113,8 +106,69 @@ export class MeModel {
}), }),
]) ])
this.rootStore.emitSessionLoaded() this.rootStore.emitSessionLoaded()
await this.fetchInviteCodes()
} else { } else {
this.clear() 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 {LinkMetasCache} from './cache/link-metas'
import {NotificationsFeedItemModel} from './feeds/notifications' import {NotificationsFeedItemModel} from './feeds/notifications'
import {MeModel} from './me' import {MeModel} from './me'
import {InvitedUsers} from './invited-users'
import {PreferencesModel} from './ui/preferences' import {PreferencesModel} from './ui/preferences'
import {resetToTab} from '../../Navigation' import {resetToTab} from '../../Navigation'
import {ImageSizesCache} from './cache/image-sizes' import {ImageSizesCache} from './cache/image-sizes'
@ -36,6 +37,7 @@ export class RootStoreModel {
shell = new ShellUiModel(this) shell = new ShellUiModel(this)
preferences = new PreferencesModel() preferences = new PreferencesModel()
me = new MeModel(this) me = new MeModel(this)
invitedUsers = new InvitedUsers(this)
profiles = new ProfilesCache(this) profiles = new ProfilesCache(this)
linkMetas = new LinkMetasCache(this) linkMetas = new LinkMetasCache(this)
imageSizes = new ImageSizesCache() imageSizes = new ImageSizesCache()
@ -61,6 +63,7 @@ export class RootStoreModel {
me: this.me.serialize(), me: this.me.serialize(),
shell: this.shell.serialize(), shell: this.shell.serialize(),
preferences: this.preferences.serialize(), preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(),
} }
} }
@ -84,6 +87,9 @@ export class RootStoreModel {
if (hasProp(v, 'preferences')) { if (hasProp(v, 'preferences')) {
this.preferences.hydrate(v.preferences) this.preferences.hydrate(v.preferences)
} }
if (hasProp(v, 'invitedUsers')) {
this.invitedUsers.hydrate(v.invitedUsers)
}
} }
} }
@ -141,7 +147,7 @@ export class RootStoreModel {
return return
} }
try { try {
await this.me.notifications.loadUnreadCount() await this.me.updateIfNeeded()
} catch (e: any) { } catch (e: any) {
this.log.error('Failed to fetch latest state', e) this.log.error('Failed to fetch latest state', e)
} }

View File

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

View File

@ -35,6 +35,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
Invite code Invite code
</Text> </Text>
<TextInput <TextInput
testID="inviteCodeInput"
icon="ticket" icon="ticket"
placeholder="Required for this provider" placeholder="Required for this provider"
value={model.inviteCode} value={model.inviteCode}

View File

@ -0,0 +1,191 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import Clipboard from '@react-native-clipboard/clipboard'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import * as Toast from '../util/Toast'
import {useStores} from 'state/index'
import {ScrollView} from './util'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
export const snapPoints = ['70%']
export function Component({}: {}) {
const pal = usePalette('default')
const store = useStores()
const onClose = React.useCallback(() => {
store.shell.closeModal()
}, [store])
if (store.me.invites.length === 0) {
return (
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
<View style={[styles.empty, pal.viewLight]}>
<Text type="lg" style={[pal.text, styles.emptyText]}>
You don't have any invite codes yet! We'll send you some when you've
been on Bluesky for a little longer.
</Text>
</View>
<View style={styles.flex1} />
<View style={styles.btnContainer}>
<Button
type="primary"
label="Done"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onClose}
/>
</View>
</View>
)
}
return (
<View style={[styles.container, pal.view]} testID="inviteCodesModal">
<Text type="title-xl" style={[styles.title, pal.text]}>
Invite a Friend
</Text>
<Text type="lg" style={[styles.description, pal.text]}>
Send these invites to your friends so they can create an account. Each
code works once!
</Text>
<Text type="sm" style={[styles.description, pal.textLight]}>
( We'll send you more periodically. )
</Text>
<ScrollView style={[styles.scrollContainer, pal.border]}>
{store.me.invites.map((invite, i) => (
<InviteCode
testID={`inviteCode-${i}`}
key={invite.code}
code={invite.code}
used={invite.available - invite.uses.length <= 0 || invite.disabled}
/>
))}
</ScrollView>
<View style={styles.btnContainer}>
<Button
testID="closeBtn"
type="primary"
label="Done"
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onClose}
/>
</View>
</View>
)
}
function InviteCode({
testID,
code,
used,
}: {
testID: string
code: string
used?: boolean
}) {
const pal = usePalette('default')
const [wasCopied, setWasCopied] = React.useState(false)
const onPress = React.useCallback(() => {
Clipboard.setString(code)
Toast.show('Copied to clipboard')
setWasCopied(true)
}, [code])
return (
<TouchableOpacity
testID={testID}
style={[styles.inviteCode, pal.border]}
onPress={onPress}>
<Text
testID={`${testID}-code`}
type={used ? 'md' : 'md-bold'}
style={used ? [pal.textLight, styles.strikeThrough] : pal.text}>
{code}
</Text>
{wasCopied ? (
<Text style={pal.textLight}>Copied</Text>
) : !used ? (
<FontAwesomeIcon
icon={['far', 'clone']}
style={pal.text as FontAwesomeIconStyle}
/>
) : undefined}
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isDesktopWeb ? 0 : 50,
},
title: {
textAlign: 'center',
marginTop: 12,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 42,
marginBottom: 14,
},
scrollContainer: {
flex: 1,
borderTopWidth: 1,
marginTop: 4,
marginBottom: 16,
},
flex1: {
flex: 1,
},
empty: {
paddingHorizontal: 20,
paddingVertical: 20,
borderRadius: 16,
marginHorizontal: 24,
marginTop: 10,
},
emptyText: {
textAlign: 'center',
},
inviteCode: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
paddingHorizontal: 20,
paddingVertical: 14,
},
strikeThrough: {
textDecorationLine: 'line-through',
textDecorationStyle: 'solid',
},
btnContainer: {
flexDirection: 'row',
justifyContent: 'center',
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
paddingHorizontal: 60,
paddingVertical: 14,
},
btnLabel: {
fontSize: 18,
},
})

View File

@ -14,6 +14,7 @@ import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as ChangeHandleModal from './ChangeHandle' import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist' import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native' import {StyleSheet} from 'react-native'
@ -73,6 +74,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'waitlist') { } else if (activeModal?.name === 'waitlist') {
snapPoints = WaitlistModal.snapPoints snapPoints = WaitlistModal.snapPoints
element = <WaitlistModal.Component /> element = <WaitlistModal.Component />
} else if (activeModal?.name === 'invite-codes') {
snapPoints = InviteCodesModal.snapPoints
element = <InviteCodesModal.Component />
} else { } else {
return <View /> return <View />
} }

View File

@ -16,6 +16,7 @@ import * as RepostModal from './Repost'
import * as CropImageModal from './crop-image/CropImage.web' import * as CropImageModal from './crop-image/CropImage.web'
import * as ChangeHandleModal from './ChangeHandle' import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist' import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
export const ModalsContainer = observer(function ModalsContainer() { export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores() const store = useStores()
@ -72,6 +73,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ChangeHandleModal.Component {...modal} /> element = <ChangeHandleModal.Component {...modal} />
} else if (modal.name === 'waitlist') { } else if (modal.name === 'waitlist') {
element = <WaitlistModal.Component /> element = <WaitlistModal.Component />
} else if (modal.name === 'invite-codes') {
element = <InviteCodesModal.Component />
} else { } else {
return null return null
} }

View File

@ -0,0 +1,112 @@
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'
export const InvitedUsers = observer(() => {
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={`/profile/${profile.handle}`}>
<UserAvatar avatar={profile.avatar} size={35} />
</Link>
<Text style={[styles.desc, pal.text]}>
<TextLink
type="md-bold"
style={pal.text}
href={`/profile/${profile.handle}`}
text={profile.displayName || profile.handle}
/>{' '}
joined using your invite code!
</Text>
<View style={styles.btns}>
<FollowButton
unfollowedType="primary"
followedType="primary-light"
did={profile.did}
/>
<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,
},
})

View File

@ -6,13 +6,15 @@ import {useStores} from 'state/index'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {FollowState} from 'state/models/cache/my-follows' import {FollowState} from 'state/models/cache/my-follows'
const FollowButton = observer( export const FollowButton = observer(
({ ({
type = 'inverted', unfollowedType = 'inverted',
followedType = 'inverted',
did, did,
onToggleFollow, onToggleFollow,
}: { }: {
type?: ButtonType unfollowedType?: ButtonType
followedType?: ButtonType
did: string did: string
onToggleFollow?: (v: boolean) => void onToggleFollow?: (v: boolean) => void
}) => { }) => {
@ -48,12 +50,12 @@ const FollowButton = observer(
return ( return (
<Button <Button
type={followState === FollowState.Following ? 'default' : type} type={
followState === FollowState.Following ? followedType : unfollowedType
}
onPress={onToggleFollowInner} onPress={onToggleFollowInner}
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
/> />
) )
}, },
) )
export default FollowButton

View File

@ -8,7 +8,7 @@ import {UserAvatar} from '../util/UserAvatar'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import FollowButton from './FollowButton' import {FollowButton} from './FollowButton'
export function ProfileCard({ export function ProfileCard({
testID, testID,

View File

@ -7,7 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {UserAvatar} from './UserAvatar' import {UserAvatar} from './UserAvatar'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import FollowButton from '../profile/FollowButton' import {FollowButton} from '../profile/FollowButton'
import {FollowState} from 'state/models/cache/my-follows' import {FollowState} from 'state/models/cache/my-follows'
interface PostMetaOpts { interface PostMetaOpts {
@ -78,7 +78,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
<View> <View>
<FollowButton <FollowButton
type="default" unfollowedType="default"
did={opts.did} did={opts.did}
onToggleFollow={onToggleFollow} onToggleFollow={onToggleFollow}
/> />

View File

@ -25,6 +25,7 @@ export function Button({
type = 'primary', type = 'primary',
label, label,
style, style,
labelStyle,
onPress, onPress,
children, children,
testID, testID,
@ -32,87 +33,94 @@ export function Button({
type?: ButtonType type?: ButtonType
label?: string label?: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
labelStyle?: StyleProp<TextStyle>
onPress?: () => void onPress?: () => void
testID?: string testID?: string
}>) { }>) {
const theme = useTheme() const theme = useTheme()
const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, { const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
primary: { type,
backgroundColor: theme.palette.primary.background, {
primary: {
backgroundColor: theme.palette.primary.background,
},
secondary: {
backgroundColor: theme.palette.secondary.background,
},
default: {
backgroundColor: theme.palette.default.backgroundLight,
},
inverted: {
backgroundColor: theme.palette.inverted.background,
},
'primary-outline': {
backgroundColor: theme.palette.default.background,
borderWidth: 1,
borderColor: theme.palette.primary.border,
},
'secondary-outline': {
backgroundColor: theme.palette.default.background,
borderWidth: 1,
borderColor: theme.palette.secondary.border,
},
'primary-light': {
backgroundColor: theme.palette.default.background,
},
'secondary-light': {
backgroundColor: theme.palette.default.background,
},
'default-light': {
backgroundColor: theme.palette.default.background,
},
}, },
secondary: { )
backgroundColor: theme.palette.secondary.background, const typeLabelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(
type,
{
primary: {
color: theme.palette.primary.text,
fontWeight: '600',
},
secondary: {
color: theme.palette.secondary.text,
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
},
default: {
color: theme.palette.default.text,
},
inverted: {
color: theme.palette.inverted.text,
fontWeight: '600',
},
'primary-outline': {
color: theme.palette.primary.textInverted,
fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined,
},
'secondary-outline': {
color: theme.palette.secondary.textInverted,
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
},
'primary-light': {
color: theme.palette.primary.textInverted,
fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined,
},
'secondary-light': {
color: theme.palette.secondary.textInverted,
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
},
'default-light': {
color: theme.palette.default.text,
fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
},
}, },
default: { )
backgroundColor: theme.palette.default.backgroundLight,
},
inverted: {
backgroundColor: theme.palette.inverted.background,
},
'primary-outline': {
backgroundColor: theme.palette.default.background,
borderWidth: 1,
borderColor: theme.palette.primary.border,
},
'secondary-outline': {
backgroundColor: theme.palette.default.background,
borderWidth: 1,
borderColor: theme.palette.secondary.border,
},
'primary-light': {
backgroundColor: theme.palette.default.background,
},
'secondary-light': {
backgroundColor: theme.palette.default.background,
},
'default-light': {
backgroundColor: theme.palette.default.background,
},
})
const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
primary: {
color: theme.palette.primary.text,
fontWeight: '600',
},
secondary: {
color: theme.palette.secondary.text,
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
},
default: {
color: theme.palette.default.text,
},
inverted: {
color: theme.palette.inverted.text,
fontWeight: '600',
},
'primary-outline': {
color: theme.palette.primary.textInverted,
fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined,
},
'secondary-outline': {
color: theme.palette.secondary.textInverted,
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
},
'primary-light': {
color: theme.palette.primary.textInverted,
fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined,
},
'secondary-light': {
color: theme.palette.secondary.textInverted,
fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined,
},
'default-light': {
color: theme.palette.default.text,
fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
},
})
return ( return (
<TouchableOpacity <TouchableOpacity
style={[outerStyle, styles.outer, style]} style={[typeOuterStyle, styles.outer, style]}
onPress={onPress} onPress={onPress}
testID={testID}> testID={testID}>
{label ? ( {label ? (
<Text type="button" style={[labelStyle]}> <Text type="button" style={[typeLabelStyle, labelStyle]}>
{label} {label}
</Text> </Text>
) : ( ) : (

View File

@ -10,6 +10,7 @@ import {
import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader' import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/notifications/Feed' import {Feed} from '../com/notifications/Feed'
import {InvitedUsers} from '../com/notifications/InvitedUsers'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles' import {s} from 'lib/styles'
@ -89,6 +90,7 @@ export const NotificationsScreen = withAuthRequired(
return ( return (
<View testID="notificationsScreen" style={s.hContentRegion}> <View testID="notificationsScreen" style={s.hContentRegion}>
<ViewHeader title="Notifications" canGoBack={false} /> <ViewHeader title="Notifications" canGoBack={false} />
<InvitedUsers />
<Feed <Feed
view={store.me.notifications} view={store.me.notifications}
onPressTryAgain={onPressTryAgain} onPressTryAgain={onPressTryAgain}

View File

@ -2,8 +2,10 @@ import React from 'react'
import { import {
ActivityIndicator, ActivityIndicator,
StyleSheet, StyleSheet,
TextStyle,
TouchableOpacity, TouchableOpacity,
View, View,
ViewStyle,
} from 'react-native' } from 'react-native'
import { import {
useFocusEffect, useFocusEffect,
@ -27,22 +29,39 @@ import {Text} from '../com/util/text/Text'
import * as Toast from '../com/util/Toast' import * as Toast from '../com/util/Toast'
import {UserAvatar} from '../com/util/UserAvatar' import {UserAvatar} from '../com/util/UserAvatar'
import {DropdownButton} from 'view/com/util/forms/DropdownButton' import {DropdownButton} from 'view/com/util/forms/DropdownButton'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
import {AccountData} from 'state/models/session' import {AccountData} from 'state/models/session'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export const SettingsScreen = withAuthRequired( export const SettingsScreen = withAuthRequired(
observer(function Settings({}: Props) { observer(function Settings({}: Props) {
const theme = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const [isSwitching, setIsSwitching] = React.useState(false) const [isSwitching, setIsSwitching] = React.useState(false)
const primaryBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.blue0},
dark: {backgroundColor: colors.blue6},
})
const primaryText = useCustomPalette<TextStyle>({
light: {color: colors.blue3},
dark: {color: colors.blue2},
})
const dangerBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.red1},
dark: {backgroundColor: colors.red7},
})
const dangerText = useCustomPalette<TextStyle>({
light: {color: colors.red4},
dark: {color: colors.red2},
})
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
screen('Settings') screen('Settings')
@ -50,29 +69,34 @@ export const SettingsScreen = withAuthRequired(
}, [screen, store]), }, [screen, store]),
) )
const onPressSwitchAccount = async (acct: AccountData) => { const onPressSwitchAccount = React.useCallback(
track('Settings:SwitchAccountButtonClicked') async (acct: AccountData) => {
setIsSwitching(true) track('Settings:SwitchAccountButtonClicked')
if (await store.session.resumeSession(acct)) { setIsSwitching(true)
if (await store.session.resumeSession(acct)) {
setIsSwitching(false)
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
return
}
setIsSwitching(false) setIsSwitching(false)
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab') navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop()) navigation.dispatch(StackActions.popToTop())
Toast.show(`Signed in as ${acct.displayName || acct.handle}`) store.session.clear()
return },
} [track, setIsSwitching, navigation, store],
setIsSwitching(false) )
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab') const onPressAddAccount = React.useCallback(() => {
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
const onPressAddAccount = () => {
track('Settings:AddAccountButtonClicked') track('Settings:AddAccountButtonClicked')
navigation.navigate('HomeTab') navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop()) navigation.dispatch(StackActions.popToTop())
store.session.clear() store.session.clear()
} }, [track, navigation, store])
const onPressChangeHandle = () => {
const onPressChangeHandle = React.useCallback(() => {
track('Settings:ChangeHandleButtonClicked') track('Settings:ChangeHandleButtonClicked')
store.shell.openModal({ store.shell.openModal({
name: 'change-handle', name: 'change-handle',
@ -93,14 +117,21 @@ export const SettingsScreen = withAuthRequired(
) )
}, },
}) })
} }, [track, store, setIsSwitching])
const onPressSignout = () => {
const onPressInviteCodes = React.useCallback(() => {
track('Settings:InvitecodesButtonClicked')
store.shell.openModal({name: 'invite-codes'})
}, [track, store])
const onPressSignout = React.useCallback(() => {
track('Settings:SignOutButtonClicked') track('Settings:SignOutButtonClicked')
store.session.logout() store.session.logout()
} }, [track, store])
const onPressDeleteAccount = () => {
const onPressDeleteAccount = React.useCallback(() => {
store.shell.openModal({name: 'delete-account'}) store.shell.openModal({name: 'delete-account'})
} }, [store])
return ( return (
<View style={[s.hContentRegion]} testID="settingsScreen"> <View style={[s.hContentRegion]} testID="settingsScreen">
@ -183,6 +214,37 @@ export const SettingsScreen = withAuthRequired(
<View style={styles.spacer20} /> <View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
Invite a friend
</Text>
<TouchableOpacity
testID="inviteFriendBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressInviteCodes}>
<View
style={[
styles.iconContainer,
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
]}>
<FontAwesomeIcon
icon="ticket"
style={
(store.me.invitesAvailable > 0
? primaryText
: pal.text) as FontAwesomeIconStyle
}
/>
</View>
<Text
type="lg"
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
{store.me.invitesAvailable} invite{' '}
{pluralize(store.me.invitesAvailable, 'code')} available
</Text>
</TouchableOpacity>
<View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}> <Text type="xl-bold" style={[pal.text, styles.heading]}>
Advanced Advanced
</Text> </Text>
@ -209,30 +271,14 @@ export const SettingsScreen = withAuthRequired(
<TouchableOpacity <TouchableOpacity
style={[pal.view, styles.linkCard]} style={[pal.view, styles.linkCard]}
onPress={onPressDeleteAccount}> onPress={onPressDeleteAccount}>
<View <View style={[styles.iconContainer, dangerBg]}>
style={[
styles.iconContainer,
theme.colorScheme === 'dark'
? styles.trashIconContainerDark
: styles.trashIconContainerLight,
]}>
<FontAwesomeIcon <FontAwesomeIcon
icon={['far', 'trash-can']} icon={['far', 'trash-can']}
style={ style={dangerText as FontAwesomeIconStyle}
theme.colorScheme === 'dark'
? styles.dangerDark
: styles.dangerLight
}
size={21} size={21}
/> />
</View> </View>
<Text <Text type="lg" style={dangerText}>
type="lg"
style={
theme.colorScheme === 'dark'
? styles.dangerDark
: styles.dangerLight
}>
Delete my account Delete my account
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -331,18 +377,6 @@ const styles = StyleSheet.create({
borderRadius: 30, borderRadius: 30,
marginRight: 12, marginRight: 12,
}, },
trashIconContainerDark: {
backgroundColor: colors.red7,
},
trashIconContainerLight: {
backgroundColor: colors.red1,
},
dangerLight: {
color: colors.red4,
},
dangerDark: {
color: colors.red2,
},
buildInfo: { buildInfo: {
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 18, paddingHorizontal: 18,

View File

@ -167,7 +167,9 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
) )
} }
onPress={onPressNotifications} onPress={onPressNotifications}
notificationCount={store.me.notifications.unreadCount} notificationCount={
store.me.notifications.unreadCount + store.invitedUsers.numNotifs
}
/> />
<Btn <Btn
testID="bottomBarProfileBtn" testID="bottomBarProfileBtn"

View File

@ -103,63 +103,19 @@ export const DrawerContent = observer(() => {
store.shell.closeDrawer() store.shell.closeDrawer()
}, [navigation, track, store.shell]) }, [navigation, track, store.shell])
const onPressFeedback = () => { const onPressFeedback = React.useCallback(() => {
track('Menu:FeedbackClicked') track('Menu:FeedbackClicked')
Linking.openURL(FEEDBACK_FORM_URL) Linking.openURL(FEEDBACK_FORM_URL)
} }, [track])
const onDarkmodePress = React.useCallback(() => {
track('Menu:ItemClicked', {url: '#darkmode'})
store.shell.setDarkMode(!store.shell.darkMode)
}, [track, store])
// rendering // rendering
// = // =
const MenuItem = ({
icon,
label,
count,
bold,
onPress,
}: {
icon: JSX.Element
label: string
count?: number
bold?: boolean
onPress: () => void
}) => (
<TouchableOpacity
testID={`menuItemButton-${label}`}
style={styles.menuItem}
onPress={onPress}>
<View style={[styles.menuItemIconWrapper]}>
{icon}
{count ? (
<View
style={[
styles.menuItemCount,
count > 99
? styles.menuItemCountHundreds
: count > 9
? styles.menuItemCountTens
: undefined,
]}>
<Text style={styles.menuItemCountLabel} numberOfLines={1}>
{count > 999 ? `${Math.round(count / 1000)}k` : count}
</Text>
</View>
) : undefined}
</View>
<Text
type={bold ? '2xl-bold' : '2xl'}
style={[pal.text, s.flex1]}
numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
const onDarkmodePress = () => {
track('Menu:ItemClicked', {url: '/darkmode'})
store.shell.setDarkMode(!store.shell.darkMode)
}
return ( return (
<View <View
testID="drawer" testID="drawer"
@ -168,29 +124,34 @@ export const DrawerContent = observer(() => {
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode, theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
]}> ]}>
<SafeAreaView style={s.flex1}> <SafeAreaView style={s.flex1}>
<TouchableOpacity testID="profileCardButton" onPress={onPressProfile}> <View style={styles.main}>
<UserAvatar size={80} avatar={store.me.avatar} /> <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}>
<Text <UserAvatar size={80} avatar={store.me.avatar} />
type="title-lg" <Text
style={[pal.text, s.bold, styles.profileCardDisplayName]}> type="title-lg"
{store.me.displayName || store.me.handle} style={[pal.text, s.bold, styles.profileCardDisplayName]}>
</Text> {store.me.displayName || store.me.handle}
<Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}> </Text>
@{store.me.handle} <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
</Text> @{store.me.handle}
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> </Text>
<Text type="xl-medium" style={pal.text}> <Text
{store.me.followersCount || 0} type="xl"
</Text>{' '} style={[pal.textLight, styles.profileCardFollowers]}>
{pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '} <Text type="xl-medium" style={pal.text}>
<Text type="xl-medium" style={pal.text}> {store.me.followersCount || 0}
{store.me.followsCount || 0} </Text>{' '}
</Text>{' '} {pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '}
following <Text type="xl-medium" style={pal.text}>
</Text> {store.me.followsCount || 0}
</TouchableOpacity> </Text>{' '}
following
</Text>
</TouchableOpacity>
</View>
<InviteCodes />
<View style={s.flex1} /> <View style={s.flex1} />
<View> <View style={styles.main}>
<MenuItem <MenuItem
icon={ icon={
isAtSearch ? ( isAtSearch ? (
@ -248,7 +209,9 @@ export const DrawerContent = observer(() => {
) )
} }
label="Notifications" label="Notifications"
count={store.me.notifications.unreadCount} count={
store.me.notifications.unreadCount + store.invitedUsers.numNotifs
}
bold={isAtNotifications} bold={isAtNotifications}
onPress={onPressNotifications} onPress={onPressNotifications}
/> />
@ -315,16 +278,97 @@ export const DrawerContent = observer(() => {
) )
}) })
function MenuItem({
icon,
label,
count,
bold,
onPress,
}: {
icon: JSX.Element
label: string
count?: number
bold?: boolean
onPress: () => void
}) {
const pal = usePalette('default')
return (
<TouchableOpacity
testID={`menuItemButton-${label}`}
style={styles.menuItem}
onPress={onPress}>
<View style={[styles.menuItemIconWrapper]}>
{icon}
{count ? (
<View
style={[
styles.menuItemCount,
count > 99
? styles.menuItemCountHundreds
: count > 9
? styles.menuItemCountTens
: undefined,
]}>
<Text style={styles.menuItemCountLabel} numberOfLines={1}>
{count > 999 ? `${Math.round(count / 1000)}k` : count}
</Text>
</View>
) : undefined}
</View>
<Text
type={bold ? '2xl-bold' : '2xl'}
style={[pal.text, s.flex1]}
numberOfLines={1}>
{label}
</Text>
</TouchableOpacity>
)
}
const InviteCodes = observer(() => {
const {track} = useAnalytics()
const store = useStores()
const pal = usePalette('default')
const onPress = React.useCallback(() => {
track('Menu:ItemClicked', {url: '#invite-codes'})
store.shell.closeDrawer()
store.shell.openModal({name: 'invite-codes'})
}, [store, track])
return (
<TouchableOpacity
testID="menuItemInviteCodes"
style={[styles.inviteCodes]}
onPress={onPress}>
<FontAwesomeIcon
icon="ticket"
style={[
styles.inviteCodesIcon,
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
]}
size={18}
/>
<Text
type="lg-medium"
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
{store.me.invitesAvailable} invite{' '}
{pluralize(store.me.invitesAvailable, 'code')}
</Text>
</TouchableOpacity>
)
})
const styles = StyleSheet.create({ const styles = StyleSheet.create({
view: { view: {
flex: 1, flex: 1,
paddingTop: 20, paddingTop: 20,
paddingBottom: 50, paddingBottom: 50,
paddingLeft: 20,
}, },
viewDarkMode: { viewDarkMode: {
backgroundColor: '#1B1919', backgroundColor: '#1B1919',
}, },
main: {
paddingLeft: 20,
},
profileCardDisplayName: { profileCardDisplayName: {
marginTop: 20, marginTop: 20,
@ -336,7 +380,7 @@ const styles = StyleSheet.create({
}, },
profileCardFollowers: { profileCardFollowers: {
marginTop: 16, marginTop: 16,
paddingRight: 30, paddingRight: 10,
}, },
menuItem: { menuItem: {
@ -376,11 +420,22 @@ const styles = StyleSheet.create({
color: colors.white, color: colors.white,
}, },
inviteCodes: {
paddingLeft: 22,
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
},
inviteCodesIcon: {
marginRight: 6,
},
footer: { footer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingRight: 30, paddingRight: 30,
paddingTop: 80, paddingTop: 20,
paddingLeft: 20,
}, },
footerBtn: { footerBtn: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -157,7 +157,9 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
/> />
<NavItem <NavItem
href="/notifications" href="/notifications"
count={store.me.notifications.unreadCount} count={
store.me.notifications.unreadCount + store.invitedUsers.numNotifs
}
icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />} icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />}
iconFilled={ iconFilled={
<BellIconSolid strokeWidth={1.5} size={24} style={pal.text} /> <BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />

View File

@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {DesktopSearch} from './Search' import {DesktopSearch} from './Search'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
@ -8,6 +9,7 @@ import {TextLink} from 'view/com/util/Link'
import {FEEDBACK_FORM_URL} from 'lib/constants' import {FEEDBACK_FORM_URL} from 'lib/constants'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {pluralize} from 'lib/strings/helpers'
export const DesktopRightNav = observer(function DesktopRightNav() { export const DesktopRightNav = observer(function DesktopRightNav() {
const store = useStores() const store = useStores()
@ -38,10 +40,40 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
/> />
</View> </View>
</View> </View>
<InviteCodes />
</View> </View>
) )
}) })
function InviteCodes() {
const store = useStores()
const pal = usePalette('default')
const onPress = React.useCallback(() => {
store.shell.openModal({name: 'invite-codes'})
}, [store])
return (
<TouchableOpacity
style={[styles.inviteCodes, pal.border]}
onPress={onPress}>
<FontAwesomeIcon
icon="ticket"
style={[
styles.inviteCodesIcon,
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
]}
size={16}
/>
<Text
type="md-medium"
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
{store.me.invitesAvailable} invite{' '}
{pluralize(store.me.invitesAvailable, 'code')} available
</Text>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
rightNav: { rightNav: {
position: 'absolute', position: 'absolute',
@ -57,4 +89,16 @@ const styles = StyleSheet.create({
messageLine: { messageLine: {
marginBottom: 10, marginBottom: 10,
}, },
inviteCodes: {
marginTop: 12,
borderTopWidth: 1,
paddingHorizontal: 16,
paddingVertical: 12,
flexDirection: 'row',
alignItems: 'center',
},
inviteCodesIcon: {
marginRight: 6,
},
}) })

View File

@ -30,10 +30,10 @@
tlds "^1.234.0" tlds "^1.234.0"
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
"@atproto/api@0.2.3": "@atproto/api@0.2.4":
version "0.2.3" version "0.2.4"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.3.tgz#0eb9cb542c113b2c839f2c5ca284c30b117f489a" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.4.tgz#0a14af4f37aa665bd70a1b5f9f5d31db02313ad9"
integrity sha512-i0tWdOPQyZuSlkd2MY3s7QTac2ovH104tzy5rJwTZXZyhpf2Zom1xedaHb+pQmFzug7YaD7tx7OMSPlJIV0dpg== integrity sha512-EOegRw4/TaN8Px9M/rPiWQlqIkN+QXeU3Y8NUFofqgApPiatmayiYpQiR0iBhZmFnlYFuRt6tLQBjPypI/dvfA==
dependencies: dependencies:
"@atproto/common-web" "*" "@atproto/common-web" "*"
"@atproto/uri" "*" "@atproto/uri" "*"
@ -122,10 +122,10 @@
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
"@atproto/pds@^0.1.0": "@atproto/pds@^0.1.3":
version "0.1.0" version "0.1.3"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.0.tgz#8014269c12a322b14618e0991c534979a4b145d7" resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.3.tgz#601c556cd1e10306c9b741d9361bc54d70bb2869"
integrity sha512-f1KPONxim674owWcTsR8S5r57+b7evg+zy+jkcTX00BB0fO6PchDL6sTQQc1x3u2QZArHDSUUUgoHt4IWwsfkw== integrity sha512-cVvmgXkzu7w1tDGGDK904sDzxF2AUqu0ij/1EU2rYmnZZAK+FTjKs8cqrJzRur9vm07A23JvBTuINtYzxHwSzA==
dependencies: dependencies:
"@atproto/api" "*" "@atproto/api" "*"
"@atproto/common" "*" "@atproto/common" "*"