Compare commits
23 Commits
ls
...
session-alignment
| Author | SHA1 | Date | |
|---|---|---|---|
| 3149f5234d | |||
| 73fec7fb3c | |||
| e07269cca2 | |||
| c6e77961fc | |||
| 9721bbaeb3 | |||
| fd085fd437 | |||
| 0d88a212ee | |||
| d9d9032456 | |||
| 1f9679b920 | |||
| 896e67120c | |||
| d0ffb90fb3 | |||
| 5be41c967b | |||
| dfb0aae97a | |||
| fd001836ed | |||
| cbac0a91cf | |||
| ef7e2a981e | |||
| b8ecb06322 | |||
| 57c2d61931 | |||
| 5d9e48308d | |||
| e4e5bfb4c9 | |||
| de370bd2cc | |||
| 0ba76deb0c | |||
| cd5b555b08 |
+7
-7
@@ -16,8 +16,13 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from '#/state/session'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
@@ -31,11 +36,6 @@ import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
@@ -66,7 +66,7 @@ function InnerApp() {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
|
||||
const account = persisted.get('session').currentAccount
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [resumeSession, _])
|
||||
|
||||
|
||||
+7
-7
@@ -7,8 +7,13 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from '#/state/session'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
@@ -19,11 +24,6 @@ import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
@@ -42,7 +42,7 @@ function InnerApp() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
const account = persisted.get('session').currentAccount
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const BSKY_SERVICE = 'https://bsky.social'
|
||||
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {useCallback} from 'react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {useSessionApi, SessionAccount} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {SessionAccount, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
@@ -44,9 +45,14 @@ export function useAccountSwitcher() {
|
||||
'circle-exclamation',
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
Toast.show('Sorry! We need you to enter your password.')
|
||||
} catch (e: any) {
|
||||
logger.error(`switch account: selectAccount failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
clearCurrentAccount() // back user out to login
|
||||
setTimeout(() => {
|
||||
Toast.show('Sorry! We need you to enter your password.')
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -38,15 +39,22 @@ export const ChooseAccountForm = ({
|
||||
setShowLoggedOut(false)
|
||||
Toast.show(_(msg`Already signed in as @${account.handle}`))
|
||||
} else {
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
try {
|
||||
await initSession(account)
|
||||
logEvent('account:loggedIn', {
|
||||
logContext: 'ChooseAccountForm',
|
||||
withPassword: false,
|
||||
})
|
||||
track('Sign In', {resumedSession: true})
|
||||
setTimeout(() => {
|
||||
Toast.show(_(msg`Signed in as @${account.handle}`))
|
||||
}, 100)
|
||||
} catch (e: any) {
|
||||
logger.error('choose account: initSession failed', {
|
||||
message: e.message,
|
||||
})
|
||||
onSelectAccount(account)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onSelectAccount(account)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import {logger} from '#/logger'
|
||||
import {defaults, Schema} from '#/state/persisted/schema'
|
||||
import {migrate} from '#/state/persisted/legacy'
|
||||
import * as store from '#/state/persisted/store'
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
|
||||
export type {Schema, PersistedAccount} from '#/state/persisted/schema'
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {logger} from '#/logger'
|
||||
import {migrate} from '#/state/persisted/legacy'
|
||||
import {defaults, Schema} from '#/state/persisted/schema'
|
||||
import * as store from '#/state/persisted/store'
|
||||
|
||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||
export {defaults} from '#/state/persisted/schema'
|
||||
|
||||
const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {z} from 'zod'
|
||||
|
||||
import {deviceLocales} from '#/platform/detection'
|
||||
|
||||
const externalEmbedOptions = ['show', 'hide'] as const
|
||||
|
||||
// only data needed for rendering account page
|
||||
/**
|
||||
* A account persisted to storage. Stored in the `accounts[]` array. Contains
|
||||
* base account info and access tokens.
|
||||
*/
|
||||
const accountSchema = z.object({
|
||||
service: z.string(),
|
||||
did: z.string(),
|
||||
@@ -16,12 +20,25 @@ const accountSchema = z.object({
|
||||
})
|
||||
export type PersistedAccount = z.infer<typeof accountSchema>
|
||||
|
||||
/**
|
||||
* The current account. Stored in the `currentAccount` field.
|
||||
*
|
||||
* In previous versions, this included tokens and other info. Now, it's used
|
||||
* only to reference the `did` field, and all other fields are marked as
|
||||
* optional. They should be considered deprecated and not used, but are kept
|
||||
* here for backwards compat.
|
||||
*/
|
||||
const currentAccountScheme = accountSchema.extend({
|
||||
service: z.string().optional(),
|
||||
handle: z.string().optional(),
|
||||
})
|
||||
|
||||
export const schema = z.object({
|
||||
colorMode: z.enum(['system', 'light', 'dark']),
|
||||
darkTheme: z.enum(['dim', 'dark']).optional(),
|
||||
session: z.object({
|
||||
accounts: z.array(accountSchema),
|
||||
currentAccount: accountSchema.optional(),
|
||||
currentAccount: currentAccountScheme.optional(),
|
||||
}),
|
||||
reminders: z.object({
|
||||
lastEmailConfirm: z.string().optional(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
|
||||
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
|
||||
|
||||
export const PUBLIC_BSKY_AGENT = new BskyAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
service: PUBLIC_BSKY_SERVICE,
|
||||
})
|
||||
|
||||
export const STALE = {
|
||||
|
||||
+329
-492
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
|
||||
import {LogEvents} from '#/lib/statsig/statsig'
|
||||
import {PersistedAccount} from '#/state/persisted'
|
||||
|
||||
/**
|
||||
* Alias for `PersistedAccount` from persisted storage.
|
||||
*/
|
||||
export type SessionAccount = PersistedAccount
|
||||
|
||||
/**
|
||||
* Subset of `SessionAccount` that excludes tokens.
|
||||
*/
|
||||
export type CurrentAccount = Omit<SessionAccount, 'accessJwt' | 'refreshJwt'>
|
||||
|
||||
/**
|
||||
* Context shape returned from `useSession()`
|
||||
*/
|
||||
export type SessionStateContext = {
|
||||
currentAgent: BskyAgent
|
||||
isInitialLoad: boolean
|
||||
isSwitchingAccounts: boolean
|
||||
hasSession: boolean
|
||||
accounts: SessionAccount[]
|
||||
/**
|
||||
* Contains the full account object persisted to storage, minus access
|
||||
* tokens.
|
||||
*/
|
||||
currentAccount: CurrentAccount | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Context shape returned from `useSessionApi()`
|
||||
*/
|
||||
export type SessionApiContext = {
|
||||
createAccount: (props: {
|
||||
service: string
|
||||
email: string
|
||||
password: string
|
||||
handle: string
|
||||
inviteCode?: string
|
||||
verificationPhone?: string
|
||||
verificationCode?: string
|
||||
}) => Promise<void>
|
||||
login: (
|
||||
props: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
},
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => Promise<void>
|
||||
/**
|
||||
* A full logout. Clears the `currentAccount` from session, AND removes
|
||||
* access tokens from all accounts, so that returning as any user will
|
||||
* require a full login.
|
||||
*/
|
||||
logout: (
|
||||
logContext: LogEvents['account:loggedOut']['logContext'],
|
||||
) => Promise<void>
|
||||
/**
|
||||
* A partial logout. Clears the `currentAccount` from session, but DOES NOT
|
||||
* clear access tokens from accounts, allowing the user to return to their
|
||||
* other accounts without logging in.
|
||||
*
|
||||
* Used when adding a new account, deleting an account.
|
||||
*/
|
||||
clearCurrentAccount: () => void
|
||||
initSession: (account: SessionAccount) => Promise<void>
|
||||
resumeSession: (account?: SessionAccount) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
selectAccount: (
|
||||
account: SessionAccount,
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => Promise<void>
|
||||
/**
|
||||
* Refreshes the BskyAgent's session and derive a fresh `currentAccount`
|
||||
*/
|
||||
refreshSession: () => void
|
||||
/**
|
||||
* @deprecated Use `refreshSession` instead.
|
||||
*/
|
||||
updateCurrentAccount: (
|
||||
account: Partial<
|
||||
Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'>
|
||||
>,
|
||||
) => void
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api'
|
||||
import {jwtDecode} from 'jwt-decode'
|
||||
|
||||
import {IS_TEST_USER} from '#/lib/constants'
|
||||
import {hasProp} from '#/lib/type-guards'
|
||||
import {logger} from '#/logger'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {readLabelers} from '#/state/session/agent-config'
|
||||
import {SessionAccount, SessionApiContext} from '#/state/session/types'
|
||||
|
||||
export function isSessionDeactivated(accessJwt: string | undefined) {
|
||||
if (accessJwt) {
|
||||
const sessData = jwtDecode(accessJwt)
|
||||
return (
|
||||
hasProp(sessData, 'scope') && sessData.scope === 'com.atproto.deactivated'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function readLastActiveAccount() {
|
||||
const {currentAccount, accounts} = persisted.get('session')
|
||||
return accounts.find(a => a.did === currentAccount?.did)
|
||||
}
|
||||
|
||||
export function agentToSessionAccount(
|
||||
agent: BskyAgent,
|
||||
): SessionAccount | undefined {
|
||||
if (!agent.session) return undefined
|
||||
|
||||
return {
|
||||
service: agent.service.toString(),
|
||||
did: agent.session.did,
|
||||
handle: agent.session.handle,
|
||||
email: agent.session.email,
|
||||
emailConfirmed: agent.session.emailConfirmed,
|
||||
deactivated: isSessionDeactivated(agent.session.accessJwt),
|
||||
refreshJwt: agent.session.refreshJwt,
|
||||
accessJwt: agent.session.accessJwt,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionAccountToAgentSession(
|
||||
account: SessionAccount,
|
||||
): BskyAgent['session'] {
|
||||
return {
|
||||
did: account.did,
|
||||
handle: account.handle,
|
||||
email: account.email,
|
||||
emailConfirmed: account.emailConfirmed,
|
||||
accessJwt: account.accessJwt || '',
|
||||
refreshJwt: account.refreshJwt || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureModeration(
|
||||
agent: BskyAgent,
|
||||
account?: SessionAccount,
|
||||
) {
|
||||
if (account) {
|
||||
if (IS_TEST_USER(account.handle)) {
|
||||
const did = (
|
||||
await agent
|
||||
.resolveHandle({handle: 'mod-authority.test'})
|
||||
.catch(_ => undefined)
|
||||
)?.data.did
|
||||
if (did) {
|
||||
console.warn('USING TEST ENV MODERATION')
|
||||
BskyAgent.configure({appLabelers: [did]})
|
||||
}
|
||||
} else {
|
||||
BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||
|
||||
if (account) {
|
||||
const labelerDids = await readLabelers(account.did).catch(_ => {})
|
||||
if (labelerDids) {
|
||||
agent.configureLabelersHeader(
|
||||
labelerDids.filter(did => did !== BSKY_LABELER_DID),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
|
||||
}
|
||||
}
|
||||
|
||||
export function isSessionExpired(account: SessionAccount) {
|
||||
let canReusePrevSession = false
|
||||
try {
|
||||
if (account.accessJwt) {
|
||||
const decoded = jwtDecode(account.accessJwt)
|
||||
if (decoded.exp) {
|
||||
const didExpire = Date.now() >= decoded.exp * 1000
|
||||
if (!didExpire) {
|
||||
canReusePrevSession = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: could not decode jwt`)
|
||||
}
|
||||
|
||||
return !canReusePrevSession
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin({
|
||||
service,
|
||||
identifier,
|
||||
password,
|
||||
}: {
|
||||
service: string
|
||||
identifier: string
|
||||
password: string
|
||||
}) {
|
||||
const agent = new BskyAgent({service})
|
||||
await agent.login({identifier, password})
|
||||
|
||||
if (!agent.session) {
|
||||
throw new Error(`session: login failed to establish a session`)
|
||||
}
|
||||
|
||||
const account = agentToSessionAccount(agent)!
|
||||
await configureModeration(agent, account)
|
||||
|
||||
return {
|
||||
agent,
|
||||
account,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount({
|
||||
service,
|
||||
email,
|
||||
password,
|
||||
handle,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
}: Parameters<SessionApiContext['createAccount']>[0]) {
|
||||
const agent = new BskyAgent({service})
|
||||
|
||||
await agent.createAccount({
|
||||
handle,
|
||||
password,
|
||||
email,
|
||||
inviteCode,
|
||||
verificationPhone,
|
||||
verificationCode,
|
||||
})
|
||||
|
||||
if (!agent.session) {
|
||||
throw new Error(`session: createAccount failed to establish a session`)
|
||||
}
|
||||
|
||||
const deactivated = isSessionDeactivated(agent.session.accessJwt)
|
||||
if (!deactivated) {
|
||||
/*dont await*/ agent.upsertProfile(_existing => {
|
||||
return {
|
||||
displayName: '',
|
||||
|
||||
// HACKFIX
|
||||
// creating a bunch of identical profile objects is breaking the relay
|
||||
// tossing this unspecced field onto it to reduce the size of the problem
|
||||
// -prf
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const account = agentToSessionAccount(agent)!
|
||||
|
||||
await configureModeration(agent, account)
|
||||
|
||||
return {
|
||||
agent,
|
||||
account,
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, {useState} from 'react'
|
||||
import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {getAgent, useSession, useSessionApi} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useSession, useSessionApi, getAgent} from '#/state/session'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
|
||||
enum Stages {
|
||||
InputEmail,
|
||||
|
||||
@@ -6,23 +6,24 @@ import {
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {Svg, Circle, Path} from 'react-native-svg'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {Circle, Path, Svg} from 'react-native-svg'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {getAgent, useSession, useSessionApi} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useSession, useSessionApi, getAgent} from '#/state/session'
|
||||
import {logger} from '#/logger'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
|
||||
Reference in New Issue
Block a user