diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-04-05 18:56:02 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-05 18:56:02 -0500 |
commit | ea04c2bd330dc5b46d6f9df0d7d4619bbd8f56d0 (patch) | |
tree | 870c7d3dbffe1f382cba30b858eaa2b76b31af36 | |
parent | 8e28d3c6be8e063b6d563b0068cb4fc907ff5df0 (diff) | |
download | voidsky-ea04c2bd330dc5b46d6f9df0d7d4619bbd8f56d0.tar.zst |
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
26 files changed, 932 insertions, 246 deletions
diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts index 7a2be6060..7bcad47f3 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 7b2e00fb5..38466ed8e 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 000000000..e3bb5d7f2 --- /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 1e87df811..851775580 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -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', diff --git a/package.json b/package.json index 35efccc68..99f288b30 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 000000000..4f8f5c836 --- /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<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]) +} diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts new file mode 100644 index 000000000..121161a32 --- /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 3adbc7c6c..1dcccb6f1 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 0d893415f..9207f27ba 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 b782dd2f7..917e7a09f 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 8df997bd3..cf941a94e 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 </Text> <TextInput + testID="inviteCodeInput" icon="ticket" placeholder="Required for this provider" value={model.inviteCode} diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx new file mode 100644 index 000000000..5e31e16a8 --- /dev/null +++ b/src/view/com/modals/InviteCodes.tsx @@ -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, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 931e3fbe4..b1c7d4738 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -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 /> } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 1b233cf37..e6d54926b 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -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 } diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx new file mode 100644 index 000000000..2c44eb5b5 --- /dev/null +++ b/src/view/com/notifications/InvitedUsers.tsx @@ -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, + }, +}) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index f799e26f2..7e25fd88a 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -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 diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 0beac8a7f..339e535ad 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -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, diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 870f503f2..cebab59c0 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -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} /> diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index b7c058d2d..a634b47a9 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -25,6 +25,7 @@ export function Button({ type = 'primary', label, style, + labelStyle, onPress, children, testID, @@ -32,87 +33,94 @@ 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, { - primary: { - backgroundColor: theme.palette.primary.background, + const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( + type, + { + 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, - }, - 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, + ) + 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, + }, }, - }) + ) 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> ) : ( diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index e5521c7ac..2a2d3c13f 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -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} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index dc18add50..d429db1b6 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -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,29 +69,34 @@ export const SettingsScreen = withAuthRequired( }, [screen, store]), ) - const onPressSwitchAccount = async (acct: AccountData) => { - track('Settings:SwitchAccountButtonClicked') - setIsSwitching(true) - if (await store.session.resumeSession(acct)) { + const onPressSwitchAccount = React.useCallback( + async (acct: AccountData) => { + track('Settings:SwitchAccountButtonClicked') + 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) + Toast.show('Sorry! We need you to enter your password.') navigation.navigate('HomeTab') navigation.dispatch(StackActions.popToTop()) - Toast.show(`Signed in as ${acct.displayName || acct.handle}`) - return - } - setIsSwitching(false) - Toast.show('Sorry! We need you to enter your password.') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() - } - const onPressAddAccount = () => { + store.session.clear() + }, + [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"> @@ -184,6 +215,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> <TouchableOpacity @@ -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, diff --git a/src/view/shell/BottomBar.tsx b/src/view/shell/BottomBar.tsx index e46eeb991..b01366b2b 100644 --- a/src/view/shell/BottomBar.tsx +++ b/src/view/shell/BottomBar.tsx @@ -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" diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index ccf64c0e6..ebadb2126 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -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,29 +124,34 @@ export const DrawerContent = observer(() => { theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode, ]}> <SafeAreaView style={s.flex1}> - <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}> - <UserAvatar size={80} avatar={store.me.avatar} /> - <Text - type="title-lg" - style={[pal.text, s.bold, styles.profileCardDisplayName]}> - {store.me.displayName || store.me.handle} - </Text> - <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}> - @{store.me.handle} - </Text> - <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> - <Text type="xl-medium" style={pal.text}> - {store.me.followersCount || 0} - </Text>{' '} - {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} - <Text type="xl-medium" style={pal.text}> - {store.me.followsCount || 0} - </Text>{' '} - following - </Text> - </TouchableOpacity> + <View style={styles.main}> + <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}> + <UserAvatar size={80} avatar={store.me.avatar} /> + <Text + type="title-lg" + style={[pal.text, s.bold, styles.profileCardDisplayName]}> + {store.me.displayName || store.me.handle} + </Text> + <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}> + @{store.me.handle} + </Text> + <Text + type="xl" + style={[pal.textLight, styles.profileCardFollowers]}> + <Text type="xl-medium" style={pal.text}> + {store.me.followersCount || 0} + </Text>{' '} + {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} + <Text type="xl-medium" style={pal.text}> + {store.me.followsCount || 0} + </Text>{' '} + 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', diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 2c1cb6678..45dd6579f 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -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} /> diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 3f196cb70..a344f0fc0 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -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, + }, }) diff --git a/yarn.lock b/yarn.lock index 979bbdc17..4f880e94f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" "*" |