Compare commits

...

30 Commits

Author SHA1 Message Date
Eric Bailey 1e75948f91 Remove test 2026-01-26 13:28:57 -06:00
Eric Bailey 6a442675d9 Cleanup 2026-01-26 13:28:02 -06:00
Eric Bailey 2886816c1e Add idb-keyval backed archival storage 2026-01-25 18:08:12 -06:00
Eric Bailey 282b5e8460 Bump version to 1.116.0 (#9751) 2026-01-25 16:04:27 -06:00
Eric Bailey f82bc98d8d Allow users to dismiss sidebar LEF banner (#9753) 2026-01-25 15:48:18 -06:00
Eric Bailey 3b7806963a Add more Live Event refreshing (#9745)
* Refresh on foreground

* Add back interval
2026-01-24 13:39:55 -06:00
pfrazee 49e7a96cd9 Nightly source-language update 2026-01-24 02:45:32 +00:00
Eric Bailey 3523e7ce58 1.115.0 fast follows (#9743)
* Fix caching, fix attributes setting

* Use useCallOnce hook

* Update dismissal handling

* Update copy
2026-01-23 16:28:40 -06:00
Eric Bailey 967b3b49d9 1.114.1 Release Prep (#9738)
* intl pull, extract:all

* Clean cache for e2e
2026-01-22 17:51:53 -06:00
Eric Bailey 97083bb55b Enable Bluecast for Live Now (#9737) 2026-01-22 16:59:13 -06:00
Eric Bailey df89b79c2a [APP-1787] Some analytics cleanup (#9736)
* Clean up flags

* Move getMetadataForLogger

* Nail down attributers

* Polyfill features cache
2026-01-22 16:57:59 -06:00
Samuel Newman 6534630d16 Refactor GrowthHack to always render on iOS with enabled prop (#9723)
Drill down an `enabled` prop to ThreadItemAnchorFollowButtonInner so
that the GrowthHack wrapper is always rendered on iOS, even when the
follow button shouldn't be displayed. The inner component returns null
when disabled, allowing GrowthHack to render its logo without children.

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-22 16:48:33 -06:00
Eric Bailey 25a1ed1209 [APP-1782] Analytics migration (#9734)
* WIP

* Clean up growthbook code, integrate into init and sessions

* Move everything out of React

* Add metrics client

* Move to separate file

* Shared metadata cache

* Ensure we update metadata when session ID changes

* Ensure userMetadata is cleared when logging out

* WIP revamp

* Integrate feature gates into analytics context

* Clean up old code

* Fix useMeta util

* Some comments and cleanup

* Add logger to base analytics context

* Refactor current route handling

* Rip out LogEvent from navigation

* Update tracking endpoint

* Migrate toClout

* Clear out statsig client

* Add todo, reset logger readme

* Ope fix statsig noop

* Refactor logging in feed-feedback, add debug logging to metrics client

* Remove LogEvents alias for Metrics

* Prefer root package export

* Remove Metrics alias from logger

* [APP-1782] Migrate to new analytics APIs (#9735)

* Migrate logEvent to useAnalytics

* Migrate logger.metric to useAnalytics

* Migrate tricky spot, fix types

* Migrate remaining tricky spot

* Missed one

* Remove metric() from logger

* Migrate useGate to useAnalytics

* Remove all other StatSig mentions

* Update event payload

* Update logger tests

* Mock expo method

* Fix session ID bug

* Add session ID test

* Add test for metrics client

* Clarify intent

* Clean up core analytics file

* Clean up the call once utils

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Fix TODO

* Remove debug code

* Fix navigation context

* OK nav context is not working, todo

* Checkpoint: works but feels hacky

* Fix navigation context issue

* Improve feature API

* Improve metric logging

* Update logger tests
2026-01-22 15:33:45 -06:00
bnewbold 8614e5e105 bump copyright year to 2026 (#9732) 2026-01-20 08:50:07 +02:00
pfrazee 7da65efc08 Nightly source-language update 2026-01-20 02:49:52 +00:00
Eric Bailey 4f01d4cc81 Resolve same version of @types/estree (#9731) 2026-01-19 16:20:08 -06:00
Eric Bailey b4f57b94aa Fix Live Now reporting container collapse on Android (#9730) 2026-01-19 15:33:15 -06:00
Eric Bailey bd9e8244f1 [APP-1783] Clean up some feature gates and logging (#9729)
* Remove gates we don't need

* Clean up some logging

* Keep disable gates around for now

* Add missing gate

* Revert file

* Revert package changes
2026-01-19 15:32:02 -06:00
Eric Bailey 5989bf7fad Remove some unused things (#9719) 2026-01-19 10:48:13 -06:00
Samuel Newman a4f173d008 Bump version to 1.115.0 (#9726)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-18 20:03:45 +02:00
pfrazee f244afcd9c Nightly source-language update 2026-01-17 02:42:35 +00:00
Eric Bailey 62c0c06bbf Move browser env into main env (#9718) 2026-01-16 17:20:03 -06:00
Eric Bailey 0589bd7d9e Move /platform/detection vars into /env (#9707)
* Add platform vars to env

* Replace /platform/detection with /env
2026-01-16 16:28:17 -06:00
Samuel Newman bd510d8468 Upgrade ESLint to v9 with flat config (#9680)
* Upgrade ESLint to v9 with flat config

- Upgrade eslint from v8 to v9.18.0
- Migrate from .eslintrc.js to eslint.config.mjs (flat config)
- Upgrade typescript-eslint to v8.20.0 (unified package)
- Replace eslint-plugin-import with eslint-plugin-import-x for flat config support
- Add globals package for environment globals
- Update eslint-plugin-bsky-internal with proper meta objects for ESLint v9
- Fix deprecated context.getScope() API usage
- Update bskyembed to use flat config
- Remove deprecated --ext flag from lint scripts
- Configure rules to maintain previous behavior while using new ESLint version

* Fix varsIgnorePattern to require character after underscore

Restore the original pattern `^_.+` instead of `^_` so that lingui's
`const { _ } = useLingui()` will still be flagged when unused.

* Update ESLint rule tests for flat config format

- Update RuleTester to use flat config languageOptions instead of
  eslintrc parser format
- Remove duplicate test case that ESLint v9 now detects
- Add Jest globals for test files

* update eslint package versions

* lint android a11y

* enable typechecked rules, switch them to warn

* fix yarn lock ci

* Fix CI failure

* Remove unused globals?

* Organize a bit, add quiet to main lint command

* Allow ternary

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-01-16 16:24:28 -06:00
Samuel Newman acc26c965d make live now gate fail-open (#9714) 2026-01-16 08:34:00 -08:00
Samuel Newman fbd1138d97 Add fishing minigame (#9713) 2026-01-16 08:31:01 -08:00
Samuel Newman 9c563f2a48 Fix collapsed button by simplifying, keep container width (#9712) 2026-01-16 10:18:41 -06:00
Eric Bailey 1d4263078d Refetch live events every 5 min (#9706) 2026-01-15 16:26:44 -06:00
Eric Bailey 367eebe600 Reset default AA debug for E2E (#9705) 2026-01-15 13:05:11 -06:00
Eric Bailey 06a8a7efc2 intl pull and extract:all (#9704) 2026-01-15 10:58:01 -06:00
464 changed files with 76887 additions and 69058 deletions
+7
View File
@@ -28,6 +28,13 @@ 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=
-127
View File
@@ -1,127 +0,0 @@
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',
'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',
},
}
+2 -2
View File
@@ -439,9 +439,9 @@ Example from Dialog:
Platform detection:
```tsx
import {isWeb, isNative, isIOS, isAndroid} from '#/platform/detection'
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
if (isNative) {
if (IS_NATIVE) {
// Native-specific logic
}
```
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 20232025 Bluesky Social PBC
Copyright 20232026 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:
-22
View File
@@ -1,22 +0,0 @@
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
@@ -0,0 +1,52 @@
// @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,
},
],
},
},
)
+9 -4
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 --ext .js,.jsx,.ts,.tsx src",
"lint": "eslint --cache src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -17,16 +17,21 @@
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"@vitejs/plugin-legacy": "^7.0.0",
"@eslint/js": "^9.18.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.19.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint": "^9.18.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.14.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"
}
}
+308 -1350
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
// @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,
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: 'detect',
},
componentWrapperFunctions: ['observer'],
},
rules: {
/**
* Custom rules
*/
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'P',
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-prefixed-imports': 'error',
/**
* React & React Native
*/
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn',
// TODO: Fix these and set to error
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/immutability': 'warn',
/**
* Import sorting
*/
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native prioritized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'error',
/**
* Import linting
*/
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
'import-x/no-unresolved': ['error', {
/*
* The `postinstall` hook runs `compile-if-needed` locally, but not in
* CI. For CI-sake, ignore this.
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
}],
/**
* TypeScript-specific rules
*/
'no-unused-vars': 'off', // off, we use TS-specific rule below
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_.+',
caughtErrors: 'none',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-expressions': ['error', {
allowTernary: true,
}],
/**
* Maintain previous behavior - these are stricter in typescript-eslint
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky
*/
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
/**
* Turn off rules that we haven't enforced thus far
*/
'no-empty-pattern': 'off',
'no-async-promise-executor': 'off',
'no-constant-binary-expression': 'warn',
'prefer-const': 'off',
'no-empty': 'off',
'no-unsafe-optional-chaining': 'off',
'no-prototype-builtins': 'off',
'no-var': 'off',
'prefer-rest-params': 'off',
'no-case-declarations': 'off',
'no-irregular-whitespace': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-fallthrough': 'off',
'no-control-regex': 'off',
},
},
/**
* Test files configuration
*/
{
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.jest,
}
},
},
)
+9 -15
View File
@@ -1,14 +1,17 @@
const {RuleTester} = require('eslint')
const tseslint = require('typescript-eslint')
const avoidUnwrappedText = require('../avoid-unwrapped-text')
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaFeatures: {
jsx: true,
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
ecmaVersion: 6,
sourceType: 'module',
},
})
@@ -773,15 +776,6 @@ function MyText({ foo }) {
errors: 1,
},
{
code: `
<View>
<Trans>{'foo'}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View prop={
+305 -277
View File
@@ -29,303 +29,331 @@ function getTagName(node) {
return reversedIdentifiers.reverse().join('.')
}
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]
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]
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 (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 === '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')
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
// We're good.
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
}
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,
})
parent = parent.parent
continue
}
},
Literal(node) {
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
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.
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 (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 (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
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')
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression'
) {
// We're good.
return
parent = parent.parent
continue
}
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,
})
// 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
}
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>.',
})
}
},
}
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>.',
})
}
},
}
},
}
+7 -2
View File
@@ -1,10 +1,15 @@
'use strict'
module.exports = {
const plugin = {
meta: {
name: 'eslint-plugin-bsky-internal',
version: '1.0.0',
},
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'),
'use-prefixed-imports': require('./use-prefixed-imports'),
},
}
module.exports = plugin
+24 -15
View File
@@ -3,20 +3,29 @@ const BANNED_IMPORTS = [
'@fortawesome/free-solid-svg-icons',
]
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',
})
}
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Prevent importing entire icon packages',
},
}
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,7 +13,11 @@ 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
@@ -1,31 +0,0 @@
'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.
},
}
}
+1 -12
View File
@@ -99,20 +99,9 @@ jest.mock('expo-modules-core', () => ({
requireNativeViewManager: jest.fn().mockImplementation(_ => {
return () => null
}),
createPermissionHook: () => () => [true],
}))
jest.mock('expo-localization', () => ({
getLocales: () => [],
}))
jest.mock('statsig-react-native-expo', () => ({
Statsig: {
initialize() {},
initializeCalled() {
return false
},
},
}))
jest.mock('../src/logger/bitdrift/lib', () => ({}))
jest.mock('../src/lib/statsig/statsig', () => ({}))
@@ -11,7 +11,7 @@ import {
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {isIOS} from '#/platform/detection'
import {IS_IOS} from '#/env'
import {
type BottomSheetState,
type BottomSheetViewProps,
@@ -30,7 +30,7 @@ const NativeView: React.ComponentType<
const NativeModule = requireNativeModule('BottomSheet')
const isIOS15 =
const IS_IOS15 =
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 (isIOS15 && this.state.viewHeight) {
if (IS_IOS15 && 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 (isIOS15) {
if (IS_IOS15) {
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 = isIOS ? screenHeight - insets.top : screenHeight
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
return (
<NativeView
+20 -16
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.114.0",
"version": "1.116.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 --ext .js,.jsx,.ts,.tsx src",
"lint": "eslint --cache --quiet 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",
"e2e:run": "maestro test __e2e__",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
"e2e:run": "maestro test",
"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",
@@ -93,6 +93,7 @@
"@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",
@@ -170,6 +171,7 @@
"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",
@@ -219,7 +221,6 @@
"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",
@@ -231,6 +232,7 @@
"@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",
@@ -248,23 +250,24 @@
"@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": "^8.19.0",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-bsky-internal": "link:./eslint",
"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-import-x": "^4.16.1",
"eslint-plugin-lingui": "^0.11.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-native-a11y": "^3.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"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",
"file-loader": "6.2.0",
"globals": "^17.0.0",
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
@@ -279,6 +282,7 @@
"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": {
@@ -288,9 +292,9 @@
"**/@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"
"unicode-segmenter": "0.14.5",
"@types/estree": "1.0.6"
},
"jest": {
"preset": "jest-expo/ios",
+47 -39
View File
@@ -1,5 +1,4 @@
import '#/logger/sentry/setup'
import '#/logger/bitdrift/setup'
import '#/view/icons'
import React, {useEffect, useState} from 'react'
@@ -18,12 +17,10 @@ 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'
@@ -67,8 +64,17 @@ 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 {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
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,
@@ -79,10 +85,10 @@ import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
SplashScreen.preventAutoHideAsync()
if (isIOS) {
if (IS_IOS) {
SystemUI.setBackgroundColorAsync('black')
}
if (isAndroid) {
if (IS_ANDROID) {
// iOS is handled by the config plugin -sfn
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
@@ -113,7 +119,7 @@ function InnerApp() {
if (account) {
await resumeSession(account)
} else {
await tryFetchGates(undefined, 'prefer-fresh-gates')
await features.init
}
} catch (e) {
logger.error(`session: resume failed`, {message: e})
@@ -143,9 +149,9 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
@@ -191,9 +197,9 @@ function InnerApp() {
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</VideoVolumeProvider>
</Splash>
@@ -207,7 +213,7 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
)
}, [])
@@ -225,30 +231,32 @@ function App() {
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<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>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
+40 -29
View File
@@ -9,7 +9,6 @@ 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'
@@ -55,8 +54,16 @@ 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 {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
AnalyticsContext,
AnalyticsFeaturesContext,
features,
setupDeviceId,
} from '#/analytics'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
@@ -87,6 +94,8 @@ function InnerApp() {
try {
if (account) {
await resumeSession(account)
} else {
await features.init
}
} catch (e) {
logger.error(`session: resumeSession failed`, {message: e})
@@ -119,9 +128,9 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
@@ -163,9 +172,9 @@ function InnerApp() {
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
@@ -179,7 +188,7 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve()]).then(() =>
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
)
}, [])
@@ -196,25 +205,27 @@ function App() {
<Geo.Provider>
<A11yProvider>
<OnboardingProvider>
<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>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
</Geo.Provider>
+72 -86
View File
@@ -28,7 +28,7 @@ import {
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {logger as notyLogger} from '#/lib/notifications/util'
import {useCallOnce} from '#/lib/once'
import {buildStateObject} from '#/lib/routes/helpers'
import {
type AllNavigatorParams,
@@ -38,13 +38,11 @@ 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'
@@ -138,6 +136,9 @@ 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'
@@ -842,11 +843,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 (isNative) return
if (IS_NATIVE) return
return buildStateObject('Flat', 'Home', params)
}
if (isNative) {
if (IS_NATIVE) {
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
}
@@ -879,11 +880,13 @@ 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 prevLoggedRouteName = useRef<string | undefined>(undefined)
const previousScreen = useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl()
const closeAllActiveElements = useCloseAllActiveElements()
@@ -921,7 +924,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
)
async function handlePushNotificationEntry() {
if (!isNative) return
if (!IS_NATIVE) return
// deep links take precedence - on android,
// getLastNotificationResponseAsync returns a "notification"
@@ -945,11 +948,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const payload = getNotificationPayload(response.notification)
if (payload) {
notyLogger.metric(
'notifications:openApp',
{reason: payload.reason, causedBoot: true},
{statsig: false},
)
ax.metric('notifications:openApp', {
reason: payload.reason,
causedBoot: true,
})
if (payload.reason === 'chat-message') {
handleChatMessage(payload)
@@ -973,47 +975,69 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
}
}
function onReady() {
prevLoggedRouteName.current = getCurrentRouteName()
const onNavigationReady = useCallOnce(() => {
const currentScreen = getCurrentRouteName()
setNavigationMetadata({
previousScreen: currentScreen,
currentScreen,
})
previousScreen.current = currentScreen
handlePushNotificationEntry()
ax.metric('router:navigate', {})
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={() => {
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>
</>
<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>
)
}
@@ -1069,7 +1093,7 @@ function reset(): Promise<void> {
navigationRef.dispatch(
CommonActions.reset({
index: 0,
routes: [{name: isNative ? 'HomeTab' : 'Home'}],
routes: [{name: IS_NATIVE ? 'HomeTab' : 'Home'}],
}),
)
return Promise.race([
@@ -1087,44 +1111,6 @@ 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,
+12 -14
View File
@@ -9,9 +9,6 @@ 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'
@@ -38,6 +35,8 @@ 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]
@@ -45,6 +44,7 @@ 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
logger.metric(`blockedGeoOverlay:shown`, {})
logger.metric(`ageAssurance:noAccessScreen:shown`, {
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: data?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
@@ -74,7 +74,7 @@ export function NoAccessScreen() {
}, [])
const onPressLogout = useCallback(() => {
if (isWeb) {
if (IS_WEB) {
// 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,10 +103,7 @@ export function NoAccessScreen() {
label={_(msg`Click here to update your birthdate`)}
style={[textStyles]}
{...createStaticClick(() => {
logger.metric(
'ageAssurance:noAccessScreen:openBirthdateDialog',
{},
)
ax.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
birthdateControl.open()
})}>
clicking here
@@ -139,7 +136,7 @@ export function NoAccessScreen() {
contentContainerStyle={[
a.px_2xl,
{
paddingTop: isWeb
paddingTop: IS_WEB
? a.p_5xl.padding
: insets.top + a.p_2xl.padding,
paddingBottom: 100,
@@ -272,6 +269,7 @@ 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()
@@ -305,7 +303,7 @@ function AccessSection() {
label={_(msg`Contact our moderation team`)}
{...createStaticClick(() => {
appealControl.open()
logger.metric('ageAssurance:appealDialogOpen', {})
ax.metric('ageAssurance:appealDialogOpen', {})
})}>
contact our moderation team
</SimpleInlineLinkText>{' '}
@@ -321,7 +319,7 @@ function AccessSection() {
color={hasInitiated ? 'secondary' : 'primary'}
onPress={() => {
control.open()
logger.metric('ageAssurance:initDialogOpen', {
ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
@@ -359,7 +357,7 @@ function AccessSection() {
)}
<View style={[a.gap_xs]}>
{isNative && (
{IS_NATIVE && (
<>
<Admonition>
<Trans>
@@ -15,8 +15,6 @@ 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'
@@ -27,7 +25,8 @@ 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 {logger} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_WEB} from '#/env'
export type RedirectOverlayState = {
result: 'success' | 'unknown'
@@ -92,7 +91,7 @@ export function Provider({children}: {children?: React.ReactNode}) {
actorDid: params.get('actorDid') ?? undefined,
})
if (isWeb) {
if (IS_WEB) {
// Clear the URL parameters so they don't re-trigger
history.pushState(null, '', '/')
}
@@ -145,7 +144,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.
!isIOS && {zIndex: 9999},
!IS_IOS && {zIndex: 9999},
t.atoms.bg,
gtMobile ? a.p_2xl : a.p_xl,
a.align_center,
@@ -174,6 +173,7 @@ 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
logger.metric('ageAssurance:redirectDialogOpen', {})
ax.metric('ageAssurance:redirectDialogOpen', {})
wait(
3e3,
@@ -218,18 +218,18 @@ function Inner() {
setSuccess(true)
logger.metric('ageAssurance:redirectDialogSuccess', {})
ax.metric('ageAssurance:redirectDialogSuccess', {})
})
.catch(() => {
if (unmounted.current) return
setError(true)
logger.metric('ageAssurance:redirectDialogFail', {})
ax.metric('ageAssurance:redirectDialogFail', {})
})
return () => {
unmounted.current = true
}
}, [agent])
}, [ax, agent])
if (success) {
return (
+3 -3
View File
@@ -12,12 +12,12 @@ export const enabled = (IS_DEV && false) || IS_E2E
export const geolocation: Geolocation | undefined = enabled
? {
countryCode: 'BB',
countryCode: 'AA',
regionCode: undefined,
}
: undefined
const deviceGeolocationEnabled = false
const deviceGeolocationEnabled = false || IS_E2E
export const deviceGeolocation: Geolocation | undefined =
enabled && deviceGeolocationEnabled
? {
@@ -46,7 +46,7 @@ export const config: AppBskyAgeassuranceDefs.Config = {
rules: [
{
$type: ids.Default,
access: 'none',
access: 'full',
},
],
},
+7 -9
View File
@@ -12,6 +12,7 @@ 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'
@@ -19,6 +20,7 @@ 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()
@@ -48,15 +50,11 @@ export function useBeginAgeAssurance() {
appView.sessionManager.session.accessJwt = token
appView.sessionManager.session.refreshJwt = ''
logger.metric(
'ageAssurance:api:begin',
{
platform: Platform.OS,
countryCode,
regionCode,
},
{statsig: false},
)
ax.metric('ageAssurance:api:begin', {
platform: Platform.OS,
countryCode,
regionCode,
})
/*
* 2s wait is good actually. Email sending takes a hot sec and this helps
+4 -4
View File
@@ -1,6 +1,6 @@
import {type TextStyle} from 'react-native'
import {isAndroid, isWeb} from '#/platform/detection'
import {IS_ANDROID, IS_WEB} from '#/env'
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 (isAndroid) {
if (IS_ANDROID) {
style.fontFamily =
{
400: 'Inter-Regular',
@@ -71,7 +71,7 @@ export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
}
}
if (isWeb) {
if (IS_WEB) {
// 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 (isWeb) {
if (IS_WEB) {
style.fontFamily = style.fontFamily || WEB_FONT_FAMILIES
}
+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 (!isNative) {
} else if (!IS_NATIVE) {
s.lineHeight = s.fontSize
}
@@ -81,7 +81,7 @@ export function renderChildrenWithEmoji(
props: Omit<TextProps, 'children'> = {},
emoji: boolean,
) {
if (!isIOS || !emoji) {
if (!IS_IOS || !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 {isAndroid} from '#/platform/detection'
import {IS_ANDROID} from '#/env'
export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
if (isAndroid) {
if (IS_ANDROID) {
try {
if (themeType === 'theme') {
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
+2 -2
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 (isWeb && typeof window !== 'undefined') {
if (IS_WEB && typeof window !== 'undefined') {
// @ts-ignore web only
const html = window.document.documentElement
// @ts-ignore web only
+32
View File
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,2 @@
export * from '#/analytics/identifiers/device'
export * from '#/analytics/identifiers/session'
+79
View File
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,232 @@
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
@@ -0,0 +1,79 @@
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
@@ -0,0 +1,176 @@
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
@@ -0,0 +1,116 @@
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
@@ -0,0 +1,6 @@
import {MetricsClient} from '#/analytics/metrics/client'
import {type Events} from '#/analytics/metrics/types'
export type {Events as Metrics} from '#/analytics/metrics/types'
export * from '#/analytics/metrics/utils'
export const metrics = new MetricsClient<Events>()
@@ -1,12 +1,21 @@
/*
* Do not import runtime code into this file
*/
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
export type MetricEvents = {
export type Events = {
// App events
init: {
initMs: number
}
'experiment:viewed': {
experimentId: string
variationId: string
}
'account:loggedIn': {
logContext:
| 'LoginForm'
@@ -139,6 +148,7 @@ export type MetricEvents = {
feedUrl: string
feedType: string
index: number
reason?: string
}
'feed:endReached': {
feedUrl: string
@@ -374,7 +384,6 @@ export type MetricEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
@@ -468,7 +477,6 @@ export type MetricEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
| 'PostOnboardingFindFollows'
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
+7
View File
@@ -0,0 +1,7 @@
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
@@ -0,0 +1,18 @@
/**
* 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
@@ -0,0 +1,33 @@
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,7 +49,6 @@ 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,
@@ -71,6 +70,7 @@ 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: isIOS ? 1.25 : 0.75,
mass: IS_IOS ? 1.25 : 0.75,
damping: 50,
stiffness: 1100,
restDisplacementThreshold: 0.01,
}
const SPRING_OUT: WithSpringConfig = {
mass: isIOS ? 1.25 : 0.75,
mass: IS_IOS ? 1.25 : 0.75,
damping: 150,
stiffness: 1000,
restDisplacementThreshold: 0.01,
@@ -209,7 +209,7 @@ export function Root({children}: {children: React.ReactNode}) {
)
useEffect(() => {
if (isAndroid && context.isOpen) {
if (IS_ANDROID && 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({
isNative: true,
IS_NATIVE: true,
control: {isOpen: context.isOpen, open},
state: {
pressed: false,
+2 -2
View File
@@ -85,7 +85,7 @@ export type TriggerProps = {
}
export type TriggerChildProps =
| {
isNative: true
IS_NATIVE: true
control: {
isOpen: boolean
open: (mode: 'full' | 'auxiliary-only') => void
@@ -115,7 +115,7 @@ export type TriggerChildProps =
}
}
| {
isNative: false
IS_NATIVE: 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: () => {},
isNativeDialog: false,
IS_NATIVEDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
+11 -11
View File
@@ -26,7 +26,6 @@ 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'
@@ -39,6 +38,7 @@ 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,
isNativeDialog: true,
IS_NATIVEDialog: true,
nativeSnapPoint: snapPoint,
disableDrag,
setDisableDrag,
@@ -209,7 +209,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
useEnableKeyboardController(isIOS)
useEnableKeyboardController(IS_IOS)
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
@@ -224,7 +224,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
)
let paddingBottom = 0
if (isIOS) {
if (IS_IOS) {
paddingBottom += keyboardHeight / 4
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.bottom + tokens.space.md
@@ -240,7 +240,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
}
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isAndroid) {
if (!IS_ANDROID) {
return
}
const {contentOffset} = e.nativeEvent
@@ -260,12 +260,12 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
contentContainerStyle,
]}
ref={ref}
showsVerticalScrollIndicator={isAndroid ? false : undefined}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
bottomOffset={30}
scrollEventThrottle={50}
onScroll={isAndroid ? onScroll : undefined}
onScroll={IS_ANDROID ? onScroll : undefined}
keyboardShouldPersistTaps="handled"
// TODO: figure out why this positions the header absolutely (rather than stickily)
// on Android. fine to disable for now, because we don't have any
@@ -289,11 +289,11 @@ export const InnerFlatList = React.forwardRef<
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
useEnableKeyboardController(isIOS)
useEnableKeyboardController(IS_IOS)
const onScroll = (e: ScrollEvent) => {
'worklet'
if (!isAndroid) {
if (!IS_ANDROID) {
return
}
const {contentOffset} = e
@@ -311,7 +311,7 @@ export const InnerFlatList = React.forwardRef<
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={<View style={{height: insets.bottom + 100}} />}
ref={ref}
showsVerticalScrollIndicator={isAndroid ? false : undefined}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
style={[a.h_full, style]}
/>
@@ -326,7 +326,7 @@ export function FlatListFooter({children}: {children: React.ReactNode}) {
const {height} = useReanimatedKeyboardAnimation()
const animatedStyle = useAnimatedStyle(() => {
if (!isIOS) return {}
if (!IS_IOS) return {}
return {
transform: [{translateY: Math.min(0, height.get() + bottom - 10)}],
}
+1 -1
View File
@@ -98,7 +98,7 @@ export function Outer({
const context = React.useMemo(
() => ({
close,
isNativeDialog: false,
IS_NATIVEDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
+2 -2
View File
@@ -1,7 +1,7 @@
import {useCallback} from 'react'
import {SystemBars} from 'react-native-edge-to-edge'
import {isIOS} from '#/platform/detection'
import {IS_IOS} from '#/env'
/**
* If we're calling a system API like the image picker that opens a sheet
@@ -9,7 +9,7 @@ import {isIOS} from '#/platform/detection'
*/
export function useSheetWrapper() {
return useCallback(async <T>(promise: Promise<T>): Promise<T> => {
if (isIOS) {
if (IS_IOS) {
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']
isNativeDialog: boolean
IS_NATIVEDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
+29 -36
View File
@@ -7,10 +7,6 @@ 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'
@@ -39,6 +35,8 @@ 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'
@@ -434,8 +432,8 @@ export function ProfileGrid({
isVisible?: boolean
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const gate = useGate()
const moderationOpts = useModerationOpts()
const {gtMobile} = useBreakpoints()
const followDialogControl = useDialogControl()
@@ -443,7 +441,6 @@ export function ProfileGrid({
const isLoading = isSuggestionsLoading || !moderationOpts
const isProfileHeaderContext = viewContext === 'profileHeader'
const isFeedContext = viewContext === 'feed'
const showDismissButton = onDismiss && gate('suggested_users_dismiss')
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
const minLength = gtMobile ? 3 : 4
@@ -452,12 +449,11 @@ export function ProfileGrid({
const seenProfilesRef = useRef<Set<string>>(new Set())
const containerRef = useRef<View>(null)
const hasTrackedRef = useRef(false)
const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
isFeedContext
? 'InterstitialDiscover'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
? 'InterstitialDiscover'
: isProfileHeaderContext
? 'Profile'
: 'InterstitialProfile'
// Callback to fire seen events
const fireSeen = useCallback(() => {
@@ -469,20 +465,16 @@ export function ProfileGrid({
profilesToShow.forEach((profile, index) => {
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.metric('suggestedUser:seen', {
logContext,
recId,
position: index,
suggestedDid: profile.did,
category: null,
})
}
})
}, [isLoading, error, profiles, maxLength, logContext, recId])
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
// For profile header, fire when isVisible becomes true
useEffect(() => {
@@ -567,7 +559,7 @@ export function ProfileGrid({
<ProfileCard.Link
profile={profile}
onPress={() => {
logEvent('suggestedUser:press', {
ax.metric('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
@@ -584,13 +576,13 @@ export function ProfileGrid({
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
{showDismissButton && (
{onDismiss && (
<Button
label={_(msg`Dismiss this suggestion`)}
onPress={e => {
e.preventDefault()
onDismiss!(profile.did)
logEvent('suggestedUser:dismiss', {
onDismiss(profile.did)
ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
@@ -658,7 +650,7 @@ export function ProfileGrid({
withIcon={false}
style={[a.rounded_sm]}
onFollow={() => {
logEvent('suggestedUser:follow', {
ax.metric('suggestedUser:follow', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
@@ -680,7 +672,7 @@ export function ProfileGrid({
// Use totalProfileCount (before dismissals) for minLength check on initial render.
const profileCountForMinCheck = totalProfileCount ?? profiles.length
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
logger.debug(`Not enough profiles to show suggested follows`)
ax.logger.debug(`Not enough profiles to show suggested follows`)
return null
}
@@ -692,7 +684,7 @@ export function ProfileGrid({
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
<View
style={[
a.px_lg,
@@ -701,7 +693,7 @@ export function ProfileGrid({
a.align_center,
a.justify_between,
]}
pointerEvents={isIOS ? 'auto' : 'box-none'}>
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
{isFeedContext ? (
<Trans>Suggested for you</Trans>
@@ -714,7 +706,7 @@ export function ProfileGrid({
label={_(msg`See more suggested profiles`)}
onPress={() => {
followDialogControl.open()
logEvent('suggestedUser:seeMore', {
ax.metric('suggestedUser:seeMore', {
logContext: isFeedContext ? 'Explore' : 'Profile',
})
}}>
@@ -758,7 +750,7 @@ export function ProfileGrid({
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
logger.metric('suggestedUser:seeMore', {
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
@@ -796,9 +788,10 @@ 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,
@@ -831,7 +824,7 @@ export function SuggestedFeeds() {
key={feed.uri}
view={feed}
onPress={() => {
logEvent('feed:interstitial:feedCard:press', {})
ax.metric('feed:interstitial:feedCard:press', {})
}}>
{({hovered, pressed}) => (
<CardOuter
+3 -3
View File
@@ -9,7 +9,6 @@ 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'
@@ -19,6 +18,7 @@ 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>
{isWeb && canScrollLeft && (
{IS_WEB && canScrollLeft && (
<View
style={[
a.absolute,
@@ -270,7 +270,7 @@ export function InterestTabs({
</Button>
</View>
)}
{isWeb && canScrollRight && (
{IS_WEB && 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>
{isWeb ? <Flag {...item} /> : item.unicodeFlag + ' '}
{IS_WEB ? <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 (isWeb) {
if (IS_WEB) {
return (
<Image
source={svgFlag}
+4 -4
View File
@@ -6,7 +6,6 @@ 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,
@@ -29,6 +28,7 @@ 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,
isIOS && align === 'platform' && a.align_center,
IS_IOS && 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,
isIOS && align === 'platform' && a.text_center,
IS_IOS && 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,
isIOS && align === 'platform' && a.text_center,
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
+4 -4
View File
@@ -11,7 +11,6 @@ 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,
@@ -23,6 +22,7 @@ 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 (
<>
{isWeb && <WebCenterBorders />}
{IS_WEB && <WebCenterBorders />}
<View
style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]}
{...props}
@@ -98,7 +98,7 @@ export const Content = memo(
contentContainerStyle,
]}
{...props}>
{isWeb ? (
{IS_WEB ? (
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
{/* @ts-expect-error web only -esb */}
{children}
@@ -145,7 +145,7 @@ export const KeyboardAwareContent = memo(function LayoutKeyboardAwareContent({
]}
keyboardShouldPersistTaps="handled"
{...props}>
{isWeb ? <Center>{children}</Center> : children}
{IS_WEB ? <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 (isWeb) {
if (IS_WEB) {
e.preventDefault()
}
@@ -162,7 +162,7 @@ export function useLink({
]
// does not apply to web's flat navigator
if (isNative && screen !== 'NotFound') {
if (IS_NATIVE && 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 isNative && shareOnLongPress ? handleLongPress() : undefined
return IS_NATIVE && shareOnLongPress ? handleLongPress() : undefined
},
[outerOnLongPress, handleLongPress, shareOnLongPress],
)
@@ -501,7 +501,7 @@ export function WebOnlyInlineLinkText({
onPress,
...props
}: Omit<InlineLinkProps, 'onLongPress'>) {
return isWeb ? (
return IS_WEB ? (
<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 (!isWeb || !isModifiedClickEvent(e)) {
if (!IS_WEB || !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 (!isWeb) return false
if (!IS_WEB) 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 (!isWeb) return false
if (!IS_WEB) 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 (!isWeb) return false
if (!IS_WEB) 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 (!isWeb) return false
if (!IS_WEB) return false
const event = e as unknown as MouseEvent
const isMiddleClick = isWeb && event.button === 1
const isMiddleClick = IS_WEB && 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: isHighDPI ? 0.5 : StyleSheet.hairlineWidth,
web: IS_HIGH_DPI ? 0.5 : StyleSheet.hairlineWidth,
}),
},
opaque
+5 -5
View File
@@ -10,7 +10,6 @@ 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'
@@ -30,6 +29,7 @@ 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({
isNative: true,
IS_NATIVE: 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}
{isNative && showCancel && <Cancel />}
{IS_NATIVE && 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 (isAndroid) {
if (IS_ANDROID) {
/**
* Below fix for iOS doesn't work for Android, this does.
*/
onPress?.(e)
context.control.close()
} else if (isIOS) {
} else if (IS_IOS) {
/**
* Fixes a subtle bug on iOS
* {@link https://github.com/bluesky-social/social-app/pull/5849/files#diff-de516ef5e7bd9840cd639213301df38cf03acfcad5bda85a1d63efd249ba79deL124-L127}
+1 -1
View File
@@ -138,7 +138,7 @@ export function Trigger({
<RadixTriggerPassThrough>
{props =>
children({
isNative: false,
IS_NATIVE: false,
control,
state: {
hovered,
+2 -2
View File
@@ -43,7 +43,7 @@ export type TriggerProps = {
}
export type TriggerChildProps =
| {
isNative: true
IS_NATIVE: true
control: Dialog.DialogOuterProps['control']
state: {
/**
@@ -73,7 +73,7 @@ export type TriggerChildProps =
}
}
| {
isNative: false
IS_NATIVE: false
control: Dialog.DialogOuterProps['control']
state: {
hovered: boolean
+2 -2
View File
@@ -8,7 +8,6 @@ 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'
@@ -18,6 +17,7 @@ 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}
{isNative && (
{IS_NATIVE && (
<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}],
isNative && [
IS_NATIVE && [
{
paddingBottom: Math.max(insets.bottom, a.p_2xl.padding),
},
@@ -109,7 +109,7 @@ export function Overlay({
<FocusScope>
<View
accessible={isAndroid}
accessible={IS_ANDROID}
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.
!isIOS && {zIndex: 9999},
!IS_IOS && {zIndex: 9999},
]}>
<Content state={state} />
</View>
@@ -3,7 +3,6 @@ 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'
@@ -12,6 +11,7 @@ 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 = isAndroid
const label = IS_ANDROID
? _(
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 (isNative) {
if (IS_NATIVE) {
imageRef.current?.stopAnimating()
}
return false
} else {
if (isNative) {
if (IS_NATIVE) {
imageRef.current?.startAnimating()
}
return true
@@ -112,7 +112,7 @@ export function ExternalGif({
<Image
source={{
uri:
!isPrefetched || (isWeb && !isAnimating)
!isPrefetched || (IS_WEB && !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={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
cachePolicy={IS_IOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
/>
{(!isPrefetched || !isAnimating) && (
@@ -26,7 +26,6 @@ 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'
@@ -34,6 +33,7 @@ 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 = isNative // If it is native, we always want the larger number
const realWinHeight = IS_NATIVE // If it is native, we always want the larger number
? winHeight > winWidth
? winHeight
: winWidth
@@ -13,7 +13,6 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {clamp} from '#/lib/numbers'
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
import {isWeb} from '#/platform/detection'
import {useAutoplayDisabled} from '#/state/preferences'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
@@ -22,6 +21,7 @@ import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {IS_WEB} from '#/env'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
@@ -218,18 +218,18 @@ const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: isWeb ? 8 : 6,
paddingVertical: isWeb ? 6 : 3,
paddingHorizontal: IS_WEB ? 8 : 6,
paddingVertical: IS_WEB ? 6 : 3,
position: 'absolute',
// Related to margin/gap hack. This keeps the alt label in the same position
// on all platforms
right: isWeb ? 8 : 5,
bottom: isWeb ? 8 : 5,
right: IS_WEB ? 8 : 5,
bottom: IS_WEB ? 8 : 5,
zIndex: 2,
},
alt: {
color: 'white',
fontSize: isWeb ? 10 : 7,
fontSize: IS_WEB ? 10 : 7,
fontWeight: '600',
},
})
@@ -10,13 +10,13 @@ import {useHaptics} from '#/lib/haptics'
import {shareUrl} from '#/lib/sharing'
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {ExternalGif} from './ExternalGif'
import {ExternalPlayer} from './ExternalPlayer'
import {GifEmbed} from './Gif'
@@ -53,7 +53,7 @@ export const ExternalEmbed = ({
}, [playHaptic, onOpen])
const onShareExternal = useCallback(() => {
if (link.uri && isNative) {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(link.uri)
}
@@ -8,7 +8,7 @@ import React, {
} from 'react'
import {useWindowDimensions} from 'react-native'
import {isNative, isWeb} from '#/platform/detection'
import {IS_NATIVE, IS_WEB} from '#/env'
const Context = React.createContext<{
activeViewId: string | null
@@ -18,7 +18,7 @@ const Context = React.createContext<{
Context.displayName = 'ActiveVideoWebContext'
export function Provider({children}: {children: React.ReactNode}) {
if (!isWeb) {
if (!IS_WEB) {
throw new Error('ActiveVideoWebContext may only be used on web.')
}
@@ -47,7 +47,7 @@ export function Provider({children}: {children: React.ReactNode}) {
const sendViewPosition = useCallback(
(viewId: string, y: number) => {
if (isNative) return
if (IS_NATIVE) return
if (viewId === activeViewIdRef.current) {
activeViewLocationRef.current = y
@@ -3,10 +3,10 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isFirefox, isTouchDevice} from '#/lib/browser'
import {clamp} from '#/lib/numbers'
import {atoms as a, useTheme, web} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {IS_WEB_FIREFOX, IS_WEB_TOUCH_DEVICE} from '#/env'
import {formatTime} from './utils'
export function Scrubber({
@@ -100,7 +100,7 @@ export function Scrubber({
// a pointerUp event is fired outside the element that captured the
// pointer. Firefox clicks on the element the mouse is over, so we have
// to make everything unclickable while seeking -sfn
if (isFirefox && scrubberActive) {
if (IS_WEB_FIREFOX && scrubberActive) {
document.body.classList.add('force-no-clicks')
return () => {
@@ -151,7 +151,7 @@ export function Scrubber({
<View
testID="scrubber"
style={[
{height: isTouchDevice ? 32 : 18, width: '100%'},
{height: IS_WEB_TOUCH_DEVICE ? 32 : 18, width: '100%'},
a.flex_shrink_0,
a.px_xs,
]}
@@ -4,9 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import type Hls from 'hls.js'
import {isTouchDevice} from '#/lib/browser'
import {clamp} from '#/lib/numbers'
import {isIPhoneWeb} from '#/platform/detection'
import {
useAutoplayDisabled,
useSetSubtitlesEnabled,
@@ -28,6 +26,7 @@ import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Paus
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env'
import {TimeIndicator} from '../TimeIndicator'
import {ControlButton} from './ControlButton'
import {Scrubber} from './Scrubber'
@@ -215,18 +214,18 @@ export function Controls({
const seekLeft = useCallback(() => {
if (!videoRef.current) return
// eslint-disable-next-line @typescript-eslint/no-shadow
const currentTime = videoRef.current.currentTime
// eslint-disable-next-line @typescript-eslint/no-shadow
const duration = videoRef.current.duration || 0
onSeek(clamp(currentTime - 5, 0, duration))
}, [onSeek, videoRef])
const seekRight = useCallback(() => {
if (!videoRef.current) return
// eslint-disable-next-line @typescript-eslint/no-shadow
const currentTime = videoRef.current.currentTime
// eslint-disable-next-line @typescript-eslint/no-shadow
const duration = videoRef.current.duration || 0
onSeek(clamp(currentTime + 5, 0, duration))
}, [onSeek, videoRef])
@@ -342,7 +341,7 @@ export function Controls({
{opacity: showControls ? 1 : 0},
{transition: 'opacity 0.2s ease-in-out'},
]}>
{(!volumeHovered || isTouchDevice) && (
{(!volumeHovered || IS_WEB_TOUCH_DEVICE) && (
<Scrubber
duration={duration}
currentTime={currentTime}
@@ -400,7 +399,7 @@ export function Controls({
onEndHover={onVolumeEndHover}
drawFocus={drawFocus}
/>
{!isIPhoneWeb && (
{!IS_WEB_MOBILE_IOS && (
<ControlButton
active={isFullscreen}
activeLabel={_(msg`Exit fullscreen`)}
@@ -4,11 +4,11 @@ import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isSafari, isTouchDevice} from '#/lib/browser'
import {atoms as a} from '#/alf'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env'
import {ControlButton} from './ControlButton'
export function VolumeControl({
@@ -57,7 +57,7 @@ export function VolumeControl({
onPointerEnter={onHover}
onPointerLeave={onEndHover}
style={[a.relative]}>
{hovered && !isTouchDevice && (
{hovered && !IS_WEB_TOUCH_DEVICE && (
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
@@ -80,7 +80,9 @@ export function VolumeControl({
aria-label={_(msg`Volume`)}
style={
// Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h
isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'}
IS_WEB_SAFARI
? {height: 92, minHeight: '100%'}
: {height: '100%'}
}
onChange={onVolumeChange}
// @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn
@@ -1,8 +1,8 @@
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
import {isSafari} from '#/lib/browser'
import {logger} from '#/logger'
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {IS_WEB_SAFARI} from '#/env'
export function useVideoElement(ref: RefObject<HTMLVideoElement | null>) {
const [playing, setPlaying] = useState(false)
@@ -41,7 +41,7 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement | null>) {
setCurrentTime(round(ref.current.currentTime) || 0)
// HACK: Safari randomly fires `stalled` events when changing between segments
// let's just clear the buffering state if the video is still progressing -sfn
if (isSafari) {
if (IS_WEB_SAFARI) {
if (bufferingTimeout) clearTimeout(bufferingTimeout)
setBuffering(false)
}
@@ -11,7 +11,6 @@ import {type AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isFirefox} from '#/lib/browser'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useTheme} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
@@ -23,6 +22,7 @@ import {
VideoEmbedInnerWeb,
VideoNotFoundError,
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
import {IS_WEB_FIREFOX} from '#/env'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -37,7 +37,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !isFirefox) return
if (isFullscreen && !IS_WEB_FIREFOX) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
@@ -150,7 +150,7 @@ function ViewportObserver({
// observing a div of 100vh height
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !isFirefox) return
if (isFullscreen && !IS_WEB_FIREFOX) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import type React from 'react'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
@@ -15,6 +14,7 @@ import {useTheme} from '#/alf'
import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {PostControlButton, PostControlButtonIcon} from './PostControlButton'
export const BookmarkButton = memo(function BookmarkButton({
@@ -29,6 +29,7 @@ export const BookmarkButton = memo(function BookmarkButton({
hitSlop?: Insets
}): React.ReactNode {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {mutateAsync: bookmark} = useBookmarkMutation()
const cleanError = useCleanError()
@@ -52,7 +53,7 @@ export const BookmarkButton = memo(function BookmarkButton({
post,
})
logger.metric('post:bookmark', {
ax.metric('post:bookmark', {
uri: post.uri,
authorDid: post.author.did,
logContext,
@@ -92,7 +93,7 @@ export const BookmarkButton = memo(function BookmarkButton({
uri: post.uri,
})
logger.metric('post:unbookmark', {
ax.metric('post:unbookmark', {
uri: post.uri,
authorDid: post.author.did,
logContext,
@@ -3,11 +3,11 @@ import * as Clipboard from 'expo-clipboard'
import {t} from '@lingui/macro'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_INTERNAL} from '#/env'
export function DiscoverDebug({
@@ -15,12 +15,12 @@ export function DiscoverDebug({
}: {
feedContext: string | undefined
}) {
const ax = useAnalytics()
const {currentAccount} = useSession()
const gate = useGate()
const isDiscoverDebugUser =
IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
gate('debug_show_feedcontext')
ax.features.enabled(ax.features.DebugFeedContext)
const theme = useTheme()
return (
@@ -26,15 +26,17 @@ import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
import {
useHiddenPosts,
useHiddenPostsApi,
useLanguagePrefs,
} from '#/state/preferences'
import {usePinnedPostMutation} from '#/state/queries/pinned-post'
import {
usePostDeleteMutation,
@@ -71,13 +73,17 @@ import {
import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {
Mute_Stroke2_Corner0_Rounded as Mute,
Mute_Stroke2_Corner0_Rounded as MuteIcon,
} from '#/components/icons/Mute'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Person'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {
SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute,
SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon,
} from '#/components/icons/Speaker'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
@@ -87,6 +93,7 @@ import {
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {useAnalytics} from '#/analytics'
import {IS_INTERNAL} from '#/env'
import * as bsky from '#/types/bsky'
@@ -116,6 +123,7 @@ let PostMenuItems = ({
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
const {_} = useLingui()
const ax = useAnalytics()
const langPrefs = useLanguagePrefs()
const {mutateAsync: deletePostMutate} = usePostDeleteMutation()
const {mutateAsync: pinPostMutate, isPending: isPinPending} =
@@ -212,7 +220,7 @@ let PostMenuItems = ({
try {
if (isThreadMuted) {
unmuteThread()
logger.metric('post:unmute', {
ax.metric('post:unmute', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
@@ -221,7 +229,7 @@ let PostMenuItems = ({
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
logger.metric('post:mute', {
ax.metric('post:mute', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
@@ -258,21 +266,17 @@ let PostMenuItems = ({
AppBskyFeedPost.isRecord,
)
) {
logger.metric(
'translate',
{
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
},
{statsig: false},
)
ax.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
}
const onHidePost = () => {
hidePost({uri: postUri})
logEvent('thread:click:hideReplyForMe', {})
ax.metric('thread:click:hideReplyForMe', {})
}
const hideInPWI = !!postAuthor.labels?.find(
@@ -286,7 +290,7 @@ let PostMenuItems = ({
feedContext: postFeedContext,
reqId: postReqId,
})
logger.metric('post:showMore', {
ax.metric('post:showMore', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
@@ -304,7 +308,7 @@ let PostMenuItems = ({
feedContext: postFeedContext,
reqId: postReqId,
})
logger.metric('post:showLess', {
ax.metric('post:showLess', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
@@ -368,7 +372,7 @@ let PostMenuItems = ({
// Log metric only when hiding (not when showing)
if (isHide) {
logEvent('thread:click:hideReplyForEveryone', {})
ax.metric('thread:click:hideReplyForEveryone', {})
}
Toast.show(
@@ -405,7 +409,7 @@ let PostMenuItems = ({
}
const onPressPin = () => {
logEvent(isPinned ? 'post:unpin' : 'post:pin', {})
ax.metric(isPinned ? 'post:unpin' : 'post:pin', {})
pinPostMutate({
postUri,
postCid,
@@ -458,11 +462,10 @@ let PostMenuItems = ({
const onSignIn = () => requireSignIn(() => {})
const gate = useGate()
const isDiscoverDebugUser =
IS_INTERNAL ||
DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] ||
gate('debug_show_feedcontext')
ax.features.enabled(ax.features.DebugFeedContext)
return (
<>
@@ -8,7 +8,6 @@ import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {type NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
@@ -20,9 +19,11 @@ import {useDialogContext} from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function RecentChats({postUri}: {postUri: string}) {
const ax = useAnalytics()
const control = useDialogContext()
const {currentAccount} = useSession()
const {data} = useListConvosQuery({status: 'accepted'})
@@ -32,7 +33,7 @@ export function RecentChats({postUri}: {postUri: string}) {
const onSelectChat = (convoId: string) => {
control.close(() => {
logger.metric('share:press:recentDm', {}, {statsig: true})
ax.metric('share:press:recentDm', {})
navigation.navigate('MessagesConversation', {
conversation: convoId,
embed: postUri,
@@ -9,8 +9,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
@@ -24,6 +22,8 @@ import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/i
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import {useDevMode} from '#/storage/hooks/dev-mode'
import {RecentChats} from './RecentChats'
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
@@ -32,6 +32,7 @@ let ShareMenuItems = ({
post,
onShare: onShareProp,
}: ShareMenuItemsProps): React.ReactNode => {
const ax = useAnalytics()
const {hasSession} = useSession()
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
@@ -54,16 +55,16 @@ let ShareMenuItems = ({
}, [postAuthor])
const onSharePost = () => {
logger.metric('share:press:nativeShare', {}, {statsig: true})
ax.metric('share:press:nativeShare', {})
const url = toShareUrl(href)
shareUrl(url)
onShareProp()
}
const onCopyLink = async () => {
logger.metric('share:press:copyLink', {}, {statsig: true})
ax.metric('share:press:copyLink', {})
const url = toShareUrl(href)
if (isIOS) {
if (IS_IOS) {
// iOS only
await ExpoClipboard.setUrlAsync(url)
} else {
@@ -100,7 +101,7 @@ let ShareMenuItems = ({
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
onPress={() => {
logger.metric('share:press:openDmSearch', {}, {statsig: true})
ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open()
}}>
<Menu.ItemText>
@@ -8,8 +8,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {useBreakpoints} from '#/alf'
@@ -22,6 +20,8 @@ import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/compon
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
import * as Menu from '#/components/Menu'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {useDevMode} from '#/storage/hooks/dev-mode'
import {type ShareMenuItemsProps} from './ShareMenuItems.types'
@@ -31,6 +31,7 @@ let ShareMenuItems = ({
timestamp,
onShare: onShareProp,
}: ShareMenuItemsProps): React.ReactNode => {
const ax = useAnalytics()
const {hasSession} = useSession()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
@@ -56,21 +57,21 @@ let ShareMenuItems = ({
}, [postAuthor])
const onCopyLink = () => {
logger.metric('share:press:copyLink', {}, {statsig: true})
ax.metric('share:press:copyLink', {})
const url = toShareUrl(href)
shareUrl(url)
onShareProp()
}
const onSelectChatToShareTo = (conversation: string) => {
logger.metric('share:press:dmSelected', {}, {statsig: true})
ax.metric('share:press:dmSelected', {})
navigation.navigate('MessagesConversation', {
conversation,
embed: postUri,
})
}
const canEmbed = isWeb && gtMobile && !hideInPWI
const canEmbed = IS_WEB && gtMobile && !hideInPWI
const onShareATURI = () => {
shareText(postUri)
@@ -102,7 +103,7 @@ let ShareMenuItems = ({
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
onPress={() => {
logger.metric('share:press:openDmSearch', {}, {statsig: true})
ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open()
}}>
<Menu.ItemText>
@@ -117,7 +118,7 @@ let ShareMenuItems = ({
testID="postDropdownEmbedBtn"
label={_(msg`Embed post`)}
onPress={() => {
logger.metric('share:press:embed', {}, {statsig: true})
ax.metric('share:press:embed', {})
embedPostControl.open()
}}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
+12 -21
View File
@@ -12,17 +12,15 @@ import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {EventStopper} from '#/view/com/util/EventStopper'
import {native} from '#/alf'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {useMenuControl} from '#/components/Menu'
import * as Menu from '#/components/Menu'
import {useAnalytics} from '#/analytics'
import {PostControlButton, PostControlButtonIcon} from '../PostControlButton'
import {ShareMenuItems} from './ShareMenuItems'
@@ -49,14 +47,10 @@ let ShareMenuButton = ({
hitSlop?: Insets
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
}): React.ReactNode => {
const ax = useAnalytics()
const {_} = useLingui()
const gate = useGate()
const {feedDescriptor} = useFeedFeedbackContext()
const ShareIcon = gate('alt_share_icon')
? ArrowShareRightIcon
: ArrowOutOfBoxIcon
const menuControl = useMenuControl()
const [hasBeenOpen, setHasBeenOpen] = useState(false)
const lazyMenuControl = useMemo(
@@ -68,20 +62,17 @@ let ShareMenuButton = ({
// menuControl.open() fires but RN doesn't expose flushSync.
setTimeout(menuControl.open)
logger.metric(
'post:share',
{
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
postContext: big ? 'thread' : 'feed',
},
{statsig: true},
)
ax.metric('post:share', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
postContext: big ? 'thread' : 'feed',
})
},
}),
[
ax,
menuControl,
setHasBeenOpen,
big,
@@ -93,7 +84,7 @@ let ShareMenuButton = ({
)
const onNativeLongPress = () => {
logger.metric('share:press:nativeShare', {}, {statsig: true})
ax.metric('share:press:nativeShare', {})
const urip = new AtUri(post.uri)
const href = makeProfileLink(post.author, 'post', urip.rkey)
const url = toShareUrl(href)
@@ -114,7 +105,7 @@ let ShareMenuButton = ({
{...props}
onLongPress={native(onNativeLongPress)}
hitSlop={hitSlop}>
<PostControlButtonIcon icon={ShareIcon} />
<PostControlButtonIcon icon={ArrowShareRightIcon} />
</PostControlButton>
)
}}
+4 -3
View File
@@ -13,7 +13,6 @@ import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
@@ -30,6 +29,7 @@ import {atoms as a, useBreakpoints} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
import {useAnalytics} from '#/analytics'
import {BookmarkButton} from './BookmarkButton'
import {
PostControlButton,
@@ -71,6 +71,7 @@ let PostControls = ({
viaRepost?: {uri: string; cid: string}
variant?: 'compact' | 'normal' | 'large'
}): React.ReactNode => {
const ax = useAnalytics()
const {_} = useLingui()
const {openComposer} = useOpenComposer()
const {feedDescriptor} = useFeedFeedbackContext()
@@ -175,7 +176,7 @@ let PostControls = ({
feedContext,
reqId,
})
logger.metric('post:clickQuotePost', {
ax.metric('post:clickQuotePost', {
uri: post.uri,
authorDid: post.author.did,
logContext,
@@ -226,7 +227,7 @@ let PostControls = ({
!replyDisabled
? () =>
requireAuth(() => {
logger.metric('post:clickReply', {
ax.metric('post:clickReply', {
uri: post.uri,
authorDid: post.author.did,
logContext,
+3 -3
View File
@@ -16,7 +16,6 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -47,6 +46,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {type Metrics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function Default({
@@ -461,8 +461,8 @@ export function DescriptionPlaceholder({
export type FollowButtonProps = {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
logContext: Metrics['profile:follow']['logContext'] &
Metrics['profile:unfollow']['logContext']
colorInverted?: boolean
onFollow?: () => void
withIcon?: boolean
@@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {isTouchDevice} from '#/lib/browser'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
@@ -43,6 +42,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB_TOUCH_DEVICE} from '#/env'
import {type ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
@@ -70,7 +70,7 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
}
}
if (props.disable || isTouchDevice) {
if (props.disable || IS_WEB_TOUCH_DEVICE) {
return props.children
} else {
return (
+16 -19
View File
@@ -10,9 +10,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
@@ -37,6 +34,8 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {ProgressGuideTask} from './Task'
@@ -67,6 +66,7 @@ export function FollowDialog({
guide: Follow10ProgressGuide
showArrow?: boolean
}) {
const ax = useAnalytics()
const {_} = useLingui()
const control = Dialog.useDialogControl()
const {gtPhone} = useBreakpoints()
@@ -78,7 +78,7 @@ export function FollowDialog({
label={_(msg`Find people to follow`)}
onPress={() => {
control.open()
logEvent('progressGuide:followDialog:open', {})
ax.metric('progressGuide:followDialog:open', {})
}}
size={gtPhone ? 'small' : 'large'}
color="primary">
@@ -118,6 +118,7 @@ let lastSearchText = ''
function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const {_} = useLingui()
const ax = useAnalytics()
const interestsDisplayNames = useInterestsDisplayNames()
const {data: preferences} = usePreferencesQuery()
const personalizedInterests = preferences?.interests?.tags
@@ -271,17 +272,13 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const position = itemsRef.current.findIndex(
i => i.type === 'profile' && i.profile.did === item.profile.did,
)
logger.metric(
'suggestedUser:seen',
{
logContext: 'ProgressGuide',
recId: undefined,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
},
{statsig: true},
)
ax.metric('suggestedUser:seen', {
logContext: 'ProgressGuide',
recId: undefined,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
})
}
}
}
@@ -431,7 +428,7 @@ function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) {
<Trans>Find people to follow</Trans>
</Text>
{guide && (
<View style={isWeb && {paddingRight: 36}}>
<View style={IS_WEB && {paddingRight: 36}}>
<ProgressGuideTask
current={guide.numFollows + 1}
total={10 + 1}
@@ -440,12 +437,12 @@ function HeaderTop({guide}: {guide?: Follow10ProgressGuide}) {
/>
</View>
)}
{isWeb ? (
{IS_WEB ? (
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant={isWeb ? 'ghost' : 'solid'}
variant={IS_WEB ? 'ghost' : 'solid'}
color="secondary"
style={[
a.absolute,
@@ -579,7 +576,7 @@ function FollowProfileCardInner({
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
disabledPreview={!isWeb}
disabledPreview={!IS_WEB}
profile={profile}
moderationOpts={moderationOpts}
/>
+3 -3
View File
@@ -11,9 +11,9 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Portal} from '#/components/Portal'
import {IS_WEB} from '#/env'
import {AnimatedCheck, type AnimatedCheckRef} from '../anim/AnimatedCheck'
import {Text} from '../Typography'
@@ -108,11 +108,11 @@ export const ProgressGuideToast = React.forwardRef<
const containerStyle = React.useMemo(() => {
let left = 10
let right = 10
if (isWeb && winDim.width > 400) {
if (IS_WEB && winDim.width > 400) {
left = right = (winDim.width - 380) / 2
}
return {
position: isWeb ? 'fixed' : 'absolute',
position: IS_WEB ? 'fixed' : 'absolute',
top: 0,
left,
right,
+5 -5
View File
@@ -6,7 +6,6 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {isNative, isWeb} from '#/platform/detection'
import {
usePreferencesQuery,
useRemoveMutedWordsMutation,
@@ -22,6 +21,7 @@ import {
} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import {IS_NATIVE, IS_WEB} from '#/env'
export function RichTextTag({
tag,
@@ -50,7 +50,7 @@ export function RichTextTag({
const navigation = useNavigation<NavigationProp>()
const isCashtag = tag.startsWith('$')
const label = isCashtag ? _(msg`Cashtag ${tag}`) : _(msg`Hashtag ${tag}`)
const hint = isNative
const hint = IS_NATIVE
? _(msg`Long press to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
: _(msg`Click to open tag menu for ${isCashtag ? tag : `#${tag}`}`)
@@ -86,9 +86,9 @@ export function RichTextTag({
}}
{...menuProps}
onPress={e => {
if (isWeb) {
if (IS_WEB) {
return createStaticClickIfUnmodified(() => {
if (!isNative) {
if (!IS_NATIVE) {
menuProps.onPress()
}
}).onPress(e)
@@ -99,7 +99,7 @@ export function RichTextTag({
label={label}
style={textStyle}
emoji>
{isNative ? (
{IS_NATIVE ? (
display
) : (
<RNText ref={menuProps.ref}>{display}</RNText>
+3 -3
View File
@@ -8,7 +8,7 @@ import Animated, {
} from 'react-native-reanimated'
import type React from 'react'
import {isWeb} from '#/platform/detection'
import {IS_WEB} from '#/env'
export function ScreenTransition({
direction,
@@ -31,8 +31,8 @@ export function ScreenTransition({
return (
<Animated.View
entering={isWeb ? webEntering : entering}
exiting={isWeb ? webExiting : exiting}
entering={IS_WEB ? webEntering : entering}
exiting={IS_WEB ? webExiting : exiting}
style={style}>
{children}
</Animated.View>
+1 -1
View File
@@ -82,7 +82,7 @@ export function Trigger({children, label}: TriggerProps) {
if (typeof children === 'function') {
return children({
isNative: true,
IS_NATIVE: true,
control,
state: {
hovered: false,
+1 -1
View File
@@ -68,7 +68,7 @@ export function Trigger({children, label}: TriggerProps) {
<RadixTriggerPassThrough>
{props =>
children({
isNative: false,
IS_NATIVE: false,
state: {
hovered,
focused,
+2 -2
View File
@@ -65,7 +65,7 @@ export type TriggerProps = {
export type TriggerChildProps =
| {
isNative: true
IS_NATIVE: true
control: DialogControlProps
state: {
/**
@@ -92,7 +92,7 @@ export type TriggerChildProps =
}
}
| {
isNative: false
IS_NATIVE: false
state: {
hovered: boolean
focused: boolean
@@ -3,11 +3,11 @@ import {type ListRenderItemInfo, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {isNative, isWeb} from '#/platform/detection'
import {List, type ListRef} from '#/view/com/util/List'
import {type SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import {IS_NATIVE, IS_WEB} from '#/env'
function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
return item.uri
@@ -27,7 +27,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
animated: IS_NATIVE,
offset: -headerHeight,
})
}, [scrollElRef, headerHeight])
@@ -44,7 +44,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
<View
style={[
a.p_lg,
(isWeb || index !== 0) && a.border_t,
(IS_WEB || index !== 0) && a.border_t,
t.atoms.border_contrast_low,
]}>
<FeedCard.Default view={item} />
@@ -3,13 +3,13 @@ import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {type ListRef} from '#/view/com/util/List'
import {type SectionRef} from '#/screens/Profile/Sections/types'
import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag'
import {IS_NATIVE} from '#/env'
interface ProfilesListProps {
listUri: string
@@ -24,7 +24,7 @@ export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
animated: IS_NATIVE,
offset: -headerHeight,
})
}, [scrollElRef, headerHeight])
@@ -14,7 +14,6 @@ import {
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {isNative, isWeb} from '#/platform/detection'
import {useAllListMembersQuery} from '#/state/queries/list-members'
import {useSession} from '#/state/session'
import {List, type ListRef} from '#/view/com/util/List'
@@ -22,6 +21,7 @@ import {type SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
import {IS_NATIVE, IS_WEB} from '#/env'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
return `${item.did}-${index}`
@@ -75,7 +75,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
}
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
animated: IS_NATIVE,
offset: -headerHeight,
})
}, [scrollElRef, headerHeight])
@@ -93,7 +93,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
style={[
a.p_lg,
t.atoms.border_contrast_low,
(isWeb || index !== 0) && a.border_t,
(IS_WEB || index !== 0) && a.border_t,
]}>
<ProfileCard
profile={item}
@@ -19,7 +19,6 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type NavigationProp} from '#/lib/routes/types'
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
import {
EmptyState,
@@ -36,6 +35,7 @@ import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
interface SectionRef {
scrollToTop: () => void
@@ -136,7 +136,7 @@ export function ProfileStarterPacks({
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
useEffect(() => {
if (isIOS && enabled && scrollElRef.current) {
if (IS_IOS && enabled && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
+6 -6
View File
@@ -6,13 +6,13 @@ import type ViewShot from 'react-native-view-shot'
import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {isWeb} from '#/platform/detection'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import * as bsky from '#/types/bsky'
const LazyViewShot = lazy(
@@ -121,7 +121,7 @@ export function QrCodeInner({link}: {link: string}) {
return (
<View style={{position: 'relative'}}>
{/* An SVG version of the logo is placed on top of normal `QRCode` `logo` prop, since the PNG fails to load before the export completes on web. */}
{isWeb && logoArea && (
{IS_WEB && logoArea && (
<View
style={{
position: 'absolute',
@@ -139,9 +139,9 @@ export function QrCodeInner({link}: {link: string}) {
a.rounded_sm,
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
]}
pieceSize={isWeb ? 8 : 6}
pieceSize={IS_WEB ? 8 : 6}
padding={20}
pieceBorderRadius={isWeb ? 4.5 : 3.5}
pieceBorderRadius={IS_WEB ? 4.5 : 3.5}
outerEyesOptions={{
topLeft: {
borderRadius: [12, 12, 0, 12],
@@ -159,11 +159,11 @@ export function QrCodeInner({link}: {link: string}) {
innerEyesOptions={{borderRadius: 3}}
logo={{
href: require('../../../assets/logo.png'),
...(isWeb && {
...(IS_WEB && {
onChange: onLogoAreaChange,
padding: 28,
}),
...(!isWeb && {
...(!IS_WEB && {
padding: 2,
scale: 0.95,
}),

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