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 lintzio/stable
parent
8e28d3c6be
commit
ea04c2bd33
|
@ -13,7 +13,8 @@ async function main() {
|
||||||
console.log('Closing old server')
|
console.log('Closing old server')
|
||||||
await server?.close()
|
await server?.close()
|
||||||
console.log('Starting new server')
|
console.log('Starting new server')
|
||||||
server = await createServer()
|
const inviteRequired = url?.query && 'invite' in url.query
|
||||||
|
server = await createServer({inviteRequired})
|
||||||
console.log('Listening at', server.pdsUrl)
|
console.log('Listening at', server.pdsUrl)
|
||||||
if (url?.query) {
|
if (url?.query) {
|
||||||
if ('users' in url.query) {
|
if ('users' in url.query) {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {openApp, createServer} from '../util'
|
||||||
describe('Create account', () => {
|
describe('Create account', () => {
|
||||||
let service: string
|
let service: string
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
service = await createServer('mock0')
|
service = await createServer('')
|
||||||
await openApp({permissions: {notifications: 'YES'}})
|
await openApp({permissions: {notifications: 'YES'}})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
|
@ -27,7 +27,9 @@ export interface TestPDS {
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createServer(): Promise<TestPDS> {
|
export async function createServer(
|
||||||
|
{inviteRequired}: {inviteRequired: boolean} = {inviteRequired: false},
|
||||||
|
): Promise<TestPDS> {
|
||||||
const repoSigningKey = await crypto.Secp256k1Keypair.create()
|
const repoSigningKey = await crypto.Secp256k1Keypair.create()
|
||||||
const plcRotationKey = await crypto.Secp256k1Keypair.create()
|
const plcRotationKey = await crypto.Secp256k1Keypair.create()
|
||||||
const port = await getPort()
|
const port = await getPort()
|
||||||
|
@ -61,7 +63,7 @@ export async function createServer(): Promise<TestPDS> {
|
||||||
serverDid,
|
serverDid,
|
||||||
recoveryKey,
|
recoveryKey,
|
||||||
adminPassword: ADMIN_PASSWORD,
|
adminPassword: ADMIN_PASSWORD,
|
||||||
inviteRequired: false,
|
inviteRequired,
|
||||||
didPlcUrl: plcUrl,
|
didPlcUrl: plcUrl,
|
||||||
jwtSecret: 'jwt-secret',
|
jwtSecret: 'jwt-secret',
|
||||||
availableUserDomains: ['.test'],
|
availableUserDomains: ['.test'],
|
||||||
|
@ -76,6 +78,7 @@ export async function createServer(): Promise<TestPDS> {
|
||||||
blobstoreTmp: `${blobstoreLoc}/tmp`,
|
blobstoreTmp: `${blobstoreLoc}/tmp`,
|
||||||
maxSubscriptionBuffer: 200,
|
maxSubscriptionBuffer: 200,
|
||||||
repoBackfillLimitMs: HOUR,
|
repoBackfillLimitMs: HOUR,
|
||||||
|
userInviteInterval: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const db =
|
const db =
|
||||||
|
@ -131,8 +134,18 @@ class Mocker {
|
||||||
|
|
||||||
async createUser(name: string) {
|
async createUser(name: string) {
|
||||||
const agent = new BskyAgent({service: this.agent.service})
|
const agent = new BskyAgent({service: this.agent.service})
|
||||||
|
|
||||||
|
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
|
||||||
|
{useCount: 1},
|
||||||
|
{
|
||||||
|
headers: {authorization: `Basic ${btoa(`admin:${ADMIN_PASSWORD}`)}`},
|
||||||
|
encoding: 'application/json',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const email = `fake${Object.keys(this.users).length + 1}@fake.com`
|
const email = `fake${Object.keys(this.users).length + 1}@fake.com`
|
||||||
const res = await agent.createAccount({
|
const res = await agent.createAccount({
|
||||||
|
inviteCode: inviteRes.data.code,
|
||||||
email,
|
email,
|
||||||
handle: name + '.test',
|
handle: name + '.test',
|
||||||
password: 'hunter2',
|
password: 'hunter2',
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
"e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.2.3",
|
"@atproto/api": "0.2.4",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@expo/webpack-config": "^18.0.1",
|
"@expo/webpack-config": "^18.0.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||||
|
@ -120,7 +120,7 @@
|
||||||
"zod": "^3.20.2"
|
"zod": "^3.20.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@atproto/pds": "^0.1.0",
|
"@atproto/pds": "^0.1.3",
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.20.0",
|
||||||
"@babel/preset-env": "^7.20.0",
|
"@babel/preset-env": "^7.20.0",
|
||||||
"@babel/runtime": "^7.20.0",
|
"@babel/runtime": "^7.20.0",
|
||||||
|
|
|
@ -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])
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,13 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
|
import {ComAtprotoServerDefs} from '@atproto/api'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {PostsFeedModel} from './feeds/posts'
|
import {PostsFeedModel} from './feeds/posts'
|
||||||
import {NotificationsFeedModel} from './feeds/notifications'
|
import {NotificationsFeedModel} from './feeds/notifications'
|
||||||
import {MyFollowsCache} from './cache/my-follows'
|
import {MyFollowsCache} from './cache/my-follows'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
|
|
||||||
|
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
|
||||||
|
|
||||||
export class MeModel {
|
export class MeModel {
|
||||||
did: string = ''
|
did: string = ''
|
||||||
handle: string = ''
|
handle: string = ''
|
||||||
|
@ -16,6 +19,12 @@ export class MeModel {
|
||||||
mainFeed: PostsFeedModel
|
mainFeed: PostsFeedModel
|
||||||
notifications: NotificationsFeedModel
|
notifications: NotificationsFeedModel
|
||||||
follows: MyFollowsCache
|
follows: MyFollowsCache
|
||||||
|
invites: ComAtprotoServerDefs.InviteCode[] = []
|
||||||
|
lastProfileStateUpdate = Date.now()
|
||||||
|
|
||||||
|
get invitesAvailable() {
|
||||||
|
return this.invites.filter(isInviteAvailable).length
|
||||||
|
}
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
|
@ -39,6 +48,7 @@ export class MeModel {
|
||||||
this.displayName = ''
|
this.displayName = ''
|
||||||
this.description = ''
|
this.description = ''
|
||||||
this.avatar = ''
|
this.avatar = ''
|
||||||
|
this.invites = []
|
||||||
}
|
}
|
||||||
|
|
||||||
serialize(): unknown {
|
serialize(): unknown {
|
||||||
|
@ -85,6 +95,33 @@ export class MeModel {
|
||||||
if (sess.hasSession) {
|
if (sess.hasSession) {
|
||||||
this.did = sess.currentSession?.did || ''
|
this.did = sess.currentSession?.did || ''
|
||||||
this.handle = sess.currentSession?.handle || ''
|
this.handle = sess.currentSession?.handle || ''
|
||||||
|
await this.fetchProfile()
|
||||||
|
this.mainFeed.clear()
|
||||||
|
await Promise.all([
|
||||||
|
this.mainFeed.setup().catch(e => {
|
||||||
|
this.rootStore.log.error('Failed to setup main feed model', e)
|
||||||
|
}),
|
||||||
|
this.notifications.setup().catch(e => {
|
||||||
|
this.rootStore.log.error('Failed to setup notifications model', e)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
this.rootStore.emitSessionLoaded()
|
||||||
|
await this.fetchInviteCodes()
|
||||||
|
} else {
|
||||||
|
this.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIfNeeded() {
|
||||||
|
if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
|
||||||
|
this.rootStore.log.debug('Updating me profile information')
|
||||||
|
await this.fetchProfile()
|
||||||
|
await this.fetchInviteCodes()
|
||||||
|
}
|
||||||
|
await this.notifications.loadUnreadCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchProfile() {
|
||||||
const profile = await this.rootStore.agent.getProfile({
|
const profile = await this.rootStore.agent.getProfile({
|
||||||
actor: this.did,
|
actor: this.did,
|
||||||
})
|
})
|
||||||
|
@ -103,18 +140,35 @@ export class MeModel {
|
||||||
this.followersCount = undefined
|
this.followersCount = undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.mainFeed.clear()
|
}
|
||||||
await Promise.all([
|
|
||||||
this.mainFeed.setup().catch(e => {
|
async fetchInviteCodes() {
|
||||||
this.rootStore.log.error('Failed to setup main feed model', e)
|
if (this.rootStore.session) {
|
||||||
}),
|
try {
|
||||||
this.notifications.setup().catch(e => {
|
const res =
|
||||||
this.rootStore.log.error('Failed to setup notifications model', e)
|
await this.rootStore.agent.com.atproto.server.getAccountInviteCodes(
|
||||||
}),
|
{},
|
||||||
])
|
)
|
||||||
this.rootStore.emitSessionLoaded()
|
runInAction(() => {
|
||||||
} else {
|
this.invites = res.data.codes
|
||||||
this.clear()
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {ProfilesCache} from './cache/profiles-view'
|
||||||
import {LinkMetasCache} from './cache/link-metas'
|
import {LinkMetasCache} from './cache/link-metas'
|
||||||
import {NotificationsFeedItemModel} from './feeds/notifications'
|
import {NotificationsFeedItemModel} from './feeds/notifications'
|
||||||
import {MeModel} from './me'
|
import {MeModel} from './me'
|
||||||
|
import {InvitedUsers} from './invited-users'
|
||||||
import {PreferencesModel} from './ui/preferences'
|
import {PreferencesModel} from './ui/preferences'
|
||||||
import {resetToTab} from '../../Navigation'
|
import {resetToTab} from '../../Navigation'
|
||||||
import {ImageSizesCache} from './cache/image-sizes'
|
import {ImageSizesCache} from './cache/image-sizes'
|
||||||
|
@ -36,6 +37,7 @@ export class RootStoreModel {
|
||||||
shell = new ShellUiModel(this)
|
shell = new ShellUiModel(this)
|
||||||
preferences = new PreferencesModel()
|
preferences = new PreferencesModel()
|
||||||
me = new MeModel(this)
|
me = new MeModel(this)
|
||||||
|
invitedUsers = new InvitedUsers(this)
|
||||||
profiles = new ProfilesCache(this)
|
profiles = new ProfilesCache(this)
|
||||||
linkMetas = new LinkMetasCache(this)
|
linkMetas = new LinkMetasCache(this)
|
||||||
imageSizes = new ImageSizesCache()
|
imageSizes = new ImageSizesCache()
|
||||||
|
@ -61,6 +63,7 @@ export class RootStoreModel {
|
||||||
me: this.me.serialize(),
|
me: this.me.serialize(),
|
||||||
shell: this.shell.serialize(),
|
shell: this.shell.serialize(),
|
||||||
preferences: this.preferences.serialize(),
|
preferences: this.preferences.serialize(),
|
||||||
|
invitedUsers: this.invitedUsers.serialize(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,6 +87,9 @@ export class RootStoreModel {
|
||||||
if (hasProp(v, 'preferences')) {
|
if (hasProp(v, 'preferences')) {
|
||||||
this.preferences.hydrate(v.preferences)
|
this.preferences.hydrate(v.preferences)
|
||||||
}
|
}
|
||||||
|
if (hasProp(v, 'invitedUsers')) {
|
||||||
|
this.invitedUsers.hydrate(v.invitedUsers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -141,7 +147,7 @@ export class RootStoreModel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.me.notifications.loadUnreadCount()
|
await this.me.updateIfNeeded()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.log.error('Failed to fetch latest state', e)
|
this.log.error('Failed to fetch latest state', e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,10 @@ export interface WaitlistModal {
|
||||||
name: 'waitlist'
|
name: 'waitlist'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteCodesModal {
|
||||||
|
name: 'invite-codes'
|
||||||
|
}
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
| ConfirmModal
|
| ConfirmModal
|
||||||
| EditProfileModal
|
| EditProfileModal
|
||||||
|
@ -72,6 +76,7 @@ export type Modal =
|
||||||
| RepostModal
|
| RepostModal
|
||||||
| ChangeHandleModal
|
| ChangeHandleModal
|
||||||
| WaitlistModal
|
| WaitlistModal
|
||||||
|
| InviteCodesModal
|
||||||
|
|
||||||
interface LightboxModel {}
|
interface LightboxModel {}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
|
||||||
Invite code
|
Invite code
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
testID="inviteCodeInput"
|
||||||
icon="ticket"
|
icon="ticket"
|
||||||
placeholder="Required for this provider"
|
placeholder="Required for this provider"
|
||||||
value={model.inviteCode}
|
value={model.inviteCode}
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -14,6 +14,7 @@ import * as ReportAccountModal from './ReportAccount'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as ChangeHandleModal from './ChangeHandle'
|
import * as ChangeHandleModal from './ChangeHandle'
|
||||||
import * as WaitlistModal from './Waitlist'
|
import * as WaitlistModal from './Waitlist'
|
||||||
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {StyleSheet} from 'react-native'
|
import {StyleSheet} from 'react-native'
|
||||||
|
|
||||||
|
@ -73,6 +74,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'waitlist') {
|
} else if (activeModal?.name === 'waitlist') {
|
||||||
snapPoints = WaitlistModal.snapPoints
|
snapPoints = WaitlistModal.snapPoints
|
||||||
element = <WaitlistModal.Component />
|
element = <WaitlistModal.Component />
|
||||||
|
} else if (activeModal?.name === 'invite-codes') {
|
||||||
|
snapPoints = InviteCodesModal.snapPoints
|
||||||
|
element = <InviteCodesModal.Component />
|
||||||
} else {
|
} else {
|
||||||
return <View />
|
return <View />
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ import * as RepostModal from './Repost'
|
||||||
import * as CropImageModal from './crop-image/CropImage.web'
|
import * as CropImageModal from './crop-image/CropImage.web'
|
||||||
import * as ChangeHandleModal from './ChangeHandle'
|
import * as ChangeHandleModal from './ChangeHandle'
|
||||||
import * as WaitlistModal from './Waitlist'
|
import * as WaitlistModal from './Waitlist'
|
||||||
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
|
|
||||||
export const ModalsContainer = observer(function ModalsContainer() {
|
export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -72,6 +73,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <ChangeHandleModal.Component {...modal} />
|
element = <ChangeHandleModal.Component {...modal} />
|
||||||
} else if (modal.name === 'waitlist') {
|
} else if (modal.name === 'waitlist') {
|
||||||
element = <WaitlistModal.Component />
|
element = <WaitlistModal.Component />
|
||||||
|
} else if (modal.name === 'invite-codes') {
|
||||||
|
element = <InviteCodesModal.Component />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -6,13 +6,15 @@ import {useStores} from 'state/index'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '../util/Toast'
|
||||||
import {FollowState} from 'state/models/cache/my-follows'
|
import {FollowState} from 'state/models/cache/my-follows'
|
||||||
|
|
||||||
const FollowButton = observer(
|
export const FollowButton = observer(
|
||||||
({
|
({
|
||||||
type = 'inverted',
|
unfollowedType = 'inverted',
|
||||||
|
followedType = 'inverted',
|
||||||
did,
|
did,
|
||||||
onToggleFollow,
|
onToggleFollow,
|
||||||
}: {
|
}: {
|
||||||
type?: ButtonType
|
unfollowedType?: ButtonType
|
||||||
|
followedType?: ButtonType
|
||||||
did: string
|
did: string
|
||||||
onToggleFollow?: (v: boolean) => void
|
onToggleFollow?: (v: boolean) => void
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -48,12 +50,12 @@ const FollowButton = observer(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type={followState === FollowState.Following ? 'default' : type}
|
type={
|
||||||
|
followState === FollowState.Following ? followedType : unfollowedType
|
||||||
|
}
|
||||||
onPress={onToggleFollowInner}
|
onPress={onToggleFollowInner}
|
||||||
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
|
label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
export default FollowButton
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {UserAvatar} from '../util/UserAvatar'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import FollowButton from './FollowButton'
|
import {FollowButton} from './FollowButton'
|
||||||
|
|
||||||
export function ProfileCard({
|
export function ProfileCard({
|
||||||
testID,
|
testID,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {UserAvatar} from './UserAvatar'
|
import {UserAvatar} from './UserAvatar'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import FollowButton from '../profile/FollowButton'
|
import {FollowButton} from '../profile/FollowButton'
|
||||||
import {FollowState} from 'state/models/cache/my-follows'
|
import {FollowState} from 'state/models/cache/my-follows'
|
||||||
|
|
||||||
interface PostMetaOpts {
|
interface PostMetaOpts {
|
||||||
|
@ -78,7 +78,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<FollowButton
|
<FollowButton
|
||||||
type="default"
|
unfollowedType="default"
|
||||||
did={opts.did}
|
did={opts.did}
|
||||||
onToggleFollow={onToggleFollow}
|
onToggleFollow={onToggleFollow}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -25,6 +25,7 @@ export function Button({
|
||||||
type = 'primary',
|
type = 'primary',
|
||||||
label,
|
label,
|
||||||
style,
|
style,
|
||||||
|
labelStyle,
|
||||||
onPress,
|
onPress,
|
||||||
children,
|
children,
|
||||||
testID,
|
testID,
|
||||||
|
@ -32,11 +33,14 @@ export function Button({
|
||||||
type?: ButtonType
|
type?: ButtonType
|
||||||
label?: string
|
label?: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
labelStyle?: StyleProp<TextStyle>
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
testID?: string
|
testID?: string
|
||||||
}>) {
|
}>) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, {
|
const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
|
||||||
|
type,
|
||||||
|
{
|
||||||
primary: {
|
primary: {
|
||||||
backgroundColor: theme.palette.primary.background,
|
backgroundColor: theme.palette.primary.background,
|
||||||
},
|
},
|
||||||
|
@ -68,8 +72,11 @@ export function Button({
|
||||||
'default-light': {
|
'default-light': {
|
||||||
backgroundColor: theme.palette.default.background,
|
backgroundColor: theme.palette.default.background,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
const labelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(type, {
|
)
|
||||||
|
const typeLabelStyle = choose<TextStyle, Record<ButtonType, TextStyle>>(
|
||||||
|
type,
|
||||||
|
{
|
||||||
primary: {
|
primary: {
|
||||||
color: theme.palette.primary.text,
|
color: theme.palette.primary.text,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
@ -105,14 +112,15 @@ export function Button({
|
||||||
color: theme.palette.default.text,
|
color: theme.palette.default.text,
|
||||||
fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
|
fontWeight: theme.palette.default.isLowContrast ? '500' : undefined,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[outerStyle, styles.outer, style]}
|
style={[typeOuterStyle, styles.outer, style]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
testID={testID}>
|
testID={testID}>
|
||||||
{label ? (
|
{label ? (
|
||||||
<Text type="button" style={[labelStyle]}>
|
<Text type="button" style={[typeLabelStyle, labelStyle]}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
import {Feed} from '../com/notifications/Feed'
|
import {Feed} from '../com/notifications/Feed'
|
||||||
|
import {InvitedUsers} from '../com/notifications/InvitedUsers'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
|
@ -89,6 +90,7 @@ export const NotificationsScreen = withAuthRequired(
|
||||||
return (
|
return (
|
||||||
<View testID="notificationsScreen" style={s.hContentRegion}>
|
<View testID="notificationsScreen" style={s.hContentRegion}>
|
||||||
<ViewHeader title="Notifications" canGoBack={false} />
|
<ViewHeader title="Notifications" canGoBack={false} />
|
||||||
|
<InvitedUsers />
|
||||||
<Feed
|
<Feed
|
||||||
view={store.me.notifications}
|
view={store.me.notifications}
|
||||||
onPressTryAgain={onPressTryAgain}
|
onPressTryAgain={onPressTryAgain}
|
||||||
|
|
|
@ -2,8 +2,10 @@ import React from 'react'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
TextStyle,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {
|
import {
|
||||||
useFocusEffect,
|
useFocusEffect,
|
||||||
|
@ -27,22 +29,39 @@ import {Text} from '../com/util/text/Text'
|
||||||
import * as Toast from '../com/util/Toast'
|
import * as Toast from '../com/util/Toast'
|
||||||
import {UserAvatar} from '../com/util/UserAvatar'
|
import {UserAvatar} from '../com/util/UserAvatar'
|
||||||
import {DropdownButton} from 'view/com/util/forms/DropdownButton'
|
import {DropdownButton} from 'view/com/util/forms/DropdownButton'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useCustomPalette} from 'lib/hooks/useCustomPalette'
|
||||||
import {AccountData} from 'state/models/session'
|
import {AccountData} from 'state/models/session'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
import {useAnalytics} from 'lib/analytics'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
import {pluralize} from 'lib/strings/helpers'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
||||||
export const SettingsScreen = withAuthRequired(
|
export const SettingsScreen = withAuthRequired(
|
||||||
observer(function Settings({}: Props) {
|
observer(function Settings({}: Props) {
|
||||||
const theme = useTheme()
|
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
const [isSwitching, setIsSwitching] = React.useState(false)
|
const [isSwitching, setIsSwitching] = React.useState(false)
|
||||||
|
|
||||||
|
const primaryBg = useCustomPalette<ViewStyle>({
|
||||||
|
light: {backgroundColor: colors.blue0},
|
||||||
|
dark: {backgroundColor: colors.blue6},
|
||||||
|
})
|
||||||
|
const primaryText = useCustomPalette<TextStyle>({
|
||||||
|
light: {color: colors.blue3},
|
||||||
|
dark: {color: colors.blue2},
|
||||||
|
})
|
||||||
|
const dangerBg = useCustomPalette<ViewStyle>({
|
||||||
|
light: {backgroundColor: colors.red1},
|
||||||
|
dark: {backgroundColor: colors.red7},
|
||||||
|
})
|
||||||
|
const dangerText = useCustomPalette<TextStyle>({
|
||||||
|
light: {color: colors.red4},
|
||||||
|
dark: {color: colors.red2},
|
||||||
|
})
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
screen('Settings')
|
screen('Settings')
|
||||||
|
@ -50,7 +69,8 @@ export const SettingsScreen = withAuthRequired(
|
||||||
}, [screen, store]),
|
}, [screen, store]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressSwitchAccount = async (acct: AccountData) => {
|
const onPressSwitchAccount = React.useCallback(
|
||||||
|
async (acct: AccountData) => {
|
||||||
track('Settings:SwitchAccountButtonClicked')
|
track('Settings:SwitchAccountButtonClicked')
|
||||||
setIsSwitching(true)
|
setIsSwitching(true)
|
||||||
if (await store.session.resumeSession(acct)) {
|
if (await store.session.resumeSession(acct)) {
|
||||||
|
@ -65,14 +85,18 @@ export const SettingsScreen = withAuthRequired(
|
||||||
navigation.navigate('HomeTab')
|
navigation.navigate('HomeTab')
|
||||||
navigation.dispatch(StackActions.popToTop())
|
navigation.dispatch(StackActions.popToTop())
|
||||||
store.session.clear()
|
store.session.clear()
|
||||||
}
|
},
|
||||||
const onPressAddAccount = () => {
|
[track, setIsSwitching, navigation, store],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressAddAccount = React.useCallback(() => {
|
||||||
track('Settings:AddAccountButtonClicked')
|
track('Settings:AddAccountButtonClicked')
|
||||||
navigation.navigate('HomeTab')
|
navigation.navigate('HomeTab')
|
||||||
navigation.dispatch(StackActions.popToTop())
|
navigation.dispatch(StackActions.popToTop())
|
||||||
store.session.clear()
|
store.session.clear()
|
||||||
}
|
}, [track, navigation, store])
|
||||||
const onPressChangeHandle = () => {
|
|
||||||
|
const onPressChangeHandle = React.useCallback(() => {
|
||||||
track('Settings:ChangeHandleButtonClicked')
|
track('Settings:ChangeHandleButtonClicked')
|
||||||
store.shell.openModal({
|
store.shell.openModal({
|
||||||
name: 'change-handle',
|
name: 'change-handle',
|
||||||
|
@ -93,14 +117,21 @@ export const SettingsScreen = withAuthRequired(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}, [track, store, setIsSwitching])
|
||||||
const onPressSignout = () => {
|
|
||||||
|
const onPressInviteCodes = React.useCallback(() => {
|
||||||
|
track('Settings:InvitecodesButtonClicked')
|
||||||
|
store.shell.openModal({name: 'invite-codes'})
|
||||||
|
}, [track, store])
|
||||||
|
|
||||||
|
const onPressSignout = React.useCallback(() => {
|
||||||
track('Settings:SignOutButtonClicked')
|
track('Settings:SignOutButtonClicked')
|
||||||
store.session.logout()
|
store.session.logout()
|
||||||
}
|
}, [track, store])
|
||||||
const onPressDeleteAccount = () => {
|
|
||||||
|
const onPressDeleteAccount = React.useCallback(() => {
|
||||||
store.shell.openModal({name: 'delete-account'})
|
store.shell.openModal({name: 'delete-account'})
|
||||||
}
|
}, [store])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[s.hContentRegion]} testID="settingsScreen">
|
<View style={[s.hContentRegion]} testID="settingsScreen">
|
||||||
|
@ -183,6 +214,37 @@ export const SettingsScreen = withAuthRequired(
|
||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
|
Invite a friend
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="inviteFriendBtn"
|
||||||
|
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
|
||||||
|
onPress={isSwitching ? undefined : onPressInviteCodes}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.iconContainer,
|
||||||
|
store.me.invitesAvailable > 0 ? primaryBg : pal.btn,
|
||||||
|
]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="ticket"
|
||||||
|
style={
|
||||||
|
(store.me.invitesAvailable > 0
|
||||||
|
? primaryText
|
||||||
|
: pal.text) as FontAwesomeIconStyle
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
type="lg"
|
||||||
|
style={store.me.invitesAvailable > 0 ? pal.link : pal.text}>
|
||||||
|
{store.me.invitesAvailable} invite{' '}
|
||||||
|
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
Advanced
|
Advanced
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -209,30 +271,14 @@ export const SettingsScreen = withAuthRequired(
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[pal.view, styles.linkCard]}
|
style={[pal.view, styles.linkCard]}
|
||||||
onPress={onPressDeleteAccount}>
|
onPress={onPressDeleteAccount}>
|
||||||
<View
|
<View style={[styles.iconContainer, dangerBg]}>
|
||||||
style={[
|
|
||||||
styles.iconContainer,
|
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? styles.trashIconContainerDark
|
|
||||||
: styles.trashIconContainerLight,
|
|
||||||
]}>
|
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={['far', 'trash-can']}
|
icon={['far', 'trash-can']}
|
||||||
style={
|
style={dangerText as FontAwesomeIconStyle}
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? styles.dangerDark
|
|
||||||
: styles.dangerLight
|
|
||||||
}
|
|
||||||
size={21}
|
size={21}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text type="lg" style={dangerText}>
|
||||||
type="lg"
|
|
||||||
style={
|
|
||||||
theme.colorScheme === 'dark'
|
|
||||||
? styles.dangerDark
|
|
||||||
: styles.dangerLight
|
|
||||||
}>
|
|
||||||
Delete my account
|
Delete my account
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
@ -331,18 +377,6 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
trashIconContainerDark: {
|
|
||||||
backgroundColor: colors.red7,
|
|
||||||
},
|
|
||||||
trashIconContainerLight: {
|
|
||||||
backgroundColor: colors.red1,
|
|
||||||
},
|
|
||||||
dangerLight: {
|
|
||||||
color: colors.red4,
|
|
||||||
},
|
|
||||||
dangerDark: {
|
|
||||||
color: colors.red2,
|
|
||||||
},
|
|
||||||
buildInfo: {
|
buildInfo: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
paddingHorizontal: 18,
|
paddingHorizontal: 18,
|
||||||
|
|
|
@ -167,7 +167,9 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onPress={onPressNotifications}
|
onPress={onPressNotifications}
|
||||||
notificationCount={store.me.notifications.unreadCount}
|
notificationCount={
|
||||||
|
store.me.notifications.unreadCount + store.invitedUsers.numNotifs
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Btn
|
<Btn
|
||||||
testID="bottomBarProfileBtn"
|
testID="bottomBarProfileBtn"
|
||||||
|
|
|
@ -103,63 +103,19 @@ export const DrawerContent = observer(() => {
|
||||||
store.shell.closeDrawer()
|
store.shell.closeDrawer()
|
||||||
}, [navigation, track, store.shell])
|
}, [navigation, track, store.shell])
|
||||||
|
|
||||||
const onPressFeedback = () => {
|
const onPressFeedback = React.useCallback(() => {
|
||||||
track('Menu:FeedbackClicked')
|
track('Menu:FeedbackClicked')
|
||||||
Linking.openURL(FEEDBACK_FORM_URL)
|
Linking.openURL(FEEDBACK_FORM_URL)
|
||||||
}
|
}, [track])
|
||||||
|
|
||||||
|
const onDarkmodePress = React.useCallback(() => {
|
||||||
|
track('Menu:ItemClicked', {url: '#darkmode'})
|
||||||
|
store.shell.setDarkMode(!store.shell.darkMode)
|
||||||
|
}, [track, store])
|
||||||
|
|
||||||
// rendering
|
// rendering
|
||||||
// =
|
// =
|
||||||
|
|
||||||
const MenuItem = ({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
count,
|
|
||||||
bold,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
icon: JSX.Element
|
|
||||||
label: string
|
|
||||||
count?: number
|
|
||||||
bold?: boolean
|
|
||||||
onPress: () => void
|
|
||||||
}) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID={`menuItemButton-${label}`}
|
|
||||||
style={styles.menuItem}
|
|
||||||
onPress={onPress}>
|
|
||||||
<View style={[styles.menuItemIconWrapper]}>
|
|
||||||
{icon}
|
|
||||||
{count ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.menuItemCount,
|
|
||||||
count > 99
|
|
||||||
? styles.menuItemCountHundreds
|
|
||||||
: count > 9
|
|
||||||
? styles.menuItemCountTens
|
|
||||||
: undefined,
|
|
||||||
]}>
|
|
||||||
<Text style={styles.menuItemCountLabel} numberOfLines={1}>
|
|
||||||
{count > 999 ? `${Math.round(count / 1000)}k` : count}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : undefined}
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
type={bold ? '2xl-bold' : '2xl'}
|
|
||||||
style={[pal.text, s.flex1]}
|
|
||||||
numberOfLines={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
|
|
||||||
const onDarkmodePress = () => {
|
|
||||||
track('Menu:ItemClicked', {url: '/darkmode'})
|
|
||||||
store.shell.setDarkMode(!store.shell.darkMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
testID="drawer"
|
testID="drawer"
|
||||||
|
@ -168,6 +124,7 @@ export const DrawerContent = observer(() => {
|
||||||
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
|
theme.colorScheme === 'light' ? pal.view : styles.viewDarkMode,
|
||||||
]}>
|
]}>
|
||||||
<SafeAreaView style={s.flex1}>
|
<SafeAreaView style={s.flex1}>
|
||||||
|
<View style={styles.main}>
|
||||||
<TouchableOpacity testID="profileCardButton" onPress={onPressProfile}>
|
<TouchableOpacity testID="profileCardButton" onPress={onPressProfile}>
|
||||||
<UserAvatar size={80} avatar={store.me.avatar} />
|
<UserAvatar size={80} avatar={store.me.avatar} />
|
||||||
<Text
|
<Text
|
||||||
|
@ -178,7 +135,9 @@ export const DrawerContent = observer(() => {
|
||||||
<Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
|
<Text type="2xl" style={[pal.textLight, styles.profileCardHandle]}>
|
||||||
@{store.me.handle}
|
@{store.me.handle}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
|
<Text
|
||||||
|
type="xl"
|
||||||
|
style={[pal.textLight, styles.profileCardFollowers]}>
|
||||||
<Text type="xl-medium" style={pal.text}>
|
<Text type="xl-medium" style={pal.text}>
|
||||||
{store.me.followersCount || 0}
|
{store.me.followersCount || 0}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
|
@ -189,8 +148,10 @@ export const DrawerContent = observer(() => {
|
||||||
following
|
following
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<InviteCodes />
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
<View>
|
<View style={styles.main}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={
|
icon={
|
||||||
isAtSearch ? (
|
isAtSearch ? (
|
||||||
|
@ -248,7 +209,9 @@ export const DrawerContent = observer(() => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
label="Notifications"
|
label="Notifications"
|
||||||
count={store.me.notifications.unreadCount}
|
count={
|
||||||
|
store.me.notifications.unreadCount + store.invitedUsers.numNotifs
|
||||||
|
}
|
||||||
bold={isAtNotifications}
|
bold={isAtNotifications}
|
||||||
onPress={onPressNotifications}
|
onPress={onPressNotifications}
|
||||||
/>
|
/>
|
||||||
|
@ -315,16 +278,97 @@ export const DrawerContent = observer(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
bold,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
icon: JSX.Element
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
bold?: boolean
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID={`menuItemButton-${label}`}
|
||||||
|
style={styles.menuItem}
|
||||||
|
onPress={onPress}>
|
||||||
|
<View style={[styles.menuItemIconWrapper]}>
|
||||||
|
{icon}
|
||||||
|
{count ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.menuItemCount,
|
||||||
|
count > 99
|
||||||
|
? styles.menuItemCountHundreds
|
||||||
|
: count > 9
|
||||||
|
? styles.menuItemCountTens
|
||||||
|
: undefined,
|
||||||
|
]}>
|
||||||
|
<Text style={styles.menuItemCountLabel} numberOfLines={1}>
|
||||||
|
{count > 999 ? `${Math.round(count / 1000)}k` : count}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
type={bold ? '2xl-bold' : '2xl'}
|
||||||
|
style={[pal.text, s.flex1]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const InviteCodes = observer(() => {
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
const store = useStores()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const onPress = React.useCallback(() => {
|
||||||
|
track('Menu:ItemClicked', {url: '#invite-codes'})
|
||||||
|
store.shell.closeDrawer()
|
||||||
|
store.shell.openModal({name: 'invite-codes'})
|
||||||
|
}, [store, track])
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="menuItemInviteCodes"
|
||||||
|
style={[styles.inviteCodes]}
|
||||||
|
onPress={onPress}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="ticket"
|
||||||
|
style={[
|
||||||
|
styles.inviteCodesIcon,
|
||||||
|
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||||
|
]}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
type="lg-medium"
|
||||||
|
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||||
|
{store.me.invitesAvailable} invite{' '}
|
||||||
|
{pluralize(store.me.invitesAvailable, 'code')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
view: {
|
view: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingTop: 20,
|
paddingTop: 20,
|
||||||
paddingBottom: 50,
|
paddingBottom: 50,
|
||||||
paddingLeft: 20,
|
|
||||||
},
|
},
|
||||||
viewDarkMode: {
|
viewDarkMode: {
|
||||||
backgroundColor: '#1B1919',
|
backgroundColor: '#1B1919',
|
||||||
},
|
},
|
||||||
|
main: {
|
||||||
|
paddingLeft: 20,
|
||||||
|
},
|
||||||
|
|
||||||
profileCardDisplayName: {
|
profileCardDisplayName: {
|
||||||
marginTop: 20,
|
marginTop: 20,
|
||||||
|
@ -336,7 +380,7 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
profileCardFollowers: {
|
profileCardFollowers: {
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
paddingRight: 30,
|
paddingRight: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
menuItem: {
|
menuItem: {
|
||||||
|
@ -376,11 +420,22 @@ const styles = StyleSheet.create({
|
||||||
color: colors.white,
|
color: colors.white,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
inviteCodes: {
|
||||||
|
paddingLeft: 22,
|
||||||
|
paddingVertical: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
inviteCodesIcon: {
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
|
||||||
footer: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingRight: 30,
|
paddingRight: 30,
|
||||||
paddingTop: 80,
|
paddingTop: 20,
|
||||||
|
paddingLeft: 20,
|
||||||
},
|
},
|
||||||
footerBtn: {
|
footerBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|
|
@ -157,7 +157,9 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/notifications"
|
href="/notifications"
|
||||||
count={store.me.notifications.unreadCount}
|
count={
|
||||||
|
store.me.notifications.unreadCount + store.invitedUsers.numNotifs
|
||||||
|
}
|
||||||
icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />}
|
icon={<BellIcon strokeWidth={2} size={24} style={pal.text} />}
|
||||||
iconFilled={
|
iconFilled={
|
||||||
<BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />
|
<BellIconSolid strokeWidth={1.5} size={24} style={pal.text} />
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {DesktopSearch} from './Search'
|
import {DesktopSearch} from './Search'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
|
@ -8,6 +9,7 @@ import {TextLink} from 'view/com/util/Link'
|
||||||
import {FEEDBACK_FORM_URL} from 'lib/constants'
|
import {FEEDBACK_FORM_URL} from 'lib/constants'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
import {pluralize} from 'lib/strings/helpers'
|
||||||
|
|
||||||
export const DesktopRightNav = observer(function DesktopRightNav() {
|
export const DesktopRightNav = observer(function DesktopRightNav() {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -38,10 +40,40 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<InviteCodes />
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function InviteCodes() {
|
||||||
|
const store = useStores()
|
||||||
|
const pal = usePalette('default')
|
||||||
|
|
||||||
|
const onPress = React.useCallback(() => {
|
||||||
|
store.shell.openModal({name: 'invite-codes'})
|
||||||
|
}, [store])
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.inviteCodes, pal.border]}
|
||||||
|
onPress={onPress}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="ticket"
|
||||||
|
style={[
|
||||||
|
styles.inviteCodesIcon,
|
||||||
|
store.me.invitesAvailable > 0 ? pal.link : pal.textLight,
|
||||||
|
]}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
type="md-medium"
|
||||||
|
style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}>
|
||||||
|
{store.me.invitesAvailable} invite{' '}
|
||||||
|
{pluralize(store.me.invitesAvailable, 'code')} available
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
rightNav: {
|
rightNav: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
@ -57,4 +89,16 @@ const styles = StyleSheet.create({
|
||||||
messageLine: {
|
messageLine: {
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
inviteCodes: {
|
||||||
|
marginTop: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
inviteCodesIcon: {
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -30,10 +30,10 @@
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
typed-emitter "^2.1.0"
|
typed-emitter "^2.1.0"
|
||||||
|
|
||||||
"@atproto/api@0.2.3":
|
"@atproto/api@0.2.4":
|
||||||
version "0.2.3"
|
version "0.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.3.tgz#0eb9cb542c113b2c839f2c5ca284c30b117f489a"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.4.tgz#0a14af4f37aa665bd70a1b5f9f5d31db02313ad9"
|
||||||
integrity sha512-i0tWdOPQyZuSlkd2MY3s7QTac2ovH104tzy5rJwTZXZyhpf2Zom1xedaHb+pQmFzug7YaD7tx7OMSPlJIV0dpg==
|
integrity sha512-EOegRw4/TaN8Px9M/rPiWQlqIkN+QXeU3Y8NUFofqgApPiatmayiYpQiR0iBhZmFnlYFuRt6tLQBjPypI/dvfA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "*"
|
"@atproto/common-web" "*"
|
||||||
"@atproto/uri" "*"
|
"@atproto/uri" "*"
|
||||||
|
@ -122,10 +122,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
|
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
|
||||||
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
|
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
|
||||||
|
|
||||||
"@atproto/pds@^0.1.0":
|
"@atproto/pds@^0.1.3":
|
||||||
version "0.1.0"
|
version "0.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.0.tgz#8014269c12a322b14618e0991c534979a4b145d7"
|
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.3.tgz#601c556cd1e10306c9b741d9361bc54d70bb2869"
|
||||||
integrity sha512-f1KPONxim674owWcTsR8S5r57+b7evg+zy+jkcTX00BB0fO6PchDL6sTQQc1x3u2QZArHDSUUUgoHt4IWwsfkw==
|
integrity sha512-cVvmgXkzu7w1tDGGDK904sDzxF2AUqu0ij/1EU2rYmnZZAK+FTjKs8cqrJzRur9vm07A23JvBTuINtYzxHwSzA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/api" "*"
|
"@atproto/api" "*"
|
||||||
"@atproto/common" "*"
|
"@atproto/common" "*"
|
||||||
|
|
Loading…
Reference in New Issue