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')
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) {

View File

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

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>
}
export async function createServer(): Promise<TestPDS> {
export async function createServer(
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
): Promise<TestPDS> {
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<TestPDS> {
serverDid,
recoveryKey,
adminPassword: ADMIN_PASSWORD,
inviteRequired: false,
inviteRequired,
didPlcUrl: plcUrl,
jwtSecret: 'jwt-secret',
availableUserDomains: ['.test'],
@ -76,6 +78,7 @@ export async function createServer(): Promise<TestPDS> {
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',

View File

@ -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",

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 {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,6 +95,33 @@ export class MeModel {
if (sess.hasSession) {
this.did = sess.currentSession?.did || ''
this.handle = sess.currentSession?.handle || ''
await this.fetchProfile()
this.mainFeed.clear()
await Promise.all([
this.mainFeed.setup().catch(e => {
this.rootStore.log.error('Failed to setup main feed model', e)
}),
this.notifications.setup().catch(e => {
this.rootStore.log.error('Failed to setup notifications model', e)
}),
])
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,
})
@ -103,18 +140,35 @@ export class MeModel {
this.followersCount = undefined
}
})
this.mainFeed.clear()
await Promise.all([
this.mainFeed.setup().catch(e => {
this.rootStore.log.error('Failed to setup main feed model', e)
}),
this.notifications.setup().catch(e => {
this.rootStore.log.error('Failed to setup notifications model', e)
}),
])
this.rootStore.emitSessionLoaded()
} else {
this.clear()
}
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 {}

View File

@ -35,6 +35,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
Invite code
</Text>
<TextInput
testID="inviteCodeInput"
icon="ticket"
placeholder="Required for this provider"
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 ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native'
@ -73,6 +74,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'waitlist') {
snapPoints = WaitlistModal.snapPoints
element = <WaitlistModal.Component />
} else if (activeModal?.name === 'invite-codes') {
snapPoints = InviteCodesModal.snapPoints
element = <InviteCodesModal.Component />
} else {
return <View />
}

View File

@ -16,6 +16,7 @@ import * as RepostModal from './Repost'
import * as CropImageModal from './crop-image/CropImage.web'
import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes'
export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores()
@ -72,6 +73,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ChangeHandleModal.Component {...modal} />
} else if (modal.name === 'waitlist') {
element = <WaitlistModal.Component />
} else if (modal.name === 'invite-codes') {
element = <InviteCodesModal.Component />
} else {
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 {FollowState} from 'state/models/cache/my-follows'
const FollowButton = observer(
export const FollowButton = observer(
({
type = 'inverted',
unfollowedType = 'inverted',
followedType = 'inverted',
did,
onToggleFollow,
}: {
type?: ButtonType
unfollowedType?: ButtonType
followedType?: ButtonType
did: string
onToggleFollow?: (v: boolean) => void
}) => {
@ -48,12 +50,12 @@ const FollowButton = observer(
return (
<Button
type={followState === FollowState.Following ? 'default' : type}
type={
followState === FollowState.Following ? followedType : unfollowedType
}
onPress={onToggleFollowInner}
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 {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import FollowButton from './FollowButton'
import {FollowButton} from './FollowButton'
export function ProfileCard({
testID,

View File

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

View File

@ -25,6 +25,7 @@ export function Button({
type = 'primary',
label,
style,
labelStyle,
onPress,
children,
testID,
@ -32,11 +33,14 @@ export function Button({
type?: ButtonType
label?: string
style?: StyleProp<ViewStyle>
labelStyle?: StyleProp<TextStyle>
onPress?: () => void
testID?: string
}>) {
const theme = useTheme()
const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, {
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
type,
{
primary: {
backgroundColor: theme.palette.primary.background,
},
@ -68,8 +72,11 @@ export function Button({
'default-light': {
backgroundColor: theme.palette.default.background,
},
})
const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
},
)
const typeLabelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(
type,
{
primary: {
color: theme.palette.primary.text,
fontWeight: '600',
@ -105,14 +112,15 @@ export function Button({
color: theme.palette.default.text,
fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
},
})
},
)
return (
<TouchableOpacity
style={[outerStyle, styles.outer, style]}
style={[typeOuterStyle, styles.outer, style]}
onPress={onPress}
testID={testID}>
{label ? (
<Text type="button" style={[labelStyle]}>
<Text type="button" style={[typeLabelStyle, labelStyle]}>
{label}
</Text>
) : (

View File

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

View File

@ -2,8 +2,10 @@ import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TextStyle,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {
useFocusEffect,
@ -27,22 +29,39 @@ import {Text} from '../com/util/text/Text'
import * as Toast from '../com/util/Toast'
import {UserAvatar} from '../com/util/UserAvatar'
import {DropdownButton} from 'view/com/util/forms/DropdownButton'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
import {AccountData} from 'state/models/session'
import {useAnalytics} from 'lib/analytics'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export const SettingsScreen = withAuthRequired(
observer(function Settings({}: Props) {
const theme = useTheme()
const pal = usePalette('default')
const store = useStores()
const navigation = useNavigation<NavigationProp>()
const {screen, track} = useAnalytics()
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(
React.useCallback(() => {
screen('Settings')
@ -50,7 +69,8 @@ export const SettingsScreen = withAuthRequired(
}, [screen, store]),
)
const onPressSwitchAccount = async (acct: AccountData) => {
const onPressSwitchAccount = React.useCallback(
async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
if (await store.session.resumeSession(acct)) {
@ -65,14 +85,18 @@ export const SettingsScreen = withAuthRequired(
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
const onPressAddAccount = () => {
},
[track, setIsSwitching, navigation, store],
)
const onPressAddAccount = React.useCallback(() => {
track('Settings:AddAccountButtonClicked')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
const onPressChangeHandle = () => {
}, [track, navigation, store])
const onPressChangeHandle = React.useCallback(() => {
track('Settings:ChangeHandleButtonClicked')
store.shell.openModal({
name: 'change-handle',
@ -93,14 +117,21 @@ export const SettingsScreen = withAuthRequired(
)
},
})
}
const onPressSignout = () => {
}, [track, store, setIsSwitching])
const onPressInviteCodes = React.useCallback(() => {
track('Settings:InvitecodesButtonClicked')
store.shell.openModal({name: 'invite-codes'})
}, [track, store])
const onPressSignout = React.useCallback(() => {
track('Settings:SignOutButtonClicked')
store.session.logout()
}
const onPressDeleteAccount = () => {
}, [track, store])
const onPressDeleteAccount = React.useCallback(() => {
store.shell.openModal({name: 'delete-account'})
}
}, [store])
return (
<View style={[s.hContentRegion]} testID="settingsScreen">
@ -183,6 +214,37 @@ export const SettingsScreen = withAuthRequired(
<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]}>
Advanced
</Text>
@ -209,30 +271,14 @@ export const SettingsScreen = withAuthRequired(
<TouchableOpacity
style={[pal.view, styles.linkCard]}
onPress={onPressDeleteAccount}>
<View
style={[
styles.iconContainer,
theme.colorScheme === 'dark'
? styles.trashIconContainerDark
: styles.trashIconContainerLight,
]}>
<View style={[styles.iconContainer, dangerBg]}>
<FontAwesomeIcon
icon={['far', 'trash-can']}
style={
theme.colorScheme === 'dark'
? styles.dangerDark
: styles.dangerLight
}
style={dangerText as FontAwesomeIconStyle}
size={21}
/>
</View>
<Text
type="lg"
style={
theme.colorScheme === 'dark'
? styles.dangerDark
: styles.dangerLight
}>
<Text type="lg" style={dangerText}>
Delete my account
</Text>
</TouchableOpacity>
@ -331,18 +377,6 @@ const styles = StyleSheet.create({
borderRadius: 30,
marginRight: 12,
},
trashIconContainerDark: {
backgroundColor: colors.red7,
},
trashIconContainerLight: {
backgroundColor: colors.red1,
},
dangerLight: {
color: colors.red4,
},
dangerDark: {
color: colors.red2,
},
buildInfo: {
paddingVertical: 8,
paddingHorizontal: 18,

View File

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

View File

@ -103,63 +103,19 @@ export const DrawerContent = observer(() => {
store.shell.closeDrawer()
}, [navigation, track, store.shell])
const onPressFeedback = () => {
const onPressFeedback = React.useCallback(() => {
track('Menu:FeedbackClicked')
Linking.openURL(FEEDBACK_FORM_URL)
}
}, [track])
const onDarkmodePress = React.useCallback(() => {
track('Menu:ItemClicked', {url: '#darkmode'})
store.shell.setDarkMode(!store.shell.darkMode)
}, [track, store])
// 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 (
<View
testID="drawer"
@ -168,6 +124,7 @@ export const DrawerContent = observer(() => {
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
]}>
<SafeAreaView style={s.flex1}>
<View style={styles.main}>
<TouchableOpacity testID="profileCardButton" onPress={onPressProfile}>
<UserAvatar size={80} avatar={store.me.avatar} />
<Text
@ -178,7 +135,9 @@ export const DrawerContent = observer(() => {
<Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
@{store.me.handle}
</Text>
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
<Text
type="xl"
style={[pal.textLight, styles.profileCardFollowers]}>
<Text type="xl-medium" style={pal.text}>
{store.me.followersCount || 0}
</Text>{' '}
@ -189,8 +148,10 @@ export const DrawerContent = observer(() => {
following
</Text>
</TouchableOpacity>
</View>
<InviteCodes />
<View style={s.flex1} />
<View>
<View style={styles.main}>
<MenuItem
icon={
isAtSearch ? (
@ -248,7 +209,9 @@ export const DrawerContent = observer(() => {
)
}
label="Notifications"
count={store.me.notifications.unreadCount}
count={
store.me.notifications.unreadCount + store.invitedUsers.numNotifs
}
bold={isAtNotifications}
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({
view: {
flex: 1,
paddingTop: 20,
paddingBottom: 50,
paddingLeft: 20,
},
viewDarkMode: {
backgroundColor: '#1B1919',
},
main: {
paddingLeft: 20,
},
profileCardDisplayName: {
marginTop: 20,
@ -336,7 +380,7 @@ const styles = StyleSheet.create({
},
profileCardFollowers: {
marginTop: 16,
paddingRight: 30,
paddingRight: 10,
},
menuItem: {
@ -376,11 +420,22 @@ const styles = StyleSheet.create({
color: colors.white,
},
inviteCodes: {
paddingLeft: 22,
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
},
inviteCodesIcon: {
marginRight: 6,
},
footer: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingRight: 30,
paddingTop: 80,
paddingTop: 20,
paddingLeft: 20,
},
footerBtn: {
flexDirection: 'row',

View File

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

View File

@ -1,6 +1,7 @@
import React from 'react'
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 {DesktopSearch} from './Search'
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 {s} from 'lib/styles'
import {useStores} from 'state/index'
import {pluralize} from 'lib/strings/helpers'
export const DesktopRightNav = observer(function DesktopRightNav() {
const store = useStores()
@ -38,10 +40,40 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
/>
</View>
</View>
<InviteCodes />
</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({
rightNav: {
position: 'absolute',
@ -57,4 +89,16 @@ const styles = StyleSheet.create({
messageLine: {
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"
typed-emitter "^2.1.0"
"@atproto/api@0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.3.tgz#0eb9cb542c113b2c839f2c5ca284c30b117f489a"
integrity sha512-i0tWdOPQyZuSlkd2MY3s7QTac2ovH104tzy5rJwTZXZyhpf2Zom1xedaHb+pQmFzug7YaD7tx7OMSPlJIV0dpg==
"@atproto/api@0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.4.tgz#0a14af4f37aa665bd70a1b5f9f5d31db02313ad9"
integrity sha512-EOegRw4/TaN8Px9M/rPiWQlqIkN+QXeU3Y8NUFofqgApPiatmayiYpQiR0iBhZmFnlYFuRt6tLQBjPypI/dvfA==
dependencies:
"@atproto/common-web" "*"
"@atproto/uri" "*"
@ -122,10 +122,10 @@
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
"@atproto/pds@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.0.tgz#8014269c12a322b14618e0991c534979a4b145d7"
integrity sha512-f1KPONxim674owWcTsR8S5r57+b7evg+zy+jkcTX00BB0fO6PchDL6sTQQc1x3u2QZArHDSUUUgoHt4IWwsfkw==
"@atproto/pds@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.3.tgz#601c556cd1e10306c9b741d9361bc54d70bb2869"
integrity sha512-cVvmgXkzu7w1tDGGDK904sDzxF2AUqu0ij/1EU2rYmnZZAK+FTjKs8cqrJzRur9vm07A23JvBTuINtYzxHwSzA==
dependencies:
"@atproto/api" "*"
"@atproto/common" "*"