Compare commits

..

1 Commits

Author SHA1 Message Date
Samuel Newman 90fa98b777 replace lodash.isequal with fast-deep-equal 2025-12-11 12:22:57 +02:00
293 changed files with 29566 additions and 58744 deletions
+2 -5
View File
@@ -34,8 +34,5 @@ EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled.
EXPO_PUBLIC_BITDRIFT_API_KEY=
# geolocation web worker URL
GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
# bapp-config web worker URL
BAPP_CONFIG_DEV_URL=
-1
View File
@@ -35,7 +35,6 @@ module.exports = {
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
+2 -9
View File
@@ -16,9 +16,6 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
concurrency:
group: android-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -82,15 +79,13 @@ jobs:
echo "$json" > google-services.json
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
yarn use-build-number-with-bump
eas build -p android
--profile $PROFILE
--profile ${{ inputs.profile || 'testflight-android' }}
--local --output build.aab --non-interactive
- name: ✍️ Rename Testflight bundle
@@ -199,6 +194,4 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight-android' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
+2 -9
View File
@@ -16,9 +16,6 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
concurrency:
group: ios-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -94,15 +91,13 @@ jobs:
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
yarn use-build-number-with-bump
eas build -p ios
--profile $PROFILE
--profile ${{ inputs.profile || 'testflight' }}
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
@@ -152,7 +147,5 @@ jobs:
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.profile == 'testflight' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
+9 -18
View File
@@ -39,12 +39,10 @@ jobs:
# Validate the version if one is supplied. This should generally happen if the update is for a production client
- name: 🧐 Validate version
env:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
if: ${{ inputs.runtimeVersion }}
run: |
if [ -z "$RUNTIME_VERSION" ]; then
[[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
if [ -z "${{ inputs.runtimeVersion }}" ]; then
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
fi
- name: ⬇️ Checkout
@@ -105,15 +103,12 @@ jobs:
# eas.json not used here, set EXPO_PUBLIC_ENV
- name: Env
env:
CHANNEL: ${{ inputs.channel || 'testflight' }}
GITHUB_SHA: ${{ github.sha }}
id: env
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
echo "EXPO_PUBLIC_ENV=${{ inputs.channel || 'testflight' }}" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
@@ -150,15 +145,15 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-26
concurrency:
group: ios-build
cancel-in-progress: false
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -254,15 +249,13 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel == 'testflight' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: android-build
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
@@ -370,7 +363,5 @@ jobs:
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
-54
View File
@@ -1,54 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-5-20251101
+3 -7
View File
@@ -32,7 +32,7 @@ jobs:
fi
if [[ "$COMMENT" == *"ota"* ]]; then
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
has_ota=true
else
has_ota=false
@@ -75,8 +75,6 @@ jobs:
steps:
- name: Get PR HEAD SHA
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
id: pr-info
uses: actions/github-script@v7
with:
@@ -84,7 +82,7 @@ jobs:
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: process.env.ISSUE_NUMBER,
pull_number: ${{ github.event.issue.number }}
});
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
@@ -186,15 +184,13 @@ jobs:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.issue.number }}" width=300 height=300>
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
---
+5 -12
View File
@@ -35,27 +35,20 @@ jobs:
cache: yarn
- name: Ensure tracking relevant branches and checkout base
env:
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ github.base_ref }}
run: |
git checkout $HEAD_REF
git checkout $BASE_REF
git checkout ${{ github.head_ref }}
git checkout ${{ github.base_ref }}
- name: Get the base commit
id: base-commit
env:
BASE_REF: ${{ github.base_ref }}
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
- name: Merge PR commit
env:
HEAD_REF: ${{ github.head_ref }}
run: |
# Have to set a git config for the merge to work
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git merge --no-edit $HEAD_REF
git merge --no-edit ${{ github.head_ref }}
yarn install
yarn intl:build
@@ -91,7 +84,7 @@ jobs:
with:
base_path: "stats-base.json"
pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
+2 -6
View File
@@ -17,9 +17,7 @@ jobs:
fetch-depth: 0
- name: Fetch base branch
env:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
run: git fetch origin ${{ github.base_ref }} --depth=1
- name: Install node
uses: actions/setup-node@v6
@@ -27,9 +25,7 @@ jobs:
node-version-file: .nvmrc
- name: Reset yarn.lock to base
env:
BASE_REF: ${{ github.base_ref }}
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
run: git show "origin/${{ github.base_ref }}:yarn.lock" > yarn.lock
- name: Yarn install
uses: Wandalen/wretry.action@master
-1
View File
@@ -124,4 +124,3 @@ bskyogcard/src/assets/fonts/noto-*
bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
-591
View File
@@ -1,591 +0,0 @@
# CLAUDE.md - Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
## Project Overview
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:**
- React Native 0.81 with Expo 54
- TypeScript
- React Navigation for routing
- TanStack Query (React Query) for data fetching
- Lingui for internationalization
- Custom design system called ALF (Application Layout Framework)
## Essential Commands
```bash
# Development
yarn start # Start Expo dev server
yarn web # Start web version
yarn android # Run on Android
yarn ios # Run on iOS
# Testing & Quality
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
yarn intl:extract # Extract translation strings
yarn intl:compile # Compile translations for runtime
# Build
yarn build-web # Build web version
yarn prebuild # Generate native projects
```
## Project Structure
```
src/
├── alf/ # Design system (ALF) - themes, atoms, tokens
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
├── screens/ # Full-page screen components (newer pattern)
├── view/
│ ├── screens/ # Full-page screens (legacy location)
│ ├── com/ # Reusable view components
│ └── shell/ # App shell (navigation bars, tabs)
├── state/
│ ├── queries/ # TanStack Query hooks
│ ├── preferences/ # User preferences (React Context)
│ ├── session/ # Authentication state
│ └── persisted/ # Persistent storage layer
├── lib/ # Utilities, constants, helpers
├── locale/ # i18n configuration and language files
└── Navigation.tsx # Main navigation configuration
```
## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
### Basic Usage
```tsx
import {atoms as a, useTheme} from '#/alf'
function MyComponent() {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
</View>
)
}
```
### Key Concepts
**Static Atoms** - Theme-independent styles imported from `atoms`:
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities** - For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
const styles = [
a.p_md,
web({cursor: 'pointer'}),
native({paddingBottom: 20}),
platform({ios: {...}, android: {...}, web: {...}}),
]
```
**Breakpoints** - Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
if (gtMobile) {
// Tablet or desktop layout
}
```
### Naming Conventions
- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (t-shirt sizes)
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
## Component Patterns
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
```tsx
import * as Dialog from '#/components/Dialog'
function MyFeature() {
const control = Dialog.useDialogControl()
return (
<>
<Button label="Open" onPress={control.open}>
<ButtonText>Open Dialog</ButtonText>
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle /> {/* Native drag handle */}
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Close" onPress={() => control.close()}>
<ButtonText>Close</ButtonText>
</Button>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
```
### Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native.
```tsx
import * as Menu from '#/components/Menu'
function MyMenu() {
return (
<Menu.Root>
<Menu.Trigger label="Open menu">
{({props}) => (
<Button {...props} label="Menu">
<ButtonIcon icon={DotsHorizontal} />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item label="Edit" onPress={handleEdit}>
<Menu.ItemIcon icon={Pencil} />
<Menu.ItemText>Edit</Menu.ItemText>
</Menu.Item>
<Menu.Item label="Delete" onPress={handleDelete}>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemText>Delete</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
```
### Button Component
```tsx
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
// Solid primary button (most common)
<Button label="Save" onPress={handleSave} color="primary" size="large">
<ButtonText>Save</ButtonText>
</Button>
// With icon
<Button label="Share" onPress={handleShare} color="secondary" size="small">
<ButtonIcon icon={Share} />
<ButtonText>Share</ButtonText>
</Button>
// Icon-only button
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
<ButtonIcon icon={X} />
</Button>
// Ghost variant (deprecated - use color prop)
<Button label="Cancel" variant="ghost" color="secondary" size="small">
<ButtonText>Cancel</ButtonText>
</Button>
```
**Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
### Typography
```tsx
import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop
<Text emoji>Hello! 👋</Text>
```
### TextField
```tsx
import * as TextField from '#/components/forms/TextField'
<TextField.LabelText>Email</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={AtSign} />
<TextField.Input
label="Email address"
placeholder="you@example.com"
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
```
## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui.
```tsx
import {msg, Trans, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
function MyComponent() {
const {_} = useLingui()
// Simple strings - use msg() with _() function
const title = _(msg`Settings`)
const errorMessage = _(msg`Something went wrong`)
// Strings with variables
const greeting = _(msg`Hello, ${name}!`)
// Pluralization
const countLabel = _(plural(count, {
one: '# item',
other: '# items',
}))
// JSX content - use Trans component
return (
<Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
</Text>
)
}
```
**Commands:**
```bash
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile for runtime (required after changes)
```
## State Management
### TanStack Query (Data Fetching)
```tsx
// src/state/queries/profile.ts
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
// Query key pattern
const RQKEY_ROOT = 'profile'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
// Query hook
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
// Mutation hook
export function useUpdateProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data) => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
},
})
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
### Preferences (React Context)
```tsx
// Simple boolean preference pattern
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
}
```
### Session State
```tsx
import {useSession, useAgent} from '#/state/session'
function MyComponent() {
const {hasSession, currentAccount} = useSession()
const agent = useAgent()
if (!hasSession) {
return <LoginPrompt />
}
// Use agent for API calls
const response = await agent.getProfile({actor: currentAccount.did})
}
```
## Navigation
Navigation uses React Navigation with type-safe route parameters.
```tsx
// Screen component
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
return (
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
}
// Programmatic navigation
import {useNavigation} from '@react-navigation/native'
const navigation = useNavigation()
navigation.navigate('Profile', {name: 'alice.bsky.social'})
// Or use the navigate helper
import {navigate} from '#/Navigation'
navigate('Profile', {name: 'alice.bsky.social'})
```
## Platform-Specific Code
Use file extensions for platform-specific implementations:
```
Component.tsx # Shared/default
Component.web.tsx # Web-only
Component.native.tsx # iOS + Android
Component.ios.tsx # iOS-only
Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
Platform detection:
```tsx
import {isWeb, isNative, isIOS, isAndroid} from '#/platform/detection'
if (isNative) {
// Native-specific logic
}
```
## Import Aliases
Always use the `#/` alias for absolute imports:
```tsx
// Good
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
// Avoid
import {useSession} from '../../../state/session'
```
## Footguns
Common pitfalls to avoid in this codebase:
### Dialog Close Callback (Critical)
**Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates.
```tsx
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
const onConfirm = () => {
control.close(() => {
navigation.navigate('Home')
})
}
// CORRECT - opening another dialog after close
const onConfirm = () => {
control.close(() => {
otherDialogControl.open()
})
}
// CORRECT - state updates after close
const onConfirm = () => {
control.close(() => {
setSomeState(newValue)
onCallback?.()
})
}
```
This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
Prefer `defaultValue` over `value` for TextInput on the old architecture:
```tsx
// Preferred - uncontrolled
<TextField.Input
defaultValue={initialEmail}
onChangeText={setEmail}
/>
// Avoid when possible - controlled (can cause performance issues)
<TextField.Input
value={email}
onChangeText={setEmail}
/>
```
### Platform-Specific Behavior
Some components behave differently across platforms:
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button)
- `Menu.Divider` - Only renders on web
- `Menu.ContainerItem` - Only works on native
Always test on multiple platforms when using these components.
### React Compiler is Enabled
This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
```tsx
// UNNECESSARY - React Compiler handles this
const handlePress = useCallback(() => {
doSomething()
}, [doSomething])
// JUST WRITE THIS
const handlePress = () => {
doSomething()
}
```
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability
## Best Practices
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
4. **State**: Use TanStack Query for server state, React Context for UI preferences
5. **Components**: Check if a component exists in `#/components/` before creating new ones
6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
7. **Testing**: Components should have `testID` props for E2E testing
## Key Files Reference
| Purpose | Location |
|---------|----------|
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
-7
View File
@@ -389,13 +389,6 @@ module.exports = function (_config) {
],
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
['expo-location'],
[
'expo-contacts',
{
contactsPermission:
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
},
],
].filter(Boolean),
extra: {
eas: {
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2.972 12a9 9 0 0 1 9-9c1.726 0 3.165.423 4.448 1.21A10 10 0 0 1 18 5.428V4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 1 1 0-2h1.756a8.3 8.3 0 0 0-1.382-1.085C14.417 5.327 13.341 5 11.972 5a7 7 0 1 0 6.601 9.333A1 1 0 0 1 20.46 15a9 9 0 0 1-17.487-3Z"/></svg>

Before

Width:  |  Height:  |  Size: 349 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 2a3 3 0 0 0-3 3v14.5A2.5 2.5 0 0 0 6.5 22H19a1 1 0 1 0 0-2H6.5a.5.5 0 0 1 0-1H19a1 1 0 0 0 1-1V4a2 2 0 0 0-2-2H7Zm5 5a1.75 1.75 0 1 0 0 3.5A1.75 1.75 0 0 0 12 7Zm0 4c-.894 0-1.57.188-2.068.512a2.07 2.07 0 0 0-.852 1.058c-.286.838.432 1.43 1.03 1.43h3.78c.598 0 1.316-.592 1.03-1.43a2.07 2.07 0 0 0-.852-1.058C13.57 11.189 12.894 11 12 11Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 484 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M7 4a1 1 0 0 0-1 1v12.05q.243-.05.5-.05H18V4H7Zm5 7c.894 0 1.57.188 2.068.513a2.1 2.1 0 0 1 .806.937l.046.12c.285.836-.43 1.43-1.03 1.43h-3.78c-.6 0-1.315-.594-1.03-1.43l.046-.12a2.1 2.1 0 0 1 .806-.937C10.43 11.188 11.106 11 12 11Zm0-4a1.75 1.75 0 1 1 0 3.5A1.75 1.75 0 0 1 12 7Zm8 11a1 1 0 0 1-1 1H6.5a.5.5 0 0 0 0 1H19a1 1 0 1 1 0 2H6.5A2.5 2.5 0 0 1 4 19.5V5a3 3 0 0 1 3-3h11a2 2 0 0 1 2 2v14Z"/></svg>

Before

Width:  |  Height:  |  Size: 500 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 12a8 8 0 1 0-8 8c2.204 0 4.2-.89 5.648-2.333A7.97 7.97 0 0 0 20 12Zm-5.707-3.707a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 1 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 1 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 1 1 1.414-1.414L12 10.586l2.293-2.293ZM22 12a9.96 9.96 0 0 1-2.269 6.34l2.891 2.89a1 1 0 1 1-1.414 1.415l-2.893-2.893A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Z"/></svg>

Before

Width:  |  Height:  |  Size: 518 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32s10.298 23 23 23a22.93 22.93 0 0 0 16.235-6.708A22.93 22.93 0 0 0 55 32Zm-15.707-8.707a1 1 0 1 1 1.414 1.414L33.414 32l7.293 7.293a1 1 0 1 1-1.414 1.414L32 33.414l-7.293 7.293a1 1 0 1 1-1.414-1.414L30.586 32l-7.293-7.293a1 1 0 1 1 1.414-1.414L32 30.586l7.293-7.293ZM57 32a24.9 24.9 0 0 1-6.66 16.985l8.808 8.808a1 1 0 0 1-1.414 1.414l-8.81-8.81A24.9 24.9 0 0 1 32 57C18.193 57 7 45.807 7 32S18.193 7 32 7s25 11.193 25 25Z"/></svg>

Before

Width:  |  Height:  |  Size: 568 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M24.952 33.435c10.897 0 19.195 6.496 22.58 15.65.784 2.117.258 4.183-1 5.683-1.242 1.48-3.194 2.414-5.33 2.415h-32.5c-2.136 0-4.089-.935-5.331-2.415-1.258-1.5-1.783-3.566-1-5.682 3.386-9.155 11.683-15.651 22.58-15.651Zm0 2.298c-9.885 0-17.355 5.849-20.425 14.15-.47 1.271-.174 2.48.604 3.407.795.947 2.096 1.594 3.57 1.594h32.5c1.476 0 2.777-.647 3.571-1.594.778-.928 1.074-2.136.605-3.406-3.07-8.302-10.54-14.15-20.425-14.15Zm33.262-14.59a1.15 1.15 0 1 1 1.625 1.626l-5.688 5.687 5.686 5.688a1.15 1.15 0 1 1-1.625 1.625l-5.686-5.688-5.687 5.688a1.15 1.15 0 1 1-1.625-1.625l5.687-5.688-5.689-5.687a1.15 1.15 0 1 1 1.625-1.625l5.689 5.687 5.688-5.687ZM24.949 5.552c6.557 0 11.874 5.316 11.874 11.874 0 6.557-5.317 11.874-11.874 11.874s-11.874-5.317-11.874-11.874S18.39 5.55 24.949 5.55Zm0 2.299a9.575 9.575 0 0 0-9.575 9.575A9.575 9.575 0 0 0 24.949 27a9.575 9.575 0 0 0 9.575-9.575A9.575 9.575 0 0 0 24.95 7.85Z"/></svg>

Before

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+1 -72
View File
@@ -9,9 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"net/netip"
"net/url"
@@ -42,11 +40,6 @@ type Server struct {
cfg *Config
ipccClient http.Client
// sitemapClient is used for fetching sitemaps from the appview. It has
// DisableCompression set to true so that gzipped responses are passed
// through without being decompressed.
sitemapClient http.Client
}
type Config struct {
@@ -123,16 +116,6 @@ func serve(cctx *cli.Context) error {
},
},
},
sitemapClient: http.Client{
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ForceAttemptHTTP2: true,
DisableCompression: true,
},
},
}
// Create the HTTP server.
@@ -308,6 +291,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
e.GET("/settings/app-icon", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
@@ -353,10 +337,6 @@ func serve(cctx *cli.Context) error {
// ipcc
e.GET("/ipcc", server.WebIpCC)
// sitemap handlers
e.GET("/sitemap/users.xml.gz", server.handleSitemapUsersIndex)
e.GET("/sitemap/users/*", server.handleSitemapUsersSubpage)
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
@@ -773,54 +753,3 @@ func (srv *Server) WebIpCC(c echo.Context) error {
}
return c.JSON(200, outResponse)
}
func (srv *Server) handleSitemapUsersIndex(c echo.Context) error {
url := fmt.Sprintf("%s/external/sitemap/users.xml.gz", srv.cfg.appviewHost)
return srv.serveSitemapRequest(c, url, "user index")
}
func (srv *Server) handleSitemapUsersSubpage(c echo.Context) error {
path := c.Param("*")
url := fmt.Sprintf("%s/external/sitemap/users/%s", srv.cfg.appviewHost, path)
return srv.serveSitemapRequest(c, url, "user subpage")
}
func (srv *Server) serveSitemapRequest(c echo.Context, url, sitemapType string) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
slog.Error("failed to construct sitemap request", "err", err, "type", sitemapType)
return c.String(http.StatusInternalServerError, "Internal Server Error")
}
resp, err := srv.sitemapClient.Do(req)
if err != nil {
slog.Error("failed to send sitemap request to appview", "err", err, "type", sitemapType)
return c.String(http.StatusInternalServerError, "Internal Server Error")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
buf, err := io.ReadAll(resp.Body)
if err != nil {
slog.Error("failed to read sitemap error response body", "err", err)
}
slog.Error("invalid sitemap response code",
"err", err,
"type", sitemapType,
"code", resp.StatusCode,
"body", string(buf),
)
return c.String(http.StatusInternalServerError, "Internal Server Error")
}
c.Response().Header().Set("Content-Type", "application/xml")
c.Response().Header().Set("Content-Encoding", "gzip")
c.Response().WriteHeader(resp.StatusCode)
if _, err = io.Copy(c.Response().Writer, resp.Body); err != nil {
slog.Error("failed to copy sitemap response body to client", "err", err, "type", sitemapType)
}
return nil
}
-2
View File
@@ -7,5 +7,3 @@
# be ok.
User-Agent: *
Allow: /
Sitemap: https://bsky.app/sitemap/users.xml.gz
+4 -4
View File
@@ -108,9 +108,9 @@
{% else %}
<meta property="og:title" content="@{{ profileHandle }}">
{% endif -%}
<meta name="description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta property="og:description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta property="twitter:description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta name="description" content="This post requires authentication to view.">
<meta property="og:description" content="This post requires authentication to view.">
<meta property="twitter:description" content="This post requires authentication to view.">
<meta name="twitter:card" content="summary">
{% endif -%}
{%- endblock %}
@@ -129,7 +129,7 @@
<div id="bsky_post_summary">
<h3>Post</h3>
<p id="bsky_handle">{{ profileHandle }}</p>
<p id="bsky_post_text">This author has chosen to make their posts visible only to people who are signed in.</p>
<p id="bsky_post_text">This post requires authentication to view.</p>
</div>
{% endif -%}
{%- endblock %}
-6
View File
@@ -1,6 +0,0 @@
{
"scripts": {
"setup": "yarn install",
"run": "yarn web --port $CONDUCTOR_PORT"
}
}
-2
View File
@@ -111,8 +111,6 @@ This is NOT required for app development but if you also want to develop the Blu
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
- Launch a Postgres database on port 5432
- `cd packages/dev-env && pnpm start`
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
Then, when logging in or creating an account, point it to the localhost port of the devserver.
+45 -50
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.114.0",
"version": "1.112.0",
"private": true,
"engines": {
"node": ">=20"
@@ -62,7 +62,6 @@
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || yarn intl:compile",
"intl:pull": "crowdin download translations --verbose -b main",
"intl:push": "crowdin push translations --verbose -b main",
"intl:push-sources": "crowdin push sources --verbose -b main",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
@@ -73,11 +72,10 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.15",
"@atproto/api": "^0.18.6",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/alf": "^0.1.5",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
@@ -100,9 +98,10 @@
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.9.0",
"@react-navigation/native": "^7.1.26",
"@react-navigation/native-stack": "^7.9.0",
"@react-navigation/bottom-tabs": "^7.3.13",
"@react-navigation/drawer": "^7.3.12",
"@react-navigation/native": "^7.1.9",
"@react-navigation/native-stack": "^7.3.13",
"@sentry/react-native": "~6.20.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "5.25.0",
@@ -133,48 +132,46 @@
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^54.0.27",
"expo-application": "~7.0.8",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-contacts": "^15.0.10",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.20",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.9",
"expo-intent-launcher": "~13.0.8",
"expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-localization": "~17.0.8",
"expo-location": "~19.0.8",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.14",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
"expo-sms": "^14.0.7",
"expo-splash-screen": "~31.0.12",
"expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"expo": "^54.0.22",
"expo-application": "~7.0.7",
"expo-blur": "~15.0.7",
"expo-build-properties": "~1.0.9",
"expo-camera": "~17.0.9",
"expo-clipboard": "~8.0.7",
"expo-dev-client": "~6.0.16",
"expo-device": "~8.0.9",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-crop-tool": "^0.4.0",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.8",
"expo-intent-launcher": "~13.0.7",
"expo-keep-awake": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-location": "~19.0.7",
"expo-media-library": "~18.2.0",
"expo-notifications": "~0.32.12",
"expo-screen-orientation": "~9.0.7",
"expo-sharing": "~14.0.7",
"expo-splash-screen": "~31.0.10",
"expo-system-ui": "~6.0.8",
"expo-task-manager": "~14.0.8",
"expo-updates": "~29.0.12",
"expo-video": "~3.0.13",
"expo-web-browser": "~15.0.9",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.6.2",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.12.31",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"fast-deep-equal": "^3.1.3",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"multiformats": "9.9.0",
@@ -194,7 +191,7 @@
"react-native-compressor": "^1.13.0",
"react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.1",
"react-native-drawer-layout": "^4.1.8",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
@@ -204,7 +201,7 @@
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "^3.19.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "^4.19.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
@@ -222,11 +219,10 @@
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
"tldts": "^6.1.46",
"unicode-segmenter": "^0.14.5",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.204",
"@atproto/dev-env": "^0.3.193",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
@@ -267,10 +263,11 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "~54.0.14",
"jest-expo": "^54.0.14",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"metro-react-native-babel-preset": "^0.77.0",
"prettier": "^3.6.0",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
@@ -284,12 +281,10 @@
"@react-native/babel-preset": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"**/@expo/image-utils": "0.8.7",
"**/@react-native-async-storage/async-storage": "2.2.0",
"**/expo-constants": "18.0.8",
"**/expo-device": "7.1.4",
"**/zod": "3.23.8",
"**/multiformats": "9.9.0",
"unicode-segmenter": "0.14.5"
"**/multiformats": "9.9.0"
},
"jest": {
"preset": "jest-expo/ios",
-136
View File
@@ -1,136 +0,0 @@
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
index b988d3f..7743421 100644
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
@@ -36,6 +36,7 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
+ consumerProguardFiles 'proguard-rules.pro'
}
lintOptions {
abortOnError false
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
new file mode 100644
index 0000000..3b5b864
--- /dev/null
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
-16
View File
@@ -1,16 +0,0 @@
diff --git a/node_modules/expo-font/ios/FontLoaderModule.swift b/node_modules/expo-font/ios/FontLoaderModule.swift
index 183480f..7b64f6e 100644
--- a/node_modules/expo-font/ios/FontLoaderModule.swift
+++ b/node_modules/expo-font/ios/FontLoaderModule.swift
@@ -2,10 +2,9 @@ import ExpoModulesCore
public final class FontLoaderModule: Module {
// could be a Set, but to be able to pass to JS we keep it as an array
- private var registeredFonts: [String]
+ private lazy var registeredFonts: [String] = queryCustomNativeFonts()
public required init(appContext: AppContext) {
- self.registeredFonts = queryCustomNativeFonts()
super.init(appContext: appContext)
}
@@ -1,3 +1,24 @@
diff --git a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
index e9a8e3d..3c684e0 100644
--- a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
+++ b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
@@ -296,14 +296,13 @@ class LoaderTask(
) {
try {
val embeddedLoader = EmbeddedLoader(context, configuration, logger, database, directory)
- val result = embeddedLoader.load { updateResponse ->
+ embeddedLoader.load { _ ->
Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
}
- launcher.launch(database)
} catch (e: Exception) {
logger.error("Unexpected error copying embedded update", e, UpdatesErrorCode.Unknown)
- launcher.launch(database)
}
+ launcher.launch(database)
} else {
launcher.launch(database)
}
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
index 68086bd..78c7761 100644
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
@@ -1,5 +1,5 @@
diff --git a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
index 40fdcdb..770fd67 100644
index efa71f9..a23c624 100644
--- a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
+++ b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
@@ -124,15 +124,21 @@ export function Drawer({
@@ -31,7 +31,7 @@ index 40fdcdb..770fd67 100644
- }, finished => runOnJS(onAnimationEnd)(open, finished));
+ }, finished => {
+ animatingTo.value = null;
+ runOnJS(onAnimationEnd)(open, finished);
+ runOnJS(onAnimationEnd)(open, finished)
+ });
if (open) {
runOnJS(onOpen)();
@@ -1,30 +0,0 @@
diff --git a/node_modules/react-native-pager-view/ios/RNCPagerView.m b/node_modules/react-native-pager-view/ios/RNCPagerView.m
index adfc7c6..366df60 100644
--- a/node_modules/react-native-pager-view/ios/RNCPagerView.m
+++ b/node_modules/react-native-pager-view/ios/RNCPagerView.m
@@ -498,6 +498,25 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni
return YES;
}
+ // iOS 26+ full-screen back gesture (interactiveContentPopGestureRecognizer)
+ if (@available(iOS 26, *)) {
+ if (gestureRecognizer == self.panGestureRecognizer &&
+ otherGestureRecognizer == self.reactViewController.navigationController.interactiveContentPopGestureRecognizer) {
+ UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
+ CGPoint velocity = [panGestureRecognizer velocityInView:self];
+ BOOL isLTR = [self isLtrLayout];
+ BOOL isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0);
+
+ if (self.currentIndex == 0 && isBackGesture) {
+ self.scrollView.panGestureRecognizer.enabled = false;
+ } else {
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
+ }
+
+ return YES;
+ }
+ }
+
self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
return NO;
}
@@ -1,11 +0,0 @@
# react-native-pager-view+6.8.0.patch
Adds support for iOS 26's `interactiveContentPopGestureRecognizer` (full-screen back gesture).
The pager already handles `RNSPanGestureRecognizer` (react-native-screens' custom full-screen gesture for pre-iOS 26) in `shouldRecognizeSimultaneouslyWithGestureRecognizer:`. It checks if the user is on the leftmost page and swiping right - if so, it disables the scrollview's pan gesture to let the back gesture through.
This patch adds the same logic for iOS 26's native `interactiveContentPopGestureRecognizer`, so the back gesture works on the leftmost page while the pager still handles swipes on other pages.
Related issues:
- https://github.com/software-mansion/react-native-screens/issues/3512
- https://github.com/software-mansion/react-native-screens/pull/3420
+326
View File
@@ -0,0 +1,326 @@
diff --git a/node_modules/react-native-screens/ios/RNSScreen.mm b/node_modules/react-native-screens/ios/RNSScreen.mm
index b62a2e2..cb469db 100644
--- a/node_modules/react-native-screens/ios/RNSScreen.mm
+++ b/node_modules/react-native-screens/ios/RNSScreen.mm
@@ -729,9 +729,26 @@ - (void)notifyTransitionProgress:(double)progress closing:(BOOL)closing goingFor
#endif
}
-#if !RCT_NEW_ARCH_ENABLED
+- (void)willMoveToWindow:(UIWindow *)newWindow
+{
+ if (@available(iOS 26, *)) {
+ // In iOS 26, as soon as another screen appears in transition, it is interactable
+ // To avoid glitches resulting from clicking buttons mid transition, we temporarily disable all interactions
+ // Disabling interactions for parent navigation controller won't be enough in case of nested stack
+ // Furthermore, a stack put inside a modal will exist in an entirely different hierarchy
+ // To be sure, we block interactions on the whole window.
+ // Note that newWindows is nil when moving from instead of moving to, and Obj-C handles nil correctly
+ newWindow.userInteractionEnabled = false;
+ }
+}
+
- (void)presentationControllerWillDismiss:(UIPresentationController *)presentationController
{
+ if (@available(iOS 26, *)) {
+ // Disable interactions to disallow multiple modals dismissed at once; see willMoveToWindow
+ presentationController.containerView.window.userInteractionEnabled = false;
+ }
+#if !RCT_NEW_ARCH_ENABLED
// On Paper, we need to call both "cancel" and "reset" here because RN's gesture
// recognizer does not handle the scenario when it gets cancelled by other top
// level gesture recognizer. In this case by the modal dismiss gesture.
@@ -744,8 +761,8 @@ - (void)presentationControllerWillDismiss:(UIPresentationController *)presentati
// down.
[_touchHandler cancel];
[_touchHandler reset];
-}
#endif // !RCT_NEW_ARCH_ENABLED
+}
- (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController
{
@@ -757,6 +774,10 @@ - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presenta
- (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController
{
+ if (@available(iOS 26, *)) {
+ // Reenable interactions; see presentationControllerWillDismiss
+ presentationController.containerView.window.userInteractionEnabled = true;
+ }
// NOTE(kkafar): We should consider depracating the use of gesture cancel here & align
// with usePreventRemove API of react-navigation v7.
[self notifyGestureCancel];
@@ -767,6 +788,11 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)pr
- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
{
+ if (@available(iOS 26, *)) {
+ // Reenable interactions; see presentationControllerWillDismiss
+ // Dismissed screen doesn't hold a reference to window, but presentingViewController.view does
+ presentationController.presentingViewController.view.window.userInteractionEnabled = true;
+ }
if ([_reactSuperview respondsToSelector:@selector(presentationControllerDidDismiss:)]) {
[_reactSuperview performSelector:@selector(presentationControllerDidDismiss:) withObject:presentationController];
}
@@ -1518,6 +1544,10 @@ - (void)viewWillDisappear:(BOOL)animated
- (void)viewDidAppear:(BOOL)animated
{
+ if (@available(iOS 26, *)) {
+ // Reenable interactions, see willMoveToWindow
+ self.view.window.userInteractionEnabled = true;
+ }
[super viewDidAppear:animated];
if (!_isSwiping || _shouldNotify) {
// we are going forward or dismissing without swipe
diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm
index 229dc58..10b365b 100644
--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm
+++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm
@@ -62,26 +62,6 @@ @interface RNSScreenStackView () <
@implementation RNSNavigationController
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-- (void)viewDidLoad
-{
- // iOS 26 introduces new gesture recognizer which replaces our RNSPanGestureRecognizer.
- // The problem is that we are not able to handle it here for various reasons:
- // - the new recognizer comes with its own delegate and our current approach is to wire
- // all recognizers to RNSScreenStackView; to be 100% sure we don't break the logic,
- // we would have to decorate its delegate and call it after our code, which would
- // break other recognizers that the stack view is the delegate for
- // - when RNSScreenStackView.setupGestureHandler method is called, the recognizer hasn't been
- // loaded yet and there is no other place to configure in a not "hacky" way
- // - the official docs warn us to not use it for anything other than "setting up failure requirements with it"
- // - we expose fullScreenGestureEnabled prop to enable/disable the feature,
- // so we need control over the delegate
- if (@available(iOS 26.0, *)) {
- self.interactiveContentPopGestureRecognizer.enabled = NO;
- }
-}
-#endif // iOS 26
-
#if !TARGET_OS_TV
- (UIViewController *)childViewControllerForStatusBarStyle
{
@@ -219,50 +199,6 @@ - (bool)onRepeatedTabSelectionOfTabScreenController:(RNSTabsScreenViewController
return false;
}
-#pragma mark - UINavigationBarDelegate
-
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
-{
- if (@available(iOS 26, *)) {
- // To prevent popping multiple screens when back button is pressed repeatedly,
- // We allow for pop operation to proceed only if no transition is in progress,
- // which we check indirectly by checking if transitionCoordinator is set.
- // If it's not, we are safe to proceed.
- if (self.transitionCoordinator == nil) {
- // We still need to disable interactions for back button so click effects are not applied,
- // and there is unfortunately no better place for it currently
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
- if (button != nil) {
- button.userInteractionEnabled = false;
- }
-
- return true;
- }
-
- return false;
- }
-
- return true;
-}
-
-- (void)navigationBar:(UINavigationBar *)navigationBar didPopItem:(UINavigationItem *)item
-{
- if (@available(iOS 26, *)) {
- // Reset interactions on back button -> see navigationBar:shouldPopItem
- // IMPORTANT: This reset won't execute when preventNativeDismiss is on.
- // However, on iOS 26, unlike in previous versions, the back button instance changes
- // when handling preventNativeDismiss and userIteractionEnabled is reset.
- // The instance also changes when regular screen pop happens, but in that case
- // the value of userInteractionEnabled is carried on, and we reset it here.
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
- if (button != nil) {
- button.userInteractionEnabled = true;
- }
- }
-}
-#endif // Check for iOS >= 26
-
#pragma mark - RNSFrameCorrectionProvider
#ifdef RNS_GAMMA_ENABLED
@@ -327,7 +263,7 @@ @implementation RNSScreenStackView {
UINavigationController *_controller;
NSMutableArray<RNSScreenView *> *_reactSubviews;
BOOL _invalidated;
- BOOL _isFullWidthSwiping;
+ BOOL _isFullWidthSwipingWithPanGesture; // used only for content swipe with RNSPanGestureRecognizer
RNSPercentDrivenInteractiveTransition *_interactionController;
__weak RNSScreenStackManager *_manager;
BOOL _updateScheduled;
@@ -522,6 +458,11 @@ - (void)reactAddControllerToClosestParent:(UIViewController *)controller
[self addSubview:controller.view];
#if !TARGET_OS_TV
_controller.interactivePopGestureRecognizer.delegate = self;
+ #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26, *)) {
+ _controller.interactiveContentPopGestureRecognizer.delegate = self;
+ }
+#endif // Check for iOS >= 26.0
#endif
[controller didMoveToParentViewController:parentView.reactViewController];
// On iOS pre 12 we observed that `willShowViewController` delegate method does not always
@@ -943,7 +884,7 @@ - (void)dismissOnReload
// when preventing the native dismiss with back button, we have to return the animator.
// Also, we need to return the animator when full width swiping even if the animation is not custom,
// otherwise the screen will be just popped immediately due to no animation
- ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwiping ||
+ ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwipingWithPanGesture ||
[RNSScreenStackAnimator isCustomAnimation:screen.stackAnimation] || _customAnimation)) {
return [[RNSScreenStackAnimator alloc] initWithOperation:operation];
}
@@ -967,23 +908,39 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
}
RNSScreenView *topScreen = _reactSubviews.lastObject;
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
+
#if TARGET_OS_TV || TARGET_OS_VISION
[self cancelTouchesInParent];
return YES;
#else
- // RNSPanGestureRecognizer will receive events iff topScreen.fullScreenSwipeEnabled == YES;
- // Events are filtered in gestureRecognizer:shouldReceivePressOrTouchEvent: method
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
- if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
- _isFullWidthSwiping = YES;
- [self cancelTouchesInParent];
- return YES;
+ // On iOS < 26, we have a custom full screen swipe recognizer that functions similarily
+ // to interactiveContentPopGestureRecognizer introduced in iOS 26.
+ // On iOS >= 26, we want to use the native one, but we are unable to handle custom animations
+ // with native interactiveContentPopGestureRecognizer, so we have to fallback to the old implementation.
+ // In this case, the old one should behave as close as the new native one, having only the difference
+ // in animation, and without any customization that is exclusive for it (e.g. gestureResponseDistance).
+ if (@available(iOS 26, *)) {
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
+ _isFullWidthSwipingWithPanGesture = YES;
+ [self cancelTouchesInParent];
+ return YES;
+ }
+ return NO;
+ } else {
+ if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
+ _isFullWidthSwipingWithPanGesture = YES;
+ [self cancelTouchesInParent];
+ return YES;
+ }
+ return NO;
}
- return NO;
}
// Now we're dealing with RNSScreenEdgeGestureRecognizer (or _UIParallaxTransitionPanGestureRecognizer)
- if (topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation]) {
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
if ([gestureRecognizer isKindOfClass:[RNSScreenEdgeGestureRecognizer class]]) {
UIRectEdge edges = ((RNSScreenEdgeGestureRecognizer *)gestureRecognizer).edges;
BOOL isRTL = _controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft;
@@ -1028,7 +985,9 @@ - (void)setupGestureHandlers
rightEdgeSwipeGestureRecognizer.delegate = self;
[self addGestureRecognizer:rightEdgeSwipeGestureRecognizer];
- // gesture recognizer for full width swipe gesture
+ // Starting from iOS 26, RNSPanGestureRecognizer has been mostly replaced by native
+ // interactiveContentPopGestureRecognizer. It still needs to handle custom dismiss animations,
+ // which we are not able to handle with the latter.
RNSPanGestureRecognizer *panRecognizer = [[RNSPanGestureRecognizer alloc] initWithTarget:self
action:@selector(handleSwipe:)];
panRecognizer.delegate = self;
@@ -1091,7 +1050,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
[_interactionController cancelInteractiveTransition];
}
_interactionController = nil;
- _isFullWidthSwiping = NO;
+ _isFullWidthSwipingWithPanGesture = NO;
}
default: {
break;
@@ -1225,14 +1184,6 @@ - (BOOL)isScrollViewPanGestureRecognizer:(UIGestureRecognizer *)gestureRecognize
// Be careful when adding another type of gesture recognizer.
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePressOrTouchEvent:(NSObject *)event
{
- if (@available(iOS 26, *)) {
- // in iOS 26, you can swipe to pop screen before the previous one finished transitioning;
- // this prevents from registering the second gesture
- if ([self isTransitionInProgress]) {
- return NO;
- }
- }
-
RNSScreenView *topScreen = _reactSubviews.lastObject;
for (RNSScreenView *s in _reactSubviews.reverseObjectEnumerator) {
@@ -1249,10 +1200,30 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
return NO;
}
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26, *)) {
+ // On iOS 26, fullScreenSwipeEnabled takes no effect, and depending on whether custom animations are on,
+ // we select either interactiveContentPopGestureRecognizer or RNSPanGestureRecognizer
+ if (([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]] &&
+ !customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) ||
+ (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
+ customAnimationOnSwipePropSetAndSelectedAnimationIsCustom)) {
+ return NO;
+ }
+ } else {
+ // We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
+ if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
+ return topScreen.fullScreenSwipeEnabled;
+ }
+ }
+#else // check for iOS >= 26
// We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
return topScreen.fullScreenSwipeEnabled;
}
+#endif // check for iOS >= 26
// RNSScreenEdgeGestureRecognizer || _UIParallaxTransitionPanGestureRecognizer
return YES;
@@ -1268,15 +1239,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
return [self gestureRecognizer:gestureRecognizer shouldReceivePressOrTouchEvent:touch];
}
-- (BOOL)isTransitionInProgress
-{
- if (_controller.transitionCoordinator != nil) {
- return YES;
- }
-
- return NO;
-}
-
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
@@ -1289,7 +1251,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
if (gestureRecognizer.state == UIGestureRecognizerStateBegan || isBackGesture) {
return NO;
}
-
return YES;
}
return NO;
+45 -50
View File
@@ -61,6 +61,7 @@ import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -69,10 +70,6 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
@@ -96,7 +93,6 @@ if (isAndroid) {
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -146,51 +142,50 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
+41 -46
View File
@@ -48,6 +48,7 @@ import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -57,10 +58,6 @@ import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbe
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
} from '#/features/liveEvents/context'
import * as Geo from '#/geolocation'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -71,7 +68,6 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
*/
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -122,47 +118,46 @@ function InnerApp() {
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
+9 -38
View File
@@ -18,9 +18,7 @@ import {
} from '@react-navigation/native'
import {timeout} from '#/lib/async/timeout'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {
getNotificationPayload,
type NotificationPayload,
@@ -47,12 +45,10 @@ import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders'
import {useCloseAllActiveElements} from '#/state/util'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod'
@@ -68,14 +64,13 @@ import {PostThreadScreen} from '#/view/screens/PostThread'
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {ProfileScreen} from '#/view/screens/Profile'
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
import {StorybookScreen} from '#/view/screens/Storybook'
import {Storybook} from '#/view/screens/Storybook'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {BookmarksScreen} from '#/screens/Bookmarks'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
@@ -107,7 +102,6 @@ import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
@@ -140,6 +134,10 @@ import {
} from '#/components/dialogs/EmailDialog'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
import {useLoggedOutViewControls} from './state/shell/logged-out'
import {useCloseAllActiveElements} from './state/util'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -304,7 +302,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
/>
<Stack.Screen
name="Debug"
getComponent={() => StorybookScreen}
getComponent={() => Storybook}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
<Stack.Screen
@@ -418,14 +416,6 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="FindContactsSettings"
getComponent={() => FindContactsSettingsScreen}
options={{
title: title(msg`Find Contacts`),
requireAuth: true,
}}
/>
<Stack.Screen
name="NotificationSettings"
getComponent={() => NotificationSettingsScreen}
@@ -619,15 +609,6 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="FindContactsFlow"
getComponent={() => FindContactsFlowScreen}
options={{
title: title(msg`Find Contacts`),
requireAuth: true,
gestureEnabled: false,
}}
/>
</>
)
}
@@ -636,11 +617,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
* The TabsNavigator is used by native mobile to represent the routes
* in 3 distinct tab-stacks with a different root screen on each.
*/
function TabsNavigator({
layout,
}: {
layout: React.ComponentProps<typeof Tab.Navigator>['layout']
}) {
function TabsNavigator() {
const tabBar = useCallback(
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
<BottomBar {...props} />
@@ -653,8 +630,7 @@ function TabsNavigator({
initialRouteName="HomeTab"
backBehavior="initialRoute"
screenOptions={{headerShown: false, lazy: true}}
tabBar={tabBar}
layout={layout}>
tabBar={tabBar}>
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
<Tab.Screen
@@ -762,11 +738,7 @@ function MessagesTabNavigator() {
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
const FlatNavigator = ({
layout,
}: {
layout: React.ComponentProps<typeof Flat.Navigator>['layout']
}) => {
const FlatNavigator = () => {
const t = useTheme()
const numUnread = useUnreadNotifications()
const screenListeners = useWebScrollRestoration()
@@ -774,7 +746,6 @@ const FlatNavigator = ({
return (
<Flat.Navigator
layout={layout}
screenListeners={screenListeners}
screenOptions={screenOptions(t)}>
<Flat.Screen
+119 -143
View File
@@ -9,11 +9,12 @@ import {
useCreateSupportLink,
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {isAppPassword} from '#/lib/jwt'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isNative} from '#/platform/detection'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSessionApi} from '#/state/session'
import {useSession, useSessionApi} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
@@ -29,7 +30,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/ic
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {Outlet as PortalOutlet} from '#/components/Portal'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {Span, Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
@@ -54,6 +55,9 @@ export function NoAccessScreen() {
const {logoutCurrentAccount} = useSessionApi()
const createSupportLink = useCreateSupportLink()
const {currentAccount} = useSession()
const isUsingAppPassword = isAppPassword(currentAccount?.accessJwt || '')
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
@@ -64,13 +68,6 @@ export function NoAccessScreen() {
useEffect(() => {
// just counting overall hits here
logger.metric(`blockedGeoOverlay:shown`, {})
logger.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: data?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
canUpdateBirthday,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onPressLogout = useCallback(() => {
@@ -85,38 +82,21 @@ export function NoAccessScreen() {
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
}, [logoutCurrentAccount])
const orgAdmonition = (
<Admonition type="tip">
<Trans>
For organizational accounts, use the birthdate of the person who is
responsible for the account.
</Trans>
</Admonition>
)
const birthdateUpdateText = canUpdateBirthday ? (
<>
<Text style={[textStyles]}>
<Trans>
If you believe your birthdate is incorrect, you can update it by{' '}
<SimpleInlineLinkText
label={_(msg`Click here to update your birthdate`)}
style={[textStyles]}
{...createStaticClick(() => {
logger.metric(
'ageAssurance:noAccessScreen:openBirthdateDialog',
{},
)
birthdateControl.open()
})}>
clicking here
</SimpleInlineLinkText>
.
</Trans>
</Text>
{orgAdmonition}
</>
<Text style={[textStyles]}>
<Trans>
If you believe your birthdate is incorrect, you can update it by{' '}
<SimpleInlineLinkText
label={_(msg`Click here to update your birthdate`)}
style={[textStyles]}
{...createStaticClick(() => {
birthdateControl.open()
})}>
clicking here
</SimpleInlineLinkText>
.
</Trans>
</Text>
) : (
<Text style={[textStyles]}>
<Trans>
@@ -134,127 +114,123 @@ export function NoAccessScreen() {
return (
<>
<View style={[a.util_screen_outer, a.flex_1]}>
<ScrollView
contentContainerStyle={[
a.px_2xl,
<ScrollView
contentContainerStyle={[
a.px_2xl,
{
paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding,
paddingBottom: 100,
},
]}>
<View
style={[
a.mx_auto,
a.w_full,
web({
maxWidth: 380,
paddingTop: gtPhone ? '8vh' : undefined,
}),
{
paddingTop: isWeb
? a.p_5xl.padding
: insets.top + a.p_2xl.padding,
paddingBottom: 100,
gap: 32,
},
]}>
<View
style={[
a.mx_auto,
a.w_full,
web({
maxWidth: 380,
paddingTop: gtPhone ? '8vh' : undefined,
}),
{
gap: 32,
},
]}>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
</View>
<View style={[a.align_start]}>
<AgeAssuranceBadge />
</View>
{hasDeclaredAge ? (
<>
{isAARegion ? (
<>
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hey there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
You are accessing Bluesky from a region that legally
requires us to verify your age before allowing you to
access the app.
</Trans>
</Text>
{!aa.flags.isOverRegionMinAccessAge && (
<Text style={[textStyles]}>
<Trans>
Unfortunately, your declared age indicates that you
are not old enough to access Bluesky in your region.
</Trans>
</Text>
)}
{!isBlocked && birthdateUpdateText}
</View>
{aa.flags.isOverRegionMinAccessAge && <AccessSection />}
</>
) : (
{hasDeclaredAge ? (
<>
{isAARegion ? (
<>
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hey there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
Unfortunately, the birthdate you have saved to your
profile makes you too young to access Bluesky.
You are accessing Bluesky from a region that legally
requires us to verify your age before allowing you to
access the app.
</Trans>
</Text>
{birthdateUpdateText}
{!isBlocked && birthdateUpdateText}
</View>
)}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hi there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
In order to provide an age-appropriate experience, we need
to know your birthdate. This is a one-time thing, and your
data will be kept private.
</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
Set your birthdate below and we'll get you back to posting
and exploring in no time!
</Trans>
</Text>
<Button
color="primary"
size="large"
label={_(msg`Click here to update your birthdate`)}
onPress={() => birthdateControl.open()}>
<ButtonText>
<Trans>Add your birthdate</Trans>
</ButtonText>
</Button>
{orgAdmonition}
</View>
)}
<AccessSection />
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>
Unfortunately, the birthdate you have saved to your
profile makes you too young to access Bluesky.
</Trans>
</Text>
<View style={[a.pt_lg, a.gap_xl]}>
<Logo width={120} textFill={t.atoms.text.color} />
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
{birthdateUpdateText}
</View>
)}
</>
) : (
<View style={[a.gap_lg]}>
<Text style={[textStyles]}>
<Trans>Hi there!</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
To log out,{' '}
<SimpleInlineLinkText
label={_(msg`Click here to log out`)}
{...createStaticClick(() => {
onPressLogout()
})}>
click here
</SimpleInlineLinkText>
.
In order to provide an age-appropriate experience, we need to
know your birthdate. This is a one-time thing, and your data
will be kept private.
</Trans>
</Text>
<Text style={[textStyles]}>
<Trans>
Set your birthdate below and we'll get you back to posting and
exploring in no time!
</Trans>
</Text>
<Button
color="primary"
size="large"
label={_(msg`Click here to update your birthdate`)}
onPress={() => birthdateControl.open()}>
<ButtonText>
<Trans>Add your birthdate</Trans>
</ButtonText>
</Button>
{isUsingAppPassword && (
<Admonition type="info">
<Trans>
Hmm, it looks like you're logged in with an{' '}
<Span style={[a.italic]}>App Password</Span>. To set your
birthdate, you'll need to log in with your main account
password, or ask whomever controls this account to do so.
</Trans>
</Admonition>
)}
</View>
)}
<View style={[a.pt_lg, a.gap_xl]}>
<Logo width={120} textFill={t.atoms.text.color} />
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_medium]}>
<Trans>
To log out,{' '}
<SimpleInlineLinkText
label={_(msg`Click here to log out`)}
{...createStaticClick(() => {
onPressLogout()
})}>
click here
</SimpleInlineLinkText>
.
</Trans>
</Text>
</View>
</ScrollView>
</View>
</View>
</ScrollView>
<BirthDateSettingsDialog control={birthdateControl} />
-13
View File
@@ -136,15 +136,6 @@ export async function prefetchConfig() {
}
})
}
export async function refetchConfig() {
logger.debug(`refetchConfig: fetching...`)
const res = await getConfig()
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
configQueryKey,
res,
)
return res
}
export function useConfigQuery() {
return useQuery(
{
@@ -155,10 +146,6 @@ export function useConfigQuery() {
* @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
*/
staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
/**
* N.B. if prefetch failed above, we'll have no `initialData`, and this
* query will run on startup.
*/
initialData: getConfigFromCache(),
initialDataUpdatedAt: () =>
qc.getQueryState(configQueryKey)?.dataUpdatedAt,
+7 -21
View File
@@ -12,26 +12,23 @@ export const enabled = (IS_DEV && false) || IS_E2E
export const geolocation: Geolocation | undefined = enabled
? {
countryCode: 'BB',
countryCode: 'AA',
regionCode: undefined,
}
: undefined
const deviceGeolocationEnabled = false
export const deviceGeolocation: Geolocation | undefined =
enabled && deviceGeolocationEnabled
? {
countryCode: 'AA',
regionCode: undefined,
}
: undefined
export const deviceGeolocation: Geolocation | undefined = enabled
? {
countryCode: 'AA',
regionCode: undefined,
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
@@ -39,17 +36,6 @@ export const config: AppBskyAgeassuranceDefs.Config = {
},
],
},
{
countryCode: 'BB',
regionCode: undefined,
minAccessAge: 16,
rules: [
{
$type: ids.Default,
access: 'none',
},
],
},
],
}
+5 -23
View File
@@ -14,11 +14,7 @@ import {
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
import {
isUnderAge,
MIN_ACCESS_AGE,
useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
import {isUserUnderAdultAge} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
@@ -28,7 +24,6 @@ export {
usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger'
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
@@ -37,8 +32,6 @@ const AgeAssuranceStateContext = createContext<{
flags: {
adultContentDisabled: boolean
chatDisabled: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
}>({
Access: AgeAssuranceAccess,
@@ -51,8 +44,6 @@ const AgeAssuranceStateContext = createContext<{
flags: {
adultContentDisabled: false,
chatDisabled: false,
isOverRegionMinAccessAge: false,
isOverAppMinAccessAge: false,
},
})
@@ -78,7 +69,6 @@ export function Provider({children}: {children: React.ReactNode}) {
function InnerProvider({children}: {children: React.ReactNode}) {
const state = useAgeAssuranceState()
const {data} = useAgeAssuranceDataContext()
const config = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
@@ -99,17 +89,11 @@ function InnerProvider({children}: {children: React.ReactNode}) {
<AgeAssuranceStateContext.Provider
value={useMemo(() => {
const chatDisabled = state.access !== AgeAssuranceAccess.Full
const isUnderAdultAge = data?.birthdate
? isUnderAge(data.birthdate, 18)
const isUnderage = data?.birthdate
? isUserUnderAdultAge(data.birthdate)
: true
const isOverRegionMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, config.minAccessAge)
: false
const isOverAppMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isUnderAdultAge
state.access !== AgeAssuranceAccess.Full || isUnderage
return {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
@@ -117,11 +101,9 @@ function InnerProvider({children}: {children: React.ReactNode}) {
flags: {
adultContentDisabled,
chatDisabled,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
},
}
}, [state, data, config])}>
}, [state, data])}>
{children}
</AgeAssuranceStateContext.Provider>
)
+2 -9
View File
@@ -30,19 +30,12 @@ export function useAgeAssuranceState(): AgeAssuranceState {
access: AgeAssuranceAccess.Safe,
}
/**
* This can happen if the prefetch fails (such as due to network issues).
* The query handler will try it again, but if it continues to fail, of
* course we won't have config.
*
* In this case, fail open to avoid blocking users.
*/
// should never happen, but need to guard
if (!config) {
logger.warn('useAgeAssuranceState: missing config')
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
error: 'config',
access: AgeAssuranceAccess.Unknown,
}
}
-1
View File
@@ -18,7 +18,6 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
error?: 'config' // maybe other specific cases in the future
}
export function parseStatusFromString(raw: string) {
+5 -16
View File
@@ -1,4 +1,3 @@
import {Platform} from 'react-native'
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
@@ -9,9 +8,9 @@ import {
PUBLIC_APPVIEW_DID,
} from '#/lib/constants'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance/logger'
import {BLUESKY_PROXY_DID} from '#/env'
import {useGeolocation} from '#/geolocation'
@@ -30,8 +29,8 @@ export function useBeginAgeAssurance() {
'countryCode' | 'regionCode'
>,
) {
const countryCode = geolocation?.countryCode?.toUpperCase()
const regionCode = geolocation?.regionCode?.toUpperCase()
const countryCode = geolocation?.countryCode
const regionCode = geolocation?.regionCode
if (!countryCode) {
throw new Error(`Geolocation not available, cannot init age assurance.`)
}
@@ -48,16 +47,6 @@ export function useBeginAgeAssurance() {
appView.sessionManager.session.accessJwt = token
appView.sessionManager.session.refreshJwt = ''
logger.metric(
'ageAssurance:api:begin',
{
platform: Platform.OS,
countryCode,
regionCode,
},
{statsig: false},
)
/*
* 2s wait is good actually. Email sending takes a hot sec and this helps
* ensure the email is ready for the user once they open their inbox.
@@ -66,8 +55,8 @@ export function useBeginAgeAssurance() {
2e3,
appView.app.bsky.ageassurance.begin({
...props,
countryCode,
regionCode,
countryCode: countryCode.toUpperCase(),
regionCode: regionCode ? regionCode.toUpperCase() : undefined,
}),
)
+26 -40
View File
@@ -3,32 +3,14 @@ import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
getAgeAssuranceRegionConfig,
type ModerationPrefs,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
export const MIN_ACCESS_AGE = 13
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
const DEFAULT_MIN_AGE = 13
/**
* Get age assurance region config based on geolocation, with fallback to
@@ -46,7 +28,23 @@ export function getAgeAssuranceRegionConfigWithFallback(
regionCode: geolocation.regionCode,
})
return region || FALLBACK_REGION_CONFIG
return (
region || {
countryCode: '*',
regionCode: undefined,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: DEFAULT_MIN_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
)
}
/**
@@ -67,14 +65,6 @@ export function useAgeAssuranceRegionConfig() {
}, [config, geolocation])
}
/**
* Hook to get the age assurance region config based on current geolocation.
* Falls back to our app defaults if no region config is found.
*/
export function useAgeAssuranceRegionConfigWithFallback() {
return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG
}
/**
* Some users may have erroneously set their birth date to the current date
* if one wasn't set on their account. We previously didn't do validation on
@@ -86,11 +76,15 @@ export function isLegacyBirthdateBug(birthDate: string) {
}
/**
* Returns whether the date (converted to an age as a whole integer) is under
* the provided minimum age.
* Returns whether the user is under the minimum age required to use the app.
* This applies to all regions.
*/
export function isUnderAge(birthDate: string, age: number) {
return getAge(new Date(birthDate)) < age
export function isUserUnderMinimumAge(birthDate: string) {
return getAge(new Date(birthDate)) < DEFAULT_MIN_AGE
}
export function isUserUnderAdultAge(birthDate: string) {
return getAge(new Date(birthDate)) < 18
}
export function getBirthdateStringFromAge(age: number) {
@@ -101,11 +95,3 @@ export function getBirthdateStringFromAge(age: number) {
today.getDate() - 1, // set to day before to ensure age is reached
).toISOString()
}
export const makeAgeRestrictedModerationPrefs = (
prefs: ModerationPrefs,
): ModerationPrefs => ({
...prefs,
adultContentEnabled: false,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})
+1 -6
View File
@@ -11,12 +11,7 @@ import {
import {themes} from '#/alf/themes'
import {type Device} from '#/storage'
export {
type TextStyleProp,
type Theme,
utils,
type ViewStyleProp,
} from '@bsky.app/alf'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
export {atoms} from '#/alf/atoms'
export * from '#/alf/breakpoints'
export * from '#/alf/fonts'
+2 -8
View File
@@ -359,13 +359,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
)
}
export function Handle({
difference = false,
fill,
}: {
difference?: boolean
fill?: string
}) {
export function Handle({difference = false}: {difference?: boolean}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
@@ -396,7 +390,7 @@ export function Handle({
opacity: 0.75,
}
: {
backgroundColor: fill || t.palette.contrast_975,
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
+3 -35
View File
@@ -1,4 +1,4 @@
import React, {useMemo} from 'react'
import React from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
type AppBskyFeedDefs,
@@ -21,21 +21,19 @@ import {
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, select, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, type RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
import type * as bsky from '#/types/bsky'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
@@ -51,11 +49,7 @@ export function Default(props: Props) {
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={view.displayName}
creator={view.creator}
uri={view.uri}
/>
<TitleAndByline title={view.displayName} creator={view.creator} />
<SaveButton view={view} pin />
</Header>
<Description description={view.description} />
@@ -124,40 +118,14 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({
title,
creator,
uri,
}: {
title: string
creator?: bsky.profile.AnyProfileView
uri?: string
}) {
const t = useTheme()
const activeLiveEvents = useActiveLiveEventFeedUris()
const liveColor = useMemo(
() =>
select(t.name, {
dark: t.palette.negative_600,
dim: t.palette.negative_600,
light: t.palette.negative_500,
}),
[t],
)
return (
<View style={[a.flex_1]}>
{uri && activeLiveEvents.has(uri) && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<LiveIcon size="xs" fill={liveColor} />
<Text
style={[
a.text_2xs,
a.font_medium,
a.leading_snug,
{color: liveColor},
]}>
<Trans>Happening now</Trans>
</Text>
</View>
)}
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
+79 -318
View File
@@ -1,13 +1,12 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {ScrollView, View} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {isIOS} from '#/platform/detection'
@@ -15,10 +14,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {
useSuggestedFollowsByActorQuery,
useSuggestedFollowsQuery,
} from '#/state/queries/suggested-follows'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {type SeenPost} from '#/state/userActionHistory'
@@ -35,7 +31,6 @@ import {useDialogControl} from '#/components/Dialog'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
@@ -43,8 +38,6 @@ import type * as bsky from '#/types/bsky'
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
import {ProgressGuideList} from './ProgressGuide/List'
const DISMISS_ANIMATION_DURATION = 200
const MOBILE_CARD_WIDTH = 165
const FINAL_CARD_WIDTH = 120
@@ -56,7 +49,6 @@ function CardOuter({
const {gtMobile} = useBreakpoints()
return (
<View
testID="CardOuter"
style={[
a.flex_1,
a.w_full,
@@ -210,9 +202,6 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
}
export function SuggestedFollowsProfile({did}: {did: string}) {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 6
const {
isLoading: isSuggestionsLoading,
data,
@@ -220,194 +209,29 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
} = useSuggestedFollowsByActorQuery({
did,
})
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = React.useCallback((dismissedDid: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(dismissedDid))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(dismissedDid)
return next
})
}, DISMISS_ANIMATION_DURATION)
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = React.useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did) && profile.did !== did) {
seen.add(profile.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
profiles={data?.suggestions ?? []}
recId={data?.recId}
error={error}
viewContext="profile"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
export function SuggestedFollowsHome() {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 6
const {
isLoading: isSuggestionsLoading,
profiles: experimentalProfiles,
error: experimentalError,
profiles,
error,
} = useExperimentalSuggestedUsersQuery()
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = React.useCallback((did: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(did))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(did))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(did)
return next
})
}, DISMISS_ANIMATION_DURATION)
}, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = React.useMemo(() => {
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
// Dedupe by did, preferring experimental profiles
const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = []
for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
}
}
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
error={experimentalError || suggestionsError}
profiles={profiles}
error={error}
viewContext="feed"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -416,26 +240,19 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
totalProfileCount,
recId,
viewContext = 'feed',
onDismiss,
dismissingDids,
isVisible = true,
}: {
isSuggestionsLoading: boolean
profiles: bsky.profile.AnyProfileView[]
totalProfileCount?: number
recId?: number
error: Error | null
dismissingDids?: Set<string>
viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void
isVisible?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
const gate = useGate()
const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints()
const followDialogControl = useDialogControl()
@@ -443,7 +260,6 @@ export function ProfileGrid({
const isLoading = isSuggestionsLoading || !moderationOpts
const isProfileHeaderContext = viewContext === 'profileHeader'
const isFeedContext = viewContext === 'feed'
const showDismissButton = onDismiss && gate('suggested_users_dismiss')
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
const minLength = gtMobile ? 3 : 4
@@ -547,9 +363,20 @@ export function ProfileGrid({
: error || !profiles.length
? null
: profiles.slice(0, maxLength).map((profile, index) => (
<Animated.View
<ProfileCard.Link
key={profile.did}
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
profile={profile}
onPress={() => {
logEvent('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
position: index,
suggestedDid: profile.did,
category: null,
})
}}
style={[
a.flex_1,
gtMobile &&
@@ -558,128 +385,68 @@ export function ProfileGrid({
a.flex_grow,
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
]),
{
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
transitionProperty: 'opacity',
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
},
]}>
<ProfileCard.Link
profile={profile}
onPress={() => {
logEvent('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
position: index,
suggestedDid: profile.did,
category: null,
})
}}
style={[a.flex_1]}>
{({hovered, pressed}) => (
<CardOuter
style={[
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
{showDismissButton && (
<Button
label={_(msg`Dismiss this suggestion`)}
onPress={e => {
e.preventDefault()
onDismiss!(profile.did)
logEvent('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
position: index,
suggestedDid: profile.did,
recId,
})
}}
style={[
a.absolute,
a.z_10,
a.p_xs,
{top: -4, right: -4},
]}>
{({
hovered: dismissHovered,
pressed: dismissPressed,
}) => (
<X
size="xs"
fill={
dismissHovered || dismissPressed
? t.atoms.text.color
: t.atoms.text_contrast_medium.color
}
/>
)}
</Button>
)}
<View
style={[
a.flex_col,
a.align_center,
a.gap_sm,
a.pb_sm,
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
size={88}
/>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile}
numberOfLines={2}
style={[
t.atoms.text_contrast_medium,
a.text_center,
a.text_xs,
]}
/>
</View>
</View>
<ProfileCard.FollowButton
{({hovered, pressed}) => (
<CardOuter
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
<ProfileCard.Outer>
<View
style={[
a.flex_col,
a.align_center,
a.gap_sm,
a.pb_sm,
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
style={[a.rounded_sm]}
onFollow={() => {
logEvent('suggestedUser:follow', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
position: index,
suggestedDid: profile.did,
category: null,
})
}}
disabledPreview
size={88}
/>
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
</Animated.View>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile}
numberOfLines={2}
style={[
t.atoms.text_contrast_medium,
a.text_center,
a.text_xs,
]}
/>
</View>
</View>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
style={[a.rounded_sm]}
onFollow={() => {
logEvent('suggestedUser:follow', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
position: index,
suggestedDid: profile.did,
category: null,
})
}}
/>
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
))
// Use totalProfileCount (before dismissals) for minLength check on initial render.
const profileCountForMinCheck = totalProfileCount ?? profiles.length
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
if (error || (!isLoading && profiles.length < minLength)) {
logger.debug(`Not enough profiles to show suggested follows`)
return null
}
@@ -772,6 +539,7 @@ export function ProfileGrid({
}
function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
const t = useTheme()
const {_} = useLingui()
return (
@@ -785,6 +553,7 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
a.gap_sm,
a.p_md,
a.rounded_lg,
t.atoms.shadow_sm,
{width: FINAL_CARD_WIDTH},
]}>
<ButtonIcon icon={ArrowRight} size="lg" />
@@ -842,7 +611,6 @@ export function SuggestedFeeds() {
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
uri={feed.uri}
/>
</FeedCard.Header>
<FeedCard.Description
@@ -934,15 +702,8 @@ export function SuggestedFeeds() {
export function ProgressGuide() {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
!gtMobile && {marginTop: 4},
]}>
<View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}>
<ProgressGuideList />
</View>
)
@@ -1,11 +1,9 @@
import {Fragment, useMemo} from 'react'
import {Text as RNText} from 'react-native'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type CountryCode,
getDefaultCountry,
INTERNATIONAL_TELEPHONE_CODES,
} from '#/lib/international-telephone-codes'
@@ -25,8 +23,8 @@ export function InternationalPhoneCodeSelect({
value,
onChange,
}: {
value?: CountryCode
onChange: (value: CountryCode) => void
value?: string
onChange: (value: string) => void
}) {
const {_, i18n} = useLingui()
const location = useGeolocation()
@@ -64,7 +62,7 @@ export function InternationalPhoneCodeSelect({
}, [value, items])
return (
<Select.Root value={value} onValueChange={onChange as (v: string) => void}>
<Select.Root value={value} onValueChange={onChange}>
<Select.Trigger label={_(msg`Select telephone code`)}>
<Select.ValueText placeholder="+..." webOverrideValue={selected}>
{selected => (
@@ -114,5 +112,5 @@ function Flag({unicodeFlag, svgFlag}: {unicodeFlag: string; svgFlag: any}) {
/>
)
}
return <RNText style={[{lineHeight: 21}]}>{unicodeFlag + ' '}</RNText>
return unicodeFlag + ' '
}
+2 -3
View File
@@ -38,7 +38,7 @@ export function Outer({
}: {
children: React.ReactNode
noBottomBorder?: boolean
headerRef?: React.RefObject<View | null>
headerRef?: React.MutableRefObject<View | null>
sticky?: boolean
}) {
const t = useTheme()
@@ -129,7 +129,6 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
@@ -184,7 +183,7 @@ export function TitleText({
<Text
style={[
a.text_lg,
a.font_semi_bold,
a.font_bold,
a.leading_tight,
isIOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl,
+5 -7
View File
@@ -167,7 +167,6 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
a.gap_sm,
a.px_md,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
@@ -194,6 +193,7 @@ export function ItemText({children, style}: ItemTextProps) {
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_high,
{paddingTop: 3},
style,
disabled && t.atoms.text_contrast_low,
]}>
@@ -202,18 +202,16 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
export function ItemIcon({icon: Comp}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
<Comp
size="lg"
fill={
fill
? fill({disabled})
: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
)
+4 -7
View File
@@ -262,7 +262,6 @@ export function Item({children, label, onPress, style, ...rest}: ItemProps) {
a.gap_lg,
a.py_sm,
a.rounded_xs,
a.overflow_hidden,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
(hovered || focused) &&
@@ -303,7 +302,7 @@ export function ItemText({children, style}: ItemTextProps) {
)
}
export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
@@ -320,11 +319,9 @@ export function ItemIcon({icon: Comp, position = 'left', fill}: ItemIconProps) {
<Comp
size="md"
fill={
fill
? fill({disabled})
: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
</View>
-1
View File
@@ -107,7 +107,6 @@ export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
export type ItemIconProps = React.PropsWithChildren<{
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
fill?: (props: {disabled: boolean}) => string
}>
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
@@ -11,7 +11,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {clamp} from '#/lib/numbers'
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
import {isWeb} from '#/platform/detection'
import {useAutoplayDisabled} from '#/state/preferences'
@@ -112,15 +111,6 @@ export function GifEmbed({
playerRef.current?.toggleAsync()
}, [])
let aspectRatio = 1
if (params.dimensions) {
aspectRatio = clamp(
params.dimensions.width / params.dimensions.height,
0.75,
4,
)
}
return (
<View
style={[
@@ -128,8 +118,7 @@ export function GifEmbed({
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{backgroundColor: t.palette.black},
{aspectRatio},
{aspectRatio: params.dimensions!.width / params.dimensions!.height},
style,
]}>
<View
+3 -3
View File
@@ -17,16 +17,16 @@ export function FeedEmbed({
return (
<FeedCard.Link
view={embed.view}
style={[a.border, t.atoms.border_contrast_low, a.p_sm, a.rounded_md]}>
style={[a.border, t.atoms.border_contrast_low, a.p_md, a.rounded_sm]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={embed.view.avatar} size={48} />
<FeedCard.Avatar src={embed.view.avatar} />
<FeedCard.TitleAndByline
title={embed.view.displayName}
creator={embed.view.creator}
uri={embed.view.uri}
/>
</FeedCard.Header>
<FeedCard.Likes count={embed.view.likeCount || 0} />
</FeedCard.Outer>
</FeedCard.Link>
)
@@ -4,7 +4,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotate'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text as TypoText} from '#/components/Typography'
+13 -29
View File
@@ -1,4 +1,4 @@
import {useCallback, useMemo} from 'react'
import React from 'react'
import {View} from 'react-native'
import {
type $Typed,
@@ -11,6 +11,7 @@ import {
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/profile'
@@ -18,7 +19,6 @@ import {useSession} from '#/state/session'
import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
@@ -232,7 +232,7 @@ export function QuoteEmbed({
viewContext?: QuoteEmbedViewContext
}) {
const moderationOpts = useModerationOpts()
const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>(
() => ({
...embed.view,
$type: 'app.bsky.feed.defs#postView',
@@ -241,17 +241,18 @@ export function QuoteEmbed({
}),
[embed],
)
const moderation = useMemo(() => {
const moderation = React.useMemo(() => {
return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
}, [quote, moderationOpts])
const t = useTheme()
const queryClient = useQueryClient()
const pal = usePalette('default')
const itemUrip = new AtUri(quote.uri)
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
const itemTitle = `Post by ${quote.author.handle}`
const richText = useMemo(() => {
const richText = React.useMemo(() => {
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
quote.record,
@@ -265,26 +266,17 @@ export function QuoteEmbed({
: undefined
}, [quote.record])
const onBeforePress = useCallback(() => {
const onBeforePress = React.useCallback(() => {
unstableCacheProfileView(queryClient, quote.author)
onOpen?.()
}, [queryClient, quote.author, onOpen])
const {
state: hover,
onIn: onPointerEnter,
onOut: onPointerLeave,
} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const [hover, setHover] = React.useState(false)
return (
<View
style={[a.mt_sm]}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}>
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
@@ -292,21 +284,13 @@ export function QuoteEmbed({
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{!active && <SubtleHover hover={hover} style={[a.rounded_md]} />}
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
onBeforePress={onBeforePress}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
+2 -14
View File
@@ -8,7 +8,6 @@ import type React from 'react'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
import {useRequireAuth} from '#/state/session'
import {useTheme} from '#/alf'
@@ -33,7 +32,6 @@ export const BookmarkButton = memo(function BookmarkButton({
const {mutateAsync: bookmark} = useBookmarkMutation()
const cleanError = useCleanError()
const requireAuth = useRequireAuth()
const {feedDescriptor} = useFeedFeedbackContext()
const {viewer} = post
const isBookmarked = !!viewer?.bookmarked
@@ -52,12 +50,7 @@ export const BookmarkButton = memo(function BookmarkButton({
post,
})
logger.metric('post:bookmark', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
logger.metric('post:bookmark', {logContext})
toast.show(
<toast.Outer>
@@ -92,12 +85,7 @@ export const BookmarkButton = memo(function BookmarkButton({
uri: post.uri,
})
logger.metric('post:unbookmark', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
logger.metric('post:unbookmark', {logContext})
toast.show(
<toast.Outer>
@@ -98,7 +98,6 @@ let PostMenuItems = ({
richText,
threadgateRecord,
onShowLess,
logContext,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@@ -112,7 +111,6 @@ let PostMenuItems = ({
timestamp: string
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
const {_} = useLingui()
@@ -212,21 +210,9 @@ let PostMenuItems = ({
try {
if (isThreadMuted) {
unmuteThread()
logger.metric('post:unmute', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
logger.metric('post:mute', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(
_(msg`You will no longer receive notifications for this thread`),
)
@@ -286,12 +272,6 @@ let PostMenuItems = ({
feedContext: postFeedContext,
reqId: postReqId,
})
logger.metric('post:showMore', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
Toast.show(
_(msg({message: 'Feedback sent to feed operator', context: 'toast'})),
)
@@ -304,12 +284,6 @@ let PostMenuItems = ({
feedContext: postFeedContext,
reqId: postReqId,
})
logger.metric('post:showLess', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
if (onShowLess) {
onShowLess({
item: postUri,
@@ -29,7 +29,6 @@ let PostMenuButton = ({
threadgateRecord,
onShowLess,
hitSlop,
logContext,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@@ -42,7 +41,6 @@ let PostMenuButton = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => {
const {_} = useLingui()
@@ -89,7 +87,6 @@ let PostMenuButton = ({
timestamp={timestamp}
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
logContext={logContext}
/>
)}
</Menu.Root>
@@ -16,7 +16,6 @@ import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {EventStopper} from '#/view/com/util/EventStopper'
import {native} from '#/alf'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
@@ -36,7 +35,6 @@ let ShareMenuButton = ({
threadgateRecord,
onShare,
hitSlop,
logContext,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@@ -47,11 +45,9 @@ let ShareMenuButton = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
onShare: () => void
hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => {
const {_} = useLingui()
const gate = useGate()
const {feedDescriptor} = useFeedFeedbackContext()
const ShareIcon = gate('alt_share_icon')
? ArrowShareRightIcon
@@ -69,27 +65,13 @@ let ShareMenuButton = ({
setTimeout(menuControl.open)
logger.metric(
'post:share',
{
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
postContext: big ? 'thread' : 'feed',
},
'share:open',
{context: big ? 'thread' : 'feed'},
{statsig: true},
)
},
}),
[
menuControl,
setHasBeenOpen,
big,
logContext,
feedDescriptor,
post.uri,
post.author.did,
],
[menuControl, setHasBeenOpen, big],
)
const onNativeLongPress = () => {
+1 -19
View File
@@ -13,7 +13,6 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
@@ -175,12 +174,6 @@ let PostControls = ({
feedContext,
reqId,
})
logger.metric('post:clickQuotePost', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
openComposer({
quote: post,
onPost: onPostReply,
@@ -224,16 +217,7 @@ let PostControls = ({
testID="replyBtn"
onPress={
!replyDisabled
? () =>
requireAuth(() => {
logger.metric('post:clickReply', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
onPressReply()
})
? () => requireAuth(() => onPressReply())
: undefined
}
label={_(
@@ -331,7 +315,6 @@ let PostControls = ({
left: secondaryControlSpacingStyles.gap / 2,
right: secondaryControlSpacingStyles.gap / 2,
}}
logContext={logContext}
/>
<PostMenuButton
testID="postDropdownBtn"
@@ -347,7 +330,6 @@ let PostControls = ({
hitSlop={{
left: secondaryControlSpacingStyles.gap / 2,
}}
logContext={logContext}
/>
</View>
</View>
+2 -8
View File
@@ -330,20 +330,14 @@ export function Name({
)
}
export function Handle({
profile,
textStyle,
}: {
profile: bsky.profile.AnyProfileView
textStyle?: StyleProp<TextStyle>
}) {
export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
const t = useTheme()
const handle = sanitizeHandle(profile.handle, '@')
return (
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]}
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
</Text>
@@ -389,7 +389,6 @@ let Card = ({
{data && moderationOpts ? (
status.isActive ? (
<LiveStatus
status={status}
profile={data}
embed={status.embed}
padding="lg"
+8 -14
View File
@@ -31,8 +31,8 @@ import {
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import * as ProfileCard from '#/components/ProfileCard'
@@ -60,16 +60,10 @@ type Item =
key: string
}
export function FollowDialog({
guide,
showArrow,
}: {
guide: Follow10ProgressGuide
showArrow?: boolean
}) {
export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) {
const {_} = useLingui()
const control = Dialog.useDialogControl()
const {gtPhone} = useBreakpoints()
const {gtMobile} = useBreakpoints()
const {height: minHeight} = useWindowDimensions()
return (
@@ -80,12 +74,13 @@ export function FollowDialog({
control.open()
logEvent('progressGuide:followDialog:open', {})
}}
size={gtPhone ? 'small' : 'large'}
color="primary">
size={gtMobile ? 'small' : 'large'}
color="primary"
variant="solid">
<ButtonIcon icon={PersonGroupIcon} />
<ButtonText>
<Trans>Find people to follow</Trans>
</ButtonText>
{showArrow && <ButtonIcon icon={ArrowRightIcon} />}
</Button>
<Dialog.Outer control={control} nativeOptions={{minHeight}}>
<Dialog.Handle />
@@ -579,7 +574,6 @@ function FollowProfileCardInner({
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
disabledPreview={!isWeb}
profile={profile}
moderationOpts={moderationOpts}
/>
+21 -124
View File
@@ -2,62 +2,37 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {
useProgressGuide,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {FollowDialog} from './FollowDialog'
import {ProgressGuideTask} from './Task'
const TOTAL_AVATARS = 10
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme()
const {_} = useLingui()
const {gtPhone} = useBreakpoints()
const {rightNavVisible} = useLayoutBreakpoints()
const {currentAccount} = useSession()
const followProgressGuide = useProgressGuide('follow-10')
const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
const guide = followProgressGuide || followAndLikeProgressGuide
const {endProgressGuide} = useProgressGuideControls()
const {data: follows} = useProfileFollowsQuery(currentAccount?.did, {
limit: TOTAL_AVATARS,
})
const actualFollowsCount = follows?.pages?.[0]?.follows?.length ?? 0
// Hide if user already follows 10+ people
if (guide?.guide === 'follow-10' && actualFollowsCount >= TOTAL_AVATARS) {
return null
}
// Inline layout when left nav visible but no right sidebar (800-1100px)
const inlineLayout = gtPhone && !rightNavVisible
if (guide) {
return (
<View
style={[
a.flex_col,
a.gap_md,
a.rounded_md,
t.atoms.bg_contrast_25,
a.p_lg,
style,
]}>
<View style={[a.flex_col, a.gap_md, style]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}>
<Trans>Follow 10 people to get started</Trans>
<Text
style={[
t.atoms.text_contrast_medium,
a.font_semi_bold,
a.text_sm,
{textTransform: 'uppercase'},
]}>
<Trans>Getting started</Trans>
</Text>
<Button
variant="ghost"
@@ -65,28 +40,20 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
color="secondary"
shape="round"
label={_(msg`Dismiss getting started guide`)}
onPress={endProgressGuide}
style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
<ButtonIcon icon={Times} size="xs" />
onPress={endProgressGuide}>
<ButtonIcon icon={Times} size="sm" />
</Button>
</View>
{guide.guide === 'follow-10' && (
<View
style={[
inlineLayout
? [
a.flex_row,
a.flex_wrap,
a.align_center,
a.justify_between,
a.gap_sm,
]
: a.flex_col,
!inlineLayout && a.gap_md,
]}>
<StackedAvatars follows={follows?.pages?.[0]?.follows} />
<FollowDialog guide={guide} showArrow={inlineLayout} />
</View>
<>
<ProgressGuideTask
current={guide.numFollows + 1}
total={10 + 1}
title={_(msg`Follow 10 accounts`)}
subtitle={_(msg`Bluesky is better with friends!`)}
/>
<FollowDialog guide={guide} />
</>
)}
{guide.guide === 'like-10-and-follow-7' && (
<>
@@ -109,73 +76,3 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
}
return null
}
function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
const t = useTheme()
const {centerColumnOffset} = useLayoutBreakpoints()
// Smaller avatars for narrower viewport
const avatarSize = centerColumnOffset ? 30 : 37
const overlap = centerColumnOffset ? 9 : 11
const iconSize = centerColumnOffset ? 14 : 18
// Use actual follows count, not the guide's event counter
const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
const remainingSlots = TOTAL_AVATARS - followedAvatars.length
// Total width calculation: first avatar + (remaining * visible portion)
const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap)
return (
<View style={[a.flex_row, a.self_start, {width: totalWidth}]}>
{/* Show followed user avatars */}
{followedAvatars.map((follow, i) => (
<View
key={follow.did}
style={[
a.rounded_full,
{
marginLeft: i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<UserAvatar
type="user"
size={avatarSize - 4}
avatar={follow.avatar}
/>
</View>
))}
{/* Show placeholder avatars for remaining slots */}
{Array(remainingSlots)
.fill(0)
.map((_, i) => (
<View
key={`placeholder-${i}`}
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_100,
{
width: avatarSize,
height: avatarSize,
marginLeft:
followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - followedAvatars.length - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<PersonIcon
width={iconSize}
height={iconSize}
fill={t.atoms.text_contrast_low.color}
/>
</View>
))}
</View>
)
}
+2 -2
View File
@@ -31,11 +31,11 @@ export function ProgressGuideTask({
size={20}
thickness={3}
borderWidth={0}
unfilledColor={t.palette.contrast_100}
unfilledColor={t.palette.contrast_50}
/>
)}
<View style={[a.flex_col, a.gap_xs, subtitle && {marginTop: -2}]}>
<View style={[a.flex_col, a.gap_2xs, subtitle && {marginTop: -2}]}>
<Text
style={[
a.text_sm,
+1 -5
View File
@@ -11,9 +11,6 @@ import {RichTextTag} from '#/components/RichTextTag'
import {Text, type TextProps} from '#/components/Typography'
const WORD_WRAP = {wordWrap: 1}
// lifted from facet detection in `RichText` impl, _without_ `gm` flags
const URL_REGEX =
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/i
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable' | 'onLayout' | 'onTextLayout'> & {
@@ -118,8 +115,7 @@ export function RichText({
</ProfileHoverCard>,
)
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
const isValidLink = URL_REGEX.test(link.uri)
if (!isValidLink || disableLinks) {
if (disableLinks) {
els.push(toShortUrl(segment.text))
} else {
els.push(
+8 -17
View File
@@ -12,7 +12,7 @@ import {
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {
@@ -48,11 +48,10 @@ export function RichTextTag({
reset: resetRemove,
} = useRemoveMutedWordsMutation()
const navigation = useNavigation<NavigationProp>()
const isCashtag = tag.startsWith('$')
const label = isCashtag ? _(msg`Cashtag ${tag}`) : _(msg`Hashtag ${tag}`)
const label = _(msg`Hashtag ${tag}`)
const hint = isNative
? _(msg`Long press to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
: _(msg`Click to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
? _(msg`Long press to open tag menu for #${tag}`)
: _(msg`Click to open tag menu for ${tag}`)
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
@@ -110,24 +109,20 @@ export function RichTextTag({
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts`)}
label={_(msg`See ${tag} posts`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
}}>
<Menu.ItemText>
{isCashtag ? (
<Trans>See {tag} posts</Trans>
) : (
<Trans>See #{tag} posts</Trans>
)}
<Trans>See #{tag} posts</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Search} />
</Menu.Item>
{authorHandle && !isInvalidHandle(authorHandle) && (
<Menu.Item
label={_(msg`See ${isCashtag ? tag : `#${tag}`} posts by user`)}
label={_(msg`See ${tag} posts by user`)}
onPress={() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
@@ -135,11 +130,7 @@ export function RichTextTag({
})
}}>
<Menu.ItemText>
{isCashtag ? (
<Trans>See {tag} posts by user</Trans>
) : (
<Trans>See #{tag} posts by user</Trans>
)}
<Trans>See #{tag} posts by user</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Person} />
</Menu.Item>
+5 -4
View File
@@ -1,9 +1,10 @@
import {View} from 'react-native'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {usePalette} from '#/lib/hooks/usePalette'
import {atoms as a, useBreakpoints} from '#/alf'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
export function SearchError({
title,
@@ -13,7 +14,7 @@ export function SearchError({
children?: React.ReactNode
}) {
const {gtMobile} = useBreakpoints()
const t = useTheme()
const pal = usePalette('default')
return (
<Layout.Content>
@@ -26,7 +27,7 @@ export function SearchError({
paddingVertical: 150,
},
]}>
<XIcon width={32} style={[t.atoms.text_contrast_low]} />
<TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} />
<View
style={[
a.align_center,
+33 -68
View File
@@ -1,4 +1,4 @@
import {lazy, useState} from 'react'
import {lazy} from 'react'
import {View} from 'react-native'
// @ts-expect-error missing types
import QRCode from 'react-native-qrcode-styled'
@@ -102,74 +102,39 @@ export function QrCode({
export function QrCodeInner({link}: {link: string}) {
const t = useTheme()
const [logoArea, setLogoArea] = useState<{
x: number
y: number
width: number
height: number
} | null>(null)
const onLogoAreaChange = (area: {
x: number
y: number
width: number
height: number
}) => {
setLogoArea(area)
}
return (
<View style={{position: 'relative'}}>
{/* An SVG version of the logo is placed on top of normal `QRCode` `logo` prop, since the PNG fails to load before the export completes on web. */}
{isWeb && logoArea && (
<View
style={{
position: 'absolute',
left: logoArea.x,
top: logoArea.y + 1,
zIndex: 1,
padding: 4,
}}>
<Logo width={logoArea.width - 14} height={logoArea.height - 14} />
</View>
)}
<QRCode
data={link}
style={[
a.rounded_sm,
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
]}
pieceSize={isWeb ? 8 : 6}
padding={20}
pieceBorderRadius={isWeb ? 4.5 : 3.5}
outerEyesOptions={{
topLeft: {
borderRadius: [12, 12, 0, 12],
color: t.palette.primary_500,
},
topRight: {
borderRadius: [12, 12, 12, 0],
color: t.palette.primary_500,
},
bottomLeft: {
borderRadius: [12, 0, 12, 12],
color: t.palette.primary_500,
},
}}
innerEyesOptions={{borderRadius: 3}}
logo={{
href: require('../../../assets/logo.png'),
...(isWeb && {
onChange: onLogoAreaChange,
padding: 28,
}),
...(!isWeb && {
padding: 2,
scale: 0.95,
}),
hidePieces: true,
}}
/>
</View>
<QRCode
data={link}
style={[
a.rounded_sm,
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
]}
pieceSize={isWeb ? 8 : 6}
padding={20}
// pieceLiquidRadius={2}
pieceBorderRadius={isWeb ? 4.5 : 3.5}
outerEyesOptions={{
topLeft: {
borderRadius: [12, 12, 0, 12],
color: t.palette.primary_500,
},
topRight: {
borderRadius: [12, 12, 12, 0],
color: t.palette.primary_500,
},
bottomLeft: {
borderRadius: [12, 0, 12, 12],
color: t.palette.primary_500,
},
}}
innerEyesOptions={{borderRadius: 3}}
logo={{
href: require('../../../assets/logo.png'),
scale: 0.95,
padding: 2,
hidePieces: true,
}}
/>
)
}
+9 -10
View File
@@ -20,12 +20,8 @@ export function TrendingTopic({
topic: raw,
size,
style,
hovered,
}: {
topic: TrendingTopic
size?: 'large' | 'small'
hovered?: boolean
} & ViewStyleProp) {
}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) {
const t = useTheme()
const topic = useTopic(raw)
const isSmall = size === 'small'
@@ -37,14 +33,18 @@ export function TrendingTopic({
style={[
a.flex_row,
a.align_center,
a.rounded_full,
a.border,
t.atoms.border_contrast_medium,
t.atoms.bg,
isSmall
? [
{
paddingVertical: 2,
paddingHorizontal: 4,
paddingVertical: 5,
paddingHorizontal: 10,
},
]
: [a.py_xs, a.px_sm],
: [a.py_sm, a.px_md],
hasIcon && {gap: 6},
style,
]}>
@@ -93,7 +93,6 @@ export function TrendingTopic({
a.font_semi_bold,
a.leading_tight,
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
hovered && {textDecorationLine: 'underline'},
]}
numberOfLines={1}>
{topic.displayName}
@@ -8,7 +8,6 @@ import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {
AgeAssuranceInitDialog,
useDialogControl,
@@ -28,13 +27,6 @@ import {useDeviceGeolocationApi} from '#/geolocation'
export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) {
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
if (aa.state.error === 'config') {
return (
<View style={style}>
<AgeAssuranceConfigUnavailableError />
</View>
)
}
return <Inner style={style} />
}
@@ -3,7 +3,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, select, useTheme, type ViewStyleProp} from '#/alf'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useDialogControl} from '#/components/ageAssurance/AgeAssuranceInitDialog'
import type * as Dialog from '#/components/Dialog'
import {ShieldCheck_Stroke2_Corner0_Rounded as Shield} from '#/components/icons/Shield'
@@ -20,9 +19,6 @@ export function AgeAssuranceAdmonition({
const aa = useAgeAssurance()
if (aa.state.access === aa.Access.Full) return null
if (aa.state.error === 'config') {
return <AgeAssuranceConfigUnavailableError style={style} />
}
return (
<Inner style={style} control={control}>
@@ -23,7 +23,6 @@ export function useInternalState() {
const visible = useMemo(() => {
if (aa.state.access === aa.Access.Full) return false
if (aa.state.lastInitiatedAt) return false
if (aa.state.error === 'config') return false
if (hidden) return false
if (nux && nux.completed) return false
return true
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, type ViewStyleProp} from '#/alf'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -27,37 +26,33 @@ export function AgeAssuranceDismissibleNotice({style}: ViewStyleProp & {}) {
return (
<View style={style}>
{aa.state.error === 'config' ? (
<AgeAssuranceConfigUnavailableError />
) : (
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
<View>
<AgeAssuranceAdmonition>{copy.notice}</AgeAssuranceAdmonition>
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
)}
<Button
label={_(msg`Don't show again`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() => {
save({
id: Nux.AgeAssuranceDismissibleNotice,
completed: true,
data: undefined,
})
logger.metric('ageAssurance:dismissSettingsNotice', {})
}}
style={[
a.absolute,
{
top: 12,
right: 12,
},
]}>
<ButtonIcon icon={X} />
</Button>
</View>
</View>
)
}
@@ -1,37 +0,0 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type ViewStyleProp} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {refetchConfig} from '#/ageAssurance/data'
export function AgeAssuranceConfigUnavailableError(props: ViewStyleProp) {
const {_} = useLingui()
return (
<Admonition.Outer type="error" style={props.style}>
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
We were unable to load the age assurance configuration for your
region, probably due to a network error. Some content and features
may be unavailable temporarily. Please try again later.
</Trans>
</Admonition.Text>
</Admonition.Content>
<Admonition.Button
color="negative_subtle"
label={_(msg`Retry`)}
onPress={() => refetchConfig().catch(() => {})}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={RetryIcon} />
</Admonition.Button>
</Admonition.Row>
</Admonition.Outer>
)
}
@@ -16,7 +16,7 @@ import {isEmailMaybeInvalid} from '#/lib/strings/email'
import {type AppLanguage} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import {atoms as a, web} from '#/alf'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {urls} from '#/components/ageAssurance/const'
@@ -63,6 +63,7 @@ export function AgeAssuranceInitDialog({
}
function Inner() {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const langPrefs = useLanguagePrefs()
@@ -322,6 +323,30 @@ function Inner() {
/>
</Button>
</View>
<Text
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
By continuing, you agree to the{' '}
<SimpleInlineLinkText
label={_(msg`KWS Terms of Use`)}
to={urls.kwsTermsOfUse}
style={[a.text_xs, a.leading_snug]}>
KWS Terms of Use
</SimpleInlineLinkText>{' '}
and acknowledge that KWS will store your verified status with
your hashed email address in accordance with the{' '}
<SimpleInlineLinkText
label={_(msg`KWS Privacy Policy`)}
to={urls.kwsPrivacyPolicy}
style={[a.text_xs, a.leading_snug]}>
KWS Privacy Policy
</SimpleInlineLinkText>
. This means you wont need to verify again the next time you
use this email for other apps, games, and services powered by
KWS technology.
</Trans>
</Text>
</>
)}
</View>
@@ -5,7 +5,6 @@ import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
import {AgeAssuranceConfigUnavailableError} from '#/components/ageAssurance/AgeAssuranceErrors'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
@@ -45,12 +44,6 @@ export function AgeRestrictedScreen({
</Layout.Header.Outer>
<Layout.Content>
<View style={[a.p_lg]}>
{aa.state.error === 'config' && (
<View style={[a.pb_lg]}>
<AgeAssuranceConfigUnavailableError />
</View>
)}
<View style={[a.align_start, a.pb_lg]}>
<AgeAssuranceBadge />
</View>
@@ -1,113 +0,0 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {Link} from '../Link'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist'
export function FindContactsBannerNUX() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
if (!visible) return null
return (
<View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}>
<View style={a.w_full}>
<Link
to={{screen: 'FindContactsFlow'}}
label={_(msg`Import contacts to find your friends`)}
onPress={() => {
logger.metric('contacts:nux:bannerPressed', {})
}}
style={[
a.w_full,
a.rounded_xl,
a.curve_continuous,
a.overflow_hidden,
]}>
<LinearGradient
colors={[t.palette.primary_200, t.palette.primary_50]}
start={{x: 0, y: 0.5}}
end={{x: 1, y: 0.5}}
style={[
a.w_full,
a.h_full,
a.flex_row,
a.align_center,
a.gap_lg,
a.pl_lg,
]}>
<Image
source={require('../../../assets/images/find_friends_illustration_small.webp')}
accessibilityIgnoresInvertColors
style={[
{height: 70, aspectRatio: 573 / 286},
a.self_end,
a.mt_sm,
]}
/>
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
<Text
style={[
a.text_md,
a.font_bold,
{color: t.palette.primary_900},
]}>
<Trans>Import contacts to find your friends</Trans>
</Text>
</View>
</LinearGradient>
</Link>
<Button
label={_(msg`Dismiss banner`)}
hitSlop={HITSLOP_10}
onPress={close}
style={[a.absolute, {top: 14, right: 14}]}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
<XIcon size="xs" style={[t.atoms.text_contrast_low]} />
</Button>
</View>
</View>
)
}
function useInternalState() {
const {nux} = useNux(Nux.FindContactsDismissibleBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation()
const gate = useGate()
const visible = useMemo(() => {
if (isWeb) return false
if (hidden) return false
if (nux && nux.completed) return false
if (!isFeatureEnabled) return false
if (gate('disable_settings_find_contacts')) return false
return true
}, [hidden, nux, isFeatureEnabled, gate])
const close = () => {
save({
id: Nux.FindContactsDismissibleBanner,
completed: true,
data: undefined,
})
logger.metric('contacts:nux:bannerDismissed', {})
}
return {visible, close}
}
@@ -1,56 +0,0 @@
import {GetContacts} from './screens/GetContacts'
import {PhoneInput} from './screens/PhoneInput'
import {VerifyNumber} from './screens/VerifyNumber'
import {ViewMatches} from './screens/ViewMatches'
import {type Action, FindContactsGoBackContext, type State} from './state'
export function FindContactsFlow({
state,
dispatch,
onBack,
onCancel,
context = 'Standalone',
}: {
state: State
dispatch: React.ActionDispatch<[Action]>
onBack?: () => void
onCancel: () => void
context: 'Onboarding' | 'Standalone'
}) {
return (
<FindContactsGoBackContext value={onBack}>
{state.step === '1: phone input' && (
<PhoneInput
state={state}
dispatch={dispatch}
context={context}
onSkip={onCancel}
/>
)}
{state.step === '2: verify number' && (
<VerifyNumber
state={state}
dispatch={dispatch}
context={context}
onSkip={onCancel}
/>
)}
{state.step === '3: get contacts' && (
<GetContacts
state={state}
dispatch={dispatch}
onCancel={onCancel}
context={context}
/>
)}
{state.step === '4: view matches' && (
<ViewMatches
state={state}
dispatch={dispatch}
context={context}
onNext={onCancel}
/>
)}
</FindContactsGoBackContext>
)
}
@@ -1,3 +0,0 @@
export function FindContactsFlow() {
throw new Error('FindContactsFlow is not available on web')
}
@@ -1,33 +0,0 @@
import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
export function ContactsHeroImage() {
const t = useTheme()
const {_} = useLingui()
return (
<View
style={[
a.w_full,
a.pl_3xl,
a.pr_2xl,
a.pt_4xl,
a.pb_3xl,
a.rounded_lg,
{backgroundColor: t.palette.primary_50},
]}>
<Image
source={require('../../../../assets/images/find_friends_illustration.webp')}
accessibilityIgnoresInvertColors
style={[a.w_full, {aspectRatio: 1278 / 661}]}
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
/>
</View>
)
}
@@ -1,85 +0,0 @@
import {type StyleProp, type TextStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {android, atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
import {Text} from '#/components/Typography'
export function InviteInfo({
iconStyle,
iconOffset = 0,
}: {
iconStyle: StyleProp<TextStyle>
/**
* Adjust the vertical position of the icon via `translateY`
*
* @platform android
*/
iconOffset?: number
}) {
const {_} = useLingui()
const control = Dialog.useDialogControl()
const style = [a.text_md, a.leading_snug, a.mt_xs]
return (
<>
<Button
label={_(msg`Learn more about how inviting friends works`)}
onPress={control.open}
hitSlop={HITSLOP_20}
style={android({transform: [{translateY: iconOffset}]})}>
<InfoIcon style={iconStyle} size="sm" />
</Button>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Invite Friends`)}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Invite Friends</Trans>
</Text>
<Text style={style}>
<Trans>
It looks like some of your contacts have not tried to find you
here yet. You can personally invite them by customizing a draft
message we will provide.
</Trans>
</Text>
<Text style={[style, a.font_medium, a.mt_lg]}>
<Trans>How it works:</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Choose who to invite</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Personalize the message</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Send the message from your phone</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>
We don't store your friends' phone numbers or send any messages
</Trans>
</Text>
<Button
label={_(msg`Done`)}
onPress={() => control.close()}
size="large"
color="primary"
style={[a.mt_2xl]}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
@@ -1,143 +0,0 @@
import {useRef, useState} from 'react'
import {
Pressable,
TextInput,
type TextInputSelectionChangeEvent,
View,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {mergeRefs} from '#/lib/merge-refs'
import {isAndroid, isIOS} from '#/platform/detection'
import {atoms as a, ios, platform, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
export function OTPInput({
label,
value,
onChange,
ref,
numberOfDigits = 6,
onComplete,
}: {
label: string
value: string
onChange: (text: string) => void
ref?: React.Ref<TextInput>
numberOfDigits?: number
onComplete?: (code: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const innerRef = useRef<TextInput>(null)
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [selection, setSelection] = useState({start: 0, end: 0})
const onChangeText = (text: string) => {
// only numbers
text = text.replace(/[^0-9]/g, '')
text = text.slice(0, numberOfDigits)
onChange(text)
if (text.length === numberOfDigits) {
onComplete?.(text)
innerRef.current?.blur()
}
}
const onSelectionChange = (evt: TextInputSelectionChangeEvent) => {
setSelection(evt.nativeEvent.selection)
}
return (
<Pressable
accessibilityLabel={_(msg`Focus code input`)}
accessibilityRole="button"
accessibilityHint=""
style={[a.w_full, a.relative]}
onPress={() => {
innerRef.current?.focus()
innerRef.current?.clear()
}}>
<View style={[a.w_full, a.flex_row, a.gap_sm]}>
{[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => {
const selected = focused
? selection.start === selection.end
? selection.start === index
: index >= selection.start && index < selection.end
: false
return (
<View
key={index}
style={[
a.flex_1,
a.align_center,
a.justify_center,
t.atoms.bg_contrast_50,
{
height: 64,
borderWidth: 1,
borderRadius: 10,
borderColor: selected
? t.palette.primary_500
: t.atoms.bg_contrast_50.backgroundColor,
},
]}>
<Text style={[a.text_2xl, a.text_center, a.font_bold]}>
{digit}
</Text>
</View>
)
})}
</View>
<TextInput
// SMS autofill is borked on iOS if you open the keyboard immediately -sfn
onLayout={ios(() => setTimeout(() => innerRef.current?.focus(), 100))}
autoFocus={isAndroid}
accessible
accessibilityLabel={label}
accessibilityHint=""
accessibilityRole="text"
ref={mergeRefs(ref ? [ref, innerRef] : [innerRef])}
value={value}
onChangeText={onChangeText}
onSelectionChange={onSelectionChange}
keyboardAppearance={t.scheme}
inputMode="numeric"
keyboardType="number-pad"
textContentType="oneTimeCode"
autoComplete={platform({
android: 'sms-otp',
ios: 'one-time-code',
})}
onFocus={onFocus}
onBlur={onBlur}
maxLength={numberOfDigits}
style={[
a.absolute,
a.inset_0,
// roughly vibe align the characters
// with the visible ones so that
// moving the caret via long press
// still kinda sorta works
{
fontVariant: ['tabular-nums'],
textAlignVertical: 'center',
letterSpacing: 24,
fontSize: 60,
paddingLeft: 6,
},
platform({
// completely transparent inputs on iOS cannot be pasted into
ios: {opacity: 0.02, color: 'transparent'},
android: {opacity: 0},
}),
]}
caretHidden={isIOS}
clearTextOnFocus
/>
</Pressable>
)
}
-100
View File
@@ -1,100 +0,0 @@
import {type AppBskyContactDefs} from '@atproto/api'
import {type CountryCode} from '#/lib/international-telephone-codes'
import {normalizePhoneNumber} from './phone-number'
import {type Contact, type Match} from './state'
/**
* Filters out contacts that do not have any associated phone numbers,
* as well as businesses
*/
export function contactsWithPhoneNumbersOnly(contacts: Contact[]) {
return contacts.filter(
contact =>
contact.phoneNumbers &&
contact.phoneNumbers.length > 0 &&
contact.contactType !== 'company',
)
}
/**
* Takes the raw contact book and returns a plain list of numbers in E.164 format, along
* with a mapping to retrieve the contact ID when we get the results back.
*
* `countryCode` is used as a fallback for local numbers that don't have a country code associated with them.
* I'm making the assumption that most local numbers in someone's phone book will be the same as theirs.
*/
export function normalizeContactBook(
contacts: Contact[],
countryCode: CountryCode,
ownNumber: string,
): {
phoneNumbers: string[]
indexToContactId: Map<number, Contact['id']>
} {
const phoneNumbers: string[] = []
const indexToContactId = new Map<number, Contact['id']>()
for (const contact of contacts) {
for (const number of contact.phoneNumbers ?? []) {
let rawNumber: string
if (number.number) {
rawNumber = number.number
} else if (number.digits) {
rawNumber = number.digits
} else {
continue
}
const normalized = normalizePhoneNumber(
rawNumber,
number.countryCode,
countryCode,
)
if (normalized === null) continue
// skip if it's your own number
if (normalized === ownNumber) continue
phoneNumbers.push(normalized)
indexToContactId.set(phoneNumbers.length - 1, contact.id)
}
}
return {
phoneNumbers,
indexToContactId,
}
}
export function filterMatchedNumbers(
contacts: Contact[],
results: AppBskyContactDefs.MatchAndContactIndex[],
mapping: Map<number, Contact['id']>,
) {
const filteredIds = new Set<Contact['id']>()
for (const result of results) {
const id = mapping.get(result.contactIndex)
if (id !== undefined) {
filteredIds.add(id)
}
}
return contacts.filter(contact => !filteredIds.has(contact.id))
}
export function getMatchedContacts(
contacts: Contact[],
results: AppBskyContactDefs.MatchAndContactIndex[],
mapping: Map<number, Contact['id']>,
): Array<Match> {
const contactsById = new Map(contacts.map(c => [c.id, c]))
return results.map(result => {
const id = mapping.get(result.contactIndex)
const contact = id !== undefined ? contactsById.get(id) : undefined
return {profile: result.match, contact}
})
}
@@ -1,39 +0,0 @@
import {type CountryCode} from '#/lib/international-telephone-codes'
import {IS_DEV} from '#/env'
import {useGeolocation} from '#/geolocation'
const FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST = [
'US',
'GB',
'JP',
'CA',
'DE',
'FR',
'ES',
'BR',
'KR',
'NL',
'AU',
'SE',
'IT',
] satisfies CountryCode[] as string[]
export function isFindContactsFeatureEnabled(countryCode?: string): boolean {
if (IS_DEV) return true
/*
* This should never happen unless geolocation fails entirely. In that
* case, let the user try, since it should work as long as they have a
* phone number from one of the allow-listed countries.
*/
if (!countryCode) return true
return FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST.includes(
countryCode.toUpperCase(),
)
}
export function useIsFindContactsFeatureEnabledBasedOnGeolocation() {
const location = useGeolocation()
return isFindContactsFeatureEnabled(location.countryCode)
}
-162
View File
@@ -1,162 +0,0 @@
import {t} from '@lingui/macro'
import {
isSupportedCountry,
ParseError,
parsePhoneNumber,
parsePhoneNumberWithError,
type PhoneNumber,
} from 'libphonenumber-js/max'
import {type CountryCode} from '#/lib/international-telephone-codes'
/**
* Intended for after the user has finished inputting their phone number.
*/
export function processPhoneNumber(
number: string,
country: CountryCode,
):
| {
valid: true
formatted: string
countryCode: CountryCode
}
| {
valid: false
reason?: string
} {
try {
const phoneNumber = parsePhoneNumberWithError(number, {
defaultCountry: country,
})
if (!phoneNumber.isValid()) {
return {valid: false, reason: t`Invalid phone number`}
}
const type = phoneNumber.getType()
if (
type !== 'MOBILE' &&
type !== 'FIXED_LINE_OR_MOBILE' &&
type !== 'PERSONAL_NUMBER'
) {
return {
valid: false,
reason: t`Number should be a mobile number`,
}
}
let countryCode = country
if (phoneNumber.country && phoneNumber.country !== country) {
if (phoneNumber.country === 'AC' || phoneNumber.country === 'TA') {
countryCode = 'SH'
} else {
countryCode = phoneNumber.country
}
}
return {
valid: true,
formatted: formatE164lWithoutCountryCode(phoneNumber),
countryCode,
}
} catch (error) {
if (error instanceof ParseError) {
return {valid: false, reason: error.message}
} else {
return {valid: false}
}
}
}
/**
* Format a phone number as the international format with the prefix
* removed.
*/
function formatE164lWithoutCountryCode(phoneNumber: PhoneNumber) {
const intl = phoneNumber.format('E.164')
const prefix = '+' + phoneNumber.countryCallingCode
return intl.replace(prefix, '').trim()
}
/**
* Takes a country code and a prefix-less phone number and constructs a full phone number.
*
* Does not have nice error handling - if you're unsure if the number is valid, use
* `processPhoneNumber` instead
*/
export function constructFullPhoneNumber(
countryCode: CountryCode,
phoneNumber: string,
) {
const result = parsePhoneNumber(phoneNumber, {defaultCountry: countryCode})
if (!result.isValid())
throw new Error('Invalid phone number passed to constructFullPhoneNumber')
return result.format('E.164')
}
/**
* Takes a phone number and applies human-readable formatting. Do not sent to the API - they
* expect E.164 format.
*/
export function prettyPhoneNumber(phoneNumber: string) {
const result = parsePhoneNumber(phoneNumber)
return result.formatNational()
}
/**
* Attempts to parse a phone number from a string, and returns the country code
* and the rest of the number if possible. If the number is invalid, returns undefined.
*/
export function getCountryCodeFromPastedNumber(
text: string,
): {countryCode: CountryCode; rest: string} | undefined {
try {
const phoneNumber = parsePhoneNumber(text)
if (!phoneNumber.isValid()) {
return undefined
}
const countryCode = phoneNumber.country
// we don't have AC and TA in our dropdown - see `#/lib/international-telephone-codes`
if (countryCode && countryCode !== 'AC' && countryCode !== 'TA') {
return {
countryCode,
rest: formatE164lWithoutCountryCode(phoneNumber),
}
} else {
return undefined
}
} catch (error) {
return undefined
}
}
/**
* Normalizes a phone number into E.164 format
*/
export function normalizePhoneNumber(
rawNumber: string,
countryCode: string | undefined,
fallbackCountryCode: CountryCode,
): string | null {
try {
const result = parsePhoneNumber(rawNumber, {
defaultCountry:
countryCode && isSupportedCountry(countryCode)
? countryCode
: fallbackCountryCode,
})
if (!result.isValid()) return null
const type = result.getType()
if (
type !== 'MOBILE' &&
type !== 'FIXED_LINE_OR_MOBILE' &&
type !== 'PERSONAL_NUMBER'
) {
return null
}
return result.format('E.164')
} catch (error) {
console.log('Failed to normalize phone number:', error)
return null
}
}
@@ -1,351 +0,0 @@
import {useContext} from 'react'
import {Alert, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as Contacts from 'expo-contacts'
import type AtpAgent from '@atproto/api'
import {
type AppBskyActorProfile,
AppBskyContactImportContacts,
type Un$Typed,
} from '@atproto/api'
import {msg, t, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {uploadBlob} from '#/lib/api'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
import {useAgent} from '#/state/session'
import {
Context as OnboardingContext,
type OnboardingAction,
type OnboardingState,
} from '#/screens/Onboarding/state'
import {atoms as a, ios, tokens, useGutters} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {
contactsWithPhoneNumbersOnly,
filterMatchedNumbers,
getMatchedContacts,
normalizeContactBook,
} from '../contacts'
import {constructFullPhoneNumber} from '../phone-number'
import {type Action, type State} from '../state'
const MAX_UPLOAD_COUNT = 1000
export function GetContacts({
state,
dispatch,
onCancel,
context,
}: {
state: Extract<State, {step: '3: get contacts'}>
dispatch: React.ActionDispatch<[Action]>
onCancel: () => void
context: 'Onboarding' | 'Standalone'
}) {
const {_} = useLingui()
const agent = useAgent()
const insets = useSafeAreaInsets()
const gutters = useGutters([0, 'wide'])
const queryClient = useQueryClient()
const maybeOnboardingContext = useContext(OnboardingContext)
const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({
mutationFn: async (contacts: Contacts.ExistingContact[]) => {
/**
* `importContacts` triggers a notification for the people you match with,
* however we prevent notifications coming from users without profiles.
* If you're using this as the onboarding flow, we need to create a profile
* record before this.
*
* When you finish onboarding, we'll upsert again - bit wasteful but fine.
*/
if (context === 'Onboarding' && maybeOnboardingContext) {
try {
await createProfileRecord(agent, maybeOnboardingContext)
} catch (error) {
logger.debug('Error creating profile record:', {safeMessage: error})
}
}
const {phoneNumbers, indexToContactId} = normalizeContactBook(
contacts,
state.phoneCountryCode,
constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
)
if (phoneNumbers.length > 0) {
const res = await agent.app.bsky.contact.importContacts({
token: state.token,
contacts: phoneNumbers.slice(0, MAX_UPLOAD_COUNT),
})
return {
matches: res.data.matchesAndContactIndexes,
indexToContactId,
}
} else {
return {
matches: [],
indexToContactId,
}
}
},
onSuccess: (result, contacts) => {
if (context === 'Onboarding') {
logger.metric('onboarding:contacts:contactsShared', {})
}
if (result.matches.length > 0) {
logger.metric('contacts:import:success', {
contactCount: contacts.length,
matchCount: result.matches.length,
entryPoint: context,
})
} else {
logger.metric('contacts:import:failure', {
reason: 'noValidNumbers',
entryPoint: context,
})
}
dispatch({
type: 'SYNC_CONTACTS_SUCCESS',
payload: {
matches: getMatchedContacts(
contacts,
result.matches,
result.indexToContactId,
),
contacts: filterMatchedNumbers(
contacts,
result.matches,
result.indexToContactId,
),
},
})
queryClient.invalidateQueries({
queryKey: findContactsStatusQueryKey,
})
},
onError: err => {
logger.metric('contacts:import:failure', {
reason: isNetworkError(err) ? 'networkError' : 'unknown',
entryPoint: context,
})
if (isNetworkError(err)) {
Toast.show(
_(
msg`There was a problem with your internet connection, please try again`,
),
{type: 'error'},
)
} else if (
err instanceof AppBskyContactImportContacts.TooManyContactsError
) {
Toast.show(
_(
msg`Too many contacts - you've exceeded the number of contacts you can import to find your friends`,
),
{type: 'error'},
)
} else if (
err instanceof AppBskyContactImportContacts.InvalidTokenError
) {
Toast.show(
_(
msg`Could not upload contacts. You need to re-verify your phone number to proceed`,
),
{type: 'error'},
)
} else {
logger.error('Error uploading contacts', {safeMessage: err})
Toast.show(_(msg`Could not upload contacts. ${cleanError(err)}`), {
type: 'error',
})
}
},
})
const {mutate: getContacts, isPending: isGetContactsPending} = useMutation({
mutationFn: async () => {
let permissions = await Contacts.getPermissionsAsync()
if (!permissions.granted && permissions.canAskAgain) {
permissions = await Contacts.requestPermissionsAsync()
}
logger.metric('contacts:permission:request', {
status: permissions.granted ? 'granted' : 'denied',
accessLevelIOS: ios(permissions.accessPrivileges),
})
if (!permissions.granted) {
throw new PermissionDeniedError()
}
const contacts = await Contacts.getContactsAsync({
fields: [
Contacts.Fields.FirstName,
Contacts.Fields.LastName,
Contacts.Fields.PhoneNumbers,
Contacts.Fields.Image,
],
})
return contactsWithPhoneNumbersOnly(contacts.data)
},
onSuccess: contacts => {
dispatch({
type: 'GET_CONTACTS_SUCCESS',
payload: {contacts},
})
uploadContacts(contacts)
},
onError: err => {
if (err instanceof PermissionDeniedError) {
showPermissionDeniedAlert()
} else {
logger.error('Error getting contacts', {safeMessage: err})
}
},
})
const isPending = isUploadPending || isGetContactsPending
const style = [a.text_md, a.leading_snug, a.mt_md]
return (
<View style={[a.h_full]}>
<Layout.Content
contentContainerStyle={[gutters, a.flex_1, a.pt_xl]}
bounces={false}>
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Share your contacts to find friends</Trans>
</Text>
<Text style={style}>
<Trans>
Bluesky helps friends find each other by creating an encoded digital
fingerprint, called a "hash", and then looking for matching hashes.
</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We never keep plain phone numbers</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We delete hashes after matches are made</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We only suggest follows if both people consent</Trans>
</Text>
<Text style={style}>
&bull; <Trans>You can always opt out and delete your data</Trans>
</Text>
<Text style={[style, a.mt_lg]}>
<Trans>
We apply the highest privacy standards, and never share or sell your
contact information.
</Trans>
</Text>
</Layout.Content>
<View
style={[
gutters,
a.pt_xs,
{paddingBottom: Math.max(insets.bottom, tokens.space.xl)},
a.gap_md,
]}>
<Text style={[a.text_sm, a.pb_xs]}>
<Trans>
I consent to Bluesky using my contacts for mutual friend discovery
and to retain hashed data for matching until I opt out.
</Trans>
</Text>
<Button
label={_(msg`Find my friends`)}
size="large"
color="primary"
onPress={() => getContacts()}
disabled={isPending}>
{isUploadPending ? (
<>
<ButtonText>
<Trans>Finding friends...</Trans>
</ButtonText>
<ButtonIcon icon={Loader} />
</>
) : (
<ButtonText>
<Trans>Find my friends</Trans>
</ButtonText>
)}
</Button>
<Button
label={_(msg`Cancel`)}
size="large"
color="secondary"
onPress={onCancel}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}
class PermissionDeniedError extends Error {
constructor() {
super('Permission denied')
}
}
function showPermissionDeniedAlert() {
Alert.alert(
t`You've denied access to your contacts`,
t`You'll need to go to the System Settings for Bluesky and give permission if you want to use this feature.`,
[
{
text: t`OK`,
style: 'default',
},
],
)
}
/**
* Copied from `#/screens/Onboarding/StepFinished/index.tsx`
*/
async function createProfileRecord(
agent: AtpAgent,
onboardingContext: {
state: OnboardingState
dispatch: React.Dispatch<OnboardingAction>
},
) {
const profileStepResults = onboardingContext.state.profileStepResults
const {imageUri, imageMime} = profileStepResults
const blobPromise =
imageUri && imageMime ? uploadBlob(agent, imageUri, imageMime) : undefined
await agent.upsertProfile(async existing => {
let next: Un$Typed<AppBskyActorProfile.Record> = existing ?? {}
if (blobPromise) {
const res = await blobPromise
if (res.data.blob) {
next.avatar = res.data.blob
}
}
next.displayName = ''
next.createdAt = new Date().toISOString()
return next
})
}
@@ -1,327 +0,0 @@
import {useState} from 'react'
import {Keyboard, View} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyContactStartPhoneVerification} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {urls} from '#/lib/constants'
import {
type CountryCode,
getDefaultCountry,
} from '#/lib/international-telephone-codes'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {
android,
atoms as a,
platform,
tokens,
useGutters,
useTheme,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useGeolocation} from '#/geolocation'
import {isFindContactsFeatureEnabled} from '../country-allowlist'
import {
constructFullPhoneNumber,
getCountryCodeFromPastedNumber,
processPhoneNumber,
} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state'
export function PhoneInput({
state,
dispatch,
context,
onSkip,
}: {
state: Extract<State, {step: '1: phone input'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onSkip: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const location = useGeolocation()
const [countryCode, setCountryCode] = useState(
() => state.phoneCountryCode ?? getDefaultCountry(location),
)
const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
const gutters = useGutters([0, 'wide'])
const insets = useSafeAreaInsets()
// for API/generic errors
const [error, setError] = useState('')
// for issues with parsing the number
const [formatError, setFormatError] = useState('')
const {mutate: submit, isPending} = useMutation({
mutationFn: async ({
phoneCountryCode,
phoneNumber,
}: {
phoneCountryCode: CountryCode
phoneNumber: string
}) => {
// sends a onetime code to the user's phone number
await agent.app.bsky.contact.startPhoneVerification({
phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
})
},
onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
dispatch({
type: 'SUBMIT_PHONE_NUMBER',
payload: {phoneCountryCode, phoneNumber},
})
logger.metric('contacts:phone:phoneEntered', {entryPoint: context})
},
onMutate: () => {
Keyboard.dismiss()
setError('')
setFormatError('')
},
onError: err => {
if (isNetworkError(err)) {
setError(
_(
msg`A network error occurred. Please check your internet connection`,
),
)
} else if (
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError(_(msg`Rate limit exceeded. Please try again later.`))
} else if (
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError(
_(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
)
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError(_(msg`An error occurred. ${cleanError(err)}`))
}
},
})
const isFeatureEnabled = isFindContactsFeatureEnabled(countryCode)
const onSubmitNumber = () => {
if (!isFeatureEnabled) return
if (!phoneNumber) return
const result = processPhoneNumber(phoneNumber, countryCode)
if (result.valid) {
setPhoneNumber(result.formatted)
setCountryCode(result.countryCode)
if (!isFindContactsFeatureEnabled(result.countryCode)) return
submit({
phoneCountryCode: result.countryCode,
phoneNumber: result.formatted,
})
} else {
setFormatError(result.reason ?? _(msg`Invalid phone number`))
}
}
const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
const onPressBack = useOnPressBackButton()
return (
<View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content />
{context === 'Onboarding' ? (
<Button
size="small"
color="secondary"
variant="ghost"
label={_(msg`Skip contact sharing and continue to the app`)}
onPress={onSkip}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<Layout.Content
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
keyboardShouldPersistTaps="handled">
{context === 'Onboarding' && <OnboardingPosition />}
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Verify phone number</Trans>
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.mt_sm,
]}>
<Trans>
We need to verify your number before we can look for your friends. A
verification code will be sent to this number.
</Trans>
</Text>
<View style={[a.mt_2xl]}>
<TextField.LabelText>
<Trans>Phone number</Trans>
</TextField.LabelText>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<View>
<InternationalPhoneCodeSelect
value={countryCode}
onChange={value => setCountryCode(value)}
/>
</View>
<View style={[a.flex_1]}>
<TextField.Root isInvalid={!!formatError || !isFeatureEnabled}>
<TextField.Input
label={_(msg`Phone number`)}
value={phoneNumber}
onChangeText={text => {
if (formatError) setFormatError('')
if (Math.abs(text.length - phoneNumber.length) > 1) {
// possibly pasted/autocompleted? auto-switch
// country code if possible
const result = getCountryCodeFromPastedNumber(text)
if (result) {
setCountryCode(result.countryCode)
setPhoneNumber(result.rest)
return
}
}
setPhoneNumber(text)
}}
placeholder={null}
keyboardType={platform({
ios: 'number-pad',
android: 'phone-pad',
})}
autoComplete="tel"
returnKeyType={android('next')}
onSubmitEditing={onSubmitNumber}
/>
</TextField.Root>
</View>
</View>
</View>
{!isFeatureEnabled && (
<ErrorText>
<Trans>
Support for this feature in your country has not been enabled yet!
Please check back later.
</Trans>
</ErrorText>
)}
{error && <ErrorText>{error}</ErrorText>}
{formatError && <ErrorText>{formatError}</ErrorText>}
<View style={[a.mt_auto, a.py_xl]}>
<LegalDisclaimer />
</View>
</Layout.Content>
<KeyboardAvoidingView
behavior="padding"
keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}>
<View style={[gutters, {paddingBottom}]}>
<Button
disabled={!phoneNumber || isPending}
label={_(msg`Send code`)}
size="large"
color="primary"
onPress={onSubmitNumber}>
<ButtonText>
<Trans>Send code</Trans>
</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
</View>
</KeyboardAvoidingView>
</View>
)
}
function LegalDisclaimer() {
const t = useTheme()
const {_} = useLingui()
const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug]
return (
<View style={[a.gap_xs]}>
<Text style={[style, a.font_medium]}>
<Trans>How we use your number:</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Sent to our phone number verification provider Plivo</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Deleted by Plivo after verification</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Stored as part of a secure code for matching with others</Trans>
</Text>
<Text style={[style, a.mt_xs]}>
<Trans>
By continuing, you consent to this use. You may change your mind any
time by visiting settings.{' '}
<InlineLinkText
to={urls.website.support.findFriendsPrivacyPolicy}
label={_(
msg({
message: `Learn more about importing contacts`,
context: `english-only-resource`,
}),
)}
style={[a.text_xs, a.leading_snug]}>
<Trans context="english-only-resource">Learn more</Trans>
</InlineLinkText>
</Trans>
</Text>
</View>
)
}
function ErrorText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text
style={[
a.text_md,
{color: t.palette.negative_500},
a.leading_snug,
a.mt_md,
]}>
{children}
</Text>
)
}
@@ -1,386 +0,0 @@
import {useEffect, useMemo, useState} from 'react'
import {Text as NestedText, View} from 'react-native'
import {
AppBskyContactStartPhoneVerification,
AppBskyContactVerifyPhone,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {clamp} from '#/lib/numbers'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {OTPInput} from '../components/OTPInput'
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state'
export function VerifyNumber({
state,
dispatch,
context,
onSkip,
}: {
state: Extract<State, {step: '2: verify number'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onSkip: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const gutters = useGutters([0, 'wide'])
const [otpCode, setOtpCode] = useState('')
const [error, setError] = useState<{
retryable: boolean
isResendError: boolean
message: string
} | null>(null)
const [prevOtpCode, setPrevOtpCode] = useState(otpCode)
if (otpCode !== prevOtpCode) {
setPrevOtpCode(otpCode)
setError(null)
}
const phone = useMemo(
() => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
[state.phoneCountryCode, state.phoneNumber],
)
const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
const {
mutate: verifyNumber,
isPending,
isSuccess,
} = useMutation({
mutationFn: async (code: string) => {
const res = await agent.app.bsky.contact.verifyPhone({code, phone})
return res.data.token
},
onSuccess: async token => {
// let the success state show for a moment
setTimeout(() => {
dispatch({
type: 'VERIFY_PHONE_NUMBER_SUCCESS',
payload: {
token,
},
})
}, 1000)
logger.metric('contacts:phone:phoneVerified', {entryPoint: context})
},
onMutate: () => setError(null),
onError: err => {
setOtpCode('')
if (isNetworkError(err)) {
setError({
retryable: true,
isResendError: false,
message: _(
msg`A network error occurred. Please check your internet connection.`,
),
})
} else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) {
setError({
retryable: true,
isResendError: true,
message: _(msg`This code is invalid. Resend to get a new code.`),
})
} else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) {
setError({
retryable: false,
isResendError: false,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof AppBskyContactVerifyPhone.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: false,
message: _(
msg`Too many attempts. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError({
retryable: true,
isResendError: false,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}
},
})
const {mutate: resendCode, isPending: isResendingCode} = useMutation({
mutationFn: async () => {
await agent.app.bsky.contact.startPhoneVerification({phone: phone})
},
onSuccess: () => {
dispatch({type: 'RESEND_VERIFICATION_CODE'})
Toast.show(_(msg`A new code has been sent`))
},
onMutate: () => {
setOtpCode('')
setError(null)
},
onError: err => {
if (isNetworkError(err)) {
setError({
retryable: true,
isResendError: true,
message: _(
msg`A network error occurred. Please check your internet connection.`,
),
})
} else if (
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError({
retryable: false,
isResendError: true,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: true,
message: _(
msg`Too many codes sent. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Resend failed', {safeMessage: err})
setError({
retryable: true,
isResendError: true,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}
},
})
const onPressBack = useOnPressBackButton()
return (
<View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content />
{context === 'Onboarding' ? (
<Button
size="small"
color="secondary"
variant="ghost"
label={_(msg`Skip contact sharing and continue to the app`)}
onPress={onSkip}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<Layout.Content
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
keyboardShouldPersistTaps="always">
{context === 'Onboarding' && <OnboardingPosition />}
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Verify phone number</Trans>
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.mt_sm,
]}>
<Trans>Enter the 6-digit code sent to {prettyNumber}</Trans>
</Text>
<View style={[a.mt_2xl]}>
<OTPInput
label={_(
msg`Enter 6-digit code that was sent to your phone number`,
)}
value={otpCode}
onChange={setOtpCode}
onComplete={code => verifyNumber(code)}
/>
</View>
<View style={[a.mt_sm]}>
<OTPStatus
error={error}
isPending={isPending}
isResendingCode={isResendingCode}
isSuccess={isSuccess}
onResend={() => resendCode()}
onRetry={() => verifyNumber(otpCode)}
lastCodeSentAt={state.lastSentAt}
/>
</View>
</Layout.Content>
</View>
)
}
/**
* Horrible component that takes all the state above and figures out what messages
* and buttons to display.
*/
function OTPStatus({
error,
isPending,
isResendingCode,
isSuccess,
onResend,
onRetry,
lastCodeSentAt,
}: {
error: {
retryable: boolean
isResendError: boolean
message: string
} | null
isPending: boolean
isResendingCode: boolean
isSuccess: boolean
onResend: () => void
onRetry: () => void
lastCodeSentAt: Date | null
}) {
const {_} = useLingui()
const t = useTheme()
const [time, setTime] = useState(Date.now())
useEffect(() => {
const interval = setInterval(() => {
setTime(Date.now())
}, 1000)
return () => clearInterval(interval)
}, [])
const timeUntilCanResend = Math.max(
0,
30000 - (time - (lastCodeSentAt?.getTime() ?? 0)),
)
const isWaiting = timeUntilCanResend > 0
let Icon: React.ComponentType<SVGIconProps> | null = null
let text = ''
let textColor = t.atoms.text_contrast_medium.color
let showResendButton = false
let showRetryButton = false
if (isSuccess) {
Icon = CircleCheckIcon
text = _(msg`Phone number verified`)
textColor = t.palette.positive_500
} else if (isPending) {
text = _(msg`Verifying...`)
} else if (error) {
Icon = WarningIcon
text = error.message
textColor = t.palette.negative_500
if (error.retryable) {
if (error.isResendError) {
showResendButton = true
} else {
showRetryButton = true
}
}
} else {
showResendButton = true
}
return (
<View style={[a.w_full, a.align_center]}>
{text && (
<View
style={[
a.gap_xs,
a.flex_row,
a.align_center,
(isSuccess || isPending) && a.mt_lg,
]}>
{Icon && <Icon size="xs" style={{color: textColor}} />}
<Text
style={[
{color: textColor},
a.text_sm,
a.leading_snug,
a.text_center,
]}>
{text}
</Text>
</View>
)}
{showRetryButton && (
<Button
size="small"
color="secondary_inverted"
label={_(msg`Retry`)}
onPress={onRetry}
style={[a.mt_2xl]}>
<ButtonIcon icon={RetryIcon} />
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
)}
{showResendButton && (
<Button
size="large"
color="secondary"
variant="ghost"
label={_(msg`Resend code`)}
disabled={isResendingCode || isWaiting}
onPress={onResend}
style={[a.mt_2xl]}>
{isResendingCode && <ButtonIcon icon={Loader} />}
<ButtonText>
{isWaiting ? (
<Trans>
Resend code in{' '}
<NestedText style={{fontVariant: ['tabular-nums']}}>
00:
{String(
clamp(Math.round(timeUntilCanResend / 1000), 0, 30),
).padStart(2, '0')}
</NestedText>
</Trans>
) : (
<Trans>Resend code</Trans>
)}
</ButtonText>
</Button>
)}
</View>
)
}

Some files were not shown because too many files have changed in this diff Show More