Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Paul Frazee and committed by
GitHub
ea04c2bd 8e28d3c6

+932 -246
+2 -1
__e2e__/mock-server.ts
··· 13 13 console.log('Closing old server') 14 14 await server?.close() 15 15 console.log('Starting new server') 16 - server = await createServer() 16 + const inviteRequired = url?.query && 'invite' in url.query 17 + server = await createServer({inviteRequired}) 17 18 console.log('Listening at', server.pdsUrl) 18 19 if (url?.query) { 19 20 if ('users' in url.query) {
+1 -1
__e2e__/tests/create-account.test.ts
··· 5 5 describe('Create account', () => { 6 6 let service: string 7 7 beforeAll(async () => { 8 - service = await createServer('mock0') 8 + service = await createServer('') 9 9 await openApp({permissions: {notifications: 'YES'}}) 10 10 }) 11 11
+64
__e2e__/tests/invite-codes.test.ts
··· 1 + /* eslint-env detox/detox */ 2 + 3 + import {openApp, login, createServer} from '../util' 4 + 5 + describe('invite-codes', () => { 6 + let service: string 7 + let inviteCode = '' 8 + beforeAll(async () => { 9 + service = await createServer('?users&invite') 10 + await openApp({permissions: {notifications: 'YES'}}) 11 + }) 12 + 13 + it('I can fetch invite codes', async () => { 14 + await expect(element(by.id('signInButton'))).toBeVisible() 15 + await login(service, 'alice', 'hunter2') 16 + await element(by.id('viewHeaderDrawerBtn')).tap() 17 + await expect(element(by.id('drawer'))).toBeVisible() 18 + await element(by.id('menuItemInviteCodes')).tap() 19 + await expect(element(by.id('inviteCodesModal'))).toBeVisible() 20 + const attrs = await element(by.id('inviteCode-0-code')).getAttributes() 21 + inviteCode = attrs.text 22 + await element(by.id('closeBtn')).tap() 23 + await element(by.id('viewHeaderDrawerBtn')).tap() 24 + await element(by.id('menuItemButton-Settings')).tap() 25 + await element(by.id('signOutBtn')).tap() 26 + }) 27 + 28 + it('I can create a new account with the invite code', async () => { 29 + await element(by.id('createAccountButton')).tap() 30 + await device.takeScreenshot('1- opened create account screen') 31 + await element(by.id('otherServerBtn')).tap() 32 + await device.takeScreenshot('2- selected other server') 33 + await element(by.id('customServerInput')).clearText() 34 + await element(by.id('customServerInput')).typeText(service) 35 + await device.takeScreenshot('3- input test server URL') 36 + await element(by.id('nextBtn')).tap() 37 + await element(by.id('inviteCodeInput')).typeText(inviteCode) 38 + await element(by.id('emailInput')).typeText('example@test.com') 39 + await element(by.id('passwordInput')).typeText('hunter2') 40 + await element(by.id('is13Input')).tap() 41 + await device.takeScreenshot('4- entered account details') 42 + await element(by.id('nextBtn')).tap() 43 + await element(by.id('handleInput')).typeText('e2e-test') 44 + await device.takeScreenshot('4- entered handle') 45 + await element(by.id('nextBtn')).tap() 46 + await expect(element(by.id('homeScreen'))).toBeVisible() 47 + await element(by.id('viewHeaderDrawerBtn')).tap() 48 + await element(by.id('menuItemButton-Settings')).tap() 49 + await element(by.id('signOutBtn')).tap() 50 + }) 51 + 52 + it('I get a notification for the new user', async () => { 53 + await expect(element(by.id('signInButton'))).toBeVisible() 54 + await login(service, 'alice', 'hunter2') 55 + await element(by.id('viewHeaderDrawerBtn')).tap() 56 + await element(by.id('menuItemButton-Notifications')).tap() 57 + await expect(element(by.id('invitedUser'))).toBeVisible() 58 + }) 59 + 60 + it('I can dismiss the new user notification', async () => { 61 + await element(by.id('dismissBtn')).tap() 62 + await expect(element(by.id('invitedUser'))).not.toBeVisible() 63 + }) 64 + })
+15 -2
jest/test-pds.ts
··· 27 27 close: () => Promise<void> 28 28 } 29 29 30 - export async function createServer(): Promise<TestPDS> { 30 + export async function createServer( 31 + {inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false}, 32 + ): Promise<TestPDS> { 31 33 const repoSigningKey = await crypto.Secp256k1Keypair.create() 32 34 const plcRotationKey = await crypto.Secp256k1Keypair.create() 33 35 const port = await getPort() ··· 61 63 serverDid, 62 64 recoveryKey, 63 65 adminPassword: ADMIN_PASSWORD, 64 - inviteRequired: false, 66 + inviteRequired, 65 67 didPlcUrl: plcUrl, 66 68 jwtSecret: 'jwt-secret', 67 69 availableUserDomains: ['.test'], ··· 76 78 blobstoreTmp: `${blobstoreLoc}/tmp`, 77 79 maxSubscriptionBuffer: 200, 78 80 repoBackfillLimitMs: HOUR, 81 + userInviteInterval: 1, 79 82 }) 80 83 81 84 const db = ··· 131 134 132 135 async createUser(name: string) { 133 136 const agent = new BskyAgent({service: this.agent.service}) 137 + 138 + const inviteRes = await agent.api.com.atproto.server.createInviteCode( 139 + {useCount: 1}, 140 + { 141 + headers: {authorization: `Basic ${btoa(`admin:${ADMIN_PASSWORD}`)}`}, 142 + encoding: 'application/json', 143 + }, 144 + ) 145 + 134 146 const email = `fake${Object.keys(this.users).length + 1}@fake.com` 135 147 const res = await agent.createAccount({ 148 + inviteCode: inviteRes.data.code, 136 149 email, 137 150 handle: name + '.test', 138 151 password: 'hunter2',
+2 -2
package.json
··· 21 21 "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" 22 22 }, 23 23 "dependencies": { 24 - "@atproto/api": "0.2.3", 24 + "@atproto/api": "0.2.4", 25 25 "@bam.tech/react-native-image-resizer": "^3.0.4", 26 26 "@expo/webpack-config": "^18.0.1", 27 27 "@fortawesome/fontawesome-svg-core": "^6.1.1", ··· 120 120 "zod": "^3.20.2" 121 121 }, 122 122 "devDependencies": { 123 - "@atproto/pds": "^0.1.0", 123 + "@atproto/pds": "^0.1.3", 124 124 "@babel/core": "^7.20.0", 125 125 "@babel/preset-env": "^7.20.0", 126 126 "@babel/runtime": "^7.20.0",
+13
src/lib/hooks/useCustomPalette.ts
··· 1 + import React from 'react' 2 + import {useTheme} from 'lib/ThemeContext' 3 + import {choose} from 'lib/functions' 4 + 5 + export function useCustomPalette<T>({light, dark}: {light: T; dark: T}) { 6 + const theme = useTheme() 7 + return React.useMemo(() => { 8 + return choose<T, Record<string, T>>(theme.colorScheme, { 9 + dark, 10 + light, 11 + }) 12 + }, [theme.colorScheme, dark, light]) 13 + }
+70
src/state/models/invited-users.ts
··· 1 + import {makeAutoObservable, runInAction} from 'mobx' 2 + import {ComAtprotoServerDefs, AppBskyActorDefs} from '@atproto/api' 3 + import {RootStoreModel} from './root-store' 4 + import {isObj, hasProp, isStrArray} from 'lib/type-guards' 5 + 6 + export class InvitedUsers { 7 + seenDids: string[] = [] 8 + profiles: AppBskyActorDefs.ProfileViewDetailed[] = [] 9 + 10 + get numNotifs() { 11 + return this.profiles.length 12 + } 13 + 14 + constructor(public rootStore: RootStoreModel) { 15 + makeAutoObservable( 16 + this, 17 + {rootStore: false, serialize: false, hydrate: false}, 18 + {autoBind: true}, 19 + ) 20 + } 21 + 22 + serialize() { 23 + return {seenDids: this.seenDids} 24 + } 25 + 26 + hydrate(v: unknown) { 27 + if (isObj(v) && hasProp(v, 'seenDids') && isStrArray(v.seenDids)) { 28 + this.seenDids = v.seenDids 29 + } 30 + } 31 + 32 + async fetch(invites: ComAtprotoServerDefs.InviteCode[]) { 33 + // pull the dids of invited users not marked seen 34 + const dids = [] 35 + for (const invite of invites) { 36 + for (const use of invite.uses) { 37 + if (!this.seenDids.includes(use.usedBy)) { 38 + dids.push(use.usedBy) 39 + } 40 + } 41 + } 42 + 43 + // fetch their profiles 44 + this.profiles = [] 45 + if (dids.length) { 46 + try { 47 + const res = await this.rootStore.agent.app.bsky.actor.getProfiles({ 48 + actors: dids, 49 + }) 50 + runInAction(() => { 51 + // save the ones following -- these are the ones we want to notify the user about 52 + this.profiles = res.data.profiles.filter( 53 + profile => !profile.viewer?.following, 54 + ) 55 + }) 56 + this.rootStore.me.follows.hydrateProfiles(this.profiles) 57 + } catch (e) { 58 + this.rootStore.log.error( 59 + 'Failed to fetch profiles for invited users', 60 + e, 61 + ) 62 + } 63 + } 64 + } 65 + 66 + markSeen(did: string) { 67 + this.seenDids.push(did) 68 + this.profiles = this.profiles.filter(profile => profile.did !== did) 69 + } 70 + }
+72 -18
src/state/models/me.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 + import {ComAtprotoServerDefs} from '@atproto/api' 2 3 import {RootStoreModel} from './root-store' 3 4 import {PostsFeedModel} from './feeds/posts' 4 5 import {NotificationsFeedModel} from './feeds/notifications' 5 6 import {MyFollowsCache} from './cache/my-follows' 6 7 import {isObj, hasProp} from 'lib/type-guards' 8 + 9 + const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min 7 10 8 11 export class MeModel { 9 12 did: string = '' ··· 16 19 mainFeed: PostsFeedModel 17 20 notifications: NotificationsFeedModel 18 21 follows: MyFollowsCache 22 + invites: ComAtprotoServerDefs.InviteCode[] = [] 23 + lastProfileStateUpdate = Date.now() 24 + 25 + get invitesAvailable() { 26 + return this.invites.filter(isInviteAvailable).length 27 + } 19 28 20 29 constructor(public rootStore: RootStoreModel) { 21 30 makeAutoObservable( ··· 39 48 this.displayName = '' 40 49 this.description = '' 41 50 this.avatar = '' 51 + this.invites = [] 42 52 } 43 53 44 54 serialize(): unknown { ··· 85 95 if (sess.hasSession) { 86 96 this.did = sess.currentSession?.did || '' 87 97 this.handle = sess.currentSession?.handle || '' 88 - const profile = await this.rootStore.agent.getProfile({ 89 - actor: this.did, 90 - }) 91 - runInAction(() => { 92 - if (profile?.data) { 93 - this.displayName = profile.data.displayName || '' 94 - this.description = profile.data.description || '' 95 - this.avatar = profile.data.avatar || '' 96 - this.followsCount = profile.data.followsCount 97 - this.followersCount = profile.data.followersCount 98 - } else { 99 - this.displayName = '' 100 - this.description = '' 101 - this.avatar = '' 102 - this.followsCount = profile.data.followsCount 103 - this.followersCount = undefined 104 - } 105 - }) 98 + await this.fetchProfile() 106 99 this.mainFeed.clear() 107 100 await Promise.all([ 108 101 this.mainFeed.setup().catch(e => { ··· 113 106 }), 114 107 ]) 115 108 this.rootStore.emitSessionLoaded() 109 + await this.fetchInviteCodes() 116 110 } else { 117 111 this.clear() 118 112 } 119 113 } 114 + 115 + async updateIfNeeded() { 116 + if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) { 117 + this.rootStore.log.debug('Updating me profile information') 118 + await this.fetchProfile() 119 + await this.fetchInviteCodes() 120 + } 121 + await this.notifications.loadUnreadCount() 122 + } 123 + 124 + async fetchProfile() { 125 + const profile = await this.rootStore.agent.getProfile({ 126 + actor: this.did, 127 + }) 128 + runInAction(() => { 129 + if (profile?.data) { 130 + this.displayName = profile.data.displayName || '' 131 + this.description = profile.data.description || '' 132 + this.avatar = profile.data.avatar || '' 133 + this.followsCount = profile.data.followsCount 134 + this.followersCount = profile.data.followersCount 135 + } else { 136 + this.displayName = '' 137 + this.description = '' 138 + this.avatar = '' 139 + this.followsCount = profile.data.followsCount 140 + this.followersCount = undefined 141 + } 142 + }) 143 + } 144 + 145 + async fetchInviteCodes() { 146 + if (this.rootStore.session) { 147 + try { 148 + const res = 149 + await this.rootStore.agent.com.atproto.server.getAccountInviteCodes( 150 + {}, 151 + ) 152 + runInAction(() => { 153 + this.invites = res.data.codes 154 + this.invites.sort((a, b) => { 155 + if (!isInviteAvailable(a)) { 156 + return 1 157 + } 158 + if (!isInviteAvailable(b)) { 159 + return -1 160 + } 161 + return 0 162 + }) 163 + }) 164 + } catch (e) { 165 + this.rootStore.log.error('Failed to fetch user invite codes', e) 166 + } 167 + await this.rootStore.invitedUsers.fetch(this.invites) 168 + } 169 + } 170 + } 171 + 172 + function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean { 173 + return invite.available - invite.uses.length > 0 && !invite.disabled 120 174 }
+7 -1
src/state/models/root-store.ts
··· 16 16 import {LinkMetasCache} from './cache/link-metas' 17 17 import {NotificationsFeedItemModel} from './feeds/notifications' 18 18 import {MeModel} from './me' 19 + import {InvitedUsers} from './invited-users' 19 20 import {PreferencesModel} from './ui/preferences' 20 21 import {resetToTab} from '../../Navigation' 21 22 import {ImageSizesCache} from './cache/image-sizes' ··· 36 37 shell = new ShellUiModel(this) 37 38 preferences = new PreferencesModel() 38 39 me = new MeModel(this) 40 + invitedUsers = new InvitedUsers(this) 39 41 profiles = new ProfilesCache(this) 40 42 linkMetas = new LinkMetasCache(this) 41 43 imageSizes = new ImageSizesCache() ··· 61 63 me: this.me.serialize(), 62 64 shell: this.shell.serialize(), 63 65 preferences: this.preferences.serialize(), 66 + invitedUsers: this.invitedUsers.serialize(), 64 67 } 65 68 } 66 69 ··· 84 87 if (hasProp(v, 'preferences')) { 85 88 this.preferences.hydrate(v.preferences) 86 89 } 90 + if (hasProp(v, 'invitedUsers')) { 91 + this.invitedUsers.hydrate(v.invitedUsers) 92 + } 87 93 } 88 94 } 89 95 ··· 141 147 return 142 148 } 143 149 try { 144 - await this.me.notifications.loadUnreadCount() 150 + await this.me.updateIfNeeded() 145 151 } catch (e: any) { 146 152 this.log.error('Failed to fetch latest state', e) 147 153 }
+5
src/state/models/ui/shell.ts
··· 61 61 name: 'waitlist' 62 62 } 63 63 64 + export interface InviteCodesModal { 65 + name: 'invite-codes' 66 + } 67 + 64 68 export type Modal = 65 69 | ConfirmModal 66 70 | EditProfileModal ··· 72 76 | RepostModal 73 77 | ChangeHandleModal 74 78 | WaitlistModal 79 + | InviteCodesModal 75 80 76 81 interface LightboxModel {} 77 82
+1
src/view/com/auth/create/Step2.tsx
··· 35 35 Invite code 36 36 </Text> 37 37 <TextInput 38 + testID="inviteCodeInput" 38 39 icon="ticket" 39 40 placeholder="Required for this provider" 40 41 value={model.inviteCode}
+191
src/view/com/modals/InviteCodes.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import { 4 + FontAwesomeIcon, 5 + FontAwesomeIconStyle, 6 + } from '@fortawesome/react-native-fontawesome' 7 + import Clipboard from '@react-native-clipboard/clipboard' 8 + import {Text} from '../util/text/Text' 9 + import {Button} from '../util/forms/Button' 10 + import * as Toast from '../util/Toast' 11 + import {useStores} from 'state/index' 12 + import {ScrollView} from './util' 13 + import {usePalette} from 'lib/hooks/usePalette' 14 + import {isDesktopWeb} from 'platform/detection' 15 + 16 + export const snapPoints = ['70%'] 17 + 18 + export function Component({}: {}) { 19 + const pal = usePalette('default') 20 + const store = useStores() 21 + 22 + const onClose = React.useCallback(() => { 23 + store.shell.closeModal() 24 + }, [store]) 25 + 26 + if (store.me.invites.length === 0) { 27 + return ( 28 + <View style={[styles.container, pal.view]} testID="inviteCodesModal"> 29 + <View style={[styles.empty, pal.viewLight]}> 30 + <Text type="lg" style={[pal.text, styles.emptyText]}> 31 + You don't have any invite codes yet! We'll send you some when you've 32 + been on Bluesky for a little longer. 33 + </Text> 34 + </View> 35 + <View style={styles.flex1} /> 36 + <View style={styles.btnContainer}> 37 + <Button 38 + type="primary" 39 + label="Done" 40 + style={styles.btn} 41 + labelStyle={styles.btnLabel} 42 + onPress={onClose} 43 + /> 44 + </View> 45 + </View> 46 + ) 47 + } 48 + 49 + return ( 50 + <View style={[styles.container, pal.view]} testID="inviteCodesModal"> 51 + <Text type="title-xl" style={[styles.title, pal.text]}> 52 + Invite a Friend 53 + </Text> 54 + <Text type="lg" style={[styles.description, pal.text]}> 55 + Send these invites to your friends so they can create an account. Each 56 + code works once! 57 + </Text> 58 + <Text type="sm" style={[styles.description, pal.textLight]}> 59 + ( We'll send you more periodically. ) 60 + </Text> 61 + <ScrollView style={[styles.scrollContainer, pal.border]}> 62 + {store.me.invites.map((invite, i) => ( 63 + <InviteCode 64 + testID={`inviteCode-${i}`} 65 + key={invite.code} 66 + code={invite.code} 67 + used={invite.available - invite.uses.length <= 0 || invite.disabled} 68 + /> 69 + ))} 70 + </ScrollView> 71 + <View style={styles.btnContainer}> 72 + <Button 73 + testID="closeBtn" 74 + type="primary" 75 + label="Done" 76 + style={styles.btn} 77 + labelStyle={styles.btnLabel} 78 + onPress={onClose} 79 + /> 80 + </View> 81 + </View> 82 + ) 83 + } 84 + 85 + function InviteCode({ 86 + testID, 87 + code, 88 + used, 89 + }: { 90 + testID: string 91 + code: string 92 + used?: boolean 93 + }) { 94 + const pal = usePalette('default') 95 + const [wasCopied, setWasCopied] = React.useState(false) 96 + 97 + const onPress = React.useCallback(() => { 98 + Clipboard.setString(code) 99 + Toast.show('Copied to clipboard') 100 + setWasCopied(true) 101 + }, [code]) 102 + 103 + return ( 104 + <TouchableOpacity 105 + testID={testID} 106 + style={[styles.inviteCode, pal.border]} 107 + onPress={onPress}> 108 + <Text 109 + testID={`${testID}-code`} 110 + type={used ? 'md' : 'md-bold'} 111 + style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> 112 + {code} 113 + </Text> 114 + {wasCopied ? ( 115 + <Text style={pal.textLight}>Copied</Text> 116 + ) : !used ? ( 117 + <FontAwesomeIcon 118 + icon={['far', 'clone']} 119 + style={pal.text as FontAwesomeIconStyle} 120 + /> 121 + ) : undefined} 122 + </TouchableOpacity> 123 + ) 124 + } 125 + 126 + const styles = StyleSheet.create({ 127 + container: { 128 + flex: 1, 129 + paddingBottom: isDesktopWeb ? 0 : 50, 130 + }, 131 + title: { 132 + textAlign: 'center', 133 + marginTop: 12, 134 + marginBottom: 12, 135 + }, 136 + description: { 137 + textAlign: 'center', 138 + paddingHorizontal: 42, 139 + marginBottom: 14, 140 + }, 141 + 142 + scrollContainer: { 143 + flex: 1, 144 + borderTopWidth: 1, 145 + marginTop: 4, 146 + marginBottom: 16, 147 + }, 148 + 149 + flex1: { 150 + flex: 1, 151 + }, 152 + empty: { 153 + paddingHorizontal: 20, 154 + paddingVertical: 20, 155 + borderRadius: 16, 156 + marginHorizontal: 24, 157 + marginTop: 10, 158 + }, 159 + emptyText: { 160 + textAlign: 'center', 161 + }, 162 + 163 + inviteCode: { 164 + flexDirection: 'row', 165 + alignItems: 'center', 166 + justifyContent: 'space-between', 167 + borderBottomWidth: 1, 168 + paddingHorizontal: 20, 169 + paddingVertical: 14, 170 + }, 171 + strikeThrough: { 172 + textDecorationLine: 'line-through', 173 + textDecorationStyle: 'solid', 174 + }, 175 + 176 + btnContainer: { 177 + flexDirection: 'row', 178 + justifyContent: 'center', 179 + }, 180 + btn: { 181 + flexDirection: 'row', 182 + alignItems: 'center', 183 + justifyContent: 'center', 184 + borderRadius: 32, 185 + paddingHorizontal: 60, 186 + paddingVertical: 14, 187 + }, 188 + btnLabel: { 189 + fontSize: 18, 190 + }, 191 + })
+4
src/view/com/modals/Modal.tsx
··· 14 14 import * as DeleteAccountModal from './DeleteAccount' 15 15 import * as ChangeHandleModal from './ChangeHandle' 16 16 import * as WaitlistModal from './Waitlist' 17 + import * as InviteCodesModal from './InviteCodes' 17 18 import {usePalette} from 'lib/hooks/usePalette' 18 19 import {StyleSheet} from 'react-native' 19 20 ··· 73 74 } else if (activeModal?.name === 'waitlist') { 74 75 snapPoints = WaitlistModal.snapPoints 75 76 element = <WaitlistModal.Component /> 77 + } else if (activeModal?.name === 'invite-codes') { 78 + snapPoints = InviteCodesModal.snapPoints 79 + element = <InviteCodesModal.Component /> 76 80 } else { 77 81 return <View /> 78 82 }
+3
src/view/com/modals/Modal.web.tsx
··· 16 16 import * as CropImageModal from './crop-image/CropImage.web' 17 17 import * as ChangeHandleModal from './ChangeHandle' 18 18 import * as WaitlistModal from './Waitlist' 19 + import * as InviteCodesModal from './InviteCodes' 19 20 20 21 export const ModalsContainer = observer(function ModalsContainer() { 21 22 const store = useStores() ··· 72 73 element = <ChangeHandleModal.Component {...modal} /> 73 74 } else if (modal.name === 'waitlist') { 74 75 element = <WaitlistModal.Component /> 76 + } else if (modal.name === 'invite-codes') { 77 + element = <InviteCodesModal.Component /> 75 78 } else { 76 79 return null 77 80 }
+112
src/view/com/notifications/InvitedUsers.tsx
··· 1 + import React from 'react' 2 + import { 3 + FontAwesomeIcon, 4 + FontAwesomeIconStyle, 5 + } from '@fortawesome/react-native-fontawesome' 6 + import {StyleSheet, View} from 'react-native' 7 + import {observer} from 'mobx-react-lite' 8 + import {AppBskyActorDefs} from '@atproto/api' 9 + import {UserAvatar} from '../util/UserAvatar' 10 + import {Text} from '../util/text/Text' 11 + import {Link, TextLink} from '../util/Link' 12 + import {Button} from '../util/forms/Button' 13 + import {FollowButton} from '../profile/FollowButton' 14 + import {CenteredView} from '../util/Views.web' 15 + import {useStores} from 'state/index' 16 + import {usePalette} from 'lib/hooks/usePalette' 17 + import {s} from 'lib/styles' 18 + 19 + export const InvitedUsers = observer(() => { 20 + const store = useStores() 21 + return ( 22 + <CenteredView> 23 + {store.invitedUsers.profiles.map(profile => ( 24 + <InvitedUser key={profile.did} profile={profile} /> 25 + ))} 26 + </CenteredView> 27 + ) 28 + }) 29 + 30 + function InvitedUser({ 31 + profile, 32 + }: { 33 + profile: AppBskyActorDefs.ProfileViewDetailed 34 + }) { 35 + const pal = usePalette('default') 36 + const store = useStores() 37 + 38 + const onPressDismiss = React.useCallback(() => { 39 + store.invitedUsers.markSeen(profile.did) 40 + }, [store, profile]) 41 + 42 + return ( 43 + <View 44 + testID="invitedUser" 45 + style={[ 46 + styles.layout, 47 + { 48 + backgroundColor: pal.colors.unreadNotifBg, 49 + borderColor: pal.colors.unreadNotifBorder, 50 + }, 51 + ]}> 52 + <View style={styles.layoutIcon}> 53 + <FontAwesomeIcon 54 + icon="user-plus" 55 + size={24} 56 + style={[styles.icon, s.blue3 as FontAwesomeIconStyle]} 57 + /> 58 + </View> 59 + <View style={s.flex1}> 60 + <Link href={`/profile/${profile.handle}`}> 61 + <UserAvatar avatar={profile.avatar} size={35} /> 62 + </Link> 63 + <Text style={[styles.desc, pal.text]}> 64 + <TextLink 65 + type="md-bold" 66 + style={pal.text} 67 + href={`/profile/${profile.handle}`} 68 + text={profile.displayName || profile.handle} 69 + />{' '} 70 + joined using your invite code! 71 + </Text> 72 + <View style={styles.btns}> 73 + <FollowButton 74 + unfollowedType="primary" 75 + followedType="primary-light" 76 + did={profile.did} 77 + /> 78 + <Button 79 + testID="dismissBtn" 80 + type="primary-light" 81 + label="Dismiss" 82 + onPress={onPressDismiss} 83 + /> 84 + </View> 85 + </View> 86 + </View> 87 + ) 88 + } 89 + 90 + const styles = StyleSheet.create({ 91 + layout: { 92 + flexDirection: 'row', 93 + borderTopWidth: 1, 94 + padding: 10, 95 + }, 96 + layoutIcon: { 97 + width: 70, 98 + alignItems: 'flex-end', 99 + paddingTop: 2, 100 + }, 101 + icon: { 102 + marginRight: 10, 103 + marginTop: 4, 104 + }, 105 + desc: { 106 + paddingVertical: 6, 107 + }, 108 + btns: { 109 + flexDirection: 'row', 110 + gap: 10, 111 + }, 112 + })
+8 -6
src/view/com/profile/FollowButton.tsx
··· 6 6 import * as Toast from '../util/Toast' 7 7 import {FollowState} from 'state/models/cache/my-follows' 8 8 9 - const FollowButton = observer( 9 + export const FollowButton = observer( 10 10 ({ 11 - type = 'inverted', 11 + unfollowedType = 'inverted', 12 + followedType = 'inverted', 12 13 did, 13 14 onToggleFollow, 14 15 }: { 15 - type?: ButtonType 16 + unfollowedType?: ButtonType 17 + followedType?: ButtonType 16 18 did: string 17 19 onToggleFollow?: (v: boolean) => void 18 20 }) => { ··· 48 50 49 51 return ( 50 52 <Button 51 - type={followState === FollowState.Following ? 'default' : type} 53 + type={ 54 + followState === FollowState.Following ? followedType : unfollowedType 55 + } 52 56 onPress={onToggleFollowInner} 53 57 label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} 54 58 /> 55 59 ) 56 60 }, 57 61 ) 58 - 59 - export default FollowButton
+1 -1
src/view/com/profile/ProfileCard.tsx
··· 8 8 import {s} from 'lib/styles' 9 9 import {usePalette} from 'lib/hooks/usePalette' 10 10 import {useStores} from 'state/index' 11 - import FollowButton from './FollowButton' 11 + import {FollowButton} from './FollowButton' 12 12 13 13 export function ProfileCard({ 14 14 testID,
+2 -2
src/view/com/util/PostMeta.tsx
··· 7 7 import {useStores} from 'state/index' 8 8 import {UserAvatar} from './UserAvatar' 9 9 import {observer} from 'mobx-react-lite' 10 - import FollowButton from '../profile/FollowButton' 10 + import {FollowButton} from '../profile/FollowButton' 11 11 import {FollowState} from 'state/models/cache/my-follows' 12 12 13 13 interface PostMetaOpts { ··· 78 78 79 79 <View> 80 80 <FollowButton 81 - type="default" 81 + unfollowedType="default" 82 82 did={opts.did} 83 83 onToggleFollow={onToggleFollow} 84 84 />
+78 -70
src/view/com/util/forms/Button.tsx
··· 25 25 type = 'primary', 26 26 label, 27 27 style, 28 + labelStyle, 28 29 onPress, 29 30 children, 30 31 testID, ··· 32 33 type?: ButtonType 33 34 label?: string 34 35 style?: StyleProp<ViewStyle> 36 + labelStyle?: StyleProp<TextStyle> 35 37 onPress?: () => void 36 38 testID?: string 37 39 }>) { 38 40 const theme = useTheme() 39 - const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, { 40 - primary: { 41 - backgroundColor: theme.palette.primary.background, 42 - }, 43 - secondary: { 44 - backgroundColor: theme.palette.secondary.background, 45 - }, 46 - default: { 47 - backgroundColor: theme.palette.default.backgroundLight, 48 - }, 49 - inverted: { 50 - backgroundColor: theme.palette.inverted.background, 51 - }, 52 - 'primary-outline': { 53 - backgroundColor: theme.palette.default.background, 54 - borderWidth: 1, 55 - borderColor: theme.palette.primary.border, 56 - }, 57 - 'secondary-outline': { 58 - backgroundColor: theme.palette.default.background, 59 - borderWidth: 1, 60 - borderColor: theme.palette.secondary.border, 61 - }, 62 - 'primary-light': { 63 - backgroundColor: theme.palette.default.background, 64 - }, 65 - 'secondary-light': { 66 - backgroundColor: theme.palette.default.background, 67 - }, 68 - 'default-light': { 69 - backgroundColor: theme.palette.default.background, 70 - }, 71 - }) 72 - const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, { 73 - primary: { 74 - color: theme.palette.primary.text, 75 - fontWeight: '600', 76 - }, 77 - secondary: { 78 - color: theme.palette.secondary.text, 79 - fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, 80 - }, 81 - default: { 82 - color: theme.palette.default.text, 83 - }, 84 - inverted: { 85 - color: theme.palette.inverted.text, 86 - fontWeight: '600', 87 - }, 88 - 'primary-outline': { 89 - color: theme.palette.primary.textInverted, 90 - fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, 91 - }, 92 - 'secondary-outline': { 93 - color: theme.palette.secondary.textInverted, 94 - fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, 95 - }, 96 - 'primary-light': { 97 - color: theme.palette.primary.textInverted, 98 - fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, 99 - }, 100 - 'secondary-light': { 101 - color: theme.palette.secondary.textInverted, 102 - fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, 41 + const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( 42 + type, 43 + { 44 + primary: { 45 + backgroundColor: theme.palette.primary.background, 46 + }, 47 + secondary: { 48 + backgroundColor: theme.palette.secondary.background, 49 + }, 50 + default: { 51 + backgroundColor: theme.palette.default.backgroundLight, 52 + }, 53 + inverted: { 54 + backgroundColor: theme.palette.inverted.background, 55 + }, 56 + 'primary-outline': { 57 + backgroundColor: theme.palette.default.background, 58 + borderWidth: 1, 59 + borderColor: theme.palette.primary.border, 60 + }, 61 + 'secondary-outline': { 62 + backgroundColor: theme.palette.default.background, 63 + borderWidth: 1, 64 + borderColor: theme.palette.secondary.border, 65 + }, 66 + 'primary-light': { 67 + backgroundColor: theme.palette.default.background, 68 + }, 69 + 'secondary-light': { 70 + backgroundColor: theme.palette.default.background, 71 + }, 72 + 'default-light': { 73 + backgroundColor: theme.palette.default.background, 74 + }, 103 75 }, 104 - 'default-light': { 105 - color: theme.palette.default.text, 106 - fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, 76 + ) 77 + const typeLabelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>( 78 + type, 79 + { 80 + primary: { 81 + color: theme.palette.primary.text, 82 + fontWeight: '600', 83 + }, 84 + secondary: { 85 + color: theme.palette.secondary.text, 86 + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, 87 + }, 88 + default: { 89 + color: theme.palette.default.text, 90 + }, 91 + inverted: { 92 + color: theme.palette.inverted.text, 93 + fontWeight: '600', 94 + }, 95 + 'primary-outline': { 96 + color: theme.palette.primary.textInverted, 97 + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, 98 + }, 99 + 'secondary-outline': { 100 + color: theme.palette.secondary.textInverted, 101 + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, 102 + }, 103 + 'primary-light': { 104 + color: theme.palette.primary.textInverted, 105 + fontWeight: theme.palette.primary.isLowContrast ? '500' : undefined, 106 + }, 107 + 'secondary-light': { 108 + color: theme.palette.secondary.textInverted, 109 + fontWeight: theme.palette.secondary.isLowContrast ? '500' : undefined, 110 + }, 111 + 'default-light': { 112 + color: theme.palette.default.text, 113 + fontWeight: theme.palette.default.isLowContrast ? '500' : undefined, 114 + }, 107 115 }, 108 - }) 116 + ) 109 117 return ( 110 118 <TouchableOpacity 111 - style={[outerStyle, styles.outer, style]} 119 + style={[typeOuterStyle, styles.outer, style]} 112 120 onPress={onPress} 113 121 testID={testID}> 114 122 {label ? ( 115 - <Text type="button" style={[labelStyle]}> 123 + <Text type="button" style={[typeLabelStyle, labelStyle]}> 116 124 {label} 117 125 </Text> 118 126 ) : (
+2
src/view/screens/Notifications.tsx
··· 10 10 import {withAuthRequired} from 'view/com/auth/withAuthRequired' 11 11 import {ViewHeader} from '../com/util/ViewHeader' 12 12 import {Feed} from '../com/notifications/Feed' 13 + import {InvitedUsers} from '../com/notifications/InvitedUsers' 13 14 import {useStores} from 'state/index' 14 15 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' 15 16 import {s} from 'lib/styles' ··· 89 90 return ( 90 91 <View testID="notificationsScreen" style={s.hContentRegion}> 91 92 <ViewHeader title="Notifications" canGoBack={false} /> 93 + <InvitedUsers /> 92 94 <Feed 93 95 view={store.me.notifications} 94 96 onPressTryAgain={onPressTryAgain}
+88 -54
src/view/screens/Settings.tsx
··· 2 2 import { 3 3 ActivityIndicator, 4 4 StyleSheet, 5 + TextStyle, 5 6 TouchableOpacity, 6 7 View, 8 + ViewStyle, 7 9 } from 'react-native' 8 10 import { 9 11 useFocusEffect, ··· 27 29 import * as Toast from '../com/util/Toast' 28 30 import {UserAvatar} from '../com/util/UserAvatar' 29 31 import {DropdownButton} from 'view/com/util/forms/DropdownButton' 30 - import {useTheme} from 'lib/ThemeContext' 31 32 import {usePalette} from 'lib/hooks/usePalette' 33 + import {useCustomPalette} from 'lib/hooks/useCustomPalette' 32 34 import {AccountData} from 'state/models/session' 33 35 import {useAnalytics} from 'lib/analytics' 34 36 import {NavigationProp} from 'lib/routes/types' 37 + import {pluralize} from 'lib/strings/helpers' 35 38 36 39 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 37 40 export const SettingsScreen = withAuthRequired( 38 41 observer(function Settings({}: Props) { 39 - const theme = useTheme() 40 42 const pal = usePalette('default') 41 43 const store = useStores() 42 44 const navigation = useNavigation<NavigationProp>() 43 45 const {screen, track} = useAnalytics() 44 46 const [isSwitching, setIsSwitching] = React.useState(false) 45 47 48 + const primaryBg = useCustomPalette<ViewStyle>({ 49 + light: {backgroundColor: colors.blue0}, 50 + dark: {backgroundColor: colors.blue6}, 51 + }) 52 + const primaryText = useCustomPalette<TextStyle>({ 53 + light: {color: colors.blue3}, 54 + dark: {color: colors.blue2}, 55 + }) 56 + const dangerBg = useCustomPalette<ViewStyle>({ 57 + light: {backgroundColor: colors.red1}, 58 + dark: {backgroundColor: colors.red7}, 59 + }) 60 + const dangerText = useCustomPalette<TextStyle>({ 61 + light: {color: colors.red4}, 62 + dark: {color: colors.red2}, 63 + }) 64 + 46 65 useFocusEffect( 47 66 React.useCallback(() => { 48 67 screen('Settings') ··· 50 69 }, [screen, store]), 51 70 ) 52 71 53 - const onPressSwitchAccount = async (acct: AccountData) => { 54 - track('Settings:SwitchAccountButtonClicked') 55 - setIsSwitching(true) 56 - if (await store.session.resumeSession(acct)) { 72 + const onPressSwitchAccount = React.useCallback( 73 + async (acct: AccountData) => { 74 + track('Settings:SwitchAccountButtonClicked') 75 + setIsSwitching(true) 76 + if (await store.session.resumeSession(acct)) { 77 + setIsSwitching(false) 78 + navigation.navigate('HomeTab') 79 + navigation.dispatch(StackActions.popToTop()) 80 + Toast.show(`Signed in as ${acct.displayName || acct.handle}`) 81 + return 82 + } 57 83 setIsSwitching(false) 84 + Toast.show('Sorry! We need you to enter your password.') 58 85 navigation.navigate('HomeTab') 59 86 navigation.dispatch(StackActions.popToTop()) 60 - Toast.show(`Signed in as ${acct.displayName || acct.handle}`) 61 - return 62 - } 63 - setIsSwitching(false) 64 - Toast.show('Sorry! We need you to enter your password.') 65 - navigation.navigate('HomeTab') 66 - navigation.dispatch(StackActions.popToTop()) 67 - store.session.clear() 68 - } 69 - const onPressAddAccount = () => { 87 + store.session.clear() 88 + }, 89 + [track, setIsSwitching, navigation, store], 90 + ) 91 + 92 + const onPressAddAccount = React.useCallback(() => { 70 93 track('Settings:AddAccountButtonClicked') 71 94 navigation.navigate('HomeTab') 72 95 navigation.dispatch(StackActions.popToTop()) 73 96 store.session.clear() 74 - } 75 - const onPressChangeHandle = () => { 97 + }, [track, navigation, store]) 98 + 99 + const onPressChangeHandle = React.useCallback(() => { 76 100 track('Settings:ChangeHandleButtonClicked') 77 101 store.shell.openModal({ 78 102 name: 'change-handle', ··· 93 117 ) 94 118 }, 95 119 }) 96 - } 97 - const onPressSignout = () => { 120 + }, [track, store, setIsSwitching]) 121 + 122 + const onPressInviteCodes = React.useCallback(() => { 123 + track('Settings:InvitecodesButtonClicked') 124 + store.shell.openModal({name: 'invite-codes'}) 125 + }, [track, store]) 126 + 127 + const onPressSignout = React.useCallback(() => { 98 128 track('Settings:SignOutButtonClicked') 99 129 store.session.logout() 100 - } 101 - const onPressDeleteAccount = () => { 130 + }, [track, store]) 131 + 132 + const onPressDeleteAccount = React.useCallback(() => { 102 133 store.shell.openModal({name: 'delete-account'}) 103 - } 134 + }, [store]) 104 135 105 136 return ( 106 137 <View style={[s.hContentRegion]} testID="settingsScreen"> ··· 184 215 <View style={styles.spacer20} /> 185 216 186 217 <Text type="xl-bold" style={[pal.text, styles.heading]}> 218 + Invite a friend 219 + </Text> 220 + <TouchableOpacity 221 + testID="inviteFriendBtn" 222 + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 223 + onPress={isSwitching ? undefined : onPressInviteCodes}> 224 + <View 225 + style={[ 226 + styles.iconContainer, 227 + store.me.invitesAvailable > 0 ? primaryBg : pal.btn, 228 + ]}> 229 + <FontAwesomeIcon 230 + icon="ticket" 231 + style={ 232 + (store.me.invitesAvailable > 0 233 + ? primaryText 234 + : pal.text) as FontAwesomeIconStyle 235 + } 236 + /> 237 + </View> 238 + <Text 239 + type="lg" 240 + style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> 241 + {store.me.invitesAvailable} invite{' '} 242 + {pluralize(store.me.invitesAvailable, 'code')} available 243 + </Text> 244 + </TouchableOpacity> 245 + 246 + <View style={styles.spacer20} /> 247 + 248 + <Text type="xl-bold" style={[pal.text, styles.heading]}> 187 249 Advanced 188 250 </Text> 189 251 <TouchableOpacity ··· 209 271 <TouchableOpacity 210 272 style={[pal.view, styles.linkCard]} 211 273 onPress={onPressDeleteAccount}> 212 - <View 213 - style={[ 214 - styles.iconContainer, 215 - theme.colorScheme === 'dark' 216 - ? styles.trashIconContainerDark 217 - : styles.trashIconContainerLight, 218 - ]}> 274 + <View style={[styles.iconContainer, dangerBg]}> 219 275 <FontAwesomeIcon 220 276 icon={['far', 'trash-can']} 221 - style={ 222 - theme.colorScheme === 'dark' 223 - ? styles.dangerDark 224 - : styles.dangerLight 225 - } 277 + style={dangerText as FontAwesomeIconStyle} 226 278 size={21} 227 279 /> 228 280 </View> 229 - <Text 230 - type="lg" 231 - style={ 232 - theme.colorScheme === 'dark' 233 - ? styles.dangerDark 234 - : styles.dangerLight 235 - }> 281 + <Text type="lg" style={dangerText}> 236 282 Delete my account 237 283 </Text> 238 284 </TouchableOpacity> ··· 330 376 height: 40, 331 377 borderRadius: 30, 332 378 marginRight: 12, 333 - }, 334 - trashIconContainerDark: { 335 - backgroundColor: colors.red7, 336 - }, 337 - trashIconContainerLight: { 338 - backgroundColor: colors.red1, 339 - }, 340 - dangerLight: { 341 - color: colors.red4, 342 - }, 343 - dangerDark: { 344 - color: colors.red2, 345 379 }, 346 380 buildInfo: { 347 381 paddingVertical: 8,
+3 -1
src/view/shell/BottomBar.tsx
··· 167 167 ) 168 168 } 169 169 onPress={onPressNotifications} 170 - notificationCount={store.me.notifications.unreadCount} 170 + notificationCount={ 171 + store.me.notifications.unreadCount + store.invitedUsers.numNotifs 172 + } 171 173 /> 172 174 <Btn 173 175 testID="bottomBarProfileBtn"
+132 -77
src/view/shell/Drawer.tsx
··· 103 103 store.shell.closeDrawer() 104 104 }, [navigation, track, store.shell]) 105 105 106 - const onPressFeedback = () => { 106 + const onPressFeedback = React.useCallback(() => { 107 107 track('Menu:FeedbackClicked') 108 108 Linking.openURL(FEEDBACK_FORM_URL) 109 - } 109 + }, [track]) 110 + 111 + const onDarkmodePress = React.useCallback(() => { 112 + track('Menu:ItemClicked', {url: '#darkmode'}) 113 + store.shell.setDarkMode(!store.shell.darkMode) 114 + }, [track, store]) 110 115 111 116 // rendering 112 117 // = 113 118 114 - const MenuItem = ({ 115 - icon, 116 - label, 117 - count, 118 - bold, 119 - onPress, 120 - }: { 121 - icon: JSX.Element 122 - label: string 123 - count?: number 124 - bold?: boolean 125 - onPress: () => void 126 - }) => ( 127 - <TouchableOpacity 128 - testID={`menuItemButton-${label}`} 129 - style={styles.menuItem} 130 - onPress={onPress}> 131 - <View style={[styles.menuItemIconWrapper]}> 132 - {icon} 133 - {count ? ( 134 - <View 135 - style={[ 136 - styles.menuItemCount, 137 - count > 99 138 - ? styles.menuItemCountHundreds 139 - : count > 9 140 - ? styles.menuItemCountTens 141 - : undefined, 142 - ]}> 143 - <Text style={styles.menuItemCountLabel} numberOfLines={1}> 144 - {count > 999 ? `${Math.round(count / 1000)}k` : count} 145 - </Text> 146 - </View> 147 - ) : undefined} 148 - </View> 149 - <Text 150 - type={bold ? '2xl-bold' : '2xl'} 151 - style={[pal.text, s.flex1]} 152 - numberOfLines={1}> 153 - {label} 154 - </Text> 155 - </TouchableOpacity> 156 - ) 157 - 158 - const onDarkmodePress = () => { 159 - track('Menu:ItemClicked', {url: '/darkmode'}) 160 - store.shell.setDarkMode(!store.shell.darkMode) 161 - } 162 - 163 119 return ( 164 120 <View 165 121 testID="drawer" ··· 168 124 theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode, 169 125 ]}> 170 126 <SafeAreaView style={s.flex1}> 171 - <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}> 172 - <UserAvatar size={80} avatar={store.me.avatar} /> 173 - <Text 174 - type="title-lg" 175 - style={[pal.text, s.bold, styles.profileCardDisplayName]}> 176 - {store.me.displayName || store.me.handle} 177 - </Text> 178 - <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}> 179 - @{store.me.handle} 180 - </Text> 181 - <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> 182 - <Text type="xl-medium" style={pal.text}> 183 - {store.me.followersCount || 0} 184 - </Text>{' '} 185 - {pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '} 186 - <Text type="xl-medium" style={pal.text}> 187 - {store.me.followsCount || 0} 188 - </Text>{' '} 189 - following 190 - </Text> 191 - </TouchableOpacity> 127 + <View style={styles.main}> 128 + <TouchableOpacity testID="profileCardButton" onPress={onPressProfile}> 129 + <UserAvatar size={80} avatar={store.me.avatar} /> 130 + <Text 131 + type="title-lg" 132 + style={[pal.text, s.bold, styles.profileCardDisplayName]}> 133 + {store.me.displayName || store.me.handle} 134 + </Text> 135 + <Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}> 136 + @{store.me.handle} 137 + </Text> 138 + <Text 139 + type="xl" 140 + style={[pal.textLight, styles.profileCardFollowers]}> 141 + <Text type="xl-medium" style={pal.text}> 142 + {store.me.followersCount || 0} 143 + </Text>{' '} 144 + {pluralize(store.me.followersCount || 0, 'follower')} &middot;{' '} 145 + <Text type="xl-medium" style={pal.text}> 146 + {store.me.followsCount || 0} 147 + </Text>{' '} 148 + following 149 + </Text> 150 + </TouchableOpacity> 151 + </View> 152 + <InviteCodes /> 192 153 <View style={s.flex1} /> 193 - <View> 154 + <View style={styles.main}> 194 155 <MenuItem 195 156 icon={ 196 157 isAtSearch ? ( ··· 248 209 ) 249 210 } 250 211 label="Notifications" 251 - count={store.me.notifications.unreadCount} 212 + count={ 213 + store.me.notifications.unreadCount + store.invitedUsers.numNotifs 214 + } 252 215 bold={isAtNotifications} 253 216 onPress={onPressNotifications} 254 217 /> ··· 315 278 ) 316 279 }) 317 280 281 + function MenuItem({ 282 + icon, 283 + label, 284 + count, 285 + bold, 286 + onPress, 287 + }: { 288 + icon: JSX.Element 289 + label: string 290 + count?: number 291 + bold?: boolean 292 + onPress: () => void 293 + }) { 294 + const pal = usePalette('default') 295 + return ( 296 + <TouchableOpacity 297 + testID={`menuItemButton-${label}`} 298 + style={styles.menuItem} 299 + onPress={onPress}> 300 + <View style={[styles.menuItemIconWrapper]}> 301 + {icon} 302 + {count ? ( 303 + <View 304 + style={[ 305 + styles.menuItemCount, 306 + count > 99 307 + ? styles.menuItemCountHundreds 308 + : count > 9 309 + ? styles.menuItemCountTens 310 + : undefined, 311 + ]}> 312 + <Text style={styles.menuItemCountLabel} numberOfLines={1}> 313 + {count > 999 ? `${Math.round(count / 1000)}k` : count} 314 + </Text> 315 + </View> 316 + ) : undefined} 317 + </View> 318 + <Text 319 + type={bold ? '2xl-bold' : '2xl'} 320 + style={[pal.text, s.flex1]} 321 + numberOfLines={1}> 322 + {label} 323 + </Text> 324 + </TouchableOpacity> 325 + ) 326 + } 327 + 328 + const InviteCodes = observer(() => { 329 + const {track} = useAnalytics() 330 + const store = useStores() 331 + const pal = usePalette('default') 332 + const onPress = React.useCallback(() => { 333 + track('Menu:ItemClicked', {url: '#invite-codes'}) 334 + store.shell.closeDrawer() 335 + store.shell.openModal({name: 'invite-codes'}) 336 + }, [store, track]) 337 + return ( 338 + <TouchableOpacity 339 + testID="menuItemInviteCodes" 340 + style={[styles.inviteCodes]} 341 + onPress={onPress}> 342 + <FontAwesomeIcon 343 + icon="ticket" 344 + style={[ 345 + styles.inviteCodesIcon, 346 + store.me.invitesAvailable > 0 ? pal.link : pal.textLight, 347 + ]} 348 + size={18} 349 + /> 350 + <Text 351 + type="lg-medium" 352 + style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> 353 + {store.me.invitesAvailable} invite{' '} 354 + {pluralize(store.me.invitesAvailable, 'code')} 355 + </Text> 356 + </TouchableOpacity> 357 + ) 358 + }) 359 + 318 360 const styles = StyleSheet.create({ 319 361 view: { 320 362 flex: 1, 321 363 paddingTop: 20, 322 364 paddingBottom: 50, 323 - paddingLeft: 20, 324 365 }, 325 366 viewDarkMode: { 326 367 backgroundColor: '#1B1919', 368 + }, 369 + main: { 370 + paddingLeft: 20, 327 371 }, 328 372 329 373 profileCardDisplayName: { ··· 336 380 }, 337 381 profileCardFollowers: { 338 382 marginTop: 16, 339 - paddingRight: 30, 383 + paddingRight: 10, 340 384 }, 341 385 342 386 menuItem: { ··· 376 420 color: colors.white, 377 421 }, 378 422 423 + inviteCodes: { 424 + paddingLeft: 22, 425 + paddingVertical: 8, 426 + flexDirection: 'row', 427 + alignItems: 'center', 428 + }, 429 + inviteCodesIcon: { 430 + marginRight: 6, 431 + }, 432 + 379 433 footer: { 380 434 flexDirection: 'row', 381 435 justifyContent: 'space-between', 382 436 paddingRight: 30, 383 - paddingTop: 80, 437 + paddingTop: 20, 438 + paddingLeft: 20, 384 439 }, 385 440 footerBtn: { 386 441 flexDirection: 'row',
+3 -1
src/view/shell/desktop/LeftNav.tsx
··· 157 157 /> 158 158 <NavItem 159 159 href="/notifications" 160 - count={store.me.notifications.unreadCount} 160 + count={ 161 + store.me.notifications.unreadCount + store.invitedUsers.numNotifs 162 + } 161 163 icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />} 162 164 iconFilled={ 163 165 <BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />
+45 -1
src/view/shell/desktop/RightNav.tsx
··· 1 1 import React from 'react' 2 2 import {observer} from 'mobx-react-lite' 3 - import {StyleSheet, View} from 'react-native' 3 + import {StyleSheet, TouchableOpacity, View} from 'react-native' 4 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 5 import {usePalette} from 'lib/hooks/usePalette' 5 6 import {DesktopSearch} from './Search' 6 7 import {Text} from 'view/com/util/text/Text' ··· 8 9 import {FEEDBACK_FORM_URL} from 'lib/constants' 9 10 import {s} from 'lib/styles' 10 11 import {useStores} from 'state/index' 12 + import {pluralize} from 'lib/strings/helpers' 11 13 12 14 export const DesktopRightNav = observer(function DesktopRightNav() { 13 15 const store = useStores() ··· 38 40 /> 39 41 </View> 40 42 </View> 43 + <InviteCodes /> 41 44 </View> 42 45 ) 43 46 }) 44 47 48 + function InviteCodes() { 49 + const store = useStores() 50 + const pal = usePalette('default') 51 + 52 + const onPress = React.useCallback(() => { 53 + store.shell.openModal({name: 'invite-codes'}) 54 + }, [store]) 55 + return ( 56 + <TouchableOpacity 57 + style={[styles.inviteCodes, pal.border]} 58 + onPress={onPress}> 59 + <FontAwesomeIcon 60 + icon="ticket" 61 + style={[ 62 + styles.inviteCodesIcon, 63 + store.me.invitesAvailable > 0 ? pal.link : pal.textLight, 64 + ]} 65 + size={16} 66 + /> 67 + <Text 68 + type="md-medium" 69 + style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> 70 + {store.me.invitesAvailable} invite{' '} 71 + {pluralize(store.me.invitesAvailable, 'code')} available 72 + </Text> 73 + </TouchableOpacity> 74 + ) 75 + } 76 + 45 77 const styles = StyleSheet.create({ 46 78 rightNav: { 47 79 position: 'absolute', ··· 56 88 }, 57 89 messageLine: { 58 90 marginBottom: 10, 91 + }, 92 + 93 + inviteCodes: { 94 + marginTop: 12, 95 + borderTopWidth: 1, 96 + paddingHorizontal: 16, 97 + paddingVertical: 12, 98 + flexDirection: 'row', 99 + alignItems: 'center', 100 + }, 101 + inviteCodesIcon: { 102 + marginRight: 6, 59 103 }, 60 104 })
+8 -8
yarn.lock
··· 30 30 tlds "^1.234.0" 31 31 typed-emitter "^2.1.0" 32 32 33 - "@atproto/api@0.2.3": 34 - version "0.2.3" 35 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.3.tgz#0eb9cb542c113b2c839f2c5ca284c30b117f489a" 36 - integrity sha512-i0tWdOPQyZuSlkd2MY3s7QTac2ovH104tzy5rJwTZXZyhpf2Zom1xedaHb+pQmFzug7YaD7tx7OMSPlJIV0dpg== 33 + "@atproto/api@0.2.4": 34 + version "0.2.4" 35 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.4.tgz#0a14af4f37aa665bd70a1b5f9f5d31db02313ad9" 36 + integrity sha512-EOegRw4/TaN8Px9M/rPiWQlqIkN+QXeU3Y8NUFofqgApPiatmayiYpQiR0iBhZmFnlYFuRt6tLQBjPypI/dvfA== 37 37 dependencies: 38 38 "@atproto/common-web" "*" 39 39 "@atproto/uri" "*" ··· 122 122 resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" 123 123 integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== 124 124 125 - "@atproto/pds@^0.1.0": 126 - version "0.1.0" 127 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.0.tgz#8014269c12a322b14618e0991c534979a4b145d7" 128 - integrity sha512-f1KPONxim674owWcTsR8S5r57+b7evg+zy+jkcTX00BB0fO6PchDL6sTQQc1x3u2QZArHDSUUUgoHt4IWwsfkw== 125 + "@atproto/pds@^0.1.3": 126 + version "0.1.3" 127 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.3.tgz#601c556cd1e10306c9b741d9361bc54d70bb2869" 128 + integrity sha512-cVvmgXkzu7w1tDGGDK904sDzxF2AUqu0ij/1EU2rYmnZZAK+FTjKs8cqrJzRur9vm07A23JvBTuINtYzxHwSzA== 129 129 dependencies: 130 130 "@atproto/api" "*" 131 131 "@atproto/common" "*"