diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 6fa740978b..3cdac1d101 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1121,69 +1121,7 @@
"count": 2
}
},
- "src/screens/Login/ChooseAccountForm.tsx": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- },
- "@typescript-eslint/no-misused-promises": {
- "count": 1
- }
- },
- "src/screens/Login/ForgotPasswordForm.tsx": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- },
- "@typescript-eslint/no-misused-promises": {
- "count": 1
- },
- "@typescript-eslint/no-unsafe-call": {
- "count": 1
- },
- "@typescript-eslint/no-unsafe-member-access": {
- "count": 1
- }
- },
- "src/screens/Login/LoginForm.tsx": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- },
- "@typescript-eslint/no-floating-promises": {
- "count": 1
- },
- "@typescript-eslint/no-misused-promises": {
- "count": 3
- },
- "@typescript-eslint/no-unsafe-call": {
- "count": 4
- },
- "@typescript-eslint/no-unsafe-member-access": {
- "count": 4
- },
- "react-hooks/refs": {
- "count": 1
- }
- },
- "src/screens/Login/SetNewPasswordForm.tsx": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- },
- "@typescript-eslint/no-misused-promises": {
- "count": 2
- },
- "@typescript-eslint/no-unsafe-call": {
- "count": 1
- },
- "@typescript-eslint/no-unsafe-member-access": {
- "count": 1
- }
- },
"src/screens/Login/index.tsx": {
- "@typescript-eslint/no-misused-promises": {
- "count": 1
- },
- "react-hooks/purity": {
- "count": 1
- },
"react-hooks/refs": {
"count": 1
},
diff --git a/package.json b/package.json
index 36b03ea251..8843628e44 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,7 @@
},
"dependencies": {
"@atproto/api": "0.20.25",
+ "@atproto/common-web": "0.5.3",
"@atproto/syntax": "0.6.4",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b47a7bde03..eeb429173d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -244,6 +244,9 @@ importers:
'@atproto/api':
specifier: 0.20.25
version: 0.20.25
+ '@atproto/common-web':
+ specifier: 0.5.3
+ version: 0.5.3
'@atproto/syntax':
specifier: 0.6.4
version: 0.6.4
diff --git a/src/components/dialogs/ServerInput.tsx b/src/components/dialogs/ServerInput.tsx
index 2a8c263aa6..2b6ddf19dd 100644
--- a/src/components/dialogs/ServerInput.tsx
+++ b/src/components/dialogs/ServerInput.tsx
@@ -131,11 +131,11 @@ function DialogInner({
style={web({maxWidth: 500})}>
- Choose your account provider
+ Choose your hosting provider
-
-
-
- {error}
-
-
-
- )
-}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index ec116d64a4..df5e1d54dc 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -47,6 +47,10 @@ const Context = createContext<{
})
Context.displayName = 'TextFieldContext'
+export function useTextFieldContext() {
+ return useContext(Context)
+}
+
export type RootProps = React.PropsWithChildren<
{isInvalid?: boolean} & TextStyleProp
>
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 38d3d70813..4fb8034a51 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -8,6 +8,7 @@ import {startUriToStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
export const BSKY_APP_HOST = 'https://bsky.app'
+export const BSKY_HOSTING_ENDSWITH = '.host.bsky.network'
const BSKY_TRUSTED_HOSTS = [
'bsky\\.app',
'bsky\\.social',
@@ -91,6 +92,35 @@ export function toBskyAppUrl(url: string): string {
return new URL(url, BSKY_APP_HOST).toString()
}
+export function toNiceHostingUrl(url: string): string {
+ try {
+ const urlp = new URL(url)
+ if (urlp.host.endsWith(BSKY_HOSTING_ENDSWITH)) {
+ return 'Bluesky'
+ }
+ return urlp.host
+ } catch {
+ return url
+ }
+}
+
+/**
+ * Whether the given service URL points at a Bluesky-operated PDS. True when the
+ * host is `bsky.social` (the {@link BSKY_SERVICE} host) or ends with
+ * `.host.bsky.network`. Returns false if the URL can't be parsed.
+ */
+export function isBlueskyHostedUrl(url: string): boolean {
+ try {
+ const {host} = new URL(url)
+ return (
+ host === new URL(BSKY_SERVICE).host ||
+ host.endsWith(BSKY_HOSTING_ENDSWITH)
+ )
+ } catch {
+ return false
+ }
+}
+
export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/')
}
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx
index 8ce8cf4bc2..317a3bcdc3 100644
--- a/src/screens/Login/ChooseAccountForm.tsx
+++ b/src/screens/Login/ChooseAccountForm.tsx
@@ -1,8 +1,6 @@
import {useCallback, useState} from 'react'
import {View} from 'react-native'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
-import {Trans} from '@lingui/react/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
import {logger} from '#/logger'
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
@@ -24,7 +22,7 @@ export const ChooseAccountForm = ({
onPressBack: () => void
}) => {
const [pendingDid, setPendingDid] = useState(null)
- const {_} = useLingui()
+ const {t: l} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
@@ -43,7 +41,7 @@ export const ChooseAccountForm = ({
}
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
- Toast.show(_(msg`Already signed in as @${account.handle}`))
+ Toast.show(l`Already signed in as @${account.handle}`)
return
}
try {
@@ -53,10 +51,10 @@ export const ChooseAccountForm = ({
logContext: 'ChooseAccountForm',
withPassword: false,
})
- Toast.show(_(msg`Signed in as @${account.handle}`))
- } catch (e: any) {
+ Toast.show(l`Signed in as @${account.handle}`)
+ } catch (err) {
logger.warn('choose account: initSession failed', {
- message: e instanceof Error ? e.message : 'Unknown error',
+ message: err instanceof Error ? err.message : String(err),
})
// Move to login form.
onSelectAccount(account)
@@ -70,7 +68,7 @@ export const ChooseAccountForm = ({
pendingDid,
onSelectAccount,
setShowLoggedOut,
- _,
+ l,
ax,
],
)
@@ -83,11 +81,11 @@ export const ChooseAccountForm = ({
{IS_WEB && (
- Sign in as...
+ Sign in as…
)}
void onSelect(account)}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
@@ -95,11 +93,11 @@ export const ChooseAccountForm = ({
{IS_WEB && (
diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx
index 1cf3051129..79fdbc5d6a 100644
--- a/src/screens/Login/ForgotPasswordForm.tsx
+++ b/src/screens/Login/ForgotPasswordForm.tsx
@@ -1,17 +1,15 @@
import {useCallback, useState} from 'react'
import {Keyboard, View} from 'react-native'
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
-import {Trans} from '@lingui/react/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
import * as EmailValidator from 'email-validator'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
@@ -42,7 +40,7 @@ export const ForgotPasswordForm = ({
const t = useTheme()
const [isProcessing, setIsProcessing] = useState(false)
const [email, setEmail] = useState('')
- const {_} = useLingui()
+ const {t: l} = useLingui()
const onPressSelectService = useCallback(() => {
Keyboard.dismiss()
@@ -50,7 +48,7 @@ export const ForgotPasswordForm = ({
const onPressNext = async () => {
if (!EmailValidator.validate(email)) {
- return setError(_(msg`Your email appears to be invalid.`))
+ return setError(l`Your email appears to be invalid.`)
}
setError('')
@@ -60,18 +58,15 @@ export const ForgotPasswordForm = ({
const agent = new Agent(null, {service: serviceUrl})
await agent.com.atproto.server.requestPasswordReset({email})
onEmailSent()
- } catch (e: any) {
- const errMsg = e.toString()
- logger.warn('Failed to request password reset', {error: e})
+ } catch (err) {
+ logger.warn('Failed to request password reset', {error: err})
setIsProcessing(false)
- if (isNetworkError(e)) {
+ if (isNetworkError(err)) {
setError(
- _(
- msg`Unable to contact your service. Please check your Internet connection.`,
- ),
+ l`Unable to contact your service. Please check your Internet connection.`,
)
} else {
- setError(cleanError(errMsg))
+ setError(cleanError(err))
}
}
}
@@ -98,7 +93,7 @@ export const ForgotPasswordForm = ({
-
Enter the email you used to create your account. We'll send you a
"reset code" so you can set a new password.
-
-
-
+ {error && {error}}
{IS_WEB && (
<>
+
+
)
diff --git a/src/state/queries/pds-detection.ts b/src/state/queries/pds-detection.ts
new file mode 100644
index 0000000000..697963aa93
--- /dev/null
+++ b/src/state/queries/pds-detection.ts
@@ -0,0 +1,330 @@
+import {useState} from 'react'
+import {type DidDocument, getPdsEndpoint} from '@atproto/common-web'
+import {useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
+import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
+import {isNetworkError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {STALE} from '#/state/queries'
+import {Agent} from '#/state/session/agent'
+
+const RQKEY_ROOT = 'pds-detection'
+export const RQKEY = (identifier: string) => [RQKEY_ROOT, identifier]
+
+/**
+ * Normalize a login identifier for detection: lowercase, trim, and strip a
+ * single leading `@`. Handles are often typed as `@alice.example.com`; without
+ * stripping the `@` the identifier looks like an email and detection is
+ * disabled. A real email (`a@b.com`) has no leading `@`, so it still contains
+ * an `@` after normalization and classifies as an email correctly.
+ */
+function normalizeIdentifier(identifier: string): string {
+ return identifier.trim().toLowerCase().replace(/^@/, '')
+}
+
+/**
+ * Per-request timeout for identity/PDS resolution network calls. Without it a
+ * hanging plc.directory / did:web `.well-known` fetch (or handle resolution)
+ * could leave the sign-in button spinning indefinitely.
+ */
+const RESOLVE_TIMEOUT = 20e3
+
+/**
+ * Run a resolution network op with a per-request timeout. `run` receives an
+ * `AbortSignal` that fires after `RESOLVE_TIMEOUT`, so callers that support
+ * cancellation (fetch, the XRPC client) abort the in-flight request. A timeout
+ * is surfaced as a network error so the caller fails the login rather than
+ * silently falling back to the default service.
+ */
+async function withResolveTimeout(
+ run: (signal: AbortSignal) => Promise,
+): Promise {
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), RESOLVE_TIMEOUT)
+ try {
+ return await run(controller.signal)
+ } catch (err) {
+ if (controller.signal.aborted) {
+ throw new Error('Network request failed: resolution timed out')
+ }
+ throw err
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+/**
+ * Whether a non-ok HTTP status from an identity fetch is server-side or
+ * transient (5xx or 429) rather than a genuine "not found / invalid" (other
+ * 4xx). Transient statuses must not be treated as "identity doesn't exist",
+ * since that would silently fall back to the default service.
+ */
+function isTransientHttpStatus(status: number): boolean {
+ return status >= 500 || status === 429
+}
+
+/**
+ * Resolve a DID document without a session.
+ *
+ * `com.atproto.identity.resolveIdentity` would give us the DID doc in a single
+ * call, but it requires auth on the entryway and is not implemented on the
+ * appview, so it is unusable here. Instead we resolve the DID doc directly:
+ * `did:plc` via the PLC directory, `did:web` via its `.well-known` endpoint.
+ *
+ * Returns `null` for a genuine "not found / invalid" response (a 4xx or an
+ * unsupported DID method). Throws a network error for transient server-side
+ * failures (5xx, 429) so the caller fails the login rather than silently
+ * submitting the password to the default service during, e.g., a plc.directory
+ * blip.
+ */
+async function resolveDidDoc(
+ did: string,
+ signal?: AbortSignal,
+): Promise {
+ if (did.startsWith('did:plc:')) {
+ const res = await fetch(`https://plc.directory/${did}`, {signal})
+ if (!res.ok) {
+ logger.debug('pds-detection: plc.directory returned non-ok status', {
+ did,
+ status: res.status,
+ })
+ if (isTransientHttpStatus(res.status)) {
+ throw new Error(
+ `Network request failed: plc.directory returned ${res.status}`,
+ )
+ }
+ return null
+ }
+ return (await res.json()) as DidDocument
+ }
+ if (did.startsWith('did:web:')) {
+ const domain = did.slice('did:web:'.length)
+ /*
+ * did:web method-specific ids are domains; a `:` would indicate a path
+ * component, which the network does not support. Reject those rather than
+ * building a malformed URL.
+ */
+ if (domain.includes(':')) return null
+ const res = await fetch(
+ `https://${decodeURIComponent(domain)}/.well-known/did.json`,
+ {signal},
+ )
+ if (!res.ok) {
+ logger.debug(
+ 'pds-detection: did:web .well-known returned non-ok status',
+ {
+ did,
+ status: res.status,
+ },
+ )
+ if (isTransientHttpStatus(res.status)) {
+ throw new Error(
+ `Network request failed: did:web .well-known returned ${res.status}`,
+ )
+ }
+ return null
+ }
+ return (await res.json()) as DidDocument
+ }
+ logger.debug('pds-detection: unsupported DID method', {did})
+ return null
+}
+
+/**
+ * Resolve the identity behind a given identifier (handle or DID).
+ *
+ * Returns the resolved DID together with the PDS URL declared by its DID
+ * document (verbatim). `pdsUrl` is `null` when the DID resolved but its
+ * document declares no PDS endpoint. Returns `null` altogether when the
+ * identifier itself cannot be resolved (unknown handle, broken identity,
+ * unsupported DID method).
+ *
+ * Rethrows only on genuine network errors, so a "not found" during typing
+ * stays quiet.
+ */
+export async function resolvePdsForIdentifier(
+ identifier: string,
+): Promise<{did: string; pdsUrl: string | null} | null> {
+ const norm = normalizeIdentifier(identifier)
+ const agent = new Agent(null, {service: PUBLIC_BSKY_SERVICE})
+ try {
+ let did: string
+ if (norm.startsWith('did:')) {
+ did = norm
+ } else {
+ const res = await withResolveTimeout(signal =>
+ agent.resolveHandle({handle: norm}, {signal}),
+ )
+ did = res.data.did
+ }
+ logger.debug('pds-detection: resolved identifier to DID', {
+ identifier: norm,
+ did,
+ })
+ const doc = await withResolveTimeout(signal => resolveDidDoc(did, signal))
+ logger.debug('pds-detection: resolved DID doc', {
+ did,
+ foundDoc: !!doc,
+ })
+ if (!doc) return null
+ const pds = getPdsEndpoint(doc)
+ logger.debug('pds-detection: got PDS endpoint', {
+ did,
+ pds: pds ?? null,
+ })
+ return {did, pdsUrl: pds ?? null}
+ } catch (err) {
+ logger.debug('pds-detection: resolution failed', {
+ identifier: norm,
+ error: String(err),
+ isNetworkError: isNetworkError(err),
+ })
+ if (isNetworkError(err)) throw err
+ return null
+ }
+}
+
+/**
+ * The detection lifecycle for a login identifier, derived from the debounced
+ * resolution query plus any manual override.
+ */
+export type HostingProviderState =
+ /** Empty, or not yet a plausible handle (e.g. a bare username). */
+ | {status: 'idle'}
+ /** The identifier is an email address, so PDS detection is skipped. */
+ | {status: 'email'}
+ /** A resolution query is in flight for the current identifier. */
+ | {status: 'detecting'}
+ /** Resolved to a PDS endpoint. */
+ | {status: 'detected'; pdsUrl: string}
+ /**
+ * The handle genuinely did not resolve (unknown handle, broken identity).
+ * This is the only state that should admonish the user about typos.
+ */
+ | {status: 'unresolved'}
+ /**
+ * Resolution failed for a network/transient reason (offline, plc.directory
+ * 5xx). Distinct from `unresolved` because it is not evidence the handle is
+ * invalid, so the UI must not suggest a typo. Pressing "Sign in" surfaces the
+ * connectivity error via `resolveService` re-throwing.
+ */
+ | {status: 'error'}
+ /** The user manually selected a provider. */
+ | {status: 'overridden'; pdsUrl: string}
+
+/**
+ * Autodetects the hosting provider (PDS) for a login identifier as the user
+ * types, with a manual override escape hatch.
+ *
+ * The effective `service` is `override ?? detected ?? defaultService`.
+ * `resolveService` awaits any in-flight detection against the current
+ * (non-debounced) identifier so that pressing "Sign in" mid-detection waits
+ * for resolution and then continues. It resolves to `{service, did}`: the
+ * service to log in against, plus the identifier's resolved DID (`null` when
+ * no DID was resolved - manual override, email, or bare username). It falls
+ * back to `defaultService` for anything that legitimately can't resolve a PDS
+ * (emails, bare usernames, unknown handles) but rethrows genuine network
+ * errors, so a flaky connection fails the login instead of silently
+ * submitting to the default server.
+ */
+export function useHostingProvider({
+ identifier,
+ defaultService = DEFAULT_SERVICE,
+}: {
+ identifier: string
+ defaultService?: string
+}): {
+ state: HostingProviderState
+ service: string
+ override: (url: string) => void
+ clearOverride: () => void
+ resolveService: (
+ currentIdentifier: string,
+ ) => Promise<{service: string; did: string | null}>
+} {
+ const queryClient = useQueryClient()
+ const [override, setOverride] = useState(null)
+
+ const normalized = normalizeIdentifier(identifier)
+ const isEmail = normalized.includes('@')
+ const isPlausibleHandle =
+ !!normalized &&
+ !isEmail &&
+ (normalized.includes('.') || normalized.startsWith('did:'))
+
+ const debounced = useDebouncedValue(normalized, 500)
+ const enabled = isPlausibleHandle && normalized === debounced
+
+ const query = useQuery({
+ enabled,
+ queryKey: RQKEY(debounced),
+ queryFn: () => resolvePdsForIdentifier(debounced),
+ staleTime: STALE.MINUTES.FIVE,
+ })
+
+ let state: HostingProviderState
+ if (override != null) {
+ state = {status: 'overridden', pdsUrl: override}
+ } else if (isEmail) {
+ state = {status: 'email'}
+ } else if (!isPlausibleHandle) {
+ state = {status: 'idle'}
+ } else if (normalized !== debounced) {
+ /*
+ * The identifier changed but the debounce hasn't caught up, so the query is
+ * still keyed on the old value. Report 'detecting' rather than the stale
+ * query state, which would otherwise show the previous handle's PDS as
+ * 'detected'.
+ */
+ state = {status: 'detecting'}
+ } else if (query.isPending || query.isFetching) {
+ state = {status: 'detecting'}
+ } else if (query.isError && isNetworkError(query.error)) {
+ /*
+ * A network/transient failure is not evidence the handle is invalid, so
+ * report 'error' instead of 'unresolved' to avoid a misleading typo hint.
+ */
+ state = {status: 'error'}
+ } else if (query.isError || query.data == null || query.data.pdsUrl == null) {
+ state = {status: 'unresolved'}
+ } else {
+ state = {status: 'detected', pdsUrl: query.data.pdsUrl}
+ }
+
+ const service =
+ override ?? (state.status === 'detected' ? state.pdsUrl : defaultService)
+
+ return {
+ state,
+ service,
+ override: (url: string) => setOverride(url),
+ clearOverride: () => setOverride(null),
+ resolveService: async (currentIdentifier: string) => {
+ if (override != null) return {service: override, did: null}
+ const norm = normalizeIdentifier(currentIdentifier)
+ // Emails and bare usernames can't resolve a PDS on their own.
+ if (norm.includes('@')) return {service: defaultService, did: null}
+ if (!norm.includes('.') && !norm.startsWith('did:')) {
+ return {service: defaultService, did: null}
+ }
+ /*
+ * `resolvePdsForIdentifier` only throws on genuine network errors;
+ * anything unresolvable (unknown handle, broken identity) resolves to
+ * `null`, which we treat as the default service. Network errors are left
+ * to propagate so the caller can fail the login rather than silently
+ * submit the password to the wrong server.
+ */
+ const resolved = await queryClient.ensureQueryData({
+ queryKey: RQKEY(norm),
+ queryFn: () => resolvePdsForIdentifier(norm),
+ staleTime: STALE.MINUTES.FIVE,
+ })
+ // The DID is known even when its doc declares no PDS endpoint.
+ return {
+ service: resolved?.pdsUrl ?? defaultService,
+ did: resolved?.did ?? null,
+ }
+ },
+ }
+}
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index 25f6819f44..da0038a9cc 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -160,7 +160,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
/>
) : undefined}
{screenState === ScreenState.S_Login ? (
-
+ {
+ setScreenState(ScreenState.S_CreateAccount)
+ }}
+ />
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx
index 8f83155360..54867aedc7 100644
--- a/src/view/com/auth/SplashScreen.tsx
+++ b/src/view/com/auth/SplashScreen.tsx
@@ -80,7 +80,18 @@ export const SplashScreen = ({
- {
+ onPressCreateAccount()
+ playHaptic('Light')
+ }}
+ label={_(msg`Create new account`)}
+ accessibilityHint={_(
+ msg`Opens flow to create a new Bluesky account`,
+ )}
+ size="large"
+ color={isDarkMode ? 'secondary_inverted' : 'secondary'}
style={[
t.atoms.shadow_md,
{
@@ -91,23 +102,10 @@ export const SplashScreen = ({
},
},
]}>
- {
- onPressCreateAccount()
- playHaptic('Light')
- }}
- label={_(msg`Create new account`)}
- accessibilityHint={_(
- msg`Opens flow to create a new Bluesky account`,
- )}
- size="large"
- color={isDarkMode ? 'secondary_inverted' : 'secondary'}>
-
- Create account
-
-
-
+
+ Create account
+
+
+ size="large"
+ hoverStyle={{opacity: 0.5}}>
Sign in
diff --git a/src/view/com/util/layouts/LoggedOutLayout.tsx b/src/view/com/util/layouts/LoggedOutLayout.tsx
index ff0ae7075e..76a2e50205 100644
--- a/src/view/com/util/layouts/LoggedOutLayout.tsx
+++ b/src/view/com/util/layouts/LoggedOutLayout.tsx
@@ -1,7 +1,6 @@
import {ScrollView, StyleSheet, View} from 'react-native'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
-import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {atoms as a} from '#/alf'
@@ -29,8 +28,6 @@ export const LoggedOutLayout = ({
borderLeftWidth: 1,
})
- const [isKeyboardVisible] = useIsKeyboardVisible()
-
if (isMobile) {
if (scrollable) {
return (
@@ -38,10 +35,8 @@ export const LoggedOutLayout = ({
style={a.flex_1}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="none"
- contentContainerStyle={[
- {paddingBottom: isKeyboardVisible ? 300 : 0},
- ]}>
- {children}
+ contentContainerStyle={[a.flex_grow]}>
+ {children}
)
} else {
@@ -77,7 +72,15 @@ export const LoggedOutLayout = ({
style={a.flex_1}
contentContainerStyle={styles.scrollViewContentContainer}
keyboardShouldPersistTaps="handled"
- keyboardDismissMode="on-drag">
+ /*
+ * RNW implements `on-drag` by blurring the focused element on ANY
+ * scroll event - including the one Firefox fires when swapping
+ * splash -> login content resizes the scroller - which kills the
+ * login form's autofocus. It doesn't appear to do anything anyways
+ * on web (judging by iOS safari, which keeps the keyboard open
+ * regardless of scrolling) -sfn
+ */
+ keyboardDismissMode={IS_WEB ? 'none' : 'on-drag'}>
{children}