Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be2d4120c3 | |||
| f49065d421 | |||
| 2e9f7f329f | |||
| c88aa2056b |
@@ -28,21 +28,11 @@ EXPO_PUBLIC_CHAT_PROXY_DID=
|
||||
#
|
||||
#
|
||||
|
||||
# Bluesky's metrics API
|
||||
EXPO_PUBLIC_METRICS_API_HOST=
|
||||
|
||||
# Growthbook config
|
||||
EXPO_PUBLIC_GROWTHBOOK_API_HOST=
|
||||
EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY=
|
||||
|
||||
# Sentry DSN for telemetry
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'@react-native',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-native-a11y/ios',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
'react',
|
||||
'lingui',
|
||||
'simple-import-sort',
|
||||
'bsky-internal',
|
||||
'eslint-plugin-react-compiler',
|
||||
'import',
|
||||
],
|
||||
rules: {
|
||||
'react/no-unescaped-entities': 0,
|
||||
'react/prop-types': 0,
|
||||
'react-native/no-inline-styles': 0,
|
||||
'bsky-internal/avoid-unwrapped-text': [
|
||||
'error',
|
||||
{
|
||||
impliedTextComponents: [
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-typed-gates': 'error',
|
||||
'bsky-internal/use-prefixed-imports': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Node.js builtins prefixed with `node:`.
|
||||
['^node:'],
|
||||
// Packages.
|
||||
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
|
||||
// React/React Native priortized, followed by expo
|
||||
// Followed by all packages excluding unprefixed relative ones
|
||||
[
|
||||
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
|
||||
'^(expo(.*)$)|^(expo$)',
|
||||
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
|
||||
],
|
||||
// Relative imports.
|
||||
// Ideally, anything that starts with a dot or #
|
||||
// due to unprefixed relative imports being used, we whitelist the relative paths we use
|
||||
// (?:$|\\/) matches end of string or /
|
||||
[
|
||||
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
|
||||
'^(?:#\\/)?view(?:$|\\/)',
|
||||
'^(?:#\\/)?screens(?:$|\\/)',
|
||||
'^(?:#\\/)?alf(?:$|\\/)',
|
||||
'^(?:#\\/)?components(?:$|\\/)',
|
||||
'^#\\/',
|
||||
'^\\.',
|
||||
],
|
||||
// anything else - hopefully we don't have any of these
|
||||
['^'],
|
||||
],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{argsIgnorePattern: '^_', varsIgnorePattern: '^_.+'},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party',
|
||||
'ios',
|
||||
'android',
|
||||
'coverage',
|
||||
'*.lock',
|
||||
'.husky',
|
||||
'patches',
|
||||
'*.html',
|
||||
'bskyweb',
|
||||
'bskyembed',
|
||||
'src/locale/locales/_build/',
|
||||
'src/locale/locales/**/*.js',
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
],
|
||||
settings: {
|
||||
componentWrapperFunctions: ['observer'],
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
},
|
||||
}
|
||||
@@ -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: >
|
||||
|
||||
@@ -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: >
|
||||
|
||||
@@ -157,8 +157,8 @@ jobs:
|
||||
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
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -15,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
go-version: "1.25"
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Check
|
||||
@@ -32,11 +32,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
go-version: "1.25"
|
||||
- name: Dummy Static Files
|
||||
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
|
||||
- name: Lint
|
||||
|
||||
@@ -91,7 +91,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
|
||||
|
||||
@@ -1,635 +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 (you don't typically need to run this manually, we have CI for it)
|
||||
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: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (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}>
|
||||
{/* Typically the inner part is in its own component */}
|
||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
|
||||
<Text>Dialog content here</Text>
|
||||
|
||||
<Button label="Done" onPress={() => control.close()}>
|
||||
<ButtonText>Done</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
||||
</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={XIcon} />
|
||||
</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'` | `'secondary_inverted'`
|
||||
- `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)})
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isNetworkError(error)) {
|
||||
// don't log, but inform user
|
||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||
// XRPC APIs often have typed errors, allows nicer handling
|
||||
} else {
|
||||
// Log unexpected errors to Sentry
|
||||
logger.error('Error updating profile', {safeMessage: error})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
||||
|
||||
```tsx
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['drafts'],
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: page => page.cursor,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
|
||||
|
||||
### 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)
|
||||
|
||||
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
||||
|
||||
```tsx
|
||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||
import * as storage from '#/state/drafts/storage'
|
||||
|
||||
// WRONG - don't use require() or conditional imports for platform files
|
||||
const storage = IS_NATIVE
|
||||
? require('#/state/drafts/storage')
|
||||
: require('#/state/drafts/storage.web')
|
||||
```
|
||||
|
||||
Platform detection (for runtime logic, not imports):
|
||||
```tsx
|
||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
||||
|
||||
if (IS_NATIVE) {
|
||||
// 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` |
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright 2023–2026 Bluesky Social PBC
|
||||
Copyright 2023–2025 Bluesky Social PBC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// @ts-check
|
||||
const pkg = require('./package.json')
|
||||
|
||||
/**
|
||||
* @param {import('@expo/config-types').ExpoConfig} _config
|
||||
* @returns {{ expo: import('@expo/config-types').ExpoConfig }}
|
||||
*/
|
||||
module.exports = function (_config) {
|
||||
/**
|
||||
* App version number. Should be incremented as part of a release cycle.
|
||||
@@ -20,7 +15,7 @@ module.exports = function (_config) {
|
||||
|
||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
||||
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
|
||||
const IS_DEV = !IS_TESTFLIGHT || !IS_PRODUCTION
|
||||
|
||||
const ASSOCIATED_DOMAINS = [
|
||||
'applinks:bsky.app',
|
||||
@@ -197,14 +192,10 @@ module.exports = function (_config) {
|
||||
scheme: 'https',
|
||||
host: 'bsky.app',
|
||||
},
|
||||
...(IS_DEV
|
||||
? [
|
||||
{
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
IS_DEV && {
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT'],
|
||||
},
|
||||
@@ -236,25 +227,20 @@ module.exports = function (_config) {
|
||||
'react-native-edge-to-edge',
|
||||
{android: {enforceNavigationBarContrast: false}},
|
||||
],
|
||||
...(USE_SENTRY
|
||||
? [
|
||||
/** @type {[string, any]} */ ([
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
organization: 'blueskyweb',
|
||||
project: 'app',
|
||||
url: 'https://sentry.io',
|
||||
},
|
||||
]),
|
||||
]
|
||||
: []),
|
||||
USE_SENTRY && [
|
||||
'@sentry/react-native/expo',
|
||||
{
|
||||
organization: 'blueskyweb',
|
||||
project: 'app',
|
||||
url: 'https://sentry.io',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-build-properties',
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: '15.1',
|
||||
buildReactNativeFromSource: true,
|
||||
ccacheEnabled: IS_DEV,
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 35,
|
||||
@@ -310,24 +296,26 @@ module.exports = function (_config) {
|
||||
[
|
||||
'expo-splash-screen',
|
||||
{
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#EEF5FF', // based on illustration
|
||||
image: './assets/splash/splash-mobile.png',
|
||||
resizeMode: 'cover',
|
||||
dark: {
|
||||
enableFullScreenImage_legacy: true, // iOS only
|
||||
backgroundColor: '#446DA9', // based on illustration
|
||||
image: './assets/splash/splash-mobile-dark.png',
|
||||
ios: {
|
||||
enableFullScreenImage_legacy: true,
|
||||
backgroundColor: '#ffffff',
|
||||
image: './assets/splash.png',
|
||||
resizeMode: 'cover',
|
||||
dark: {
|
||||
enableFullScreenImage_legacy: true,
|
||||
backgroundColor: '#001429',
|
||||
image: './assets/splash-dark.png',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
},
|
||||
android: {
|
||||
backgroundColor: '#A8CCFF', // primary_200
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 105,
|
||||
backgroundColor: '#0c7cff',
|
||||
image: './assets/splash-android-icon.png',
|
||||
imageWidth: 150,
|
||||
dark: {
|
||||
backgroundColor: '#00398A', // primary_800
|
||||
image: './assets/splash/android-splash-logo-white.png',
|
||||
imageWidth: 105,
|
||||
backgroundColor: '#0c2a49',
|
||||
image: './assets/splash-android-icon-dark.png',
|
||||
imageWidth: 150,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -408,7 +396,7 @@ module.exports = function (_config) {
|
||||
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
|
||||
},
|
||||
],
|
||||
],
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
eas: {
|
||||
build: {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M32.457 7c1.68 0 3.29.668 4.478 1.855L49.813 21.73a6.33 6.33 0 0 1 1.854 4.479v24.458A6.333 6.333 0 0 1 45.333 57H18.666a6.334 6.334 0 0 1-6.333-6.333V13.333A6.334 6.334 0 0 1 18.666 7h13.791ZM18.666 9a4.334 4.334 0 0 0-4.333 4.333v37.334A4.334 4.334 0 0 0 18.666 55h26.667a4.333 4.333 0 0 0 4.333-4.333V26.209c0-.418-.061-.829-.177-1.223a1 1 0 0 1-.155.014H40a6.334 6.334 0 0 1-6.325-6.008l-.008-.326V9.333q0-.08.013-.156A4.3 4.3 0 0 0 32.457 9H18.666Zm18.627 22.293a1 1 0 1 1 1.414 1.414L33.414 38l5.293 5.293a1 1 0 1 1-1.414 1.414L32 39.414l-5.293 5.293a1 1 0 1 1-1.414-1.414L30.586 38l-5.293-5.293a1 1 0 1 1 1.414-1.414L32 36.586l5.293-5.293Zm-1.626-12.627.006.224A4.333 4.333 0 0 0 40 23h8.253L35.667 10.414v8.252Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 822 B |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'simple-import-sort'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'preact',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
],
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'warn',
|
||||
'simple-import-sort/exports': 'warn',
|
||||
'no-else-return': 'off',
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// @ts-check
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
// Global ignores
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript rules with type checking
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
|
||||
// Main configuration
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'warn',
|
||||
'simple-import-sort/exports': 'warn',
|
||||
'no-else-return': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_.+',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev-snippet": "tsc --project tsconfig.snippet.json && serve -s dist -p 3000 -n",
|
||||
"build": "tsc && vite build",
|
||||
"build-snippet": "tsc --project tsconfig.snippet.json",
|
||||
"lint": "eslint --cache src",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -17,21 +17,16 @@
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@vitejs/plugin-legacy": "^7.0.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"globals": "^15.14.0",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"serve": "^14.2.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/estree": "1.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
html {
|
||||
background-color: white;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0px;
|
||||
@@ -51,19 +59,6 @@
|
||||
-ms-overflow-style: scrollbar;
|
||||
font-synthesis-weight: none;
|
||||
}
|
||||
:root {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #e2e7ee;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232e3e;
|
||||
}
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -72,32 +67,6 @@
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
html.theme--light,
|
||||
html.theme--light body,
|
||||
html.theme--light #root {
|
||||
background-color: white;
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: #DCE2EA;
|
||||
}
|
||||
html.theme--dark,
|
||||
html.theme--dark body,
|
||||
html.theme--dark #root {
|
||||
color-scheme: dark;
|
||||
background-color: black;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: #232E3E;
|
||||
}
|
||||
html.theme--dim,
|
||||
html.theme--dim body,
|
||||
html.theme--dim #root {
|
||||
color-scheme: dark;
|
||||
background-color: #151D28;
|
||||
--text: white;
|
||||
--background: #151D28;
|
||||
--backgroundLight: #2C3A4E;
|
||||
}
|
||||
#splash {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
@@ -124,12 +93,6 @@
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
const theme = localStorage.getItem('ALF_THEME')
|
||||
if (theme) {
|
||||
document.documentElement.classList.add(`theme--${theme}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ staticCDNHost }}/static/apple-touch-icon.png">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "yarn install",
|
||||
"run": "yarn web --port $CONDUCTOR_PORT"
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
// @ts-check
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
// @ts-expect-error no types
|
||||
import reactNative from 'eslint-plugin-react-native'
|
||||
// @ts-expect-error no types
|
||||
import reactNativeA11y from 'eslint-plugin-react-native-a11y'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import importX from 'eslint-plugin-import-x'
|
||||
import lingui from 'eslint-plugin-lingui'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import bskyInternal from 'eslint-plugin-bsky-internal'
|
||||
import globals from 'globals'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
|
||||
export default defineConfig(
|
||||
/**
|
||||
* Global ignores
|
||||
*/
|
||||
{
|
||||
ignores: [
|
||||
'**/__mocks__/*.ts',
|
||||
'src/platform/polyfills.ts',
|
||||
'src/third-party/**',
|
||||
'ios/**',
|
||||
'android/**',
|
||||
'coverage/**',
|
||||
'*.lock',
|
||||
'.husky/**',
|
||||
'patches/**',
|
||||
'*.html',
|
||||
'bskyweb/**',
|
||||
'bskyembed/**',
|
||||
'src/locale/locales/_build/**',
|
||||
'src/locale/locales/**/*.js',
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
'eslint.config.mjs',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Base configurations
|
||||
*/
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
reactHooks.configs.flat.recommended,
|
||||
// @ts-expect-error https://github.com/un-ts/eslint-plugin-import-x/issues/439
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
importX.flatConfigs['react-native'],
|
||||
|
||||
/**
|
||||
* Main configuration for all JS/TS/JSX/TSX files
|
||||
*/
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
react,
|
||||
'react-native': reactNative,
|
||||
'react-native-a11y': reactNativeA11y,
|
||||
'simple-import-sort': simpleImportSort,
|
||||
lingui,
|
||||
'react-compiler': reactCompiler,
|
||||
'bsky-internal': bskyInternal,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
componentWrapperFunctions: ['observer'],
|
||||
},
|
||||
rules: {
|
||||
/**
|
||||
* Custom rules
|
||||
*/
|
||||
'bsky-internal/avoid-unwrapped-text': [
|
||||
'error',
|
||||
{
|
||||
impliedTextComponents: [
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-prefixed-imports': 'error',
|
||||
|
||||
/**
|
||||
* React & React Native
|
||||
*/
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react-native/no-inline-styles': 'off',
|
||||
...reactNativeA11y.configs.all.rules,
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
// TODO: Fix these and set to error
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
|
||||
/**
|
||||
* Import sorting
|
||||
*/
|
||||
'simple-import-sort/imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Node.js builtins prefixed with `node:`.
|
||||
['^node:'],
|
||||
// Packages.
|
||||
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
|
||||
// React/React Native prioritized, followed by expo
|
||||
// Followed by all packages excluding unprefixed relative ones
|
||||
[
|
||||
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
|
||||
'^(expo(.*)$)|^(expo$)',
|
||||
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
|
||||
],
|
||||
// Relative imports.
|
||||
// Ideally, anything that starts with a dot or #
|
||||
// due to unprefixed relative imports being used, we whitelist the relative paths we use
|
||||
// (?:$|\\/) matches end of string or /
|
||||
[
|
||||
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
|
||||
'^(?:#\\/)?view(?:$|\\/)',
|
||||
'^(?:#\\/)?screens(?:$|\\/)',
|
||||
'^(?:#\\/)?alf(?:$|\\/)',
|
||||
'^(?:#\\/)?components(?:$|\\/)',
|
||||
'^#\\/',
|
||||
'^\\.',
|
||||
],
|
||||
// anything else - hopefully we don't have any of these
|
||||
['^'],
|
||||
],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
|
||||
/**
|
||||
* Import linting
|
||||
*/
|
||||
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
'import-x/no-unresolved': ['error', {
|
||||
/*
|
||||
* The `postinstall` hook runs `compile-if-needed` locally, but not in
|
||||
* CI. For CI-sake, ignore this.
|
||||
*/
|
||||
ignore: ['^#\/locale\/locales\/.+\/messages'],
|
||||
}],
|
||||
|
||||
/**
|
||||
* TypeScript-specific rules
|
||||
*/
|
||||
'no-unused-vars': 'off', // off, we use TS-specific rule below
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_.+',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': ['error', {
|
||||
allowTernary: true,
|
||||
}],
|
||||
/**
|
||||
* Maintain previous behavior - these are stricter in typescript-eslint
|
||||
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
||||
* nit-picky
|
||||
*/
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'warn',
|
||||
|
||||
/**
|
||||
* Turn off rules that we haven't enforced thus far
|
||||
*/
|
||||
'no-empty-pattern': 'off',
|
||||
'no-async-promise-executor': 'off',
|
||||
'no-constant-binary-expression': 'warn',
|
||||
'prefer-const': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-unsafe-optional-chaining': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'no-var': 'off',
|
||||
'prefer-rest-params': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'no-useless-escape': 'off',
|
||||
'no-sparse-arrays': 'off',
|
||||
'no-fallthrough': 'off',
|
||||
'no-control-regex': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Test files configuration
|
||||
*/
|
||||
{
|
||||
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,17 +1,14 @@
|
||||
const {RuleTester} = require('eslint')
|
||||
const tseslint = require('typescript-eslint')
|
||||
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 6,
|
||||
sourceType: 'module',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -776,6 +773,15 @@ function MyText({ foo }) {
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View>
|
||||
<Trans>{'foo'}</Trans>
|
||||
</View>
|
||||
`,
|
||||
errors: 1,
|
||||
},
|
||||
|
||||
{
|
||||
code: `
|
||||
<View prop={
|
||||
|
||||
@@ -29,331 +29,303 @@ function getTagName(node) {
|
||||
return reversedIdentifiers.reverse().join('.')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce text strings are wrapped in <Text> components',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
impliedTextComponents: {
|
||||
type: 'array',
|
||||
items: {type: 'string'},
|
||||
},
|
||||
impliedTextProps: {
|
||||
type: 'array',
|
||||
items: {type: 'string'},
|
||||
},
|
||||
suggestedTextWrappers: {
|
||||
type: 'object',
|
||||
additionalProperties: {type: 'string'},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const impliedTextProps = options.impliedTextProps ?? []
|
||||
const impliedTextComponents = options.impliedTextComponents ?? []
|
||||
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
|
||||
const textProps = [...impliedTextProps]
|
||||
const textComponents = ['Text', ...impliedTextComponents]
|
||||
exports.create = function create(context) {
|
||||
const options = context.options[0] || {}
|
||||
const impliedTextProps = options.impliedTextProps ?? []
|
||||
const impliedTextComponents = options.impliedTextComponents ?? []
|
||||
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
|
||||
const textProps = [...impliedTextProps]
|
||||
const textComponents = ['Text', ...impliedTextComponents]
|
||||
|
||||
function isTextComponent(tagName) {
|
||||
return textComponents.includes(tagName) || tagName.endsWith('Text')
|
||||
}
|
||||
function isTextComponent(tagName) {
|
||||
return textComponents.includes(tagName) || tagName.endsWith('Text')
|
||||
}
|
||||
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return {
|
||||
JSXText(node) {
|
||||
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this string in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'CallExpression' &&
|
||||
parent.callee.type === 'Identifier' &&
|
||||
parent.callee.name === '_'
|
||||
) {
|
||||
// This is a user-facing string, keep going up.
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression' ||
|
||||
parent.type === 'TaggedTemplateExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
JSXElement(node) {
|
||||
if (getTagName(node) !== 'Trans') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for this JSXElement.
|
||||
// TODO: Should nested <Trans> even be allowed?
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this <Trans> in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
ReturnStatement(node) {
|
||||
let fnScope = context.sourceCode.getScope(node)
|
||||
while (fnScope && fnScope.type !== 'function') {
|
||||
fnScope = fnScope.upper
|
||||
}
|
||||
if (!fnScope) {
|
||||
return
|
||||
}
|
||||
const fn = fnScope.block
|
||||
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
|
||||
return
|
||||
}
|
||||
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
|
||||
return
|
||||
}
|
||||
if (!node.argument || node.argument.type !== 'JSXElement') {
|
||||
return
|
||||
}
|
||||
const openingEl = node.argument.openingElement
|
||||
if (openingEl.name.type !== 'JSXIdentifier') {
|
||||
return
|
||||
}
|
||||
const returnedComponentName = openingEl.name.name
|
||||
if (!isTextComponent(returnedComponentName)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Components ending with *Text must return <Text> or <SomeText>.',
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this string in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
Literal(node) {
|
||||
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for <Trans> JSXElement (code below).
|
||||
// TODO: Maybe validate that it's present.
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'CallExpression' &&
|
||||
parent.callee.type === 'Identifier' &&
|
||||
parent.callee.name === '_'
|
||||
) {
|
||||
// This is a user-facing string, keep going up.
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXExpressionContainer' ||
|
||||
parent.type === 'LogicalExpression' ||
|
||||
parent.type === 'TaggedTemplateExpression'
|
||||
) {
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
|
||||
// Be conservative for other types.
|
||||
return
|
||||
}
|
||||
},
|
||||
JSXElement(node) {
|
||||
if (getTagName(node) !== 'Trans') {
|
||||
return
|
||||
}
|
||||
let parent = node.parent
|
||||
while (parent) {
|
||||
if (parent.type === 'JSXElement') {
|
||||
const tagName = getTagName(parent)
|
||||
if (isTextComponent(tagName)) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
if (tagName === 'Trans') {
|
||||
// Exit and rely on the traversal for this JSXElement.
|
||||
// TODO: Should nested <Trans> even be allowed?
|
||||
return
|
||||
}
|
||||
const suggestedWrapper = suggestedTextWrappers[tagName]
|
||||
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
|
||||
if (tagName !== 'View' && !suggestedWrapper) {
|
||||
message +=
|
||||
' If <' +
|
||||
tagName +
|
||||
'> is guaranteed to render <Text>, ' +
|
||||
'rename it to <' +
|
||||
tagName +
|
||||
'Text> or add it to impliedTextComponents.'
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parent.type === 'JSXAttribute' &&
|
||||
parent.name.type === 'JSXIdentifier' &&
|
||||
parent.parent.type === 'JSXOpeningElement' &&
|
||||
parent.parent.parent.type === 'JSXElement'
|
||||
) {
|
||||
const tagName = getTagName(parent.parent.parent)
|
||||
const propName = parent.name.name
|
||||
if (
|
||||
textProps.includes(tagName + ' ' + propName) ||
|
||||
propName === 'text' ||
|
||||
propName.endsWith('Text')
|
||||
) {
|
||||
// We're good.
|
||||
return
|
||||
}
|
||||
const message =
|
||||
'Wrap this <Trans> in <Text>.' +
|
||||
' If `' +
|
||||
propName +
|
||||
'` is guaranteed to be wrapped in <Text>, ' +
|
||||
'rename it to `' +
|
||||
propName +
|
||||
'Text' +
|
||||
'` or add it to impliedTextProps.'
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
parent = parent.parent
|
||||
continue
|
||||
}
|
||||
},
|
||||
ReturnStatement(node) {
|
||||
let fnScope = context.getScope()
|
||||
while (fnScope && fnScope.type !== 'function') {
|
||||
fnScope = fnScope.upper
|
||||
}
|
||||
if (!fnScope) {
|
||||
return
|
||||
}
|
||||
const fn = fnScope.block
|
||||
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
|
||||
return
|
||||
}
|
||||
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
|
||||
return
|
||||
}
|
||||
if (!node.argument || node.argument.type !== 'JSXElement') {
|
||||
return
|
||||
}
|
||||
const openingEl = node.argument.openingElement
|
||||
if (openingEl.name.type !== 'JSXIdentifier') {
|
||||
return
|
||||
}
|
||||
const returnedComponentName = openingEl.name.name
|
||||
if (!isTextComponent(returnedComponentName)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Components ending with *Text must return <Text> or <SomeText>.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
'use strict'
|
||||
|
||||
const plugin = {
|
||||
meta: {
|
||||
name: 'eslint-plugin-bsky-internal',
|
||||
version: '1.0.0',
|
||||
},
|
||||
module.exports = {
|
||||
rules: {
|
||||
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
|
||||
'use-exact-imports': require('./use-exact-imports'),
|
||||
'use-typed-gates': require('./use-typed-gates'),
|
||||
'use-prefixed-imports': require('./use-prefixed-imports'),
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = plugin
|
||||
|
||||
@@ -3,29 +3,20 @@ const BANNED_IMPORTS = [
|
||||
'@fortawesome/free-solid-svg-icons',
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Prevent importing entire icon packages',
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const source = node.source
|
||||
if (typeof source.value !== 'string') {
|
||||
return
|
||||
}
|
||||
if (BANNED_IMPORTS.includes(source.value)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Import the specific thing you want instead of the entire package',
|
||||
})
|
||||
}
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const source = node.source
|
||||
if (typeof source.value !== 'string') {
|
||||
return
|
||||
}
|
||||
if (BANNED_IMPORTS.includes(source.value)) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
'Import the specific thing you want instead of the entire package',
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,7 @@ const BANNED_IMPORT_PREFIXES = [
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description: 'Enforce using prefixed imports for internal paths',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
'use strict'
|
||||
|
||||
exports.create = function create(context) {
|
||||
return {
|
||||
ImportSpecifier(node) {
|
||||
if (
|
||||
!node.local ||
|
||||
node.local.type !== 'Identifier' ||
|
||||
node.local.name !== 'useGate'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
node.parent.type !== 'ImportDeclaration' ||
|
||||
!node.parent.source ||
|
||||
node.parent.source.type !== 'Literal'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const source = node.parent.source.value
|
||||
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
|
||||
})
|
||||
}
|
||||
// TODO: Verify gate() call results aren't stored in variables.
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,20 @@ jest.mock('expo-modules-core', () => ({
|
||||
requireNativeViewManager: jest.fn().mockImplementation(_ => {
|
||||
return () => null
|
||||
}),
|
||||
createPermissionHook: () => () => [true],
|
||||
}))
|
||||
|
||||
jest.mock('expo-localization', () => ({
|
||||
getLocales: () => [],
|
||||
}))
|
||||
|
||||
jest.mock('statsig-react-native-expo', () => ({
|
||||
Statsig: {
|
||||
initialize() {},
|
||||
initializeCalled() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../src/logger/bitdrift/lib', () => ({}))
|
||||
jest.mock('../src/lib/statsig/statsig', () => ({}))
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {IS_IOS} from '#/env'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
@@ -30,7 +30,7 @@ const NativeView: React.ComponentType<
|
||||
|
||||
const NativeModule = requireNativeModule('BottomSheet')
|
||||
|
||||
const IS_IOS15 =
|
||||
const isIOS15 =
|
||||
Platform.OS === 'ios' &&
|
||||
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
||||
Number(Platform.Version.split('.').at(0)) < 16
|
||||
@@ -91,7 +91,7 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
}
|
||||
|
||||
let extraStyles
|
||||
if (IS_IOS15 && this.state.viewHeight) {
|
||||
if (isIOS15 && this.state.viewHeight) {
|
||||
const {viewHeight} = this.state
|
||||
const cornerRadius = this.props.cornerRadius ?? 0
|
||||
if (viewHeight < screenHeight / 2) {
|
||||
@@ -112,7 +112,7 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
onStateChange={this.onStateChange}
|
||||
extraStyles={extraStyles}
|
||||
onLayout={e => {
|
||||
if (IS_IOS15) {
|
||||
if (isIOS15) {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
}
|
||||
@@ -153,7 +153,7 @@ function BottomSheetNativeComponentInner({
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
|
||||
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
|
||||
const sheetHeight = isIOS ? screenHeight - insets.top : screenHeight
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.116.0",
|
||||
"version": "1.113.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -41,15 +41,15 @@
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||
"lint": "eslint --cache --quiet src",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
|
||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
|
||||
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
|
||||
"e2e:run": "maestro test",
|
||||
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start",
|
||||
"e2e:run": "maestro test __e2e__",
|
||||
"perf:test": "NODE_ENV=test maestro test",
|
||||
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
|
||||
@@ -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.18",
|
||||
"@atproto/api": "^0.18.8",
|
||||
"@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/react-native-mmkv": "2.12.5",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -93,7 +91,6 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
||||
"@growthbook/growthbook-react": "^1.6.2",
|
||||
"@haileyok/bluesky-video": "0.3.2",
|
||||
"@ipld/dag-cbor": "^9.2.0",
|
||||
"@lingui/react": "^4.14.1",
|
||||
@@ -101,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",
|
||||
@@ -147,6 +145,7 @@
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-crop-tool": "^0.4.0",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.9",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -157,7 +156,6 @@
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.14",
|
||||
"expo-privacy-sensitive": "^0.1.0",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sms": "^14.0.7",
|
||||
@@ -166,19 +164,17 @@
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"expo-updates": "~29.0.14",
|
||||
"expo-video": "~3.0.15",
|
||||
"expo-video-thumbnails": "^10.0.8",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.6.2",
|
||||
"idb-keyval": "^6.2.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",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"multiformats": "9.9.0",
|
||||
@@ -198,7 +194,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",
|
||||
@@ -208,7 +204,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,18 +218,17 @@
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"sonner": "^2.0.7",
|
||||
"sonner-native": "^0.21.0",
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
"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.206",
|
||||
"@atproto/dev-env": "^0.3.196",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@expo/config-plugins": "~54.0.1",
|
||||
"@lingui/cli": "^4.14.1",
|
||||
"@lingui/macro": "^4.14.1",
|
||||
@@ -247,28 +242,28 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash.chunk": "^4.2.7",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/lodash.isequal": "^4.5.6",
|
||||
"@types/lodash.shuffle": "^4.2.7",
|
||||
"@types/psl": "^1.1.1",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||
"babel-preset-expo": "~54.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-bsky-internal": "link:./eslint",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-lingui": "^0.11.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-ft-flow": "^2.0.3",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-lingui": "^0.2.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-native": "^5.0.0",
|
||||
"eslint-plugin-react-native-a11y": "^3.5.1",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-react-native-a11y": "^3.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"file-loader": "6.2.0",
|
||||
"globals": "^17.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest": "^29.7.0",
|
||||
@@ -276,6 +271,7 @@
|
||||
"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",
|
||||
@@ -283,19 +279,16 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"@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",
|
||||
"**/multiformats": "9.9.0",
|
||||
"unicode-segmenter": "0.14.5",
|
||||
"@types/estree": "1.0.6"
|
||||
"**/zod": "3.23.8",
|
||||
"**/multiformats": "9.9.0"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
|
||||
@@ -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() {
|
||||
@@ -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,103 +0,0 @@
|
||||
diff --git a/node_modules/expo-image/build/Image.types.d.ts b/node_modules/expo-image/build/Image.types.d.ts
|
||||
index 022ae48..416504f 100644
|
||||
--- a/node_modules/expo-image/build/Image.types.d.ts
|
||||
+++ b/node_modules/expo-image/build/Image.types.d.ts
|
||||
@@ -152,6 +152,16 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
|
||||
* @default 'normal'
|
||||
*/
|
||||
priority?: 'low' | 'normal' | 'high' | null;
|
||||
+ /**
|
||||
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
|
||||
+ *
|
||||
+ * - `'lazy'` - Defers loading until the image is near the viewport.
|
||||
+ * - `'eager'` - Loads the image immediately.
|
||||
+ *
|
||||
+ * @default undefined
|
||||
+ * @platform web
|
||||
+ */
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
/**
|
||||
* Determines whether to cache the image and where: on the disk, in the memory or both.
|
||||
*
|
||||
diff --git a/node_modules/expo-image/src/ExpoImage.web.tsx b/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
index 2a49ff0..1c3de93 100644
|
||||
--- a/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
+++ b/node_modules/expo-image/src/ExpoImage.web.tsx
|
||||
@@ -70,6 +70,7 @@ export default function ExpoImage({
|
||||
onLoadEnd,
|
||||
onDisplay,
|
||||
priority,
|
||||
+ loading,
|
||||
blurRadius,
|
||||
recyclingKey,
|
||||
style,
|
||||
@@ -118,6 +119,7 @@ export default function ExpoImage({
|
||||
accessibilityLabel={accessibilityLabel ?? alt}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
+ loading={loading}
|
||||
tintColor={tintColor}
|
||||
/>
|
||||
),
|
||||
@@ -149,6 +151,7 @@ export default function ExpoImage({
|
||||
className={className}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
+ loading={loading}
|
||||
contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }}
|
||||
hashPlaceholderContentPosition={contentPosition}
|
||||
hashPlaceholderStyle={imageHashStyle}
|
||||
diff --git a/node_modules/expo-image/src/Image.types.ts b/node_modules/expo-image/src/Image.types.ts
|
||||
index 9dec0e7..61c1621 100644
|
||||
--- a/node_modules/expo-image/src/Image.types.ts
|
||||
+++ b/node_modules/expo-image/src/Image.types.ts
|
||||
@@ -178,6 +178,17 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
|
||||
*/
|
||||
priority?: 'low' | 'normal' | 'high' | null;
|
||||
|
||||
+ /**
|
||||
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
|
||||
+ *
|
||||
+ * - `'lazy'` - Defers loading until the image is near the viewport.
|
||||
+ * - `'eager'` - Loads the image immediately.
|
||||
+ *
|
||||
+ * @default undefined
|
||||
+ * @platform web
|
||||
+ */
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
+
|
||||
/**
|
||||
* Determines whether to cache the image and where: on the disk, in the memory or both.
|
||||
*
|
||||
diff --git a/node_modules/expo-image/src/web/ImageWrapper.tsx b/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
index e8f891d..89a5cb1 100644
|
||||
--- a/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
+++ b/node_modules/expo-image/src/web/ImageWrapper.tsx
|
||||
@@ -30,6 +30,7 @@ const ImageWrapper = React.forwardRef(
|
||||
contentPosition,
|
||||
hashPlaceholderContentPosition,
|
||||
priority,
|
||||
+ loading,
|
||||
style,
|
||||
hashPlaceholderStyle,
|
||||
tintColor,
|
||||
@@ -82,6 +83,7 @@ const ImageWrapper = React.forwardRef(
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')}
|
||||
+ loading={loading || undefined}
|
||||
{...getImageWrapperEventHandler(events, sourceWithHeaders)}
|
||||
{...getImgPropsFromSource(source)}
|
||||
{...props}
|
||||
diff --git a/node_modules/expo-image/src/web/ImageWrapper.types.ts b/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
index 19bbe2f..179837f 100644
|
||||
--- a/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
+++ b/node_modules/expo-image/src/web/ImageWrapper.types.ts
|
||||
@@ -29,6 +29,7 @@ export type ImageWrapperProps = {
|
||||
contentPosition?: ImageContentPositionObject;
|
||||
hashPlaceholderContentPosition?: ImageContentPositionObject;
|
||||
priority?: string | null;
|
||||
+ loading?: 'lazy' | 'eager' | null;
|
||||
style: CSSProperties;
|
||||
tintColor?: string | null;
|
||||
hashPlaceholderStyle?: CSSProperties;
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import '#/logger/sentry/setup'
|
||||
import '#/logger/bitdrift/setup'
|
||||
import '#/view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
@@ -17,10 +18,12 @@ import * as Sentry from '@sentry/react-native'
|
||||
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
|
||||
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {Provider as A11yProvider} from '#/state/a11y'
|
||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
@@ -64,31 +67,18 @@ import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdate
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {
|
||||
prefetchAgeAssuranceConfig,
|
||||
Provider as AgeAssuranceV2Provider,
|
||||
} from '#/ageAssurance'
|
||||
import {
|
||||
AnalyticsContext,
|
||||
AnalyticsFeaturesContext,
|
||||
features,
|
||||
setupDeviceId,
|
||||
} from '#/analytics'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BottomSheetProvider} from '../modules/bottom-sheet'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
if (IS_IOS) {
|
||||
if (isIOS) {
|
||||
SystemUI.setBackgroundColorAsync('black')
|
||||
}
|
||||
if (IS_ANDROID) {
|
||||
if (isAndroid) {
|
||||
// iOS is handled by the config plugin -sfn
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
@@ -102,7 +92,6 @@ if (IS_ANDROID) {
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -119,7 +108,7 @@ function InnerApp() {
|
||||
if (account) {
|
||||
await resumeSession(account)
|
||||
} else {
|
||||
await features.init
|
||||
await tryFetchGates(undefined, 'prefer-fresh-gates')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resume failed`, {message: e})
|
||||
@@ -149,57 +138,55 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<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>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<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>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
</Splash>
|
||||
@@ -213,7 +200,7 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
@@ -231,32 +218,30 @@ function App() {
|
||||
<A11yProvider>
|
||||
<KeyboardControllerProvider>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetProvider>
|
||||
<StarterPackProvider>
|
||||
<SafeAreaProvider
|
||||
initialMetrics={initialWindowMetrics}>
|
||||
<InnerApp />
|
||||
</SafeAreaProvider>
|
||||
</StarterPackProvider>
|
||||
</BottomSheetProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</OnboardingProvider>
|
||||
</KeyboardControllerProvider>
|
||||
</A11yProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {useLingui} from '@lingui/react'
|
||||
import * as Sentry from '@sentry/react-native'
|
||||
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
@@ -54,20 +55,8 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
|
||||
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
||||
import {ToastOutlet} from '#/components/Toast'
|
||||
import {
|
||||
prefetchAgeAssuranceConfig,
|
||||
Provider as AgeAssuranceV2Provider,
|
||||
} from '#/ageAssurance'
|
||||
import {
|
||||
AnalyticsContext,
|
||||
AnalyticsFeaturesContext,
|
||||
features,
|
||||
setupDeviceId,
|
||||
} from '#/analytics'
|
||||
import {
|
||||
prefetchLiveEvents,
|
||||
Provider as LiveEventsProvider,
|
||||
} from '#/features/liveEvents/context'
|
||||
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
|
||||
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
|
||||
import * as Geo from '#/geolocation'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
@@ -78,7 +67,6 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
|
||||
*/
|
||||
Geo.resolve()
|
||||
prefetchAgeAssuranceConfig()
|
||||
prefetchLiveEvents()
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -94,8 +82,6 @@ function InnerApp() {
|
||||
try {
|
||||
if (account) {
|
||||
await resumeSession(account)
|
||||
} else {
|
||||
await features.init
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resumeSession failed`, {message: e})
|
||||
@@ -128,53 +114,51 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<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>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</AnalyticsFeaturesContext>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<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>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
@@ -188,7 +172,7 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
|
||||
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
|
||||
setReady(true),
|
||||
)
|
||||
}, [])
|
||||
@@ -205,27 +189,25 @@ function App() {
|
||||
<Geo.Provider>
|
||||
<A11yProvider>
|
||||
<OnboardingProvider>
|
||||
<AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</AnalyticsContext>
|
||||
<SessionProvider>
|
||||
<PrefsStateProvider>
|
||||
<I18nProvider>
|
||||
<ShellStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</ShellStateProvider>
|
||||
</I18nProvider>
|
||||
</PrefsStateProvider>
|
||||
</SessionProvider>
|
||||
</OnboardingProvider>
|
||||
</A11yProvider>
|
||||
</Geo.Provider>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
storePayloadForAccountSwitch,
|
||||
} from '#/lib/hooks/useNotificationHandler'
|
||||
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {logger as notyLogger} from '#/lib/notifications/util'
|
||||
import {buildStateObject} from '#/lib/routes/helpers'
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
@@ -38,11 +38,13 @@ import {
|
||||
type MessagesTabNavigatorParams,
|
||||
type MyProfileTabNavigatorParams,
|
||||
type NotificationsTabNavigatorParams,
|
||||
type RouteParams,
|
||||
type SearchTabNavigatorParams,
|
||||
type State,
|
||||
} from '#/lib/routes/types'
|
||||
import {type RouteParams, type State} from '#/lib/routes/types'
|
||||
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
|
||||
import {bskyTitle} from '#/lib/strings/headings'
|
||||
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'
|
||||
@@ -66,7 +68,7 @@ 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'
|
||||
@@ -136,9 +138,6 @@ import {
|
||||
EmailDialogScreenID,
|
||||
useEmailDialogControl,
|
||||
} from '#/components/dialogs/EmailDialog'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {setNavigationMetadata} from '#/analytics/metadata'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -305,7 +304,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
|
||||
@@ -637,11 +636,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} />
|
||||
@@ -654,8 +649,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
|
||||
@@ -763,11 +757,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()
|
||||
@@ -775,7 +765,6 @@ const FlatNavigator = ({
|
||||
|
||||
return (
|
||||
<Flat.Navigator
|
||||
layout={layout}
|
||||
screenListeners={screenListeners}
|
||||
screenOptions={screenOptions(t)}>
|
||||
<Flat.Screen
|
||||
@@ -843,11 +832,11 @@ const LINKING = {
|
||||
// native, since the home tab and the home screen are defined as initial routes, we don't need to return a state
|
||||
// since it will be created by react-navigation.
|
||||
if (path.includes('intent/')) {
|
||||
if (IS_NATIVE) return
|
||||
if (isNative) return
|
||||
return buildStateObject('Flat', 'Home', params)
|
||||
}
|
||||
|
||||
if (IS_NATIVE) {
|
||||
if (isNative) {
|
||||
if (name === 'Search') {
|
||||
return buildStateObject('SearchTab', 'Search', params)
|
||||
}
|
||||
@@ -880,13 +869,11 @@ const LINKING = {
|
||||
let lastHandledNotificationDateDedupe: number | undefined
|
||||
|
||||
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const ax = useAnalytics()
|
||||
const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications)
|
||||
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const previousScreen = useRef<string | undefined>(undefined)
|
||||
const prevLoggedRouteName = useRef<string | undefined>(undefined)
|
||||
const emailDialogControl = useEmailDialogControl()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
@@ -924,7 +911,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
|
||||
async function handlePushNotificationEntry() {
|
||||
if (!IS_NATIVE) return
|
||||
if (!isNative) return
|
||||
|
||||
// deep links take precedence - on android,
|
||||
// getLastNotificationResponseAsync returns a "notification"
|
||||
@@ -948,10 +935,11 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const payload = getNotificationPayload(response.notification)
|
||||
|
||||
if (payload) {
|
||||
ax.metric('notifications:openApp', {
|
||||
reason: payload.reason,
|
||||
causedBoot: true,
|
||||
})
|
||||
notyLogger.metric(
|
||||
'notifications:openApp',
|
||||
{reason: payload.reason, causedBoot: true},
|
||||
{statsig: false},
|
||||
)
|
||||
|
||||
if (payload.reason === 'chat-message') {
|
||||
handleChatMessage(payload)
|
||||
@@ -975,69 +963,47 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
}
|
||||
|
||||
const onNavigationReady = useCallOnce(() => {
|
||||
const currentScreen = getCurrentRouteName()
|
||||
setNavigationMetadata({
|
||||
previousScreen: currentScreen,
|
||||
currentScreen,
|
||||
})
|
||||
previousScreen.current = currentScreen
|
||||
|
||||
handlePushNotificationEntry()
|
||||
|
||||
ax.metric('router:navigate', {})
|
||||
|
||||
function onReady() {
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
emailDialogControl.open({
|
||||
id: EmailDialogScreenID.VerificationReminder,
|
||||
})
|
||||
snoozeEmailConfirmationPrompt()
|
||||
}
|
||||
|
||||
ax.metric('init', {
|
||||
initMs: Math.round(
|
||||
// @ts-ignore Emitted by Metro in the bundle prelude
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
),
|
||||
})
|
||||
|
||||
if (IS_WEB) {
|
||||
const referrerInfo = Referrer.getReferrerInfo()
|
||||
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
|
||||
ax.metric('deepLink:referrerReceived', {
|
||||
to: window.location.href,
|
||||
referrer: referrerInfo?.referrer,
|
||||
hostname: referrerInfo?.hostname,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
const currentScreen = getCurrentRouteName()
|
||||
// do this before metric
|
||||
setNavigationMetadata({
|
||||
previousScreen: previousScreen.current,
|
||||
currentScreen,
|
||||
})
|
||||
ax.metric('router:navigate', {from: previousScreen.current})
|
||||
previousScreen.current = currentScreen
|
||||
}}
|
||||
onReady={onNavigationReady}
|
||||
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
|
||||
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
|
||||
// See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
|
||||
// I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
|
||||
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
|
||||
// -sfn
|
||||
navigationInChildEnabled>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
<>
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logger.metric(
|
||||
'router:navigate',
|
||||
{from: prevLoggedRouteName.current},
|
||||
{statsig: false},
|
||||
)
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logger.metric('router:navigate', {}, {statsig: false})
|
||||
handlePushNotificationEntry()
|
||||
}}
|
||||
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
|
||||
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
|
||||
// See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
|
||||
// I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
|
||||
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
|
||||
// -sfn
|
||||
navigationInChildEnabled>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1093,7 +1059,7 @@ function reset(): Promise<void> {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{name: IS_NATIVE ? 'HomeTab' : 'Home'}],
|
||||
routes: [{name: isNative ? 'HomeTab' : 'Home'}],
|
||||
}),
|
||||
)
|
||||
return Promise.race([
|
||||
@@ -1111,6 +1077,44 @@ function reset(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
let didInit = false
|
||||
function logModuleInitTime() {
|
||||
if (didInit) {
|
||||
return
|
||||
}
|
||||
didInit = true
|
||||
|
||||
const initMs = Math.round(
|
||||
// @ts-ignore Emitted by Metro in the bundle prelude
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
)
|
||||
console.log(`Time to first paint: ${initMs} ms`)
|
||||
logEvent('init', {
|
||||
initMs,
|
||||
})
|
||||
|
||||
if (isWeb) {
|
||||
const referrerInfo = Referrer.getReferrerInfo()
|
||||
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
|
||||
logEvent('deepLink:referrerReceived', {
|
||||
to: window.location.href,
|
||||
referrer: referrerInfo?.referrer,
|
||||
hostname: referrerInfo?.hostname,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
// This log is noisy, so keep false committed
|
||||
const shouldLog = false
|
||||
// Relies on our patch to polyfill.js in metro-runtime
|
||||
const initLogs = (global as any).__INIT_LOGS__
|
||||
if (shouldLog && Array.isArray(initLogs)) {
|
||||
console.log(initLogs.join('\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
FlatNavigator,
|
||||
navigate,
|
||||
|
||||
@@ -9,30 +9,55 @@ import {
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Svg, {Path, type SvgProps} from 'react-native-svg'
|
||||
import {Image} from 'expo-image'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
// @ts-ignore
|
||||
import splashImagePointer from '../assets/splash/splash-mobile.png'
|
||||
import splashImagePointer from '../assets/splash.png'
|
||||
// @ts-ignore
|
||||
import darkSplashImagePointer from '../assets/splash/splash-mobile-dark.png'
|
||||
import darkSplashImagePointer from '../assets/splash-dark.png'
|
||||
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
|
||||
const darkSplashImageUri = RNImage.resolveAssetSource(
|
||||
darkSplashImagePointer,
|
||||
).uri
|
||||
|
||||
export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
|
||||
const width = 1000
|
||||
const height = width * (67 / 64)
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
// @ts-ignore it's fiiiiine
|
||||
ref={ref}
|
||||
viewBox="0 0 64 66"
|
||||
style={[{width, height}, props.style]}>
|
||||
<Path
|
||||
fill={props.fill || '#fff'}
|
||||
d="M13.873 3.77C21.21 9.243 29.103 20.342 32 26.3v15.732c0-.335-.13.043-.41.858-1.512 4.414-7.418 21.642-20.923 7.87-7.111-7.252-3.819-14.503 9.125-16.692-7.405 1.252-15.73-.817-18.014-8.93C1.12 22.804 0 8.431 0 6.488 0-3.237 8.579-.18 13.873 3.77ZM50.127 3.77C42.79 9.243 34.897 20.342 32 26.3v15.732c0-.335.13.043.41.858 1.512 4.414 7.418 21.642 20.923 7.87 7.111-7.252 3.819-14.503-9.125-16.692 7.405 1.252 15.73-.817 18.014-8.93C62.88 22.804 64 8.431 64 6.488 64-3.237 55.422-.18 50.127 3.77Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
|
||||
type Props = {
|
||||
isReady: boolean
|
||||
}
|
||||
|
||||
export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
'use no memo'
|
||||
const insets = useSafeAreaInsets()
|
||||
const intro = useSharedValue(0)
|
||||
const outroLogo = useSharedValue(0)
|
||||
const outroApp = useSharedValue(0)
|
||||
const outroAppOpacity = useSharedValue(0)
|
||||
const colorScheme = useColorScheme()
|
||||
const [isAnimationComplete, setIsAnimationComplete] = React.useState(false)
|
||||
const [isImageLoaded, setIsImageLoaded] = React.useState(false)
|
||||
const [isLayoutReady, setIsLayoutReady] = React.useState(false)
|
||||
@@ -44,10 +69,62 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
isImageLoaded &&
|
||||
isLayoutReady &&
|
||||
reduceMotion !== undefined
|
||||
|
||||
const colorScheme = useColorScheme()
|
||||
const isDarkMode = colorScheme === 'dark'
|
||||
|
||||
const logoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
outroLogo.get(),
|
||||
[0, 0.08, 1],
|
||||
[1, 0.8, 500],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const bottomLogoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const reducedLogoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
|
||||
const logoWrapperAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[1, 1, 0, 0],
|
||||
'clamp',
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const appAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(outroApp.get(), [0, 1], [1.1, 1], 'clamp'),
|
||||
},
|
||||
],
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
@@ -65,21 +142,50 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
if (isReady) {
|
||||
SplashScreen.hideAsync()
|
||||
.then(() => {
|
||||
outroAppOpacity.set(() =>
|
||||
withTiming(1, {
|
||||
duration: 1200,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
}),
|
||||
intro.set(() =>
|
||||
withTiming(
|
||||
1,
|
||||
{duration: 400, easing: Easing.out(Easing.cubic)},
|
||||
async () => {
|
||||
// set these values to check animation at specific point
|
||||
outroLogo.set(() =>
|
||||
withTiming(
|
||||
1,
|
||||
{duration: 1200, easing: Easing.in(Easing.cubic)},
|
||||
() => {
|
||||
runOnJS(onFinish)()
|
||||
},
|
||||
),
|
||||
)
|
||||
outroApp.set(() =>
|
||||
withTiming(1, {
|
||||
duration: 1200,
|
||||
easing: Easing.inOut(Easing.cubic),
|
||||
}),
|
||||
)
|
||||
outroAppOpacity.set(() =>
|
||||
withTiming(1, {
|
||||
duration: 1200,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
}),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [onFinish, outroAppOpacity, isReady])
|
||||
}, [onFinish, intro, outroLogo, outroApp, outroAppOpacity, isReady])
|
||||
|
||||
useEffect(() => {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
|
||||
}, [])
|
||||
|
||||
const logoAnimations =
|
||||
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
||||
// special off-spec color for dark mode
|
||||
const logoBg = isDarkMode ? '#0F1824' : '#fff'
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}} onLayout={onLayout}>
|
||||
{!isAnimationComplete && (
|
||||
@@ -90,6 +196,22 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
source={{uri: isDarkMode ? darkSplashImageUri : splashImageUri}}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
bottomLogoAnimation,
|
||||
{
|
||||
position: 'absolute',
|
||||
bottom: insets.bottom + 40,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: 0,
|
||||
},
|
||||
]}>
|
||||
<Logotype fill="#fff" width={90} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -98,6 +220,24 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
<Animated.View style={[{flex: 1}, appAnimation]}>
|
||||
{props.children}
|
||||
</Animated.View>
|
||||
|
||||
{!isAnimationComplete && (
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
logoWrapperAnimation,
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
||||
},
|
||||
]}>
|
||||
<Animated.View style={[logoAnimations]}>
|
||||
<Logo fill={logoBg} />
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
useCreateSupportLink,
|
||||
} from '#/lib/hooks/useCreateSupportLink'
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
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 {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
@@ -35,8 +38,6 @@ import {
|
||||
isLegacyBirthdateBug,
|
||||
useAgeAssuranceRegionConfig,
|
||||
} from '#/ageAssurance/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {useDeviceGeolocationApi} from '#/geolocation'
|
||||
|
||||
const textStyles = [a.text_md, a.leading_snug]
|
||||
@@ -44,7 +45,6 @@ const textStyles = [a.text_md, a.leading_snug]
|
||||
export function NoAccessScreen() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const insets = useSafeAreaInsets()
|
||||
const birthdateControl = useDialogControl()
|
||||
@@ -63,8 +63,8 @@ export function NoAccessScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
// just counting overall hits here
|
||||
ax.metric(`blockedGeoOverlay:shown`, {})
|
||||
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||
logger.metric(`blockedGeoOverlay:shown`, {})
|
||||
logger.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||
accountCreatedAt: data?.accountCreatedAt || 'unknown',
|
||||
isAARegion,
|
||||
hasDeclaredAge,
|
||||
@@ -74,7 +74,7 @@ export function NoAccessScreen() {
|
||||
}, [])
|
||||
|
||||
const onPressLogout = useCallback(() => {
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
// On mobile, this gets us Home, but on the web we also need reset the URL.
|
||||
// We can't change the URL via a navigate() call because the navigator
|
||||
@@ -103,7 +103,10 @@ export function NoAccessScreen() {
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
|
||||
logger.metric(
|
||||
'ageAssurance:noAccessScreen:openBirthdateDialog',
|
||||
{},
|
||||
)
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
@@ -136,7 +139,7 @@ export function NoAccessScreen() {
|
||||
contentContainerStyle={[
|
||||
a.px_2xl,
|
||||
{
|
||||
paddingTop: IS_WEB
|
||||
paddingTop: isWeb
|
||||
? a.p_5xl.padding
|
||||
: insets.top + a.p_2xl.padding,
|
||||
paddingBottom: 100,
|
||||
@@ -174,19 +177,10 @@ export function NoAccessScreen() {
|
||||
</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 />}
|
||||
<AccessSection />
|
||||
</>
|
||||
) : (
|
||||
<View style={[a.gap_lg]}>
|
||||
@@ -269,7 +263,6 @@ export function NoAccessScreen() {
|
||||
function AccessSection() {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const control = useDialogControl()
|
||||
const appealControl = Dialog.useDialogControl()
|
||||
const locationControl = Dialog.useDialogControl()
|
||||
@@ -303,7 +296,7 @@ function AccessSection() {
|
||||
label={_(msg`Contact our moderation team`)}
|
||||
{...createStaticClick(() => {
|
||||
appealControl.open()
|
||||
ax.metric('ageAssurance:appealDialogOpen', {})
|
||||
logger.metric('ageAssurance:appealDialogOpen', {})
|
||||
})}>
|
||||
contact our moderation team
|
||||
</SimpleInlineLinkText>{' '}
|
||||
@@ -319,7 +312,7 @@ function AccessSection() {
|
||||
color={hasInitiated ? 'secondary' : 'primary'}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
ax.metric('ageAssurance:initDialogOpen', {
|
||||
logger.metric('ageAssurance:initDialogOpen', {
|
||||
hasInitiatedPreviously: hasInitiated,
|
||||
})
|
||||
}}>
|
||||
@@ -357,7 +350,7 @@ function AccessSection() {
|
||||
)}
|
||||
|
||||
<View style={[a.gap_xs]}>
|
||||
{IS_NATIVE && (
|
||||
{isNative && (
|
||||
<>
|
||||
<Admonition>
|
||||
<Trans>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {useLingui} from '@lingui/react'
|
||||
import {retry} from '#/lib/async/retry'
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
|
||||
import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge'
|
||||
@@ -25,8 +27,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icon
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {refetchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_IOS, IS_WEB} from '#/env'
|
||||
import {logger} from '#/ageAssurance'
|
||||
|
||||
export type RedirectOverlayState = {
|
||||
result: 'success' | 'unknown'
|
||||
@@ -91,7 +92,7 @@ export function Provider({children}: {children?: React.ReactNode}) {
|
||||
actorDid: params.get('actorDid') ?? undefined,
|
||||
})
|
||||
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
// Clear the URL parameters so they don't re-trigger
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
@@ -144,7 +145,7 @@ export function RedirectOverlay() {
|
||||
// setting a zIndex when using FullWindowOverlay on iOS
|
||||
// means the taps pass straight through to the underlying content (???)
|
||||
// so don't set it on iOS. FullWindowOverlay already does the job.
|
||||
!IS_IOS && {zIndex: 9999},
|
||||
!isIOS && {zIndex: 9999},
|
||||
t.atoms.bg,
|
||||
gtMobile ? a.p_2xl : a.p_xl,
|
||||
a.align_center,
|
||||
@@ -173,7 +174,6 @@ export function RedirectOverlay() {
|
||||
|
||||
function Inner() {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const polling = useRef(false)
|
||||
@@ -187,7 +187,7 @@ function Inner() {
|
||||
|
||||
polling.current = true
|
||||
|
||||
ax.metric('ageAssurance:redirectDialogOpen', {})
|
||||
logger.metric('ageAssurance:redirectDialogOpen', {})
|
||||
|
||||
wait(
|
||||
3e3,
|
||||
@@ -218,18 +218,18 @@ function Inner() {
|
||||
|
||||
setSuccess(true)
|
||||
|
||||
ax.metric('ageAssurance:redirectDialogSuccess', {})
|
||||
logger.metric('ageAssurance:redirectDialogSuccess', {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (unmounted.current) return
|
||||
setError(true)
|
||||
ax.metric('ageAssurance:redirectDialogFail', {})
|
||||
logger.metric('ageAssurance:redirectDialogFail', {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unmounted.current = true
|
||||
}
|
||||
}, [ax, agent])
|
||||
}, [agent])
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,32 +17,18 @@ export const geolocation: Geolocation | undefined = enabled
|
||||
}
|
||||
: undefined
|
||||
|
||||
const deviceGeolocationEnabled = false || IS_E2E
|
||||
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,
|
||||
access: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
countryCode: 'BB',
|
||||
regionCode: undefined,
|
||||
minAccessAge: 16,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.Default,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {BLUESKY_PROXY_DID} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
@@ -20,7 +19,6 @@ const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
|
||||
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
|
||||
|
||||
export function useBeginAgeAssurance() {
|
||||
const ax = useAnalytics()
|
||||
const agent = useAgent()
|
||||
const geolocation = useGeolocation()
|
||||
const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState()
|
||||
@@ -50,11 +48,15 @@ export function useBeginAgeAssurance() {
|
||||
appView.sessionManager.session.accessJwt = token
|
||||
appView.sessionManager.session.refreshJwt = ''
|
||||
|
||||
ax.metric('ageAssurance:api:begin', {
|
||||
platform: Platform.OS,
|
||||
countryCode,
|
||||
regionCode,
|
||||
})
|
||||
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
|
||||
|
||||
@@ -12,23 +12,7 @@ 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 +30,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 +67,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 +78,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) {
|
||||
|
||||
@@ -5,8 +5,6 @@ import {CARD_ASPECT_RATIO} from '#/lib/constants'
|
||||
import {native, platform, web} from '#/alf/util/platform'
|
||||
import * as Layout from '#/components/Layout'
|
||||
|
||||
const EXP_CURVE = 'cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
|
||||
export const atoms = {
|
||||
...baseAtoms,
|
||||
|
||||
@@ -105,7 +103,7 @@ export const atoms = {
|
||||
}),
|
||||
// special composite animation for dialogs
|
||||
zoom_fade_in: web({
|
||||
animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`,
|
||||
animation: 'zoomIn ease-out 0.1s, fadeIn ease-out 0.1s',
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {type TextStyle} from 'react-native'
|
||||
|
||||
import {IS_ANDROID, IS_WEB} from '#/env'
|
||||
import {isAndroid, isWeb} from '#/platform/detection'
|
||||
import {type Device, device} from '#/storage'
|
||||
|
||||
const WEB_FONT_FAMILIES = `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
|
||||
@@ -39,7 +39,7 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
|
||||
*/
|
||||
export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
|
||||
if (fontFamily === 'theme') {
|
||||
if (IS_ANDROID) {
|
||||
if (isAndroid) {
|
||||
style.fontFamily =
|
||||
{
|
||||
400: 'Inter-Regular',
|
||||
@@ -71,7 +71,7 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
// fallback families only supported on web
|
||||
style.fontFamily += `, ${WEB_FONT_FAMILIES}`
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
|
||||
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
|
||||
} else {
|
||||
// fallback families only supported on web
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
style.fontFamily = style.fontFamily || WEB_FONT_FAMILIES
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,9 +4,9 @@ import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
import createEmojiRegex from 'emoji-regex'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
/**
|
||||
* Ensures that `lineHeight` defaults to a relative value of `1`, or applies
|
||||
@@ -34,7 +34,7 @@ export function normalizeTextStyles(
|
||||
if (s.lineHeight !== 0 && s.lineHeight <= 2) {
|
||||
s.lineHeight = Math.round(s.fontSize * s.lineHeight)
|
||||
}
|
||||
} else if (!IS_NATIVE) {
|
||||
} else if (!isNative) {
|
||||
s.lineHeight = s.fontSize
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function renderChildrenWithEmoji(
|
||||
props: Omit<TextProps, 'children'> = {},
|
||||
emoji: boolean,
|
||||
) {
|
||||
if (!IS_IOS || !emoji) {
|
||||
if (!isIOS || !emoji) {
|
||||
return children
|
||||
}
|
||||
return Children.map(children, child => {
|
||||
|
||||
@@ -2,10 +2,10 @@ import * as SystemUI from 'expo-system-ui'
|
||||
import {type Theme} from '@bsky.app/alf'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {IS_ANDROID} from '#/env'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
|
||||
export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
|
||||
if (IS_ANDROID) {
|
||||
if (isAndroid) {
|
||||
try {
|
||||
if (themeType === 'theme') {
|
||||
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react'
|
||||
import {type ColorSchemeName, useColorScheme} from 'react-native'
|
||||
import {type ThemeName} from '@bsky.app/alf'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useThemePrefs} from '#/state/shell'
|
||||
import {dark, dim, light} from '#/alf/themes'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export function useColorModeTheme(): ThemeName {
|
||||
const theme = useThemeName()
|
||||
@@ -40,7 +40,7 @@ function getThemeName(
|
||||
|
||||
function updateDocument(theme: ThemeName) {
|
||||
// @ts-ignore web only
|
||||
if (IS_WEB && typeof window !== 'undefined') {
|
||||
if (isWeb && typeof window !== 'undefined') {
|
||||
// @ts-ignore web only
|
||||
const html = window.document.documentElement
|
||||
// @ts-ignore web only
|
||||
@@ -51,7 +51,6 @@ function updateDocument(theme: ThemeName) {
|
||||
html.classList.add(`theme--${theme}`)
|
||||
// set color to 'theme-color' meta tag
|
||||
meta?.setAttribute('content', getBackgroundColor(theme))
|
||||
window.localStorage.setItem('ALF_THEME', theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
|
||||
import {getCurrentState, onAppStateChange} from '#/lib/appState'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
/**
|
||||
* Tracks passive analytics like app foreground/background time.
|
||||
*/
|
||||
export function PassiveAnalytics() {
|
||||
const ax = useAnalytics()
|
||||
const lastActive = useRef(
|
||||
getCurrentState() === 'active' ? performance.now() : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = onAppStateChange(state => {
|
||||
if (state === 'active') {
|
||||
lastActive.current = performance.now()
|
||||
ax.metric('state:foreground', {})
|
||||
} else if (lastActive.current !== null) {
|
||||
ax.metric('state:background', {
|
||||
secondsActive: Math.round(
|
||||
(performance.now() - lastActive.current) / 1e3,
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [ax])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {setPolyfills} from '@growthbook/growthbook'
|
||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||
|
||||
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
|
||||
import * as env from '#/env'
|
||||
|
||||
export {Features} from '#/analytics/features/types'
|
||||
|
||||
const CACHE = new MMKV({id: 'bsky_features_cache'})
|
||||
|
||||
setPolyfills({
|
||||
localStorage: {
|
||||
getItem: key => {
|
||||
const value = CACHE.getString(key)
|
||||
return value != null ? JSON.parse(value) : null
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
CACHE.set(key, value)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* We vary the amount of time we wait for GrowthBook to fetch feature
|
||||
* gates based on the strategy specified.
|
||||
*/
|
||||
export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
|
||||
|
||||
const TIMEOUT_INIT = 500 // TODO should base on p99 or something
|
||||
const TIMEOUT_PREFER_LOW_LATENCY = 250
|
||||
const TIMEOUT_PREFER_FRESH_GATES = 1500
|
||||
|
||||
export const features = new GrowthBook({
|
||||
apiHost: env.GROWTHBOOK_API_HOST,
|
||||
clientKey: env.GROWTHBOOK_CLIENT_KEY,
|
||||
})
|
||||
|
||||
/**
|
||||
* Initializer promise that must be awaited before using the GrowthBook
|
||||
* instance or rendering the `AnalyticsFeaturesContext`. Note: this may not be
|
||||
* fully initialized if it takes longer than `TIMEOUT_INIT` to initialize. In
|
||||
* that case, we may see a flash of uncustomized content until the
|
||||
* initialization completes.
|
||||
*/
|
||||
export const init = new Promise<void>(async y => {
|
||||
await features.init({timeout: TIMEOUT_INIT})
|
||||
y()
|
||||
})
|
||||
|
||||
/**
|
||||
* Refresh feature gates from GrowthBook. Updates attributes based on the
|
||||
* provided account, if any.
|
||||
*/
|
||||
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
|
||||
await features.refreshFeatures({
|
||||
timeout:
|
||||
strategy === 'prefer-low-latency'
|
||||
? TIMEOUT_PREFER_LOW_LATENCY
|
||||
: TIMEOUT_PREFER_FRESH_GATES,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts our metadata into GrowthBook attributes and sets them. GrowthBook
|
||||
* attributes are manually configured in the GrowthBook dashboard. So these
|
||||
* values need to match exactly. Therefore, let's add them here manually to and
|
||||
* not spread them to avoid mistakes.
|
||||
*/
|
||||
export function setAttributes({
|
||||
base,
|
||||
geolocation,
|
||||
session,
|
||||
preferences,
|
||||
}: Metadata) {
|
||||
features.setAttributes({
|
||||
deviceId: base.deviceId,
|
||||
sessionId: base.sessionId,
|
||||
platform: base.platform,
|
||||
appVersion: base.appVersion,
|
||||
countryCode: geolocation.countryCode,
|
||||
regionCode: geolocation.regionCode,
|
||||
did: session?.did,
|
||||
isBskyPds: session?.isBskyPds,
|
||||
appLanguage: preferences?.appLanguage,
|
||||
contentLanguages: preferences?.contentLanguages,
|
||||
currentScreen: getNavigationMetadata()?.currentScreen,
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export enum Features {
|
||||
// core flags
|
||||
IsBskyTeam = 'is_bsky_team',
|
||||
|
||||
// debug flags
|
||||
DebugFeedContext = 'debug_feed_context',
|
||||
|
||||
// feature flags
|
||||
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import uuid from 'react-native-uuid'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
import {device} from '#/storage'
|
||||
|
||||
const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID'
|
||||
|
||||
export async function getAndMigrateDeviceId() {
|
||||
const migrated = getDeviceId()
|
||||
if (migrated) return migrated
|
||||
const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4()
|
||||
device.set(['deviceId'], id)
|
||||
return id
|
||||
}
|
||||
|
||||
export function getDeviceId() {
|
||||
return device.get(['deviceId'])
|
||||
}
|
||||
|
||||
export function getDeviceIdOrThrow() {
|
||||
const id = device.get(['deviceId'])
|
||||
if (!id) {
|
||||
throw new Error(`deviceId is not set, call getAndMigrateDeviceId first`)
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from '#/analytics/identifiers/device'
|
||||
export * from '#/analytics/identifiers/session'
|
||||
@@ -1,79 +0,0 @@
|
||||
jest.mock('#/storage', () => ({
|
||||
device: {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('#/analytics/identifiers/util', () => ({
|
||||
isSessionIdExpired: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('#/lib/appState', () => ({
|
||||
onAppStateChange: jest.fn(() => ({remove: jest.fn()})),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
function getMocks() {
|
||||
const {device} = require('#/storage')
|
||||
const {isSessionIdExpired} = require('#/analytics/identifiers/util')
|
||||
return {
|
||||
device: jest.mocked(device),
|
||||
isSessionIdExpired: jest.mocked(isSessionIdExpired),
|
||||
}
|
||||
}
|
||||
|
||||
describe('session initialization', () => {
|
||||
it('creates new session and sets timestamp when none exists', () => {
|
||||
const {device, isSessionIdExpired} = getMocks()
|
||||
device.get.mockReturnValue(undefined)
|
||||
isSessionIdExpired.mockReturnValue(false)
|
||||
|
||||
const {getInitialSessionId} = require('./session')
|
||||
const id = getInitialSessionId()
|
||||
|
||||
expect(id).toBeDefined()
|
||||
expect(typeof id).toBe('string')
|
||||
expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id)
|
||||
expect(device.set).toHaveBeenCalledWith(
|
||||
['nativeSessionIdLastEventAt'],
|
||||
expect.any(Number),
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses existing session when not expired', () => {
|
||||
const {device, isSessionIdExpired} = getMocks()
|
||||
const existingId = 'existing-session-id'
|
||||
device.get.mockImplementation((key: string[]) => {
|
||||
if (key[0] === 'nativeSessionId') return existingId
|
||||
if (key[0] === 'nativeSessionIdLastEventAt') return Date.now()
|
||||
return undefined
|
||||
})
|
||||
isSessionIdExpired.mockReturnValue(false)
|
||||
|
||||
const {getInitialSessionId} = require('./session')
|
||||
|
||||
expect(getInitialSessionId()).toBe(existingId)
|
||||
})
|
||||
|
||||
it('creates new session when existing is expired', () => {
|
||||
const {device, isSessionIdExpired} = getMocks()
|
||||
const existingId = 'existing-session-id'
|
||||
device.get.mockImplementation((key: string[]) => {
|
||||
if (key[0] === 'nativeSessionId') return existingId
|
||||
if (key[0] === 'nativeSessionIdLastEventAt') return Date.now() - 999999
|
||||
return undefined
|
||||
})
|
||||
isSessionIdExpired.mockReturnValue(true)
|
||||
|
||||
const {getInitialSessionId} = require('./session')
|
||||
const id = getInitialSessionId()
|
||||
|
||||
expect(id).not.toBe(existingId)
|
||||
expect(device.set).toHaveBeenCalledWith(['nativeSessionId'], id)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import uuid from 'react-native-uuid'
|
||||
|
||||
import {onAppStateChange} from '#/lib/appState'
|
||||
import {isSessionIdExpired} from '#/analytics/identifiers/util'
|
||||
import {device} from '#/storage'
|
||||
|
||||
let sessionId = (() => {
|
||||
const existing = device.get(['nativeSessionId'])
|
||||
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
|
||||
const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
|
||||
device.set(['nativeSessionId'], id)
|
||||
device.set(['nativeSessionIdLastEventAt'], Date.now())
|
||||
return id
|
||||
})()
|
||||
|
||||
export function getInitialSessionId() {
|
||||
return sessionId
|
||||
}
|
||||
|
||||
export function useSessionId() {
|
||||
const [id, setId] = useState(() => sessionId)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = onAppStateChange(state => {
|
||||
if (state === 'active') {
|
||||
const lastEvent = device.get(['nativeSessionIdLastEventAt'])
|
||||
if (isSessionIdExpired(lastEvent)) {
|
||||
sessionId = uuid.v4()
|
||||
device.set(['nativeSessionId'], sessionId)
|
||||
setId(sessionId)
|
||||
}
|
||||
}
|
||||
device.set(['nativeSessionIdLastEventAt'], Date.now())
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
return id
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import uuid from 'react-native-uuid'
|
||||
|
||||
import {onAppStateChange} from '#/lib/appState'
|
||||
import {isSessionIdExpired} from '#/analytics/identifiers/util'
|
||||
|
||||
const SESSION_ID_KEY = 'bsky_session_id'
|
||||
const LAST_EVENT_KEY = 'bsky_session_id_last_event_at'
|
||||
|
||||
let sessionId = (() => {
|
||||
const existing = window.sessionStorage.getItem(SESSION_ID_KEY)
|
||||
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
|
||||
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
|
||||
const id = existing && !isSessionIdExpired(lastEvent) ? existing : uuid.v4()
|
||||
window.sessionStorage.setItem(SESSION_ID_KEY, id)
|
||||
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
|
||||
return id
|
||||
})()
|
||||
|
||||
export function getInitialSessionId() {
|
||||
return sessionId
|
||||
}
|
||||
|
||||
export function useSessionId() {
|
||||
const [id, setId] = useState(() => sessionId)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = onAppStateChange(state => {
|
||||
if (state === 'active') {
|
||||
const lastEventStr = window.sessionStorage.getItem(LAST_EVENT_KEY)
|
||||
const lastEvent = lastEventStr ? Number(lastEventStr) : undefined
|
||||
if (isSessionIdExpired(lastEvent)) {
|
||||
sessionId = uuid.v4()
|
||||
window.sessionStorage.setItem(SESSION_ID_KEY, sessionId)
|
||||
setId(sessionId)
|
||||
}
|
||||
}
|
||||
window.sessionStorage.setItem(LAST_EVENT_KEY, String(Date.now()))
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
|
||||
return id
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import * as env from '#/env'
|
||||
|
||||
const ONE_MIN = 60 * 1e3
|
||||
const TTL = (env.IS_NATIVE ? 5 : 30) * ONE_MIN // 5 min on native
|
||||
|
||||
export function isSessionIdExpired(since: number | undefined) {
|
||||
if (since === undefined) return false
|
||||
return Date.now() - since >= TTL
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import {createContext, useContext, useEffect, useMemo} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
|
||||
import {Logger} from '#/logger'
|
||||
import {
|
||||
Features,
|
||||
features as feats,
|
||||
init,
|
||||
refresh,
|
||||
setAttributes,
|
||||
} from '#/analytics/features'
|
||||
import {
|
||||
getAndMigrateDeviceId,
|
||||
getDeviceId,
|
||||
getInitialSessionId,
|
||||
useSessionId,
|
||||
} from '#/analytics/identifiers'
|
||||
import {
|
||||
getMetadataForLogger,
|
||||
getNavigationMetadata,
|
||||
type MergeableMetadata,
|
||||
type Metadata,
|
||||
} from '#/analytics/metadata'
|
||||
import {type Metrics, metrics} from '#/analytics/metrics'
|
||||
import * as refParams from '#/analytics/misc/refParams'
|
||||
import * as env from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
import {device} from '#/storage'
|
||||
|
||||
export * as utils from '#/analytics/utils'
|
||||
export const features = {init, refresh}
|
||||
export {Features} from '#/analytics/features'
|
||||
export {type Metrics} from '#/analytics/metrics'
|
||||
|
||||
type LoggerType = {
|
||||
debug: Logger['debug']
|
||||
info: Logger['info']
|
||||
log: Logger['log']
|
||||
warn: Logger['warn']
|
||||
error: Logger['error']
|
||||
/**
|
||||
* Clones the existing logger and overrides the `context` value. Existing
|
||||
* metadata is inherited.
|
||||
*
|
||||
* ```ts
|
||||
* const ax = useAnalytics()
|
||||
* const logger = ax.logger.useChild(ax.logger.Context.Notifications)
|
||||
* ```
|
||||
*/
|
||||
useChild: (context: Exclude<Logger['context'], undefined>) => LoggerType
|
||||
Context: typeof Logger.Context
|
||||
}
|
||||
export type AnalyticsContextType = {
|
||||
metadata: Metadata
|
||||
logger: LoggerType
|
||||
metric: <E extends keyof Metrics>(
|
||||
event: E,
|
||||
payload: Metrics[E],
|
||||
metadata?: MergeableMetadata,
|
||||
) => void
|
||||
features: typeof Features & {
|
||||
enabled(feature: Features): boolean
|
||||
}
|
||||
}
|
||||
export type AnalyticsBaseContextType = Omit<AnalyticsContextType, 'features'>
|
||||
|
||||
function createLogger(
|
||||
context: Logger['context'],
|
||||
metadata: Partial<Metadata>,
|
||||
): LoggerType {
|
||||
const logger = Logger.create(context, metadata)
|
||||
return {
|
||||
debug: logger.debug.bind(logger),
|
||||
info: logger.info.bind(logger),
|
||||
log: logger.log.bind(logger),
|
||||
warn: logger.warn.bind(logger),
|
||||
error: logger.error.bind(logger),
|
||||
useChild: (context: Exclude<Logger['context'], undefined>) => {
|
||||
return useMemo(() => createLogger(context, metadata), [context, metadata])
|
||||
},
|
||||
Context: Logger.Context,
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<AnalyticsBaseContextType>({
|
||||
logger: createLogger(Logger.Context.Default, {}),
|
||||
metric: (event, payload, metadata) => {
|
||||
if (metadata && '__meta' in metadata) {
|
||||
delete metadata.__meta
|
||||
}
|
||||
metrics.track(event, payload, {
|
||||
...metadata,
|
||||
navigation: getNavigationMetadata(),
|
||||
})
|
||||
},
|
||||
metadata: {
|
||||
base: {
|
||||
deviceId: getDeviceId() ?? 'unknown',
|
||||
sessionId: getInitialSessionId(),
|
||||
platform: Platform.OS,
|
||||
appVersion: env.APP_VERSION,
|
||||
bundleIdentifier: env.BUNDLE_IDENTIFIER,
|
||||
bundleDate: env.BUNDLE_DATE,
|
||||
referrerSrc: refParams.src,
|
||||
referrerUrl: refParams.url,
|
||||
},
|
||||
geolocation: device.get(['mergedGeolocation']) || {
|
||||
countryCode: '',
|
||||
regionCode: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Ensures that deviceId is set and migrated from legacy storage. Handled on
|
||||
* startup in `App.<platform>.tsx`. This must be awaited prior to the app
|
||||
* booting up.
|
||||
*/
|
||||
export const setupDeviceId = getAndMigrateDeviceId()
|
||||
|
||||
/**
|
||||
* Analytics context provider. Decorates the parent analytics context with
|
||||
* additional metadata. Nesting should be done carefully and sparingly.
|
||||
*/
|
||||
export function AnalyticsContext({
|
||||
children,
|
||||
metadata,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
metadata?: MergeableMetadata
|
||||
}) {
|
||||
if (metadata) {
|
||||
if (!('__meta' in metadata)) {
|
||||
throw new Error(
|
||||
'Use the useMeta() helper when passing metadata to AnalyticsContext',
|
||||
)
|
||||
}
|
||||
}
|
||||
const sessionId = useSessionId()
|
||||
const geolocation = useGeolocation()
|
||||
const parentContext = useContext(Context)
|
||||
const childContext = useMemo(() => {
|
||||
const combinedMetadata = {
|
||||
...parentContext.metadata,
|
||||
...metadata,
|
||||
base: {
|
||||
...parentContext.metadata.base,
|
||||
sessionId,
|
||||
},
|
||||
geolocation,
|
||||
}
|
||||
const context: AnalyticsBaseContextType = {
|
||||
...parentContext,
|
||||
logger: createLogger(
|
||||
Logger.Context.Default,
|
||||
getMetadataForLogger(combinedMetadata),
|
||||
),
|
||||
metadata: combinedMetadata,
|
||||
metric: (event, payload, extraMetadata) => {
|
||||
parentContext.metric(event, payload, {
|
||||
...combinedMetadata,
|
||||
...extraMetadata,
|
||||
})
|
||||
},
|
||||
}
|
||||
return context
|
||||
}, [sessionId, geolocation, parentContext, metadata])
|
||||
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature gates provider. Decorates the parent analytics context with
|
||||
* feature gate capabilities. Should be mounted within `AnalyticsContext`,
|
||||
* and below the `<Fragment key={did} />` breaker in `App.<platform>.tsx`.
|
||||
*/
|
||||
export function AnalyticsFeaturesContext({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const parentContext = useContext(Context)
|
||||
|
||||
/**
|
||||
* Side-effect: we need to synchronously set this during the
|
||||
* same render cycle. It does not trigger a re-render, it just
|
||||
* sets properties on the singleton GrowthBook instance.
|
||||
*/
|
||||
setAttributes(parentContext.metadata)
|
||||
|
||||
useEffect(() => {
|
||||
feats.setTrackingCallback((experiment, result) => {
|
||||
parentContext.metric('experiment:viewed', {
|
||||
experimentId: experiment.key,
|
||||
variationId: result.key,
|
||||
})
|
||||
})
|
||||
}, [parentContext.metric])
|
||||
|
||||
const childContext = useMemo<AnalyticsContextType>(() => {
|
||||
return {
|
||||
...parentContext,
|
||||
features: {
|
||||
enabled: feats.isOn.bind(feats),
|
||||
...Features,
|
||||
},
|
||||
}
|
||||
}, [parentContext])
|
||||
|
||||
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic analytics context without feature gates. Should really only be used
|
||||
* above the `AnalyticsFeaturesContext` provider.
|
||||
*/
|
||||
export function useAnalyticsBase() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
/**
|
||||
* The main analytics context, including feature gates. Use this everywhere you
|
||||
* need metrics, features, or logging within the React tree.
|
||||
*/
|
||||
export function useAnalytics() {
|
||||
const ctx = useContext(Context)
|
||||
if (!('features' in ctx)) {
|
||||
throw new Error(
|
||||
'useAnalytics must be used within an AnalyticsFeaturesContext',
|
||||
)
|
||||
}
|
||||
return ctx as AnalyticsContextType
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export type BaseMetadata = {
|
||||
deviceId: string
|
||||
sessionId: string
|
||||
platform: string
|
||||
appVersion: string
|
||||
bundleIdentifier: string
|
||||
bundleDate: number
|
||||
referrerSrc: string
|
||||
referrerUrl: string
|
||||
}
|
||||
|
||||
export type GeolocationMetadata = Geolocation
|
||||
|
||||
export type SessionMetadata = {
|
||||
did: string
|
||||
isBskyPds: boolean
|
||||
}
|
||||
|
||||
export type PreferencesMetadata = {
|
||||
appLanguage: string
|
||||
contentLanguages: string[]
|
||||
}
|
||||
|
||||
export type MergeableMetadata = {
|
||||
session?: SessionMetadata
|
||||
preferences?: PreferencesMetadata
|
||||
/**
|
||||
* Navigation metadata is not actually available on this object, instead it's
|
||||
* merged in at time-of-log/metric. See `#/analytics/metadata.ts` for details.
|
||||
*/
|
||||
navigation?: NavigationMetadata
|
||||
}
|
||||
|
||||
export type Metadata = {
|
||||
base: BaseMetadata
|
||||
geolocation: GeolocationMetadata
|
||||
} & MergeableMetadata
|
||||
|
||||
/*
|
||||
* Navigation metadata is handle out-of-band from React, since we don't want to
|
||||
* slow down screen transitions in any way, and there doesn't seem to be a nice
|
||||
* way to get current navigation state without an additional re-render between
|
||||
* navigations.
|
||||
*
|
||||
* So instead of this data being available on the Metadata object, it's stored
|
||||
* here and merged in at time-of-log/metric.
|
||||
*/
|
||||
export type NavigationMetadata = {
|
||||
previousScreen?: string
|
||||
currentScreen?: string
|
||||
}
|
||||
let navigationMetadata: NavigationMetadata | undefined
|
||||
export function getNavigationMetadata() {
|
||||
return navigationMetadata
|
||||
}
|
||||
export function setNavigationMetadata(meta: NavigationMetadata | undefined) {
|
||||
navigationMetadata = meta
|
||||
}
|
||||
|
||||
/**
|
||||
* We don't want or need to send all data to the logger
|
||||
*/
|
||||
export function getMetadataForLogger({
|
||||
base,
|
||||
geolocation,
|
||||
session,
|
||||
}: Metadata): Record<string, any> {
|
||||
return {
|
||||
deviceId: base.deviceId,
|
||||
sessionId: base.sessionId,
|
||||
platform: base.platform,
|
||||
appVersion: base.appVersion,
|
||||
countryCode: geolocation.countryCode,
|
||||
regionCode: geolocation.regionCode,
|
||||
isBskyPds: session?.isBskyPds || 'anonymous',
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import {MetricsClient} from './client'
|
||||
|
||||
let appStateCallback: (state: string) => void
|
||||
|
||||
jest.mock('#/lib/appState', () => ({
|
||||
onAppStateChange: jest.fn(cb => {
|
||||
appStateCallback = cb
|
||||
return {remove: jest.fn()}
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('#/logger', () => ({
|
||||
Logger: {
|
||||
create: () => ({
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
Context: {Metric: 'metric'},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('#/env', () => ({
|
||||
METRICS_API_HOST: 'https://test.metrics.api',
|
||||
IS_WEB: false,
|
||||
}))
|
||||
|
||||
type TestEvents = {
|
||||
click: {button: string}
|
||||
view: {screen: string}
|
||||
}
|
||||
|
||||
describe('MetricsClient', () => {
|
||||
let fetchMock: jest.Mock
|
||||
let fetchRequests: {body: any}[]
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({advanceTimers: true})
|
||||
fetchRequests = []
|
||||
fetchMock = jest.fn().mockImplementation(async (_url, options) => {
|
||||
const body = JSON.parse(options.body)
|
||||
fetchRequests.push({body})
|
||||
return {ok: true, status: 200}
|
||||
})
|
||||
global.fetch = fetchMock
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('flushes events on interval', async () => {
|
||||
const client = new MetricsClient<TestEvents>()
|
||||
client.track('click', {button: 'submit'})
|
||||
client.track('view', {screen: 'home'})
|
||||
|
||||
expect(fetchRequests).toHaveLength(0)
|
||||
|
||||
// Advance past the 10 second interval
|
||||
await jest.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(fetchRequests).toHaveLength(1)
|
||||
expect(fetchRequests[0].body.events).toHaveLength(2)
|
||||
expect(fetchRequests[0].body.events[0].event).toBe('click')
|
||||
expect(fetchRequests[0].body.events[1].event).toBe('view')
|
||||
})
|
||||
|
||||
it('flushes when maxBatchSize is exceeded', async () => {
|
||||
const client = new MetricsClient<TestEvents>()
|
||||
client.maxBatchSize = 5
|
||||
|
||||
// Add events up to maxBatchSize (should not flush yet)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.track('click', {button: `btn-${i}`})
|
||||
}
|
||||
|
||||
expect(fetchRequests).toHaveLength(0)
|
||||
|
||||
// One more event should trigger flush (> maxBatchSize)
|
||||
client.track('click', {button: 'btn-trigger'})
|
||||
|
||||
// Allow microtasks to run
|
||||
await jest.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(fetchRequests).toHaveLength(1)
|
||||
expect(fetchRequests[0].body.events).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('retries failed events once on 500 response', async () => {
|
||||
let requestCount = 0
|
||||
|
||||
fetchMock.mockImplementation(async (_url, options) => {
|
||||
requestCount++
|
||||
const body = JSON.parse(options.body)
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First request fails with 500 - "Failed to fetch" triggers isNetworkError
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
}
|
||||
}
|
||||
|
||||
// Retry succeeds
|
||||
fetchRequests.push({body})
|
||||
return {ok: true, status: 200}
|
||||
})
|
||||
|
||||
const client = new MetricsClient<TestEvents>()
|
||||
client.track('click', {button: 'submit'})
|
||||
|
||||
// Trigger flush via interval
|
||||
await jest.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(requestCount).toBe(1)
|
||||
expect(fetchRequests).toHaveLength(0)
|
||||
|
||||
// Simulate app coming to foreground to trigger retry
|
||||
appStateCallback('active')
|
||||
await jest.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(requestCount).toBe(2)
|
||||
expect(fetchRequests).toHaveLength(1)
|
||||
expect(fetchRequests[0].body.events).toHaveLength(1)
|
||||
expect(fetchRequests[0].body.events[0].event).toBe('click')
|
||||
})
|
||||
|
||||
it('does not retry more than once', async () => {
|
||||
let requestCount = 0
|
||||
|
||||
fetchMock.mockImplementation(async () => {
|
||||
requestCount++
|
||||
// Always fail with network-like error
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal Server Error',
|
||||
}
|
||||
})
|
||||
|
||||
const client = new MetricsClient<TestEvents>()
|
||||
client.track('click', {button: 'submit'})
|
||||
|
||||
// First flush fails
|
||||
await jest.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(requestCount).toBe(1)
|
||||
|
||||
// Retry also fails
|
||||
appStateCallback('active')
|
||||
await jest.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(requestCount).toBe(2)
|
||||
|
||||
// Another foreground event should not retry again (events are dropped)
|
||||
appStateCallback('active')
|
||||
await jest.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(requestCount).toBe(2) // No additional requests
|
||||
})
|
||||
|
||||
it('flushes when app goes to background', async () => {
|
||||
const client = new MetricsClient<TestEvents>()
|
||||
client.track('click', {button: 'submit'})
|
||||
|
||||
expect(fetchRequests).toHaveLength(0)
|
||||
|
||||
// Simulate app going to background
|
||||
appStateCallback('background')
|
||||
await jest.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(fetchRequests).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import {onAppStateChange} from '#/lib/appState'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {Logger} from '#/logger'
|
||||
import * as env from '#/env'
|
||||
|
||||
type Event<M extends Record<string, any>> = {
|
||||
time: number
|
||||
event: keyof M
|
||||
payload: M[keyof M]
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t'
|
||||
const logger = Logger.create(Logger.Context.Metric, {})
|
||||
|
||||
export class MetricsClient<M extends Record<string, any>> {
|
||||
maxBatchSize = 100
|
||||
|
||||
private started: boolean = false
|
||||
private queue: Event<M>[] = []
|
||||
private failedQueue: Event<M>[] = []
|
||||
private flushInterval: NodeJS.Timeout | null = null
|
||||
|
||||
start() {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.flushInterval = setInterval(() => {
|
||||
this.flush()
|
||||
}, 10_000)
|
||||
onAppStateChange(state => {
|
||||
if (state === 'active') {
|
||||
this.retryFailedLogs()
|
||||
} else {
|
||||
this.flush()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
track<E extends keyof M>(
|
||||
event: E,
|
||||
payload: M[E],
|
||||
metadata: Record<string, any> = {},
|
||||
) {
|
||||
this.start()
|
||||
|
||||
const e = {
|
||||
time: Date.now(),
|
||||
event,
|
||||
payload,
|
||||
metadata,
|
||||
}
|
||||
this.queue.push(e)
|
||||
|
||||
logger.debug(`event: ${e.event as string}`, e)
|
||||
|
||||
if (this.queue.length > this.maxBatchSize) {
|
||||
this.flush()
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (!this.queue.length) return
|
||||
const events = this.queue.splice(0, this.queue.length)
|
||||
this.sendBatch(events)
|
||||
}
|
||||
|
||||
private async sendBatch(events: Event<M>[], isRetry: boolean = false) {
|
||||
logger.debug(`sendBatch: ${events.length}`, {
|
||||
isRetry,
|
||||
})
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({events})
|
||||
if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) {
|
||||
const success = navigator.sendBeacon(
|
||||
TRACKING_ENDPOINT,
|
||||
new Blob([body], {type: 'application/json'}),
|
||||
)
|
||||
if (!success) {
|
||||
// construct a "network error" for `isNetworkError` to work
|
||||
throw new Error(`Failed to fetch: sendBeacon returned false`)
|
||||
}
|
||||
} else {
|
||||
const res = await fetch(TRACKING_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({events}),
|
||||
keepalive: true,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text().catch(() => 'Unknown error')
|
||||
// construct a "network error" for `isNetworkError` to work
|
||||
throw new Error(`${res.status} Failed to fetch — ${error}`)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (isNetworkError(e)) {
|
||||
if (isRetry) return // retry once
|
||||
this.failedQueue.push(...events)
|
||||
return
|
||||
}
|
||||
logger.error(`Failed to send metrics`, {
|
||||
safeMessage: e.toString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private retryFailedLogs() {
|
||||
if (!this.failedQueue.length) return
|
||||
const events = this.failedQueue.splice(0, this.failedQueue.length)
|
||||
this.sendBatch(events, true)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import {MetricsClient} from '#/analytics/metrics/client'
|
||||
import {type Events} from '#/analytics/metrics/types'
|
||||
|
||||
export type {Events as Metrics} from '#/analytics/metrics/types'
|
||||
export * from '#/analytics/metrics/utils'
|
||||
export const metrics = new MetricsClient<Events>()
|
||||
@@ -1,7 +0,0 @@
|
||||
export function toClout(n: number | null | undefined): number | undefined {
|
||||
if (n == null) {
|
||||
return undefined
|
||||
} else {
|
||||
return Math.max(0, Math.round(Math.log(n)))
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* This is used for our own Bluesky post embeds, and maybe other things.
|
||||
*
|
||||
* In the case of our embeds, `ref_src=embed`. Not sure if `ref_url` is used.
|
||||
*/
|
||||
|
||||
import * as env from '#/env'
|
||||
|
||||
let refSrc = ''
|
||||
let refUrl = ''
|
||||
if (env.IS_WEB) {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
refSrc = params.get('ref_src') ?? ''
|
||||
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
|
||||
}
|
||||
|
||||
export const src = refSrc
|
||||
export const url = refUrl
|
||||
@@ -1,33 +0,0 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {BSKY_SERVICE} from '#/lib/constants'
|
||||
import {type SessionAccount} from '#/state/session'
|
||||
import {
|
||||
type MergeableMetadata,
|
||||
type SessionMetadata,
|
||||
} from '#/analytics/metadata'
|
||||
|
||||
/**
|
||||
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
|
||||
* type guard.
|
||||
*/
|
||||
export function useMeta(metadata?: MergeableMetadata) {
|
||||
const m = useMemo(() => metadata, [metadata])
|
||||
if (!m) return
|
||||
// @ts-ignore
|
||||
m.__meta = true
|
||||
return m
|
||||
}
|
||||
|
||||
export function accountToSessionMetadata(
|
||||
account: SessionAccount | undefined,
|
||||
): SessionMetadata | undefined {
|
||||
if (!account) {
|
||||
return
|
||||
} else {
|
||||
return {
|
||||
did: account.did,
|
||||
isBskyPds: account.service.startsWith(BSKY_SERVICE),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||
import {
|
||||
Context,
|
||||
@@ -70,7 +71,6 @@ import {
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {Backdrop} from './Backdrop'
|
||||
|
||||
export {
|
||||
@@ -81,14 +81,14 @@ export {
|
||||
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
|
||||
|
||||
const SPRING_IN: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
mass: isIOS ? 1.25 : 0.75,
|
||||
damping: 50,
|
||||
stiffness: 1100,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
const SPRING_OUT: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
mass: isIOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 1000,
|
||||
restDisplacementThreshold: 0.01,
|
||||
@@ -209,7 +209,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_ANDROID && context.isOpen) {
|
||||
if (isAndroid && context.isOpen) {
|
||||
const listener = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
context.close()
|
||||
return true
|
||||
@@ -331,7 +331,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
<GestureDetector gesture={composedGestures}>
|
||||
<View ref={ref} style={[{opacity: context.isOpen ? 0 : 1}, style]}>
|
||||
{children({
|
||||
IS_NATIVE: true,
|
||||
isNative: true,
|
||||
control: {isOpen: context.isOpen, open},
|
||||
state: {
|
||||
pressed: false,
|
||||
|
||||
@@ -85,7 +85,7 @@ export type TriggerProps = {
|
||||
}
|
||||
export type TriggerChildProps =
|
||||
| {
|
||||
IS_NATIVE: true
|
||||
isNative: true
|
||||
control: {
|
||||
isOpen: boolean
|
||||
open: (mode: 'full' | 'auxiliary-only') => void
|
||||
@@ -115,7 +115,7 @@ export type TriggerChildProps =
|
||||
}
|
||||
}
|
||||
| {
|
||||
IS_NATIVE: false
|
||||
isNative: false
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
state: {
|
||||
hovered: false
|
||||
|
||||
@@ -18,7 +18,7 @@ import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomShee
|
||||
|
||||
export const Context = createContext<DialogContextProps>({
|
||||
close: () => {},
|
||||
IS_NATIVEDialog: false,
|
||||
isNativeDialog: false,
|
||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
|
||||
@@ -26,6 +26,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
|
||||
@@ -38,7 +39,6 @@ import {
|
||||
type DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
type BottomSheetSnapPointChangeEvent,
|
||||
@@ -154,7 +154,7 @@ export function Outer({
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
IS_NATIVEDialog: true,
|
||||
isNativeDialog: true,
|
||||
nativeSnapPoint: snapPoint,
|
||||
disableDrag,
|
||||
setDisableDrag,
|
||||
@@ -209,7 +209,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
useEnableKeyboardController(IS_IOS)
|
||||
useEnableKeyboardController(isIOS)
|
||||
|
||||
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
|
||||
|
||||
@@ -224,7 +224,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
)
|
||||
|
||||
let paddingBottom = 0
|
||||
if (IS_IOS) {
|
||||
if (isIOS) {
|
||||
paddingBottom += keyboardHeight / 4
|
||||
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
|
||||
paddingBottom += insets.bottom + tokens.space.md
|
||||
@@ -240,7 +240,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
}
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (!IS_ANDROID) {
|
||||
if (!isAndroid) {
|
||||
return
|
||||
}
|
||||
const {contentOffset} = e.nativeEvent
|
||||
@@ -260,12 +260,12 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
contentContainerStyle,
|
||||
]}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
showsVerticalScrollIndicator={isAndroid ? false : undefined}
|
||||
{...props}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
bottomOffset={30}
|
||||
scrollEventThrottle={50}
|
||||
onScroll={IS_ANDROID ? onScroll : undefined}
|
||||
onScroll={isAndroid ? onScroll : undefined}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
// TODO: figure out why this positions the header absolutely (rather than stickily)
|
||||
// on Android. fine to disable for now, because we don't have any
|
||||
@@ -289,11 +289,11 @@ export const InnerFlatList = React.forwardRef<
|
||||
const insets = useSafeAreaInsets()
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
|
||||
useEnableKeyboardController(IS_IOS)
|
||||
useEnableKeyboardController(isIOS)
|
||||
|
||||
const onScroll = (e: ScrollEvent) => {
|
||||
'worklet'
|
||||
if (!IS_ANDROID) {
|
||||
if (!isAndroid) {
|
||||
return
|
||||
}
|
||||
const {contentOffset} = e
|
||||
@@ -311,7 +311,7 @@ export const InnerFlatList = React.forwardRef<
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
|
||||
ref={ref}
|
||||
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
|
||||
showsVerticalScrollIndicator={isAndroid ? false : undefined}
|
||||
{...props}
|
||||
style={[a.h_full, style]}
|
||||
/>
|
||||
@@ -326,7 +326,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
|
||||
const {height} = useReanimatedKeyboardAnimation()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
if (!IS_IOS) return {}
|
||||
if (!isIOS) return {}
|
||||
return {
|
||||
transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
@@ -409,7 +403,3 @@ export function Handle({
|
||||
export function Close() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function Backdrop() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
FlatList,
|
||||
type FlatListProps,
|
||||
type GestureResponderEvent,
|
||||
Pressable,
|
||||
type StyleProp,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
@@ -98,7 +98,7 @@ export function Outer({
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
IS_NATIVEDialog: false,
|
||||
isNativeDialog: false,
|
||||
nativeSnapPoint: 0,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
@@ -113,7 +113,7 @@ export function Outer({
|
||||
<Portal>
|
||||
<Context.Provider value={context}>
|
||||
<RemoveScrollBar />
|
||||
<Pressable
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close active dialog`)}
|
||||
onPress={handleBackgroundPress}>
|
||||
@@ -146,7 +146,7 @@ export function Outer({
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</TouchableWithoutFeedback>
|
||||
</Context.Provider>
|
||||
</Portal>
|
||||
)}
|
||||
@@ -304,7 +304,7 @@ export function Handle() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function Backdrop() {
|
||||
function Backdrop() {
|
||||
const t = useTheme()
|
||||
const {reduceMotionEnabled} = useA11y()
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {useCallback} from 'react'
|
||||
import {SystemBars} from 'react-native-edge-to-edge'
|
||||
|
||||
import {IS_IOS} from '#/env'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
|
||||
/**
|
||||
* If we're calling a system API like the image picker that opens a sheet
|
||||
@@ -9,7 +9,7 @@ import {IS_IOS} from '#/env'
|
||||
*/
|
||||
export function useSheetWrapper() {
|
||||
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
|
||||
if (IS_IOS) {
|
||||
if (isIOS) {
|
||||
const entry = SystemBars.pushStackEntry({
|
||||
style: {
|
||||
statusBar: 'light',
|
||||
|
||||
@@ -39,7 +39,7 @@ export type DialogControlProps = DialogControlRefProps & {
|
||||
|
||||
export type DialogContextProps = {
|
||||
close: DialogControlProps['close']
|
||||
IS_NATIVEDialog: boolean
|
||||
isNativeDialog: boolean
|
||||
nativeSnapPoint: BottomSheetSnapPoint
|
||||
disableDrag: boolean
|
||||
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import React from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -17,25 +17,26 @@ import {
|
||||
useAddSavedFeedsMutation,
|
||||
usePreferencesQuery,
|
||||
useRemoveFeedMutation,
|
||||
useUpdateSavedFeedsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
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 {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2'
|
||||
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 +52,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 +121,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]}
|
||||
@@ -166,9 +137,9 @@ export function TitleAndByline({
|
||||
</Text>
|
||||
{creator && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
<Trans>By {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -245,12 +216,14 @@ export function DescriptionPlaceholder() {
|
||||
|
||||
export function Likes({count}: {count: number}) {
|
||||
const t = useTheme()
|
||||
const {i18n} = useLingui()
|
||||
return (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.font_semi_bold]}>
|
||||
<Trans>
|
||||
Liked by <Plural value={count || 0} one="# user" other="# users" />
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_center, {gap: 2}]}>
|
||||
<HeartIcon size="xs" fill={t.atoms.text_contrast_low.color} />
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{formatCount(i18n, count)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,6 +257,8 @@ function SaveButtonInner({
|
||||
useAddSavedFeedsMutation()
|
||||
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
||||
useRemoveFeedMutation()
|
||||
const {isPending: isUpdatePending, mutateAsync: updateSavedFeeds} =
|
||||
useUpdateSavedFeedsMutation()
|
||||
|
||||
const uri = view.uri
|
||||
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
||||
@@ -291,23 +266,26 @@ function SaveButtonInner({
|
||||
const savedFeedConfig = React.useMemo(() => {
|
||||
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
||||
}, [preferences?.savedFeeds, uri])
|
||||
const isPinned = savedFeedConfig?.pinned ?? false
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const isPending = isAddSavedFeedPending || isRemovePending
|
||||
const isPending = isAddSavedFeedPending || isRemovePending || isUpdatePending
|
||||
|
||||
const toggleSave = React.useCallback(
|
||||
const onPinFeed = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
if (savedFeedConfig) {
|
||||
await removeFeed(savedFeedConfig)
|
||||
// Feed is saved but not pinned, update it to be pinned
|
||||
await updateSavedFeeds([{...savedFeedConfig, pinned: true}])
|
||||
} else {
|
||||
// Feed is not saved, save it with pinned=true
|
||||
await saveFeeds([
|
||||
{
|
||||
type,
|
||||
value: uri,
|
||||
pinned: pin || false,
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -317,10 +295,22 @@ function SaveButtonInner({
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
}
|
||||
},
|
||||
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
[_, pin, saveFeeds, updateSavedFeeds, uri, savedFeedConfig, type],
|
||||
)
|
||||
|
||||
const onPrompRemoveFeed = React.useCallback(
|
||||
const onRemoveFeed = React.useCallback(async () => {
|
||||
try {
|
||||
if (savedFeedConfig) {
|
||||
await removeFeed(savedFeedConfig)
|
||||
}
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {message: `FeedCard: failed to remove feed`})
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
}
|
||||
}, [_, removeFeed, savedFeedConfig])
|
||||
|
||||
const onPromptRemoveFeed = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -334,13 +324,17 @@ function SaveButtonInner({
|
||||
<>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
label={
|
||||
isPinned
|
||||
? _(msg`Remove this feed from your feeds`)
|
||||
: _(msg`Add this feed to your feeds`)
|
||||
}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color={savedFeedConfig ? 'secondary' : 'primary'}
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}
|
||||
color={isPinned ? 'secondary' : 'primary'}
|
||||
onPress={isPinned ? onPromptRemoveFeed : onPinFeed}
|
||||
{...buttonProps}>
|
||||
{savedFeedConfig ? (
|
||||
{isPinned ? (
|
||||
<>
|
||||
{isPending ? (
|
||||
<ButtonIcon size="md" icon={Loader} />
|
||||
@@ -349,7 +343,7 @@ function SaveButtonInner({
|
||||
)}
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Unpin Feed</Trans>
|
||||
<Trans>Unpin</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -358,7 +352,7 @@ function SaveButtonInner({
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : PinIcon} />
|
||||
{text && (
|
||||
<ButtonText>
|
||||
<Trans>Pin Feed</Trans>
|
||||
<Trans>Pin</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</>
|
||||
@@ -371,7 +365,7 @@ function SaveButtonInner({
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
onConfirm={toggleSave}
|
||||
onConfirm={onRemoveFeed}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,10 @@ 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 {logger} from '#/logger'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
@@ -35,8 +39,6 @@ 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'
|
||||
import {type Metrics, useAnalytics} from '#/analytics'
|
||||
import {IS_IOS} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
@@ -54,7 +56,6 @@ function CardOuter({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
testID="CardOuter"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
@@ -432,8 +433,8 @@ export function ProfileGrid({
|
||||
isVisible?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const followDialogControl = useDialogControl()
|
||||
@@ -441,6 +442,7 @@ 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
|
||||
@@ -449,11 +451,12 @@ export function ProfileGrid({
|
||||
const seenProfilesRef = useRef<Set<string>>(new Set())
|
||||
const containerRef = useRef<View>(null)
|
||||
const hasTrackedRef = useRef(false)
|
||||
const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: isProfileHeaderContext
|
||||
? 'Profile'
|
||||
: 'InterstitialProfile'
|
||||
const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
|
||||
isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: isProfileHeaderContext
|
||||
? 'Profile'
|
||||
: 'InterstitialProfile'
|
||||
|
||||
// Callback to fire seen events
|
||||
const fireSeen = useCallback(() => {
|
||||
@@ -465,16 +468,20 @@ export function ProfileGrid({
|
||||
profilesToShow.forEach((profile, index) => {
|
||||
if (!seenProfilesRef.current.has(profile.did)) {
|
||||
seenProfilesRef.current.add(profile.did)
|
||||
ax.metric('suggestedUser:seen', {
|
||||
logContext,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
logger.metric(
|
||||
'suggestedUser:seen',
|
||||
{
|
||||
logContext,
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
},
|
||||
{statsig: true},
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
|
||||
}, [isLoading, error, profiles, maxLength, logContext, recId])
|
||||
|
||||
// For profile header, fire when isVisible becomes true
|
||||
useEffect(() => {
|
||||
@@ -559,7 +566,7 @@ export function ProfileGrid({
|
||||
<ProfileCard.Link
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
ax.metric('suggestedUser:press', {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
@@ -568,21 +575,20 @@ export function ProfileGrid({
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
style={[a.flex_1]}>
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
{onDismiss && (
|
||||
{showDismissButton && (
|
||||
<Button
|
||||
label={_(msg`Dismiss this suggestion`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
onDismiss(profile.did)
|
||||
ax.metric('suggestedUser:dismiss', {
|
||||
onDismiss!(profile.did)
|
||||
logEvent('suggestedUser:dismiss', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
@@ -650,7 +656,7 @@ export function ProfileGrid({
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
ax.metric('suggestedUser:follow', {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
@@ -672,7 +678,7 @@ export function ProfileGrid({
|
||||
// Use totalProfileCount (before dismissals) for minLength check on initial render.
|
||||
const profileCountForMinCheck = totalProfileCount ?? profiles.length
|
||||
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
|
||||
ax.logger.debug(`Not enough profiles to show suggested follows`)
|
||||
logger.debug(`Not enough profiles to show suggested follows`)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -684,7 +690,7 @@ export function ProfileGrid({
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
@@ -693,16 +699,20 @@ export function ProfileGrid({
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}
|
||||
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
{isFeedContext ? (
|
||||
<Trans>Suggested for you</Trans>
|
||||
) : (
|
||||
<Trans>Similar accounts</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{!isProfileHeaderContext && (
|
||||
<Button
|
||||
label={_(msg`See more suggested profiles`)}
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logEvent('suggestedUser:seeMore', {
|
||||
logContext: isFeedContext ? 'Explore' : 'Profile',
|
||||
})
|
||||
}}>
|
||||
@@ -746,7 +756,7 @@ export function ProfileGrid({
|
||||
<SeeMoreSuggestedProfilesCard
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logger.metric('suggestedUser:seeMore', {
|
||||
logContext: 'Explore',
|
||||
})
|
||||
}}
|
||||
@@ -760,6 +770,7 @@ export function ProfileGrid({
|
||||
}
|
||||
|
||||
function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
@@ -773,6 +784,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" />
|
||||
@@ -784,10 +796,9 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
)
|
||||
}
|
||||
|
||||
const numFeedsToDisplay = 3
|
||||
export function SuggestedFeeds() {
|
||||
const numFeedsToDisplay = 3
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {data, isLoading, error} = useGetPopularFeedsQuery({
|
||||
limit: numFeedsToDisplay,
|
||||
@@ -820,7 +831,7 @@ export function SuggestedFeeds() {
|
||||
key={feed.uri}
|
||||
view={feed}
|
||||
onPress={() => {
|
||||
ax.metric('feed:interstitial:feedCard:press', {})
|
||||
logEvent('feed:interstitial:feedCard:press', {})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
@@ -831,7 +842,6 @@ export function SuggestedFeeds() {
|
||||
<FeedCard.TitleAndByline
|
||||
title={feed.displayName}
|
||||
creator={feed.creator}
|
||||
uri={feed.uri}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Description
|
||||
@@ -923,15 +933,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>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
|
||||
} from '#/components/icons/Arrow'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
/**
|
||||
* Tab component that automatically scrolls the selected tab into view - used for interests
|
||||
@@ -236,7 +236,7 @@ export function InterestTabs({
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
{IS_WEB && canScrollLeft && (
|
||||
{isWeb && canScrollLeft && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
@@ -270,7 +270,7 @@ export function InterestTabs({
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
{IS_WEB && canScrollRight && (
|
||||
{isWeb && canScrollRight && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
INTERNATIONAL_TELEPHONE_CODES,
|
||||
} from '#/lib/international-telephone-codes'
|
||||
import {regionName} from '#/locale/helpers'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, web} from '#/alf'
|
||||
import * as Select from '#/components/Select'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
/**
|
||||
@@ -84,7 +84,7 @@ export function InternationalPhoneCodeSelect({
|
||||
<Select.Item value={item.value} label={item.label}>
|
||||
<Select.ItemIndicator />
|
||||
<Select.ItemText style={[a.flex_1]} emoji>
|
||||
{IS_WEB ? <Flag {...item} /> : item.unicodeFlag + ' '}
|
||||
{isWeb ? <Flag {...item} /> : item.unicodeFlag + ' '}
|
||||
{item.name}
|
||||
</Select.ItemText>
|
||||
<Select.ItemText style={[a.text_right]}>
|
||||
@@ -101,7 +101,7 @@ export function InternationalPhoneCodeSelect({
|
||||
}
|
||||
|
||||
function Flag({unicodeFlag, svgFlag}: {unicodeFlag: string; svgFlag: any}) {
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<Image
|
||||
source={svgFlag}
|
||||
|
||||
@@ -157,7 +157,7 @@ export function Link({
|
||||
to={{
|
||||
screen: 'Profile',
|
||||
params: {
|
||||
name: labeler.creator.did,
|
||||
name: labeler.creator.handle,
|
||||
},
|
||||
}}
|
||||
label={_(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useSetDrawerOpen} from '#/state/shell'
|
||||
import {
|
||||
atoms as a,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
} from '#/components/Layout/const'
|
||||
import {ScrollbarOffsetContext} from '#/components/Layout/context'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_IOS} from '#/env'
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
@@ -91,7 +91,7 @@ export function Content({
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.justify_center,
|
||||
IS_IOS && align === 'platform' && a.align_center,
|
||||
isIOS && align === 'platform' && a.align_center,
|
||||
{minHeight: HEADER_SLOT_SIZE},
|
||||
]}>
|
||||
<AlignmentContext.Provider value={align}>
|
||||
@@ -186,7 +186,7 @@ export function TitleText({
|
||||
a.text_lg,
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
IS_IOS && align === 'platform' && a.text_center,
|
||||
isIOS && align === 'platform' && a.text_center,
|
||||
gtMobile && a.text_xl,
|
||||
style,
|
||||
]}
|
||||
@@ -205,7 +205,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
IS_IOS && align === 'platform' && a.text_center,
|
||||
isIOS && align === 'platform' && a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}
|
||||
numberOfLines={2}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {
|
||||
atoms as a,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
|
||||
import {ScrollbarOffsetContext} from '#/components/Layout/context'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export * from '#/components/Layout/const'
|
||||
export * as Header from '#/components/Layout/Header'
|
||||
@@ -43,7 +43,7 @@ export const Screen = memo(function Screen({
|
||||
const {top} = useSafeAreaInsets()
|
||||
return (
|
||||
<>
|
||||
{IS_WEB && <WebCenterBorders />}
|
||||
{isWeb && <WebCenterBorders />}
|
||||
<View
|
||||
style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]}
|
||||
{...props}
|
||||
@@ -98,7 +98,7 @@ export const Content = memo(
|
||||
contentContainerStyle,
|
||||
]}
|
||||
{...props}>
|
||||
{IS_WEB ? (
|
||||
{isWeb ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
@@ -145,7 +145,7 @@ export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
{...props}>
|
||||
{IS_WEB ? <Center>{children}</Center> : children}
|
||||
{isWeb ? <Center>{children}</Center> : children}
|
||||
</KeyboardAwareScrollView>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -18,12 +18,12 @@ import {
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, type ButtonProps} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {router} from '#/routes'
|
||||
import {useGlobalDialogsControlContext} from './dialogs/Context'
|
||||
|
||||
@@ -130,7 +130,7 @@ export function useLink({
|
||||
linkRequiresWarning(href, displayText),
|
||||
)
|
||||
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export function useLink({
|
||||
]
|
||||
|
||||
// does not apply to web's flat navigator
|
||||
if (IS_NATIVE && screen !== 'NotFound') {
|
||||
if (isNative && screen !== 'NotFound') {
|
||||
const state = navigation.getState()
|
||||
// if screen is not in the current navigator, it means it's
|
||||
// most likely a tab screen. note: state can be undefined
|
||||
@@ -246,7 +246,7 @@ export function useLink({
|
||||
(e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnLongPress?.(e)
|
||||
if (exitEarlyIfFalse === false) return
|
||||
return IS_NATIVE && shareOnLongPress ? handleLongPress() : undefined
|
||||
return isNative && shareOnLongPress ? handleLongPress() : undefined
|
||||
},
|
||||
[outerOnLongPress, handleLongPress, shareOnLongPress],
|
||||
)
|
||||
@@ -501,7 +501,7 @@ export function WebOnlyInlineLinkText({
|
||||
onPress,
|
||||
...props
|
||||
}: Omit<InlineLinkProps, 'onLongPress'>) {
|
||||
return IS_WEB ? (
|
||||
return isWeb ? (
|
||||
<InlineLinkText {...props} to={to} onPress={onPress}>
|
||||
{children}
|
||||
</InlineLinkText>
|
||||
@@ -547,7 +547,7 @@ export function createStaticClickIfUnmodified(
|
||||
): {onPress: Exclude<BaseLinkProps['onPress'], undefined>} {
|
||||
return {
|
||||
onPress(e: GestureResponderEvent) {
|
||||
if (!IS_WEB || !isModifiedClickEvent(e)) {
|
||||
if (!isWeb || !isModifiedClickEvent(e)) {
|
||||
e.preventDefault()
|
||||
onPressHandler(e)
|
||||
return false
|
||||
@@ -561,7 +561,7 @@ export function createStaticClickIfUnmodified(
|
||||
* intends to deviate from default behavior.
|
||||
*/
|
||||
export function isClickEventWithMetaKey(e: GestureResponderEvent) {
|
||||
if (!IS_WEB) return false
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey
|
||||
}
|
||||
@@ -570,7 +570,7 @@ export function isClickEventWithMetaKey(e: GestureResponderEvent) {
|
||||
* Determines if the web click target is anything other than `_self`
|
||||
*/
|
||||
export function isClickTargetExternal(e: GestureResponderEvent) {
|
||||
if (!IS_WEB) return false
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
const el = event.currentTarget as HTMLAnchorElement
|
||||
return el && el.target && el.target !== '_self'
|
||||
@@ -582,7 +582,7 @@ export function isClickTargetExternal(e: GestureResponderEvent) {
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
|
||||
*/
|
||||
export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
|
||||
if (!IS_WEB) return false
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
const isPrimaryButton = event.button === 0
|
||||
return (
|
||||
@@ -596,8 +596,8 @@ export function isModifiedClickEvent(e: GestureResponderEvent): boolean {
|
||||
* {@link https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button}
|
||||
*/
|
||||
export function shouldClickOpenNewTab(e: GestureResponderEvent) {
|
||||
if (!IS_WEB) return false
|
||||
if (!isWeb) return false
|
||||
const event = e as unknown as MouseEvent
|
||||
const isMiddleClick = IS_WEB && event.button === 1
|
||||
const isMiddleClick = isWeb && event.button === 1
|
||||
return isClickEventWithMetaKey(e) || isClickTargetExternal(e) || isMiddleClick
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function TitleAndByline({
|
||||
{creator && (
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
style={[a.text_xs, a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{purpose === MODLIST
|
||||
? _(msg`Moderation list by ${sanitizeHandle(creator.handle, '@')}`)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {StyleSheet} from 'react-native'
|
||||
import type React from 'react'
|
||||
|
||||
import {isHighDPI} from '#/lib/browser'
|
||||
import {atoms as a, platform, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {IS_HIGH_DPI} from '#/env'
|
||||
|
||||
/**
|
||||
* Applies and thin border within a bounding box. Used to contrast media from
|
||||
@@ -33,7 +33,7 @@ export function MediaInsetBorder({
|
||||
// while we generally use hairlineWidth (aka 1px),
|
||||
// we make an exception here for high DPI screens
|
||||
// as the 1px border is very noticeable -sfn
|
||||
web: IS_HIGH_DPI ? 0.5 : StyleSheet.hairlineWidth,
|
||||
web: isHighDPI ? 0.5 : StyleSheet.hairlineWidth,
|
||||
}),
|
||||
},
|
||||
opaque
|
||||
|
||||
@@ -135,7 +135,6 @@ export function VideoItem({
|
||||
{maxWidth: 100},
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
]}>
|
||||
<PlayButtonIcon size={24} />
|
||||
</View>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
|
||||
import {isAndroid, isIOS, isNative} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
type TriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
|
||||
|
||||
export {
|
||||
type DialogControlProps as MenuControlProps,
|
||||
@@ -70,7 +70,7 @@ export function Trigger({
|
||||
} = useInteractionState()
|
||||
|
||||
return children({
|
||||
IS_NATIVE: true,
|
||||
isNative: true,
|
||||
control: context.control,
|
||||
state: {
|
||||
hovered: false,
|
||||
@@ -111,7 +111,7 @@ export function Outer({
|
||||
<Dialog.ScrollableInner label={_(msg`Menu`)}>
|
||||
<View style={[a.gap_lg]}>
|
||||
{children}
|
||||
{IS_NATIVE && showCancel && <Cancel />}
|
||||
{isNative && showCancel && <Cancel />}
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
@@ -137,13 +137,13 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPress={async e => {
|
||||
if (IS_ANDROID) {
|
||||
if (isAndroid) {
|
||||
/**
|
||||
* Below fix for iOS doesn't work for Android, this does.
|
||||
*/
|
||||
onPress?.(e)
|
||||
context.control.close()
|
||||
} else if (IS_IOS) {
|
||||
} else if (isIOS) {
|
||||
/**
|
||||
* Fixes a subtle bug on iOS
|
||||
* {@link https://github.com/bluesky-social/social-app/pull/5849/files#diff-de516ef5e7bd9840cd639213301df38cf03acfcad5bda85a1d63efd249ba79deL124-L127}
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -138,7 +138,7 @@ export function Trigger({
|
||||
<RadixTriggerPassThrough>
|
||||
{props =>
|
||||
children({
|
||||
IS_NATIVE: false,
|
||||
isNative: false,
|
||||
control,
|
||||
state: {
|
||||
hovered,
|
||||
@@ -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>
|
||||
|
||||