Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey 65bf8aefc8 Add some device location debug 2026-01-13 10:14:39 -06:00
582 changed files with 84621 additions and 104951 deletions
+2 -12
View File
@@ -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=
+126
View File
@@ -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',
},
}
+6 -6
View File
@@ -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
-637
View File
@@ -1,637 +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
# DO NOT run these commands - extraction and compilation are handled by CI
yarn intl:extract # Extract translation strings (nightly CI job)
yarn intl:compile # Compile translations for runtime (nightly CI job)
# 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
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile translations for runtime
```
## 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 -1
View File
@@ -1,4 +1,4 @@
Copyright 20232026 Bluesky Social PBC
Copyright 20232025 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:
+26 -41
View File
@@ -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',
@@ -119,7 +114,6 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
NSPrivacyCollectedDataTypes: [
@@ -198,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'],
},
@@ -237,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,
@@ -312,25 +297,25 @@ module.exports = function (_config) {
'expo-splash-screen',
{
ios: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/splash.png',
enableFullScreenImage_legacy: true,
backgroundColor: '#ffffff',
image: './assets/splash.png',
resizeMode: 'cover',
dark: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#002861', // primary_900
image: './assets/splash/splash-dark.png',
enableFullScreenImage_legacy: true,
backgroundColor: '#001429',
image: './assets/splash-dark.png',
resizeMode: 'cover',
},
},
android: {
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, // even division of 306px
backgroundColor: '#0c7cff',
image: './assets/splash-android-icon.png',
imageWidth: 150,
dark: {
backgroundColor: '#002861', // primary_900
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102,
backgroundColor: '#0c2a49',
image: './assets/splash-android-icon-dark.png',
imageWidth: 150,
},
},
},
@@ -411,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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 606 KiB

After

Width:  |  Height:  |  Size: 606 KiB

Before

Width:  |  Height:  |  Size: 563 KiB

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

+22
View File
@@ -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,
},
}
-52
View File
@@ -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,
},
],
},
},
)
+4 -9
View File
@@ -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"
}
}
+1350 -308
View File
File diff suppressed because it is too large Load Diff
+8 -45
View File
@@ -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">
-273
View File
@@ -1,273 +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',
'bsky-internal/lingui-msg-rule': '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,
}
},
},
)
+15 -9
View File
@@ -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={
-188
View File
@@ -1,188 +0,0 @@
const {RuleTester} = require('eslint')
const tseslint = require('typescript-eslint')
const linguiMsgRule = require('../lingui-msg-rule')
const ruleTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
},
})
describe('lingui-msg-rule', () => {
const tests = {
valid: [
// msg template literal
{
code: `
const {_} = useLingui()
const x = _(msg\`Hello\`)
`,
},
// msg template literal with interpolation
{
code: `
const {_} = useLingui()
const name = 'World'
const x = _(msg\`Hello \${name}\`)
`,
},
// plural macro
{
code: `
const {_} = useLingui()
const count = 5
const x = _(plural(count, {one: '# item', other: '# items'}))
`,
},
// select macro
{
code: `
const {_} = useLingui()
const gender = 'female'
const x = _(select(gender, {male: 'He', female: 'She', other: 'They'}))
`,
},
// selectOrdinal macro
{
code: `
const {_} = useLingui()
const position = 1
const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'}))
`,
},
// msg function call with object (descriptor form)
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello'}))
`,
},
// msg function call with object and context
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello', context: 'greeting'}))
`,
},
],
invalid: [
// Plain string literal (single quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _('Bad')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Plain string literal (double quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _("Bad")
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal without msg tag - with auto-fix
{
code: `
const {_} = useLingui()
const x = _(\`Bad\`)
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal with interpolation - with auto-fix
{
code: `
const {_} = useLingui()
const name = 'World'
const x = _(\`Hello \${name}\`)
`,
output: `
const {_} = useLingui()
const name = 'World'
const x = _(msg\`Hello \${name}\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// String with backticks that need escaping
{
code: `
const {_} = useLingui()
const x = _('Use \\\`code\\\` here')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Use \\\`code\\\` here\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Variable/identifier - no auto-fix possible
{
code: `
const {_} = useLingui()
const message = 'Hello'
const x = _(message)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Arbitrary function call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(getMessage())
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Empty call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _()
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Tagged template with wrong tag - no auto-fix (would need to replace tag)
{
code: `
const {_} = useLingui()
const x = _(html\`Hello\`)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Number literal - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(123)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
],
}
ruleTester.run('lingui-msg-rule', linguiMsgRule, tests)
})
+288 -316
View File
@@ -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>.',
})
}
},
}
}
+2 -8
View File
@@ -1,16 +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'),
'lingui-msg-rule': require('./lingui-msg-rule'),
},
}
module.exports = plugin
-110
View File
@@ -1,110 +0,0 @@
'use strict'
/**
* @type {import('eslint').Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros',
recommended: true,
},
fixable: 'code',
messages: {
missingMsg:
'Lingui _() must be called with msg`...` or msg({...}) or plural/select/selectOrdinal. Example: _(msg`Hello`)',
},
schema: [],
},
create(context) {
// Valid Lingui macro functions that can be passed to _()
const VALID_MACRO_FUNCTIONS = new Set([
'msg',
'plural',
'select',
'selectOrdinal',
])
/**
* Escape backticks and backslashes for template literal
*/
function escapeForTemplateLiteral(str) {
return str.replace(/\\`/g, '`').replace(/`/g, '\\`')
}
/**
* Try to get a fixer for the given argument
* Returns null if we can't safely fix it
*/
function getFixer(firstArg) {
const sourceCode = context.sourceCode ?? context.getSourceCode()
// Fix string literals: _('foo') -> _(msg`foo`)
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
const escaped = escapeForTemplateLiteral(firstArg.value)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg`' + escaped + '`')
}
}
// Fix untagged template literals: _(`foo`) -> _(msg`foo`)
if (firstArg.type === 'TemplateLiteral') {
const text = sourceCode.getText(firstArg)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg' + text)
}
}
return null
}
return {
CallExpression(node) {
// Check if this is a call to _()
if (node.callee.type !== 'Identifier' || node.callee.name !== '_') {
return
}
// Must have at least one argument
if (node.arguments.length === 0) {
context.report({
node,
messageId: 'missingMsg',
})
return
}
const firstArg = node.arguments[0]
// Valid: _(msg`...`)
if (
firstArg.type === 'TaggedTemplateExpression' &&
firstArg.tag.type === 'Identifier' &&
firstArg.tag.name === 'msg'
) {
return
}
// Valid: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...))
if (
firstArg.type === 'CallExpression' &&
firstArg.callee.type === 'Identifier' &&
VALID_MACRO_FUNCTIONS.has(firstArg.callee.name)
) {
return
}
// Everything else is invalid
const fix = getFixer(firstArg)
context.report({
node,
messageId: 'missingMsg',
fix,
})
},
}
},
}
+15 -24
View File
@@ -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',
})
}
},
}
},
}
}
-4
View File
@@ -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 {
+31
View File
@@ -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.
},
}
}
+12 -1
View File
@@ -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', () => ({}))
+1 -1
View File
@@ -44,6 +44,6 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation 'com.google.android.material:material:1.13.0'
implementation 'com.google.android.material:material:1.12.0'
implementation "com.facebook.react:react-native:+"
}
@@ -5,12 +5,8 @@ import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
@@ -19,7 +15,6 @@ import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.internal.EdgeToEdgeUtils
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -34,26 +29,22 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null
private var isKeyboardVisible: Boolean = false
private val screenHeight =
private val rawScreenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private fun getStatusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Props
var disableDrag = false
set(value) {
field = value
@@ -65,31 +56,48 @@ class BottomSheetView(
field = value
this.dialog?.setCancelable(!value)
}
var preventExpansion = false
var minHeight = 0f
set(value) {
field = if (value < 0) 0f else dpToPx(value)
field =
if (value < 0) {
0f
} else {
dpToPx(value)
}
}
var maxHeight = this.screenHeight
var maxHeight = this.safeScreenHeight
set(value) {
val px = dpToPx(value)
field = if (px > this.screenHeight) this.screenHeight else px
field =
if (px > this.safeScreenHeight) {
this.safeScreenHeight
} else {
px
}
}
private var isOpen: Boolean = false
set(value) {
field = value
onStateChange(mapOf("state" to if (value) "open" else "closed"))
onStateChange(
mapOf(
"state" to if (value) "open" else "closed",
),
)
}
private var isOpening: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(mapOf("state" to "opening"))
onStateChange(
mapOf(
"state" to "opening",
),
)
}
}
@@ -97,21 +105,33 @@ class BottomSheetView(
set(value) {
field = value
if (value) {
onStateChange(mapOf("state" to "closing"))
onStateChange(
mapOf(
"state" to "closing",
),
)
}
}
private var selectedSnapPoint = 0
set(value) {
if (field == value) return
field = value
onSnapPointChange(mapOf("snapPoint" to value))
onSnapPointChange(
mapOf(
"snapPoint" to value,
),
)
}
// Lifecycle
init {
(appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
}
@@ -141,55 +161,27 @@ class BottomSheetView(
private fun getHalfExpandedRatio(contentHeight: Float): Float =
when {
// Full height sheets
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
contentHeight >= safeScreenHeight -> 0.99f
// Medium height sheets (>50% but <100%)
contentHeight >= safeScreenHeight / 2 ->
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
// Small height sheets (<50%)
else ->
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
}
private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight()
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
if (currentContext is android.app.Activity) {
activityWindow = currentContext.window
break
}
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
}
val originalStatusBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
}
val originalNavBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
}
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
val dialog = BottomSheetDialog(context)
dialog.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss)
dialog.setDismissWithAnimation(true)
dialog.setOnDismissListener {
this.isClosing = true
this.destroy()
}
dialog.setOnShowListener {
dialog.window?.let { window ->
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (originalNavBarAppearance != null) {
insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
}
if (originalStatusBarAppearance != null) {
EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
}
}
}
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
it.setBackgroundColor(0)
@@ -202,17 +194,7 @@ class BottomSheetView(
behavior.isDraggable = true
behavior.isHideable = true
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
} else {
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
@@ -227,10 +209,18 @@ class BottomSheetView(
newState: Int,
) {
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
BottomSheetBehavior.STATE_EXPANDED -> {
selectedSnapPoint = 2
}
BottomSheetBehavior.STATE_COLLAPSED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
}
}
@@ -241,26 +231,9 @@ class BottomSheetView(
},
)
}
this.isOpening = true
dialog.show()
this.dialog = dialog
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
val wasKeyboardVisible = isKeyboardVisible
isKeyboardVisible = imeVisible
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!imeVisible && wasKeyboardVisible) {
updateLayout()
}
insets
}
}
fun updateLayout() {
@@ -273,24 +246,12 @@ class BottomSheetView(
val currentState = behavior.state
val oldRatio = behavior.halfExpandedRatio
val newRatio = getHalfExpandedRatio(contentHeight)
var newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (isKeyboardVisible) {
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -318,19 +279,25 @@ class BottomSheetView(
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
return when {
contentHeight > maxHeight -> maxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
val height =
if (contentHeight > maxHeight) {
maxHeight
} else if (contentHeight < minHeight) {
minHeight
} else {
contentHeight
}
return height
}
private fun clampRatio(ratio: Float): Float =
when {
ratio < 0.01 -> 0.01f
ratio > 0.99 -> 0.99f
else -> ratio
private fun clampRatio(ratio: Float): Float {
if (ratio < 0.01) {
return 0.01f
} else if (ratio > 0.99) {
return 0.99f
}
return ratio
}
private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return
@@ -355,7 +322,9 @@ class BottomSheetView(
// View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) {
if (structure == null) return
if (structure == null) {
return
}
dialogRootViewGroup.dispatchProvideStructure(structure)
}
@@ -394,6 +363,7 @@ class BottomSheetView(
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics
return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
}
}
@@ -52,8 +52,6 @@ class DialogRootViewGroup(
if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this)
}
fitsSystemWindows = false
}
override fun onSizeChanged(
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsFloating">false</item>
<item name="enableEdgeToEdge">true</item>
<!-- Configure bottom sheet to respect system window insets -->
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
</style>
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingLeftSystemWindowInsets">true</item>
<item name="paddingRightSystemWindowInsets">true</item>
<item name="paddingTopSystemWindowInsets">false</item>
</style>
</resources>
@@ -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
@@ -175,7 +175,6 @@ function BottomSheetNativeComponentInner({
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
overflow: 'hidden',
},
extraStyles,
]}>
@@ -34,15 +34,12 @@ class NotificationPrefs(
is Boolean -> {
putBoolean(key, value)
}
is String -> {
putString(key, value)
}
is Array<*> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
is Map<*, *> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleImageIntents(
uris: List<Uri>,
text: String?,
text: String?
) {
var allParams = ""
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleVideoIntents(
uris: List<Uri>,
text: String?,
text: String?
) {
val uri = uris[0]
// If there is no extension for the file, substringAfterLast returns the original string - not
+20 -26
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.117.0",
"version": "1.114.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",
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.20",
"@atproto/api": "^0.18.13",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
@@ -93,7 +93,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",
@@ -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,13 +164,11 @@
"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",
@@ -202,7 +198,7 @@
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "^1.20.7",
"react-native-keyboard-controller": "1.18.5",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
@@ -222,6 +218,7 @@
"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",
@@ -229,11 +226,10 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.208",
"@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",
@@ -242,6 +238,7 @@
"@react-native/eslint-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
"@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7",
@@ -250,24 +247,23 @@
"@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",
@@ -282,19 +278,17 @@
"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",
"**/zod": "3.23.8",
"**/multiformats": "9.9.0",
"unicode-segmenter": "0.14.5",
"@types/estree": "1.0.6"
"unicode-segmenter": "0.14.5"
},
"jest": {
"preset": "jest-expo/ios",
-116
View File
@@ -18,119 +18,3 @@ index 0000000..3b5b864
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
-16
View File
@@ -1,16 +0,0 @@
diff --git a/node_modules/expo-font/ios/FontLoaderModule.swift b/node_modules/expo-font/ios/FontLoaderModule.swift
index 183480f..7b64f6e 100644
--- a/node_modules/expo-font/ios/FontLoaderModule.swift
+++ b/node_modules/expo-font/ios/FontLoaderModule.swift
@@ -2,10 +2,9 @@ import ExpoModulesCore
public final class FontLoaderModule: Module {
// could be a Set, but to be able to pass to JS we keep it as an array
- private var registeredFonts: [String]
+ private lazy var registeredFonts: [String] = queryCustomNativeFonts()
public required init(appContext: AppContext) {
- self.registeredFonts = queryCustomNativeFonts()
super.init(appContext: appContext)
}
-103
View File
@@ -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;
+83 -98
View File
@@ -1,9 +1,9 @@
import '#/logger/sentry/setup'
import '#/logger/bitdrift/setup'
import '#/view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {
initialWindowMetrics,
SafeAreaProvider,
@@ -15,12 +15,15 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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>
+68 -86
View File
@@ -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>
+86 -72
View File
@@ -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'
@@ -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'
@@ -843,11 +842,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 +879,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 +921,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 +945,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 +973,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 +1069,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 +1087,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,
+3 -4
View File
@@ -21,9 +21,9 @@ import * as SplashScreen from 'expo-splash-screen'
import {Logotype} from '#/view/icons/Logotype'
// @ts-ignore
import splashImagePointer from '../assets/splash/splash.png'
import splashImagePointer from '../assets/splash.png'
// @ts-ignore
import darkSplashImagePointer from '../assets/splash/splash-dark.png'
import darkSplashImagePointer from '../assets/splash-dark.png'
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
const darkSplashImageUri = RNImage.resolveAssetSource(
darkSplashImagePointer,
@@ -146,8 +146,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
withTiming(
1,
{duration: 400, easing: Easing.out(Easing.cubic)},
() => {
'worklet'
async () => {
// set these values to check animation at specific point
outroLogo.set(() =>
withTiming(
+14 -12
View File
@@ -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,
@@ -269,7 +272,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 +305,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 +321,7 @@ function AccessSection() {
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
ax.metric('ageAssurance:initDialogOpen', {
logger.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
@@ -357,7 +359,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 (
+2 -2
View File
@@ -6,6 +6,7 @@ import {
AtpAgent,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
@@ -13,7 +14,6 @@ import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time'
import {
hasSnoozedBirthdateUpdateForDid,
@@ -45,7 +45,7 @@ const qc = new QueryClient({
},
})
const persister = createAsyncStoragePersister({
storage: createPersistedQueryStorage('age-assurance'),
storage: AsyncStorage,
key: 'age-assurance-query-client',
})
const [, cacheHydrationPromise] = persistQueryClient({
+3 -3
View File
@@ -12,12 +12,12 @@ export const enabled = (IS_DEV && false) || IS_E2E
export const geolocation: Geolocation | undefined = enabled
? {
countryCode: 'AA',
countryCode: 'BB',
regionCode: undefined,
}
: undefined
const deviceGeolocationEnabled = false || IS_E2E
const deviceGeolocationEnabled = false
export const deviceGeolocation: Geolocation | undefined =
enabled && deviceGeolocationEnabled
? {
@@ -46,7 +46,7 @@ export const config: AppBskyAgeassuranceDefs.Config = {
rules: [
{
$type: ids.Default,
access: 'full',
access: 'none',
},
],
},
+9 -7
View File
@@ -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
+1 -3
View File
@@ -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',
}),
/**
+4 -4
View File
@@ -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
}
+1 -6
View File
@@ -11,12 +11,7 @@ import {
import {themes} from '#/alf/themes'
import {type Device} from '#/storage'
export {
type TextStyleProp,
type Theme,
utils,
type ViewStyleProp,
} from '@bsky.app/alf'
export {type TextStyleProp, type Theme, type ViewStyleProp} from '@bsky.app/alf'
export {atoms} from '#/alf/atoms'
export * from '#/alf/breakpoints'
export * from '#/alf/fonts'
+4 -4
View File
@@ -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 -2
View File
@@ -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 -3
View File
@@ -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)
}
}
-32
View File
@@ -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
}
-89
View File
@@ -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,
})
}
-12
View File
@@ -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',
}
-26
View File
@@ -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
}
-2
View File
@@ -1,2 +0,0 @@
export * from '#/analytics/identifiers/device'
export * from '#/analytics/identifiers/session'
-79
View File
@@ -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)
})
})
-40
View File
@@ -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
}
-44
View File
@@ -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
}
-9
View File
@@ -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
}
-232
View File
@@ -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
}
-79
View File
@@ -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',
}
}
-176
View File
@@ -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)
})
})
-116
View File
@@ -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)
}
}
-6
View File
@@ -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>()
-7
View File
@@ -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)))
}
}
-18
View File
@@ -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
-33
View File
@@ -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),
}
}
}
+5 -5
View File
@@ -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,
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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: () => {},
+18 -24
View File
@@ -11,7 +11,6 @@ import {
} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewRef,
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
@@ -24,8 +23,10 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
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,9 +209,10 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
useEnableKeyboardController(isIOS)
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
useKeyboardHandler(
{
onEnd: e => {
@@ -223,13 +224,14 @@ 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
}
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
} else {
paddingBottom += keyboardHeight
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.top
}
@@ -238,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
@@ -257,13 +259,13 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
{paddingBottom},
contentContainerStyle,
]}
ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
ref={ref}
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
@@ -287,9 +289,11 @@ export const InnerFlatList = React.forwardRef<
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
useEnableKeyboardController(isIOS)
const onScroll = (e: ScrollEvent) => {
'worklet'
if (!IS_ANDROID) {
if (!isAndroid) {
return
}
const {contentOffset} = e
@@ -307,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]}
/>
@@ -322,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)}],
}
@@ -355,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()
@@ -392,7 +390,7 @@ export function Handle({
opacity: 0.75,
}
: {
backgroundColor: fill || t.palette.contrast_975,
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
@@ -405,7 +403,3 @@ export function Handle({
export function Close() {
return null
}
export function Backdrop() {
return null
}
+5 -5
View File
@@ -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 (
+2 -2
View File
@@ -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',
+1 -1
View File
@@ -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>>
+3 -35
View File
@@ -1,4 +1,4 @@
import React, {useMemo} from 'react'
import React from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
type AppBskyFeedDefs,
@@ -21,21 +21,19 @@ import {
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, select, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText, type RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useActiveLiveEventFeedUris} from '#/features/liveEvents/context'
import type * as bsky from '#/types/bsky'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from './icons/Trash'
@@ -51,11 +49,7 @@ export function Default(props: Props) {
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={view.displayName}
creator={view.creator}
uri={view.uri}
/>
<TitleAndByline title={view.displayName} creator={view.creator} />
<SaveButton view={view} pin />
</Header>
<Description description={view.description} />
@@ -124,40 +118,14 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({
title,
creator,
uri,
}: {
title: string
creator?: bsky.profile.AnyProfileView
uri?: string
}) {
const t = useTheme()
const activeLiveEvents = useActiveLiveEventFeedUris()
const liveColor = useMemo(
() =>
select(t.name, {
dark: t.palette.negative_600,
dim: t.palette.negative_600,
light: t.palette.negative_500,
}),
[t],
)
return (
<View style={[a.flex_1]}>
{uri && activeLiveEvents.has(uri) && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<LiveIcon size="xs" fill={liveColor} />
<Text
style={[
a.text_2xs,
a.font_medium,
a.leading_snug,
{color: liveColor},
]}>
<Trans>Happening now</Trans>
</Text>
</View>
)}
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.leading_snug]}
+156 -127
View File
@@ -1,18 +1,16 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import React, {useCallback, useEffect, useRef} from 'react'
import {ScrollView, View} from 'react-native'
import Animated, {
Easing,
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {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'
@@ -27,7 +25,6 @@ import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
atoms as a,
native,
useBreakpoints,
useTheme,
type ViewStyleProp,
@@ -42,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'
@@ -159,7 +154,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = useMemo(() => {
const dids = React.useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
@@ -232,54 +227,67 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = useCallback((dismissedDid: string) => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
const onDismiss = React.useCallback((dismissedDid: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(dismissedDid))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(dismissedDid)
return next
})
}, DISMISS_ANIMATION_DURATION)
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = useMemo(() => {
const allProfiles = React.useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
const combined: bsky.profile.AnyProfileView[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push({actor: profile, recId: data?.recId})
combined.push(profile)
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
seen.add(profile.actor.did)
if (!seen.has(profile.did) && profile.did !== did) {
seen.add(profile.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
}, [data?.suggestions, moreSuggestions?.pages, did])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
useEffect(() => {
React.useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage()
fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -295,9 +303,11 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error}
viewContext="profile"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -319,36 +329,46 @@ export function SuggestedFollowsHome() {
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = useCallback((did: string) => {
setDismissedDids(prev => new Set(prev).add(did))
const onDismiss = React.useCallback((did: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(did))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(did))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(did)
return next
})
}, DISMISS_ANIMATION_DURATION)
}, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = useMemo(() => {
const allProfiles = React.useMemo(() => {
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
// Dedupe by did, preferring experimental profiles
const seen = new Set<string>()
const combined: Array<{
actor: bsky.profile.AnyProfileView
recId?: number
}> = []
const combined: bsky.profile.AnyProfileView[] = []
for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push({actor: profile, recId: undefined})
combined.push(profile)
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.actor.did)) {
seen.add(profile.actor.did)
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
}
}
@@ -356,19 +376,19 @@ export function SuggestedFollowsHome() {
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
useEffect(() => {
React.useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage()
fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -387,6 +407,7 @@ export function SuggestedFollowsHome() {
error={experimentalError || suggestionsError}
viewContext="feed"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -396,21 +417,25 @@ export function ProfileGrid({
error,
profiles,
totalProfileCount,
recId,
viewContext = 'feed',
onDismiss,
dismissingDids,
isVisible = true,
}: {
isSuggestionsLoading: boolean
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
profiles: bsky.profile.AnyProfileView[]
totalProfileCount?: number
recId?: number
error: Error | null
dismissingDids?: Set<string>
viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void
isVisible?: boolean
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const gate = useGate()
const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints()
const followDialogControl = useDialogControl()
@@ -418,6 +443,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
@@ -426,11 +452,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(() => {
@@ -440,18 +467,22 @@ export function ProfileGrid({
const profilesToShow = profiles.slice(0, maxLength)
profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.actor.did)) {
seenProfilesRef.current.add(profile.actor.did)
ax.metric('suggestedUser:seen', {
logContext,
recId: profile.recId,
position: index,
suggestedDid: profile.actor.did,
category: null,
})
if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did)
logger.metric(
'suggestedUser:seen',
{
logContext,
recId,
position: index,
suggestedDid: profile.did,
category: null,
},
{statsig: true},
)
}
})
}, [ax, isLoading, error, profiles, maxLength, logContext])
}, [isLoading, error, profiles, maxLength, logContext, recId])
// For profile header, fire when isVisible becomes true
useEffect(() => {
@@ -517,15 +548,8 @@ export function ProfileGrid({
? null
: profiles.slice(0, maxLength).map((profile, index) => (
<Animated.View
key={profile.actor.did}
layout={native(
LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
Easing.out(Easing.exp),
),
)}
exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
// for web, as the cards are static, not in a list
entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
key={profile.did}
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
style={[
a.flex_1,
gtMobile &&
@@ -534,17 +558,22 @@ export function ProfileGrid({
a.flex_grow,
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
]),
{
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
transitionProperty: 'opacity',
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
},
]}>
<ProfileCard.Link
profile={profile.actor}
profile={profile}
onPress={() => {
ax.metric('suggestedUser:press', {
logEvent('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId: profile.recId,
recId,
position: index,
suggestedDid: profile.actor.did,
suggestedDid: profile.did,
category: null,
})
}}
@@ -555,19 +584,19 @@ export function ProfileGrid({
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
{onDismiss && (
{showDismissButton && (
<Button
label={_(msg`Dismiss this suggestion`)}
onPress={e => {
e.preventDefault()
onDismiss(profile.actor.did)
ax.metric('suggestedUser:dismiss', {
onDismiss!(profile.did)
logEvent('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
position: index,
suggestedDid: profile.actor.did,
recId: profile.recId,
suggestedDid: profile.did,
recId,
})
}}
style={[
@@ -600,18 +629,18 @@ export function ProfileGrid({
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile.actor}
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
size={88}
/>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile.actor}
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile.actor}
profile={profile}
numberOfLines={2}
style={[
t.atoms.text_contrast_medium,
@@ -623,20 +652,20 @@ export function ProfileGrid({
</View>
<ProfileCard.FollowButton
profile={profile.actor}
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
style={[a.rounded_sm]}
onFollow={() => {
ax.metric('suggestedUser:follow', {
logEvent('suggestedUser:follow', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId: profile.recId,
recId,
position: index,
suggestedDid: profile.actor.did,
suggestedDid: profile.did,
category: null,
})
}}
@@ -651,7 +680,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
}
@@ -663,7 +692,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,
@@ -672,16 +701,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',
})
}}>
@@ -705,37 +738,35 @@ export function ProfileGrid({
<FollowDialogWithoutGuide control={followDialogControl} />
<LayoutAnimationConfig skipExiting skipEntering>
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
) : (
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
{content}
</View>
) : (
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
{content}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
</LayoutAnimationConfig>
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
logger.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
</View>
)
}
@@ -765,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,
@@ -776,7 +806,7 @@ export function SuggestedFeeds() {
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = useMemo(() => {
const feeds = React.useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
@@ -801,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
@@ -812,7 +842,6 @@ export function SuggestedFeeds() {
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
uri={feed.uri}
/>
</FeedCard.Header>
<FeedCard.Description
+3 -3
View File
@@ -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}
+1 -1
View File
@@ -157,7 +157,7 @@ export function Link({
to={{
screen: 'Profile',
params: {
name: labeler.creator.did,
name: labeler.creator.handle,
},
}}
label={_(
+4 -4
View File
@@ -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}>
+4 -4
View File
@@ -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>
)
})
+11 -11
View File
@@ -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
}
+2 -2
View File
@@ -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
+19 -26
View File
@@ -50,11 +50,7 @@ export function Embed({
} else if (e.type === 'video') {
return (
<Outer style={style}>
{e.view.presentation === 'gif' ? (
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
) : (
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
)}
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
</Outer>
)
} else if (
@@ -85,29 +81,11 @@ export function ImageItem({
alt,
children,
}: {
thumbnail?: string
thumbnail: string
alt?: string
children?: React.ReactNode
}) {
const t = useTheme()
if (!thumbnail) {
return (
<View
style={[
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth: 100},
a.rounded_xs,
]}
accessibilityLabel={alt}
accessibilityHint="">
{children}
</View>
)
}
return (
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
<Image
@@ -125,7 +103,7 @@ export function ImageItem({
)
}
export function GifItem({thumbnail, alt}: {thumbnail?: string; alt?: string}) {
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
@@ -147,6 +125,21 @@ export function VideoItem({
thumbnail?: string
alt?: string
}) {
if (!thumbnail) {
return (
<View
style={[
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth: 100},
a.justify_center,
a.align_center,
]}>
<PlayButtonIcon size={24} />
</View>
)
}
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
@@ -163,7 +156,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
left: 5,
right: 5,
bottom: 5,
zIndex: 2,
},
+10 -12
View File
@@ -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
}
/>
)
+5 -8
View File
@@ -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>
+2 -3
View File
@@ -43,7 +43,7 @@ export type TriggerProps = {
}
export type TriggerChildProps =
| {
IS_NATIVE: true
isNative: true
control: Dialog.DialogOuterProps['control']
state: {
/**
@@ -73,7 +73,7 @@ export type TriggerChildProps =
}
}
| {
IS_NATIVE: false
isNative: false
control: Dialog.DialogOuterProps['control']
state: {
hovered: boolean
@@ -107,7 +107,6 @@ export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
export type ItemIconProps = React.PropsWithChildren<{
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
fill?: (props: {disabled: boolean}) => string
}>
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
+2 -2
View File
@@ -8,6 +8,7 @@ import {differenceInSeconds} from 'date-fns'
import {HITSLOP_10} from '#/lib/constants'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
@@ -17,7 +18,6 @@ import {useDialogControl} from '#/components/Dialog'
import {Newskie} from '#/components/icons/Newskie'
import * as StarterPackCard from '#/components/StarterPack/StarterPackCard'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
export function NewskieDialog({
profile,
@@ -162,7 +162,7 @@ function DialogInner({
</StarterPackCard.Link>
) : null}
{IS_NATIVE && (
{isNative && (
<Button
label={_(msg`Close`)}
color="secondary"
@@ -7,11 +7,11 @@ import {
import {LinearGradient} from 'expo-linear-gradient'
import {utils} from '@bsky.app/alf'
import {isAndroid, isNative} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {FocusScope} from '#/components/FocusScope'
import {LockScroll} from '#/components/LockScroll'
import {IS_ANDROID, IS_NATIVE} from '#/env'
const GUTTER = 24
@@ -80,7 +80,7 @@ export function Overlay({
a.z_20,
a.align_center,
!gtPhone && [a.justify_end, {minHeight: frame.height}],
IS_NATIVE && [
isNative && [
{
paddingBottom: Math.max(insets.bottom, a.p_2xl.padding),
},
@@ -109,7 +109,7 @@ export function Overlay({
<FocusScope>
<View
accessible={IS_ANDROID}
accessible={isAndroid}
role="dialog"
aria-role="dialog"
aria-label={label}
+2 -2
View File
@@ -1,12 +1,12 @@
import {useEffect} from 'react'
import {View} from 'react-native'
import {isIOS} from '#/platform/detection'
import {atoms as a} from '#/alf'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {usePolicyUpdateContext} from '#/components/PolicyUpdateOverlay/context'
import {Portal} from '#/components/PolicyUpdateOverlay/Portal'
import {Content} from '#/components/PolicyUpdateOverlay/updates/202508'
import {IS_IOS} from '#/env'
export {Provider} from '#/components/PolicyUpdateOverlay/context'
export {usePolicyUpdateContext} from '#/components/PolicyUpdateOverlay/context'
@@ -39,7 +39,7 @@ export function PolicyUpdateOverlay() {
// 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},
]}>
<Content state={state} />
</View>
@@ -3,6 +3,7 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isAndroid} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -11,7 +12,6 @@ import {Badge} from '#/components/PolicyUpdateOverlay/Badge'
import {Overlay} from '#/components/PolicyUpdateOverlay/Overlay'
import {type PolicyUpdateState} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
import {Text} from '#/components/Typography'
import {IS_ANDROID} from '#/env'
export function Content({state}: {state: PolicyUpdateState}) {
const t = useTheme()
@@ -56,7 +56,7 @@ export function Content({state}: {state: PolicyUpdateState}) {
size: 'small',
} as const
const label = IS_ANDROID
const label = isAndroid
? _(
msg`Were updating our Terms of Service, Privacy Policy, and Copyright Policy, effective September 15th, 2025. We're also updating our Community Guidelines, and we want your input! These new guidelines will take effect on October 15th, 2025. Learn more about these changes and how to share your thoughts with us by reading our blog post.`,
)
@@ -10,13 +10,13 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
import {isIOS, isNative, isWeb} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
export function ExternalGif({
link,
@@ -66,12 +66,12 @@ export function ExternalGif({
// Control animation on native
setIsAnimating(prev => {
if (prev) {
if (IS_NATIVE) {
if (isNative) {
imageRef.current?.stopAnimating()
}
return false
} else {
if (IS_NATIVE) {
if (isNative) {
imageRef.current?.startAnimating()
}
return true
@@ -112,7 +112,7 @@ export function ExternalGif({
<Image
source={{
uri:
!isPrefetched || (IS_WEB && !isAnimating)
!isPrefetched || (isWeb && !isAnimating)
? link.thumb
: params.playerUri,
}} // Web uses the thumb to control playback
@@ -123,7 +123,7 @@ export function ExternalGif({
accessibilityIgnoresInvertColors
accessibilityLabel={link.title}
accessibilityHint={link.title}
cachePolicy={IS_IOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
/>
{(!isPrefetched || !isAnimating) && (
@@ -26,6 +26,7 @@ import {
type EmbedPlayerParams,
getPlayerAspect,
} from '#/lib/strings/embed-player'
import {isNative} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {EventStopper} from '#/view/com/util/EventStopper'
import {atoms as a, useTheme} from '#/alf'
@@ -33,7 +34,6 @@ import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {IS_NATIVE} from '#/env'
interface ShouldStartLoadRequest {
url: string
@@ -148,7 +148,7 @@ export function ExternalPlayer({
const {height: winHeight, width: winWidth} = windowDims
// Get the proper screen height depending on what is going on
const realWinHeight = IS_NATIVE // If it is native, we always want the larger number
const realWinHeight = isNative // If it is native, we always want the larger number
? winHeight > winWidth
? winHeight
: winWidth
@@ -226,7 +226,6 @@ export function ExternalPlayer({
style={[a.flex_1]}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
loading="lazy"
/>
<Fill
style={[

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