Compare commits

..

2 Commits

Author SHA1 Message Date
Samuel Newman a917aaf453 bump runners inside of eas update flow 2025-12-12 17:41:26 +02:00
Samuel Newman 21c8b806cb fix noscript styles 2025-12-12 17:37:42 +02:00
958 changed files with 175129 additions and 251238 deletions
+2 -15
View File
@@ -28,24 +28,11 @@ EXPO_PUBLIC_CHAT_PROXY_DID=
#
#
# Bluesky's metrics API
EXPO_PUBLIC_METRICS_API_HOST=
# Growthbook config
EXPO_PUBLIC_GROWTHBOOK_API_HOST=
EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY=
# Sentry DSN for telemetry
EXPO_PUBLIC_SENTRY_DSN=
# Bitdrift API key. If undefined, Bitdrift will be disabled.
EXPO_PUBLIC_BITDRIFT_API_KEY=
# geolocation web worker URL
GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
# app-config web worker URL
APP_CONFIG_DEV_URL=
# bapp-config web worker URL
BAPP_CONFIG_DEV_URL=
+126
View File
@@ -0,0 +1,126 @@
module.exports = {
root: true,
extends: [
'@react-native',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-native-a11y/ios',
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'react',
'lingui',
'simple-import-sort',
'bsky-internal',
'eslint-plugin-react-compiler',
'import',
],
rules: {
'react/no-unescaped-entities': 0,
'react/prop-types': 0,
'react-native/no-inline-styles': 0,
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'P',
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'bsky-internal/use-prefixed-imports': 'error',
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native priortized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'error',
'react-compiler/react-compiler': 'warn',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{argsIgnorePattern: '^_', varsIgnorePattern: '^_.+'},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'import/consistent-type-specifier-style': ['warn', 'prefer-inline'],
},
ignorePatterns: [
'**/__mocks__/*.ts',
'src/platform/polyfills.ts',
'src/third-party',
'ios',
'android',
'coverage',
'*.lock',
'.husky',
'patches',
'*.html',
'bskyweb',
'bskyembed',
'src/locale/locales/_build/',
'src/locale/locales/**/*.js',
'*.e2e.ts',
'*.e2e.tsx',
],
settings: {
componentWrapperFunctions: ['observer'],
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
},
}
+2 -9
View File
@@ -16,9 +16,6 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
concurrency:
group: android-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -82,15 +79,13 @@ jobs:
echo "$json" > google-services.json
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
yarn use-build-number-with-bump
eas build -p android
--profile $PROFILE
--profile ${{ inputs.profile || 'testflight-android' }}
--local --output build.aab --non-interactive
- name: ✍️ Rename Testflight bundle
@@ -199,6 +194,4 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight-android' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
+2 -9
View File
@@ -16,9 +16,6 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
concurrency:
group: ios-build
cancel-in-progress: false
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -94,15 +91,13 @@ jobs:
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
yarn use-build-number-with-bump
eas build -p ios
--profile $PROFILE
--profile ${{ inputs.profile || 'testflight' }}
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
@@ -152,7 +147,5 @@ jobs:
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.profile == 'testflight' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
+11 -20
View File
@@ -39,12 +39,10 @@ jobs:
# Validate the version if one is supplied. This should generally happen if the update is for a production client
- name: 🧐 Validate version
env:
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
if: ${{ inputs.runtimeVersion }}
run: |
if [ -z "$RUNTIME_VERSION" ]; then
[[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
if [ -z "${{ inputs.runtimeVersion }}" ]; then
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
fi
- name: ⬇️ Checkout
@@ -105,15 +103,12 @@ jobs:
# eas.json not used here, set EXPO_PUBLIC_ENV
- name: Env
env:
CHANNEL: ${{ inputs.channel || 'testflight' }}
GITHUB_SHA: ${{ github.sha }}
id: env
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
echo "EXPO_PUBLIC_ENV=${{ inputs.channel || 'testflight' }}" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
@@ -150,15 +145,15 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-26
runs-on: macos-26-large
concurrency:
group: ios-build
cancel-in-progress: false
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -254,15 +249,13 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel == 'testflight' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
runs-on: Linux-x64-32core
concurrency:
group: android-build
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
@@ -370,7 +363,5 @@ jobs:
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
-54
View File
@@ -1,54 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-5-20251101
+6 -6
View File
@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Go tooling
uses: actions/setup-go@v6
uses: actions/setup-go@v3
with:
go-version-file: bskyweb/go.mod
go-version: "1.25"
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -32,11 +32,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Git Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Go tooling
uses: actions/setup-go@v6
uses: actions/setup-go@v3
with:
go-version-file: bskyweb/go.mod
go-version: "1.25"
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+3 -7
View File
@@ -32,7 +32,7 @@ jobs:
fi
if [[ "$COMMENT" == *"ota"* ]]; then
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
has_ota=true
else
has_ota=false
@@ -75,8 +75,6 @@ jobs:
steps:
- name: Get PR HEAD SHA
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
id: pr-info
uses: actions/github-script@v7
with:
@@ -84,7 +82,7 @@ jobs:
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: process.env.ISSUE_NUMBER,
pull_number: ${{ github.event.issue.number }}
});
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
@@ -186,15 +184,13 @@ jobs:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
message: |
Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.issue.number }}" width=300 height=300>
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
---
+5 -12
View File
@@ -35,27 +35,20 @@ jobs:
cache: yarn
- name: Ensure tracking relevant branches and checkout base
env:
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ github.base_ref }}
run: |
git checkout $HEAD_REF
git checkout $BASE_REF
git checkout ${{ github.head_ref }}
git checkout ${{ github.base_ref }}
- name: Get the base commit
id: base-commit
env:
BASE_REF: ${{ github.base_ref }}
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
- name: Merge PR commit
env:
HEAD_REF: ${{ github.head_ref }}
run: |
# Have to set a git config for the merge to work
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
git merge --no-edit $HEAD_REF
git merge --no-edit ${{ github.head_ref }}
yarn install
yarn intl:build
@@ -91,7 +84,7 @@ jobs:
with:
base_path: "stats-base.json"
pr_path: "../stats-new.json"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
+2 -6
View File
@@ -17,9 +17,7 @@ jobs:
fetch-depth: 0
- name: Fetch base branch
env:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
run: git fetch origin ${{ github.base_ref }} --depth=1
- name: Install node
uses: actions/setup-node@v6
@@ -27,9 +25,7 @@ jobs:
node-version-file: .nvmrc
- name: Reset yarn.lock to base
env:
BASE_REF: ${{ github.base_ref }}
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
run: git show "origin/${{ github.base_ref }}:yarn.lock" > yarn.lock
- name: Yarn install
uses: Wandalen/wretry.action@master
+1 -4
View File
@@ -110,9 +110,7 @@ google-services.json
# i18n
src/locale/locales/_build/
src/locale/locales/**/messages.js
src/locale/locales/**/messages.mjs
src/locale/locales/**/messages.ts
src/locale/locales/**/*.js
# local builds
*.apk
@@ -126,4 +124,3 @@ bskyogcard/src/assets/fonts/noto-*
bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
-755
View File
@@ -1,755 +0,0 @@
# CLAUDE.md - Bluesky Social App Development Guide
This document provides guidance for working effectively in the Bluesky Social app codebase.
## Project Overview
Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network.
**Tech Stack:**
- React Native 0.81 with Expo 54
- TypeScript
- React Navigation for routing
- TanStack Query (React Query) for data fetching
- Lingui for internationalization
- Custom design system called ALF (Application Layout Framework)
## Essential Commands
```bash
# Development
yarn start # Start Expo dev server
yarn web # Start web version
yarn android # Run on Android
yarn ios # Run on iOS
# Testing & Quality
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
# DO NOT run these commands - extraction and compilation are handled by CI
yarn intl:extract # Extract translation strings (nightly CI job)
yarn intl:compile # Compile translations for runtime (nightly CI job)
# Build
yarn build-web # Build web version
yarn prebuild # Generate native projects
```
## Project Structure
```
src/
├── alf/ # Design system (ALF) - themes, atoms, tokens
├── components/ # Shared UI components (Button, Dialog, Menu, etc.)
├── screens/ # Full-page screen components (newer pattern)
├── features/ # Macro-features that bridge components/screens
├── view/
│ ├── screens/ # Full-page screens (legacy location)
│ ├── com/ # Reusable view components
│ └── shell/ # App shell (navigation bars, tabs)
├── state/
│ ├── queries/ # TanStack Query hooks
│ ├── preferences/ # User preferences (React Context)
│ ├── session/ # Authentication state
│ └── persisted/ # Persistent storage layer
├── lib/ # Utilities, constants, helpers
├── locale/ # i18n configuration and language files
└── Navigation.tsx # Main navigation configuration
```
### Project Structure in Depth
When building new things, follow these guidelines for where to put code.
#### Components vs Screens vs Features
**Components** are reusable UI elements that are not full screens. Should be
platform-agnostic when possible. Examples: Button, Dialog, Menu, TextField. Put
these in `/components` if they are shared across screens.
**Screens** are full-page components that represent a route in the app. They
often contain multiple components and handle layout for a page. New screens
should go in `/screens` (not `/view/screens`) to encourage better organization
and separation from legacy code.
For complex screens that have specific components or data needs that _are not
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
`/screens/ProfileScreen/components/`.
**Features** are higher-level modules that may include context, data fetching,
components, and utilities related to a specific feature e.g.
`/features/liveNow`. They don't neatly fit into components or screens and often
span multiple screens. This is an optional pattern for organizing complex
features.
#### Legacy Directories
For the most part, avoid writing new files into the `/view` directory and
subdirectories. This is the older pattern for organizing screens and components,
and it has become a bit disorganized over time. New development should go into
`/screens`, `/components`, and `/features`.
#### State
The `/state` directory is where we've historically put all our data fetching and
state management logic. This is perfectly fine, but for new features, consider
organizing state logic closer to the components that use it, either within a
feature directory or co-located with a screen. The key is to keep related code
together and avoid having "god files" with too much unrelated logic.
#### Lib
The `/lib` directory is for utilities and helpers that don't fit into other
categories. This can include things like API clients, formatting functions,
constants, and other shared logic.
#### Top Level Directories
Avoid writing new top-level subdirectories within `/src`. We've done this for a
few things in the past that, but we have stronger patterns now. Examples:
`/logger` should probably have been written into `/lib`. And `ageAssurance` is
better classified within `/features`. We will probably migrate these things
eventually.
### File and Directory Naming Conventions
Typically JS style for variables, functions, etc. We use ProudCamelCase for
components, and camelCase directories and files.
When organizing new code, consider if it fits into a single file, or if it
should be broken down into multiple files. For "macro" component cases, or
things that live in `/features` or `/screens`, we often follow a pattern of
having an `index.tsx` for the main component, and then co-locating related
components, hooks, and utilities in the same directory. For example:
```
src
├── screens/
│ ├── ProfileScreen/
│ │ ├── index.tsx # Main screen component
│ │ ├── components/ # Sub-components used only by this screen
```
Similar patterns can be found in `/features` and `/components`. The idea here is
to keep related code together and make it easier to navigate.
You should ask yourself: if someone new was looking for the code related to this
feature or screen, where would they expect to find it? Organizing code in a way
that matches developer expectations can make the codebase much more
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
easier to understand than having it scattered across multiple directories.
No need to go overboard with this. If a component or feature fits into a single
file, there's no reason to have a `/Component/index.tsx` file when it could just
be `/Component.tsx`. Use your judgment based on the complexity and amount of
related code.
#### Platform Specific Files
We have conflicting patterns in the app for this. The preferred approach is to
group platform-specific files into a directory as much as possible. For example,
rather than having `Component.tsx`, `Component.web.tsx`, and
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file.
### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
itself well to this, since the `index.tsx` can be the main component file, and
the `README.md` can provide documentation for the whole feature. This is
optional, but can be a nice way to keep documentation close to the code it
describes.
Similarly, if there are tests that are specific to a component or feature, it
can be helpful to include them in the same directory, either as
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
related to the component or feature in one place and makes it easier to find and
maintain tests.
## Styling System (ALF)
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
### Basic Usage
```tsx
import {atoms as a, useTheme} from '#/alf'
function MyComponent() {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
Hello
</Text>
</View>
)
}
```
### Key Concepts
**Static Atoms** - Theme-independent styles imported from `atoms`:
```tsx
import {atoms as a} from '#/alf'
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
```
**Theme Atoms** - Theme-dependent colors from `useTheme()`:
```tsx
const t = useTheme()
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
// t.palette.primary_500, t.palette.negative_400, etc.
```
**Platform Utilities** - For platform-specific styles:
```tsx
import {web, native, ios, android, platform} from '#/alf'
const styles = [
a.p_md,
web({cursor: 'pointer'}),
native({paddingBottom: 20}),
platform({ios: {...}, android: {...}, web: {...}}),
]
```
**Breakpoints** - Responsive design:
```tsx
import {useBreakpoints} from '#/alf'
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
if (gtMobile) {
// Tablet or desktop layout
}
```
### Naming Conventions
- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes)
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
- Borders: `border`, `border_t`, `rounded_md`, `rounded_full`
## Component Patterns
### Dialog Component
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
```tsx
import * as Dialog from '#/components/Dialog'
function MyFeature() {
const control = Dialog.useDialogControl()
return (
<>
<Button label="Open" onPress={control.open}>
<ButtonText>Open Dialog</ButtonText>
</Button>
<Dialog.Outer control={control}>
{/* Typically the inner part is in its own component */}
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={_(msg`My Dialog`)}>
<Dialog.Header>
<Dialog.HeaderText>Title</Dialog.HeaderText>
</Dialog.Header>
<Text>Dialog content here</Text>
<Button label="Done" onPress={() => control.close()}>
<ButtonText>Done</ButtonText>
</Button>
<Dialog.Close /> {/* Web-only X button in top left */}
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
```
### Menu Component
Menus render as a dropdown on web and a bottom sheet dialog on native.
```tsx
import * as Menu from '#/components/Menu'
function MyMenu() {
return (
<Menu.Root>
<Menu.Trigger label="Open menu">
{({props}) => (
<Button {...props} label="Menu">
<ButtonIcon icon={DotsHorizontal} />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item label="Edit" onPress={handleEdit}>
<Menu.ItemIcon icon={Pencil} />
<Menu.ItemText>Edit</Menu.ItemText>
</Menu.Item>
<Menu.Item label="Delete" onPress={handleDelete}>
<Menu.ItemIcon icon={Trash} />
<Menu.ItemText>Delete</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
```
### Button Component
```tsx
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
// Solid primary button (most common)
<Button label="Save" onPress={handleSave} color="primary" size="large">
<ButtonText>Save</ButtonText>
</Button>
// With icon
<Button label="Share" onPress={handleShare} color="secondary" size="small">
<ButtonIcon icon={Share} />
<ButtonText>Share</ButtonText>
</Button>
// Icon-only button
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
<ButtonIcon icon={XIcon} />
</Button>
// Ghost variant (deprecated - use color prop)
<Button label="Cancel" variant="ghost" color="secondary" size="small">
<ButtonText>Cancel</ButtonText>
</Button>
```
**Button Props:**
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
### Typography
```tsx
import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop
<Text emoji>Hello! 👋</Text>
```
### TextField
```tsx
import * as TextField from '#/components/forms/TextField'
<TextField.LabelText>Email</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={AtSign} />
<TextField.Input
label="Email address"
placeholder="you@example.com"
defaultValue={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
</TextField.Root>
```
## Internationalization (i18n)
All user-facing strings must be wrapped for translation using Lingui.
```tsx
import {msg, plural} from '@lingui/core/macro'
import {Trans} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
function MyComponent() {
const {_} = useLingui()
// Simple strings - use msg() with _() function
const title = _(msg`Settings`)
const errorMessage = _(msg`Something went wrong`)
// Strings with variables
const greeting = _(msg`Hello, ${name}!`)
// Pluralization
const countLabel = _(plural(count, {
one: '# item',
other: '# items',
}))
// JSX content - use Trans component
return (
<Text>
<Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans>
</Text>
)
}
```
**Commands:**
```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile translations for runtime
```
## State Management
### TanStack Query (Data Fetching)
```tsx
// src/state/queries/profile.ts
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
// Query key pattern
const RQKEY_ROOT = 'profile'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
// Query hook
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY(did),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
// Mutation hook
export function useUpdateProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (data) => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
},
onError: (error) => {
if (isNetworkError(error)) {
// don't log, but inform user
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
// XRPC APIs often have typed errors, allows nicer handling
} else {
// Log unexpected errors to Sentry
logger.error('Error updating profile', {safeMessage: error})
}
}
})
}
```
**Stale Time Constants** (from `src/state/queries/index.ts`):
```tsx
STALE.SECONDS.FIFTEEN // 15 seconds
STALE.MINUTES.ONE // 1 minute
STALE.MINUTES.FIVE // 5 minutes
STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
```tsx
export function useDraftsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: ['drafts'],
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
return res.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: page => page.cursor,
})
}
```
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
### Preferences (React Context)
```tsx
// Simple boolean preference pattern
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
function SettingsScreen() {
const autoplayDisabled = useAutoplayDisabled()
const setAutoplayDisabled = useSetAutoplayDisabled()
return (
<Toggle
value={autoplayDisabled}
onValueChange={setAutoplayDisabled}
/>
)
}
```
### Session State
```tsx
import {useSession, useAgent} from '#/state/session'
function MyComponent() {
const {hasSession, currentAccount} = useSession()
const agent = useAgent()
if (!hasSession) {
return <LoginPrompt />
}
// Use agent for API calls
const response = await agent.getProfile({actor: currentAccount.did})
}
```
## Navigation
Navigation uses React Navigation with type-safe route parameters.
```tsx
// Screen component
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export function ProfileScreen({route, navigation}: Props) {
const {name} = route.params // Type-safe params
return (
<Layout.Screen>
{/* Screen content */}
</Layout.Screen>
)
}
// Programmatic navigation
import {useNavigation} from '@react-navigation/native'
const navigation = useNavigation()
navigation.navigate('Profile', {name: 'alice.bsky.social'})
// Or use the navigate helper
import {navigate} from '#/Navigation'
navigate('Profile', {name: 'alice.bsky.social'})
```
## Platform-Specific Code
Use file extensions for platform-specific implementations:
```
Component.tsx # Shared/default
Component.web.tsx # Web-only
Component.native.tsx # iOS + Android
Component.ios.tsx # iOS-only
Component.android.tsx # Android-only
```
Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
```tsx
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
import * as storage from '#/state/drafts/storage'
// WRONG - don't use require() or conditional imports for platform files
const storage = IS_NATIVE
? require('#/state/drafts/storage')
: require('#/state/drafts/storage.web')
```
Platform detection (for runtime logic, not imports):
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
if (IS_NATIVE) {
// Native-specific logic
}
```
## Import Aliases
Always use the `#/` alias for absolute imports:
```tsx
// Good
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
// Avoid
import {useSession} from '../../../state/session'
```
## Footguns
Common pitfalls to avoid in this codebase:
### Dialog Close Callback (Critical)
**Always use `control.close(() => ...)` when performing actions after closing a dialog.** The callback ensures the action runs after the dialog's close animation completes. Failing to do this causes race conditions with React state updates.
```tsx
// WRONG - causes bugs with state updates, navigation, opening other dialogs
const onConfirm = () => {
control.close()
navigation.navigate('Home') // May race with dialog animation
}
// WRONG - same problem
const onConfirm = () => {
control.close()
otherDialogControl.open() // Will likely fail or cause visual glitches
}
// CORRECT - action runs after dialog fully closes
const onConfirm = () => {
control.close(() => {
navigation.navigate('Home')
})
}
// CORRECT - opening another dialog after close
const onConfirm = () => {
control.close(() => {
otherDialogControl.open()
})
}
// CORRECT - state updates after close
const onConfirm = () => {
control.close(() => {
setSomeState(newValue)
onCallback?.()
})
}
```
This applies to:
- Navigation (`navigation.navigate()`, `navigation.push()`)
- Opening other dialogs or menus
- State updates that affect UI (`setState`, `queryClient.invalidateQueries`)
- Callbacks passed from parent components
The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`.
### Controlled vs Uncontrolled Inputs
Prefer `defaultValue` over `value` for TextInput on the old architecture:
```tsx
// Preferred - uncontrolled
<TextField.Input
defaultValue={initialEmail}
onChangeText={setEmail}
/>
// Avoid when possible - controlled (can cause performance issues)
<TextField.Input
value={email}
onChangeText={setEmail}
/>
```
### Platform-Specific Behavior
Some components behave differently across platforms:
- `Dialog.Handle` - Only renders on native (drag handle for bottom sheet)
- `Dialog.Close` - Only renders on web (X button)
- `Menu.Divider` - Only renders on web
- `Menu.ContainerItem` - Only works on native
Always test on multiple platforms when using these components.
### React Compiler is Enabled
This codebase uses React Compiler, so **don't proactively add `useMemo` or `useCallback`**. The compiler handles memoization automatically.
```tsx
// UNNECESSARY - React Compiler handles this
const handlePress = useCallback(() => {
doSomething()
}, [doSomething])
// JUST WRITE THIS
const handlePress = () => {
doSomething()
}
```
Only use `useMemo`/`useCallback` when you have a specific reason, such as:
- The value is immediately used in an effect's dependency array
- You're passing a callback to a non-React library that needs referential stability
## Best Practices
1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful
2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>`
3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles
4. **State**: Use TanStack Query for server state, React Context for UI preferences
5. **Components**: Check if a component exists in `#/components/` before creating new ones
6. **Types**: Define explicit types for props, use `NativeStackScreenProps` for screens
7. **Testing**: Components should have `testID` props for E2E testing
## Key Files Reference
| Purpose | Location |
|---------|----------|
| Theme definitions | `src/alf/themes.ts` |
| Design tokens | `src/alf/tokens.ts` |
| Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) |
| Navigation config | `src/Navigation.tsx` |
| Route definitions | `src/routes.ts` |
| Route types | `src/lib/routes/types.ts` |
| Query hooks | `src/state/queries/*.ts` |
| Session state | `src/state/session/index.tsx` |
| i18n setup | `src/locale/i18n.ts` |
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 20232026 Bluesky Social PBC
Copyright 20232025 Bluesky Social PBC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+1
View File
@@ -39,6 +39,7 @@ appId: xyz.blueskyweb.app
id: "editListNameInput"
- eraseText
- inputText: "Bad Ppl"
- hideKeyboard
- tapOn:
id: "editListDescriptionInput"
- eraseText
+6 -12
View File
@@ -35,12 +35,9 @@ appId: xyz.blueskyweb.app
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Tap on down arrow"
id: "feed-timeline-moveDown"
- tapOn:
label: "Save button"
id: "saveChangesBtn"
@@ -58,12 +55,9 @@ appId: xyz.blueskyweb.app
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Tap on down arrow"
id: "feed-feed-moveDown"
- tapOn:
label: "Save button"
id: "saveChangesBtn"
-5
View File
@@ -15,11 +15,6 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "customServerTextInput"
- inputText: "http://localhost:3000"
- runFlow:
when:
platform: Android
commands:
- hideKeyboard
- tapOn: "Done"
- tapOn:
id: "loginUsernameInput"
@@ -26,7 +26,6 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "report:details"
- inputText: "This is a test report"
- hideKeyboard
- tapOn:
id: "report:submit"
- assertNotVisible:
+9 -23
View File
@@ -3,29 +3,15 @@ appId: xyz.blueskyweb.app
- launchApp:
appId: "xyz.blueskyweb.app"
clearState: true
arguments:
"-EXDevMenuIsOnboardingFinished": true
- runFlow:
when:
platform: iOS
commands:
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
- runFlow:
when:
visible: 'Open in "Bluesky"'
commands:
- tapOn: Open
- runFlow:
when:
platform: Android
commands:
- tapOn: 'http://localhost:8081'
- runFlow:
label: "Dismiss Expo dev menu"
when:
visible: "Continue"
commands:
- back
- waitForAnimationToEnd
- tapOn: "http://localhost:8081"
- waitForAnimationToEnd
- extendedWaitUntil:
visible: "Continue"
- swipe:
from: "Bluesky"
direction: DOWN
duration: 100
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
+6
View File
@@ -0,0 +1,6 @@
export default {
requestPermission: jest.fn(),
onForegroundEvent: jest.fn(),
setBadgeCount: jest.fn(),
displayNotification: jest.fn(),
}
@@ -0,0 +1,9 @@
export const CameraRoll = {
getPhotos: jest.fn().mockResolvedValue({
edges: [
{node: {image: {uri: 'path/to/image1.jpg'}}},
{node: {image: {uri: 'path/to/image2.jpg'}}},
{node: {image: {uri: 'path/to/image3.jpg'}}},
],
}),
}
@@ -0,0 +1,4 @@
export default {
configure: jest.fn().mockResolvedValue(0),
finish: jest.fn(),
}
+1
View File
@@ -0,0 +1 @@
export default {}
+10
View File
@@ -0,0 +1,10 @@
jest.mock('rn-fetch-blob', () => {
return {
__esModule: true,
default: {
fs: {
unlink: jest.fn(),
},
},
}
})
+2
View File
@@ -0,0 +1,2 @@
export const DropdownMenu = jest.fn().mockImplementation(() => {})
export const create = jest.fn().mockImplementation(() => {})
-30
View File
@@ -1,5 +1,4 @@
import {RichText} from '@atproto/api'
import {i18n} from '@lingui/core'
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
import {
@@ -7,7 +6,6 @@ import {
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
@@ -204,9 +202,6 @@ describe('enforceLen', () => {
})
describe('cleanError', () => {
// cleanError uses lingui
i18n.loadAndActivate({locale: 'en', messages})
const inputs = [
'TypeError: Network request failed',
'Error: Aborted',
@@ -332,7 +327,6 @@ describe('shortenLinks', () => {
expect(outputRT.text).toEqual(outputs[i][0])
expect(outputRT.facets?.length).toEqual(outputs[i][1].length)
for (let j = 0; j < outputs[i][1].length; j++) {
// @ts-expect-error whatever
expect(outputRT.facets![j].features[0].uri).toEqual(outputs[i][1][j])
}
}
@@ -443,13 +437,6 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups',
'https://maxblansjaar.bandcamp.com/album/false-comforts',
'https://grmnygrmny.bandcamp.com/track/fluid',
'https://sufjanstevens.bandcamp.com/',
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
]
const outputs = [
@@ -828,23 +815,6 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
{
type: 'bandcamp_album',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fmaxblansjaar.bandcamp.com%2Falbum%2Ffalse-comforts/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
{
type: 'bandcamp_track',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fgrmnygrmny.bandcamp.com%2Ftrack%2Ffluid/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
+31 -63
View File
@@ -1,10 +1,5 @@
// @ts-check
const pkg = require('./package.json')
/**
* @param {import('@expo/config-types').ExpoConfig} _config
* @returns {{ expo: import('@expo/config-types').ExpoConfig }}
*/
module.exports = function (_config) {
/**
* App version number. Should be incremented as part of a release cycle.
@@ -20,7 +15,7 @@ module.exports = function (_config) {
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
const IS_DEV = !IS_TESTFLIGHT || !IS_PRODUCTION
const ASSOCIATED_DOMAINS = [
'applinks:bsky.app',
@@ -35,13 +30,6 @@ module.exports = function (_config) {
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
const IOS_ICON_FILE =
PLATFORM === 'web' // web build doesn't like .icon files
? './assets/app-icons/ios_icon_default_next.png'
: IS_TESTFLIGHT
? './assets/app-icons/ios_icon_testflight.icon'
: './assets/app-icons/ios_icon_default.icon'
return {
expo: {
version: VERSION,
@@ -62,7 +50,10 @@ module.exports = function (_config) {
config: {
usesNonExemptEncryption: false,
},
icon: IOS_ICON_FILE,
icon:
PLATFORM === 'web' // web build doesn't like .icon files
? './assets/app-icons/ios_icon_default_next.png'
: './assets/app-icons/ios_icon_default.icon',
infoPlist: {
UIBackgroundModes: ['remote-notification'],
NSCameraUsageDescription:
@@ -116,13 +107,13 @@ module.exports = function (_config) {
'zh-Hans',
'zh-Hant',
],
UIDesignRequiresCompatibility: true,
},
associatedDomains: ASSOCIATED_DOMAINS,
entitlements: {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
NSPrivacyCollectedDataTypes: [
@@ -201,14 +192,10 @@ module.exports = function (_config) {
scheme: 'https',
host: 'bsky.app',
},
...(IS_DEV
? [
{
scheme: 'http',
host: 'localhost:19006',
},
]
: []),
IS_DEV && {
scheme: 'http',
host: 'localhost:19006',
},
],
category: ['BROWSABLE', 'DEFAULT'],
},
@@ -240,32 +227,20 @@ module.exports = function (_config) {
'react-native-edge-to-edge',
{android: {enforceNavigationBarContrast: false}},
],
...(USE_SENTRY
? [
/** @type {[string, any]} */ ([
'@sentry/react-native/expo',
{
organization: 'blueskyweb',
project: 'app',
url: 'https://sentry.io',
},
]),
]
: []),
USE_SENTRY && [
'@sentry/react-native/expo',
{
organization: 'blueskyweb',
project: 'app',
url: 'https://sentry.io',
},
],
[
'expo-build-properties',
{
ios: {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
extraPods: [
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
},
],
},
android: {
compileSdkVersion: 35,
@@ -322,25 +297,25 @@ module.exports = function (_config) {
'expo-splash-screen',
{
ios: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/splash.png',
enableFullScreenImage_legacy: true,
backgroundColor: '#ffffff',
image: './assets/splash.png',
resizeMode: 'cover',
dark: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#002861', // primary_900
image: './assets/splash/splash-dark.png',
enableFullScreenImage_legacy: true,
backgroundColor: '#001429',
image: './assets/splash-dark.png',
resizeMode: 'cover',
},
},
android: {
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, // even division of 306px
backgroundColor: '#0c7cff',
image: './assets/splash-android-icon.png',
imageWidth: 150,
dark: {
backgroundColor: '#002861', // primary_900
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102,
backgroundColor: '#0c2a49',
image: './assets/splash-android-icon-dark.png',
imageWidth: 150,
},
},
},
@@ -414,14 +389,7 @@ module.exports = function (_config) {
],
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
['expo-location'],
[
'expo-contacts',
{
contactsPermission:
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
},
],
],
].filter(Boolean),
extra: {
eas: {
build: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

@@ -1,113 +0,0 @@
{
"fill" : {
"automatic-gradient" : "srgb:1.00000,1.00000,1.00000,1.00000"
},
"groups" : [
{
"blend-mode-specializations" : [
{
"value" : "overlay"
},
{
"appearance" : "dark",
"value" : "screen"
},
{
"appearance" : "tinted",
"value" : "screen"
}
],
"blur-material-specializations" : [
{
"value" : 0.5
},
{
"appearance" : "dark",
"value" : 0.5
},
{
"appearance" : "tinted",
"value" : null
}
],
"hidden" : false,
"layers" : [
{
"image-name" : "TestFlight notice.png",
"name" : "TestFlight notice"
}
],
"lighting" : "individual",
"position" : {
"scale" : 0.4,
"translation-in-points" : [
0,
350
]
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular-specializations" : [
{
"value" : false
},
{
"appearance" : "dark",
"value" : false
},
{
"appearance" : "tinted",
"value" : false
}
],
"translucency-specializations" : [
{
"value" : {
"enabled" : true,
"value" : 0.5
}
},
{
"appearance" : "dark",
"value" : {
"enabled" : true,
"value" : 0.5
}
},
{
"appearance" : "tinted",
"value" : {
"enabled" : true,
"value" : 0.5
}
}
]
},
{
"layers" : [
{
"fill" : "none",
"glass" : false,
"image-name" : "iOS transparent.png",
"name" : "iOS transparent"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2.972 12a9 9 0 0 1 9-9c1.726 0 3.165.423 4.448 1.21A10 10 0 0 1 18 5.428V4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 1 1 0-2h1.756a8.3 8.3 0 0 0-1.382-1.085C14.417 5.327 13.341 5 11.972 5a7 7 0 1 0 6.601 9.333A1 1 0 0 1 20.46 15a9 9 0 0 1-17.487-3Z"/></svg>

Before

Width:  |  Height:  |  Size: 349 B

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

Before

Width:  |  Height:  |  Size: 484 B

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

Before

Width:  |  Height:  |  Size: 500 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"/></svg>

Before

Width:  |  Height:  |  Size: 303 B

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

Before

Width:  |  Height:  |  Size: 518 B

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

Before

Width:  |  Height:  |  Size: 568 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M32.457 7c1.68 0 3.29.668 4.478 1.855L49.813 21.73a6.33 6.33 0 0 1 1.854 4.479v24.458A6.333 6.333 0 0 1 45.333 57H18.666a6.334 6.334 0 0 1-6.333-6.333V13.333A6.334 6.334 0 0 1 18.666 7h13.791ZM18.666 9a4.334 4.334 0 0 0-4.333 4.333v37.334A4.334 4.334 0 0 0 18.666 55h26.667a4.333 4.333 0 0 0 4.333-4.333V26.209c0-.418-.061-.829-.177-1.223a1 1 0 0 1-.155.014H40a6.334 6.334 0 0 1-6.325-6.008l-.008-.326V9.333q0-.08.013-.156A4.3 4.3 0 0 0 32.457 9H18.666Zm18.627 22.293a1 1 0 1 1 1.414 1.414L33.414 38l5.293 5.293a1 1 0 1 1-1.414 1.414L32 39.414l-5.293 5.293a1 1 0 1 1-1.414-1.414L30.586 38l-5.293-5.293a1 1 0 1 1 1.414-1.414L32 36.586l5.293-5.293Zm-1.626-12.627.006.224A4.333 4.333 0 0 0 40 23h8.253L35.667 10.414v8.252Z"/></svg>

Before

Width:  |  Height:  |  Size: 822 B

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

Before

Width:  |  Height:  |  Size: 1014 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>

Before

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Before

Width:  |  Height:  |  Size: 606 KiB

After

Width:  |  Height:  |  Size: 606 KiB

Before

Width:  |  Height:  |  Size: 563 KiB

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -16,7 +16,7 @@ module.exports = function (api) {
],
],
plugins: [
'@lingui/babel-plugin-lingui-macro',
'macros',
['babel-plugin-react-compiler', {target: '19'}],
[
'module:react-native-dotenv',
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'simple-import-sort'],
extends: [
'eslint:recommended',
'preact',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
rules: {
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'warn',
'no-else-return': 'off',
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
}
-52
View File
@@ -1,52 +0,0 @@
// @ts-check
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import globals from 'globals'
export default tseslint.config(
// Global ignores
{
ignores: ['dist/**', 'node_modules/**'],
},
// Base JS recommended rules
js.configs.recommended,
// TypeScript rules with type checking
...tseslint.configs.recommendedTypeChecked,
// Main configuration
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'simple-import-sort': simpleImportSort,
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
},
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'simple-import-sort/imports': 'warn',
'simple-import-sort/exports': 'warn',
'no-else-return': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_.+',
caughtErrors: 'none',
ignoreRestSiblings: true,
},
],
},
},
)
+4 -9
View File
@@ -7,7 +7,7 @@
"dev-snippet": "tsc --project tsconfig.snippet.json && serve -s dist -p 3000 -n",
"build": "tsc && vite build",
"build-snippet": "tsc --project tsconfig.snippet.json",
"lint": "eslint --cache src",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -17,21 +17,16 @@
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"@vitejs/plugin-legacy": "^7.0.0",
"@eslint/js": "^9.18.0",
"autoprefixer": "^10.4.19",
"eslint": "^9.18.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"globals": "^15.14.0",
"eslint": "^8.19.0",
"eslint-config-preact": "^1.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"typescript-eslint": "^8.20.0",
"serve": "^14.2.5",
"tailwindcss": "^3.4.3",
"terser": "^5.43.1",
"typescript": "^5.8.3",
"vite": "^7.0.4",
"vite-tsconfig-paths": "^5.1.4"
},
"resolutions": {
"@types/estree": "1.0.6"
}
}
+1350 -308
View File
File diff suppressed because it is too large Load Diff
+1 -12
View File
@@ -1,21 +1,10 @@
import React from 'react'
function detectMime(buf: Buffer): string {
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp'
if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif'
return 'image/jpeg'
}
export function Img(
props: Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> & {src: Buffer},
) {
const {src, ...others} = props
return (
<img
{...others}
src={`data:${detectMime(src)};base64,${src.toString('base64')}`}
/>
<img {...others} src={`data:image/jpeg;base64,${src.toString('base64')}`} />
)
}
+14 -21
View File
@@ -6,26 +6,21 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
environment set up. Either follow the top-level README, or something quick
like:
```bash
# install nodejs
nvm install
nvm use
npm install --global yarn
# install nodejs
nvm install
nvm use
npm install --global yarn
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# run yarn web dev server, if you wanted
yarn web
```
# run yarn web dev server, if you wanted
yarn web
Then build and copy over the big 'ol `bundle.web.js` file:
```bash
# in the top level of this repo
yarn build-web
```
# in the top level of this repo
yarn build-web
### Golang Daemon
@@ -33,13 +28,11 @@ Install golang. We generally develop against the current stable release of the l
In this directory (`bskyweb/`):
```bash
# re-build and run daemon
go run ./cmd/bskyweb serve
# re-build and run daemon
go run ./cmd/bskyweb serve
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
```
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
The easiest way to configure the daemon is to copy `example.env` to `.env` and
fill in auth values there.
-7
View File
@@ -2,14 +2,12 @@ package main
import (
"net/url"
"strings"
"github.com/flosch/pongo2/v6"
)
func init() {
pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL)
pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail)
}
func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
@@ -28,8 +26,3 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value
// Return the cleaned URL
return pongo2.AsValue(parsedURL.String()), nil
}
func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) {
urlStr := in.String()
return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil
}
+3 -13
View File
@@ -308,6 +308,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
e.GET("/settings/app-icon", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
@@ -574,10 +575,7 @@ func (srv *Server) WebPost(c echo.Context) error {
if postView.Embed != nil && !isEmbedHidden {
hasImages := postView.Embed.EmbedImages_View != nil
hasVideo := postView.Embed.EmbedVideo_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
if hasImages {
var thumbUrls []string
@@ -585,20 +583,12 @@ func (srv *Server) WebPost(c echo.Context) error {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasVideo {
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
}
} else if hasMediaImages {
} else if hasMedia {
var thumbUrls []string
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasMediaVideo {
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
}
}
}
+1 -8
View File
@@ -178,13 +178,6 @@ func serve(cctx *cli.Context) error {
return http.FS(fsys)
}())
// Create CORS middleware for oembed
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
})
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
@@ -212,7 +205,7 @@ func serve(cctx *cli.Context) error {
e.GET("/", server.WebHome)
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
e.GET("/embed.js", echo.WrapHandler(staticHandler))
e.GET("/oembed", server.WebOEmbed, oembedCORS)
e.GET("/oembed", server.WebOEmbed)
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
// Start the server.
+13 -50
View File
@@ -37,6 +37,14 @@
font-style: italic;
font-display: swap;
}
html {
background-color: white;
}
@media (prefers-color-scheme: dark) {
html {
background-color: black;
}
}
html,
body {
margin: 0px;
@@ -51,19 +59,6 @@
-ms-overflow-style: scrollbar;
font-synthesis-weight: none;
}
:root {
--text: black;
--background: white;
--backgroundLight: #e2e7ee;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--text: white;
--background: black;
--backgroundLight: #232e3e;
}
}
html,
body,
#root {
@@ -72,32 +67,6 @@
min-height: 100%;
width: 100%;
}
html.theme--light,
html.theme--light body,
html.theme--light #root {
background-color: white;
--text: black;
--background: white;
--backgroundLight: #DCE2EA;
}
html.theme--dark,
html.theme--dark body,
html.theme--dark #root {
color-scheme: dark;
background-color: black;
--text: white;
--background: black;
--backgroundLight: #232E3E;
}
html.theme--dim,
html.theme--dim body,
html.theme--dim #root {
color-scheme: dark;
background-color: #151D28;
--text: white;
--background: #151D28;
--backgroundLight: #2C3A4E;
}
#splash {
display: flex;
position: fixed;
@@ -124,12 +93,6 @@
overflow-y: scroll;
}
</style>
<script>
const theme = localStorage.getItem('ALF_THEME')
if (theme) {
document.documentElement.classList.add(`theme--${theme}`)
}
</script>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="{{ staticCDNHost }}/static/apple-touch-icon.png">
@@ -148,14 +111,14 @@
</head>
<body>
{%- block body_all %}
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
<div id="root">
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
</div>
<noscript>
<noscript style="position: absolute;">
<h1 lang="en">JavaScript Required</h1>
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
+6 -6
View File
@@ -35,8 +35,8 @@
{% endfor %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
<meta property="og:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar|avatar_thumbnail }}">
<meta property="og:image" content="{{ postView.Author.Avatar }}">
<meta property="twitter:image" content="{{ postView.Author.Avatar }}">
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:label1" content="Posted At">
@@ -108,9 +108,9 @@
{% else %}
<meta property="og:title" content="@{{ profileHandle }}">
{% endif -%}
<meta name="description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta property="og:description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta property="twitter:description" content="This author has chosen to make their posts visible only to people who are signed in.">
<meta name="description" content="This post requires authentication to view.">
<meta property="og:description" content="This post requires authentication to view.">
<meta property="twitter:description" content="This post requires authentication to view.">
<meta name="twitter:card" content="summary">
{% endif -%}
{%- endblock %}
@@ -129,7 +129,7 @@
<div id="bsky_post_summary">
<h3>Post</h3>
<p id="bsky_handle">{{ profileHandle }}</p>
<p id="bsky_post_text">This author has chosen to make their posts visible only to people who are signed in.</p>
<p id="bsky_post_text">This post requires authentication to view.</p>
</div>
{% endif -%}
{%- endblock %}
-6
View File
@@ -1,6 +0,0 @@
{
"scripts": {
"setup": "yarn install",
"run": "yarn web --port $CONDUCTOR_PORT"
}
}
+1 -3
View File
@@ -111,8 +111,6 @@ This is NOT required for app development but if you also want to develop the Blu
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
- Launch a Postgres database on port 5432
- `cd packages/dev-env && pnpm start`
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
Then, when logging in or creating an account, point it to the localhost port of the devserver.
@@ -164,8 +162,8 @@ See [testing.md](./testing.md).
`./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes:
- TextEncoder / TextDecoder
- react-native-url-polyfill
- Array#findLast (on web)
- setImmediate (on web)
### Sentry sourcemaps
+5 -20
View File
@@ -73,7 +73,7 @@ import { Text } from "react-native";
```jsx
// After
import { Text } from "react-native";
import { Trans } from "@lingui/react/macro";
import { Trans } from "@lingui/macro";
<Text><Trans>Hello World</Trans></Text>
```
@@ -90,33 +90,18 @@ const text = "Hello World";
```
In this case, you can use the `useLingui()` hook:
```jsx
import { msg } from "@lingui/core/macro";
import { msg } from "@lingui/macro";
import { useLingui } from "@lingui/react";
const { _ } = useLingui();
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
```
NEW: the latest Lingui version introduced a new macro version of the `useLingui` hook which lets you do this:
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { useLingui } from "@lingui/react/macro";
import { t } from "@lingui/macro";
const { t } = useLingui();
return <Text accessibilityLabel={t`Label is here`}>{text}</Text>
```
If you want to do this outside of a React component, you can use the global `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/core/macro";
// not ideal - t only gets called once at module evaluation time
const text = t`Hello World`;
// however, this is suitable for strings that are ephemeral:
function sayHello() {
Toast.show(t`Hello World`); // Each time the toast shows, the current locale at that moment is used
}
```
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
@@ -136,7 +121,7 @@ So the workflow is as follows:
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
```jsx
import { msg } from "@lingui/core/macro";
import { msg } from "@lingui/macro";
import { i18n } from "@lingui/core";
const welcomeMessage = msg`Welcome!`;
-8
View File
@@ -9,14 +9,6 @@ values.
2. You can write Maestro tests in `/.maestro/flows/` directory by creating a new `.yml` file or by modifying an existing one.
3. You can also use [Maestro Studio](https://maestro.mobile.dev/getting-started/maestro-studio) which automatically generates commands by recording your actions on the app. Therefore, you can create realistic tests without having to manually write any code. Use the `maestro studio` command to start recording your actions.
### Running on Android
You will need to allow your device access to the port that the mock server is running on.
```
adb reverse tcp:3000 tcp:3000
```
### Running Maestro tests
- In one tab, run `yarn e2e:mock-server`
-271
View File
@@ -1,271 +0,0 @@
// @ts-check
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import { defineConfig } from 'eslint/config';
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
// @ts-expect-error no types
import reactNative from 'eslint-plugin-react-native'
// @ts-expect-error no types
import reactNativeA11y from 'eslint-plugin-react-native-a11y'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import importX from 'eslint-plugin-import-x'
import lingui from 'eslint-plugin-lingui'
import reactCompiler from 'eslint-plugin-react-compiler'
import bskyInternal from 'eslint-plugin-bsky-internal'
import globals from 'globals'
import tsParser from '@typescript-eslint/parser'
export default defineConfig(
/**
* Global ignores
*/
{
ignores: [
'**/__mocks__/*.ts',
'ios/**',
'android/**',
'coverage/**',
'*.lock',
'.husky/**',
'patches/**',
'*.html',
'bskyweb/**',
'bskyembed/**',
'src/locale/locales/_build/**',
'src/locale/locales/**/*.js',
'*.e2e.ts',
'*.e2e.tsx',
'eslint.config.mjs',
],
},
/**
* Base configurations
*/
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended,
// @ts-expect-error https://github.com/un-ts/eslint-plugin-import-x/issues/439
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
importX.flatConfigs['react-native'],
/**
* Main configuration for all JS/TS/JSX/TSX files
*/
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
react,
'react-native': reactNative,
'react-native-a11y': reactNativeA11y,
'simple-import-sort': simpleImportSort,
lingui,
'react-compiler': reactCompiler,
'bsky-internal': bskyInternal,
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
parser: tsParser,
projectService: true,
tsconfigRootDir: import.meta.dirname,
ecmaFeatures: {
jsx: true,
},
},
},
settings: {
react: {
version: 'detect',
},
componentWrapperFunctions: ['observer'],
},
rules: {
/**
* Custom rules
*/
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'P',
'Admonition',
'Admonition.Admonition',
'Toast.Action',
'AgeAssuranceAdmonition',
'Span',
'StackedButton',
],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-prefixed-imports': 'error',
'bsky-internal/lingui-msg-rule': 'error',
/**
* React & React Native
*/
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn',
// TODO: Fix these and set to error
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/purity': 'warn',
'react-hooks/refs': 'warn',
'react-hooks/immutability': 'warn',
/**
* Import sorting
*/
'simple-import-sort/imports': [
'error',
{
groups: [
// Side effect imports.
['^\\u0000'],
// Node.js builtins prefixed with `node:`.
['^node:'],
// Packages.
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
// React/React Native prioritized, followed by expo
// Followed by all packages excluding unprefixed relative ones
[
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
'^(expo(.*)$)|^(expo$)',
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
],
// Relative imports.
// Ideally, anything that starts with a dot or #
// due to unprefixed relative imports being used, we whitelist the relative paths we use
// (?:$|\\/) matches end of string or /
[
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
'^(?:#\\/)?view(?:$|\\/)',
'^(?:#\\/)?screens(?:$|\\/)',
'^(?:#\\/)?alf(?:$|\\/)',
'^(?:#\\/)?components(?:$|\\/)',
'^#\\/',
'^\\.',
],
// anything else - hopefully we don't have any of these
['^'],
],
},
],
'simple-import-sort/exports': 'error',
/**
* Import linting
*/
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
'import-x/no-unresolved': ['error', {
/*
* The `postinstall` hook runs `compile-if-needed` locally, but not in
* CI. For CI-sake, ignore this.
*/
ignore: ['^#\/locale\/locales\/.+\/messages'],
}],
/**
* TypeScript-specific rules
*/
'no-unused-vars': 'off', // off, we use TS-specific rule below
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_.+',
caughtErrors: 'none',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
],
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-expressions': ['error', {
allowTernary: true,
}],
/**
* Maintain previous behavior - these are stricter in typescript-eslint
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
* nit-picky
*/
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-misused-promises': 'warn',
'@typescript-eslint/require-await': 'warn',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
'@typescript-eslint/await-thenable': 'warn',
/**
* Turn off rules that we haven't enforced thus far
*/
'no-empty-pattern': 'off',
'no-async-promise-executor': 'off',
'no-constant-binary-expression': 'warn',
'prefer-const': 'off',
'no-empty': 'off',
'no-unsafe-optional-chaining': 'off',
'no-prototype-builtins': 'off',
'no-var': 'off',
'prefer-rest-params': 'off',
'no-case-declarations': 'off',
'no-irregular-whitespace': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-fallthrough': 'off',
'no-control-regex': 'off',
},
},
/**
* Test files configuration
*/
{
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
languageOptions: {
globals: {
...globals.jest,
}
},
},
)
+15 -9
View File
@@ -1,17 +1,14 @@
const {RuleTester} = require('eslint')
const tseslint = require('typescript-eslint')
const avoidUnwrappedText = require('../avoid-unwrapped-text')
const ruleTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 6,
sourceType: 'module',
},
})
@@ -776,6 +773,15 @@ function MyText({ foo }) {
errors: 1,
},
{
code: `
<View>
<Trans>{'foo'}</Trans>
</View>
`,
errors: 1,
},
{
code: `
<View prop={
-188
View File
@@ -1,188 +0,0 @@
const {RuleTester} = require('eslint')
const tseslint = require('typescript-eslint')
const linguiMsgRule = require('../lingui-msg-rule')
const ruleTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
},
})
describe('lingui-msg-rule', () => {
const tests = {
valid: [
// msg template literal
{
code: `
const {_} = useLingui()
const x = _(msg\`Hello\`)
`,
},
// msg template literal with interpolation
{
code: `
const {_} = useLingui()
const name = 'World'
const x = _(msg\`Hello \${name}\`)
`,
},
// plural macro
{
code: `
const {_} = useLingui()
const count = 5
const x = _(plural(count, {one: '# item', other: '# items'}))
`,
},
// select macro
{
code: `
const {_} = useLingui()
const gender = 'female'
const x = _(select(gender, {male: 'He', female: 'She', other: 'They'}))
`,
},
// selectOrdinal macro
{
code: `
const {_} = useLingui()
const position = 1
const x = _(selectOrdinal(position, {one: '#st', two: '#nd', few: '#rd', other: '#th'}))
`,
},
// msg function call with object (descriptor form)
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello'}))
`,
},
// msg function call with object and context
{
code: `
const {_} = useLingui()
const x = _(msg({message: 'Hello', context: 'greeting'}))
`,
},
],
invalid: [
// Plain string literal (single quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _('Bad')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Plain string literal (double quotes) - with auto-fix
{
code: `
const {_} = useLingui()
const x = _("Bad")
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal without msg tag - with auto-fix
{
code: `
const {_} = useLingui()
const x = _(\`Bad\`)
`,
output: `
const {_} = useLingui()
const x = _(msg\`Bad\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Template literal with interpolation - with auto-fix
{
code: `
const {_} = useLingui()
const name = 'World'
const x = _(\`Hello \${name}\`)
`,
output: `
const {_} = useLingui()
const name = 'World'
const x = _(msg\`Hello \${name}\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// String with backticks that need escaping
{
code: `
const {_} = useLingui()
const x = _('Use \\\`code\\\` here')
`,
output: `
const {_} = useLingui()
const x = _(msg\`Use \\\`code\\\` here\`)
`,
errors: [{messageId: 'missingMsg'}],
},
// Variable/identifier - no auto-fix possible
{
code: `
const {_} = useLingui()
const message = 'Hello'
const x = _(message)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Arbitrary function call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(getMessage())
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Empty call - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _()
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Tagged template with wrong tag - no auto-fix (would need to replace tag)
{
code: `
const {_} = useLingui()
const x = _(html\`Hello\`)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
// Number literal - no auto-fix possible
{
code: `
const {_} = useLingui()
const x = _(123)
`,
output: null,
errors: [{messageId: 'missingMsg'}],
},
],
}
ruleTester.run('lingui-msg-rule', linguiMsgRule, tests)
})
+288 -316
View File
@@ -29,331 +29,303 @@ function getTagName(node) {
return reversedIdentifiers.reverse().join('.')
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce text strings are wrapped in <Text> components',
},
schema: [
{
type: 'object',
properties: {
impliedTextComponents: {
type: 'array',
items: {type: 'string'},
},
impliedTextProps: {
type: 'array',
items: {type: 'string'},
},
suggestedTextWrappers: {
type: 'object',
additionalProperties: {type: 'string'},
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options[0] || {}
const impliedTextProps = options.impliedTextProps ?? []
const impliedTextComponents = options.impliedTextComponents ?? []
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
const textProps = [...impliedTextProps]
const textComponents = ['Text', ...impliedTextComponents]
exports.create = function create(context) {
const options = context.options[0] || {}
const impliedTextProps = options.impliedTextProps ?? []
const impliedTextComponents = options.impliedTextComponents ?? []
const suggestedTextWrappers = options.suggestedTextWrappers ?? {}
const textProps = [...impliedTextProps]
const textComponents = ['Text', ...impliedTextComponents]
function isTextComponent(tagName) {
return textComponents.includes(tagName) || tagName.endsWith('Text')
}
function isTextComponent(tagName) {
return textComponents.includes(tagName) || tagName.endsWith('Text')
}
return {
JSXText(node) {
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return {
JSXText(node) {
if (typeof node.value !== 'string' || hasOnlyLineBreak(node.value)) {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
) {
// We're good.
return
}
const message =
'Wrap this string in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
parent = parent.parent
continue
}
},
Literal(node) {
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
TemplateLiteral(node) {
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'Identifier' &&
parent.callee.name === '_'
) {
// This is a user-facing string, keep going up.
parent = parent.parent
continue
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression' ||
parent.type === 'TaggedTemplateExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
JSXElement(node) {
if (getTagName(node) !== 'Trans') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for this JSXElement.
// TODO: Should nested <Trans> even be allowed?
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
) {
// We're good.
return
}
const message =
'Wrap this <Trans> in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
return
}
parent = parent.parent
continue
}
},
ReturnStatement(node) {
let fnScope = context.sourceCode.getScope(node)
while (fnScope && fnScope.type !== 'function') {
fnScope = fnScope.upper
}
if (!fnScope) {
return
}
const fn = fnScope.block
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
return
}
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
return
}
if (!node.argument || node.argument.type !== 'JSXElement') {
return
}
const openingEl = node.argument.openingElement
if (openingEl.name.type !== 'JSXIdentifier') {
return
}
const returnedComponentName = openingEl.name.name
if (!isTextComponent(returnedComponentName)) {
context.report({
node,
message:
'Components ending with *Text must return <Text> or <SomeText>.',
message,
})
return
}
},
}
},
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
) {
// We're good.
return
}
const message =
'Wrap this string in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
return
}
parent = parent.parent
continue
}
},
Literal(node) {
if (typeof node.value !== 'string' && typeof node.value !== 'number') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
TemplateLiteral(node) {
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for <Trans> JSXElement (code below).
// TODO: Maybe validate that it's present.
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this string in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'CallExpression' &&
parent.callee.type === 'Identifier' &&
parent.callee.name === '_'
) {
// This is a user-facing string, keep going up.
parent = parent.parent
continue
}
if (parent.type === 'BinaryExpression' && parent.operator === '+') {
parent = parent.parent
continue
}
if (
parent.type === 'JSXExpressionContainer' ||
parent.type === 'LogicalExpression' ||
parent.type === 'TaggedTemplateExpression'
) {
parent = parent.parent
continue
}
// Be conservative for other types.
return
}
},
JSXElement(node) {
if (getTagName(node) !== 'Trans') {
return
}
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement') {
const tagName = getTagName(parent)
if (isTextComponent(tagName)) {
// We're good.
return
}
if (tagName === 'Trans') {
// Exit and rely on the traversal for this JSXElement.
// TODO: Should nested <Trans> even be allowed?
return
}
const suggestedWrapper = suggestedTextWrappers[tagName]
let message = `Wrap this <Trans> in <${suggestedWrapper ?? 'Text'}>.`
if (tagName !== 'View' && !suggestedWrapper) {
message +=
' If <' +
tagName +
'> is guaranteed to render <Text>, ' +
'rename it to <' +
tagName +
'Text> or add it to impliedTextComponents.'
}
context.report({
node,
message,
})
return
}
if (
parent.type === 'JSXAttribute' &&
parent.name.type === 'JSXIdentifier' &&
parent.parent.type === 'JSXOpeningElement' &&
parent.parent.parent.type === 'JSXElement'
) {
const tagName = getTagName(parent.parent.parent)
const propName = parent.name.name
if (
textProps.includes(tagName + ' ' + propName) ||
propName === 'text' ||
propName.endsWith('Text')
) {
// We're good.
return
}
const message =
'Wrap this <Trans> in <Text>.' +
' If `' +
propName +
'` is guaranteed to be wrapped in <Text>, ' +
'rename it to `' +
propName +
'Text' +
'` or add it to impliedTextProps.'
context.report({
node,
message,
})
return
}
parent = parent.parent
continue
}
},
ReturnStatement(node) {
let fnScope = context.getScope()
while (fnScope && fnScope.type !== 'function') {
fnScope = fnScope.upper
}
if (!fnScope) {
return
}
const fn = fnScope.block
if (!fn.id || fn.id.type !== 'Identifier' || !fn.id.name) {
return
}
if (!/^[A-Z]\w*Text$/.test(fn.id.name)) {
return
}
if (!node.argument || node.argument.type !== 'JSXElement') {
return
}
const openingEl = node.argument.openingElement
if (openingEl.name.type !== 'JSXIdentifier') {
return
}
const returnedComponentName = openingEl.name.name
if (!isTextComponent(returnedComponentName)) {
context.report({
node,
message:
'Components ending with *Text must return <Text> or <SomeText>.',
})
}
},
}
}
+2 -8
View File
@@ -1,16 +1,10 @@
'use strict'
const plugin = {
meta: {
name: 'eslint-plugin-bsky-internal',
version: '1.0.0',
},
module.exports = {
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'),
'use-prefixed-imports': require('./use-prefixed-imports'),
'lingui-msg-rule': require('./lingui-msg-rule'),
},
}
module.exports = plugin
-110
View File
@@ -1,110 +0,0 @@
'use strict'
/**
* @type {import('eslint').Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Enforce that Lingui _() function is called with msg`` template literal or plural/select macros',
recommended: true,
},
fixable: 'code',
messages: {
missingMsg:
'Lingui _() must be called with msg`...` or msg({...}) or plural/select/selectOrdinal. Example: _(msg`Hello`)',
},
schema: [],
},
create(context) {
// Valid Lingui macro functions that can be passed to _()
const VALID_MACRO_FUNCTIONS = new Set([
'msg',
'plural',
'select',
'selectOrdinal',
])
/**
* Escape backticks and backslashes for template literal
*/
function escapeForTemplateLiteral(str) {
return str.replace(/\\`/g, '`').replace(/`/g, '\\`')
}
/**
* Try to get a fixer for the given argument
* Returns null if we can't safely fix it
*/
function getFixer(firstArg) {
const sourceCode = context.sourceCode ?? context.getSourceCode()
// Fix string literals: _('foo') -> _(msg`foo`)
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') {
const escaped = escapeForTemplateLiteral(firstArg.value)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg`' + escaped + '`')
}
}
// Fix untagged template literals: _(`foo`) -> _(msg`foo`)
if (firstArg.type === 'TemplateLiteral') {
const text = sourceCode.getText(firstArg)
return function (fixer) {
return fixer.replaceText(firstArg, 'msg' + text)
}
}
return null
}
return {
CallExpression(node) {
// Check if this is a call to _()
if (node.callee.type !== 'Identifier' || node.callee.name !== '_') {
return
}
// Must have at least one argument
if (node.arguments.length === 0) {
context.report({
node,
messageId: 'missingMsg',
})
return
}
const firstArg = node.arguments[0]
// Valid: _(msg`...`)
if (
firstArg.type === 'TaggedTemplateExpression' &&
firstArg.tag.type === 'Identifier' &&
firstArg.tag.name === 'msg'
) {
return
}
// Valid: _(msg(...)), _(plural(...)), _(select(...)), _(selectOrdinal(...))
if (
firstArg.type === 'CallExpression' &&
firstArg.callee.type === 'Identifier' &&
VALID_MACRO_FUNCTIONS.has(firstArg.callee.name)
) {
return
}
// Everything else is invalid
const fix = getFixer(firstArg)
context.report({
node,
messageId: 'missingMsg',
fix,
})
},
}
},
}
+15 -24
View File
@@ -3,29 +3,20 @@ const BANNED_IMPORTS = [
'@fortawesome/free-solid-svg-icons',
]
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Prevent importing entire icon packages',
exports.create = function create(context) {
return {
ImportDeclaration(node) {
const source = node.source
if (typeof source.value !== 'string') {
return
}
if (BANNED_IMPORTS.includes(source.value)) {
context.report({
node,
message:
'Import the specific thing you want instead of the entire package',
})
}
},
schema: [],
},
create(context) {
return {
ImportDeclaration(node) {
const source = node.source
if (typeof source.value !== 'string') {
return
}
if (BANNED_IMPORTS.includes(source.value)) {
context.report({
node,
message:
'Import the specific thing you want instead of the entire package',
})
}
},
}
},
}
}
-4
View File
@@ -13,11 +13,7 @@ const BANNED_IMPORT_PREFIXES = [
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Enforce using prefixed imports for internal paths',
},
fixable: 'code',
schema: [],
},
create(context) {
return {
+31
View File
@@ -0,0 +1,31 @@
'use strict'
exports.create = function create(context) {
return {
ImportSpecifier(node) {
if (
!node.local ||
node.local.type !== 'Identifier' ||
node.local.name !== 'useGate'
) {
return
}
if (
node.parent.type !== 'ImportDeclaration' ||
!node.parent.source ||
node.parent.source.type !== 'Literal'
) {
return
}
const source = node.parent.source.value
if (source.startsWith('statsig') || source.startsWith('@statsig')) {
context.report({
node,
message:
"Use useGate() from '#/lib/statsig/statsig' instead of the one on npm.",
})
}
// TODO: Verify gate() call results aren't stored in variables.
},
}
}
+14 -3
View File
@@ -1,5 +1,7 @@
/* global jest */
import 'react-native-gesture-handler/jestSetup'
// IMPORTANT: this is what's used in the native runtime
import 'react-native-url-polyfill/auto'
import {configure} from '@testing-library/react-native'
@@ -34,7 +36,6 @@ jest.mock('react-native-safe-area-context', () => {
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}),
deleteAsync: jest.fn(),
moveAsync: jest.fn().mockResolvedValue(undefined),
createDownloadResumable: jest.fn(),
}))
@@ -44,7 +45,6 @@ jest.mock('expo-image-manipulator', () => ({
}),
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}))
@@ -99,9 +99,20 @@ jest.mock('expo-modules-core', () => ({
requireNativeViewManager: jest.fn().mockImplementation(_ => {
return () => null
}),
createPermissionHook: () => () => [true],
}))
jest.mock('expo-localization', () => ({
getLocales: () => [],
}))
jest.mock('statsig-react-native-expo', () => ({
Statsig: {
initialize() {},
initializeCalled() {
return false
},
},
}))
jest.mock('../src/logger/bitdrift/lib', () => ({}))
jest.mock('../src/lib/statsig/statsig', () => ({}))
+4 -6
View File
@@ -1,7 +1,5 @@
import {defineConfig} from '@lingui/cli'
export default defineConfig({
sourceLocale: 'en',
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: [
'en',
'an',
@@ -51,5 +49,5 @@ export default defineConfig({
include: ['src'],
},
],
compileNamespace: 'ts',
})
format: 'po',
}
+1 -1
View File
@@ -44,6 +44,6 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation 'com.google.android.material:material:1.13.0'
implementation 'com.google.android.material:material:1.12.0'
implementation "com.facebook.react:react-native:+"
}
@@ -48,8 +48,6 @@ class BottomSheetModule : Module() {
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
view.preventExpansion = prop
}
Prop("sourceViewTag") { _: BottomSheetView, _: Int? -> }
}
}
}
@@ -5,12 +5,8 @@ import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
@@ -19,7 +15,6 @@ import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.internal.EdgeToEdgeUtils
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -34,26 +29,22 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null
private var isKeyboardVisible: Boolean = false
private val screenHeight =
private val rawScreenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private fun getStatusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Props
var disableDrag = false
set(value) {
field = value
@@ -65,31 +56,48 @@ class BottomSheetView(
field = value
this.dialog?.setCancelable(!value)
}
var preventExpansion = false
var minHeight = 0f
set(value) {
field = if (value < 0) 0f else dpToPx(value)
field =
if (value < 0) {
0f
} else {
dpToPx(value)
}
}
var maxHeight = this.screenHeight
var maxHeight = this.safeScreenHeight
set(value) {
val px = dpToPx(value)
field = if (px > this.screenHeight) this.screenHeight else px
field =
if (px > this.safeScreenHeight) {
this.safeScreenHeight
} else {
px
}
}
private var isOpen: Boolean = false
set(value) {
field = value
onStateChange(mapOf("state" to if (value) "open" else "closed"))
onStateChange(
mapOf(
"state" to if (value) "open" else "closed",
),
)
}
private var isOpening: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(mapOf("state" to "opening"))
onStateChange(
mapOf(
"state" to "opening",
),
)
}
}
@@ -97,21 +105,33 @@ class BottomSheetView(
set(value) {
field = value
if (value) {
onStateChange(mapOf("state" to "closing"))
onStateChange(
mapOf(
"state" to "closing",
),
)
}
}
private var selectedSnapPoint = 0
set(value) {
if (field == value) return
field = value
onSnapPointChange(mapOf("snapPoint" to value))
onSnapPointChange(
mapOf(
"snapPoint" to value,
),
)
}
// Lifecycle
init {
(appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
}
@@ -141,55 +161,27 @@ class BottomSheetView(
private fun getHalfExpandedRatio(contentHeight: Float): Float =
when {
// Full height sheets
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
contentHeight >= safeScreenHeight -> 0.99f
// Medium height sheets (>50% but <100%)
contentHeight >= safeScreenHeight / 2 ->
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
// Small height sheets (<50%)
else ->
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
}
private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight()
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
if (currentContext is android.app.Activity) {
activityWindow = currentContext.window
break
}
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
}
val originalStatusBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
}
val originalNavBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
}
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
val dialog = BottomSheetDialog(context)
dialog.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss)
dialog.setDismissWithAnimation(true)
dialog.setOnDismissListener {
this.isClosing = true
this.destroy()
}
dialog.setOnShowListener {
dialog.window?.let { window ->
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (originalNavBarAppearance != null) {
insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
}
if (originalStatusBarAppearance != null) {
EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
}
}
}
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
it.setBackgroundColor(0)
@@ -202,17 +194,7 @@ class BottomSheetView(
behavior.isDraggable = true
behavior.isHideable = true
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
} else {
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
@@ -227,10 +209,18 @@ class BottomSheetView(
newState: Int,
) {
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
BottomSheetBehavior.STATE_EXPANDED -> {
selectedSnapPoint = 2
}
BottomSheetBehavior.STATE_COLLAPSED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
}
}
@@ -241,26 +231,9 @@ class BottomSheetView(
},
)
}
this.isOpening = true
dialog.show()
this.dialog = dialog
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
val wasKeyboardVisible = isKeyboardVisible
isKeyboardVisible = imeVisible
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!imeVisible && wasKeyboardVisible) {
updateLayout()
}
insets
}
}
fun updateLayout() {
@@ -273,24 +246,12 @@ class BottomSheetView(
val currentState = behavior.state
val oldRatio = behavior.halfExpandedRatio
val newRatio = getHalfExpandedRatio(contentHeight)
var newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (isKeyboardVisible) {
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -318,19 +279,25 @@ class BottomSheetView(
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
return when {
contentHeight > maxHeight -> maxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
val height =
if (contentHeight > maxHeight) {
maxHeight
} else if (contentHeight < minHeight) {
minHeight
} else {
contentHeight
}
return height
}
private fun clampRatio(ratio: Float): Float =
when {
ratio < 0.01 -> 0.01f
ratio > 0.99 -> 0.99f
else -> ratio
private fun clampRatio(ratio: Float): Float {
if (ratio < 0.01) {
return 0.01f
} else if (ratio > 0.99) {
return 0.99f
}
return ratio
}
private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return
@@ -355,7 +322,9 @@ class BottomSheetView(
// View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) {
if (structure == null) return
if (structure == null) {
return
}
dialogRootViewGroup.dispatchProvideStructure(structure)
}
@@ -394,6 +363,7 @@ class BottomSheetView(
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics
return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
}
}
@@ -52,8 +52,6 @@ class DialogRootViewGroup(
if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this)
}
fitsSystemWindows = false
}
override fun onSizeChanged(
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsFloating">false</item>
<item name="enableEdgeToEdge">true</item>
<!-- Configure bottom sheet to respect system window insets -->
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
</style>
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingLeftSystemWindowInsets">true</item>
<item name="paddingRightSystemWindowInsets">true</item>
<item name="paddingTopSystemWindowInsets">false</item>
</style>
</resources>
@@ -42,10 +42,6 @@ public class BottomSheetModule: Module {
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
view.preventExpansion = prop
}
Prop("sourceViewTag") { (view: SheetView, prop: Int?) in
view.sourceViewTag = prop
}
}
}
}
-10
View File
@@ -26,7 +26,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
var preventDismiss = false
var preventExpansion = false
var cornerRadius: CGFloat?
var sourceViewTag: Int?
var minHeight = 0.0
var maxHeight: CGFloat! {
didSet {
@@ -136,15 +135,6 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
}
sheetVc.view.addSubview(innerView)
if #available(iOS 26.0, *),
let tag = self.sourceViewTag,
let bridge = self.appContext?.reactBridge,
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
sheetVc.preferredTransition = .zoom { _ in
return sourceView
}
}
self.sheetVc = sheetVc
self.isOpening = true
@@ -27,19 +27,6 @@ class SheetViewController: UIViewController {
return
}
// On iOS 26, the floaty sheet presentation adds the device bottom safe area
// on top of the custom detent value, creating visible padding inside the pill.
// Subtract it so the pill height matches our actual content.
var bottomSafeAreaAdjustment: CGFloat = 0
if #available(iOS 26.0, *) {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
bottomSafeAreaAdjustment = window.safeAreaInsets.bottom
}
}
let adjustedHeight = contentHeight - bottomSafeAreaAdjustment
if #available(iOS 16.0, *) {
if contentHeight > screenHeight - 100 {
sheet.detents = [
@@ -49,7 +36,7 @@ class SheetViewController: UIViewController {
} else {
sheet.detents = [
.custom { _ in
return adjustedHeight
return contentHeight
}
]
if !preventExpansion {
@@ -1,4 +1,5 @@
import {type ColorValue, type NativeSyntheticEvent} from 'react-native'
import React from 'react'
import {ColorValue, NativeSyntheticEvent} from 'react-native'
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
@@ -24,7 +25,6 @@ export interface BottomSheetViewProps {
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
disableDrag?: boolean
sourceViewTag?: number
minHeight?: number
maxHeight?: number
@@ -5,22 +5,21 @@ import {
type NativeSyntheticEvent,
Platform,
type StyleProp,
useWindowDimensions,
View,
type ViewStyle,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {IS_IOS} from '#/env'
import {isIOS} from '#/platform/detection'
import {
type BottomSheetState,
type BottomSheetViewProps,
} from './BottomSheet.types'
import {
BottomSheetPortalProvider,
Context as PortalContext,
} from './BottomSheetPortal'
import {BottomSheetPortalProvider} from './BottomSheetPortal'
import {Context as PortalContext} from './BottomSheetPortal'
const screenHeight = Dimensions.get('screen').height
const NativeView: React.ComponentType<
BottomSheetViewProps & {
@@ -31,7 +30,7 @@ const NativeView: React.ComponentType<
const NativeModule = requireNativeModule('BottomSheet')
const IS_IOS15 =
const isIOS15 =
Platform.OS === 'ios' &&
// semvar - can be 3 segments, so can't use Number(Platform.Version)
Number(Platform.Version.split('.').at(0)) < 16
@@ -92,8 +91,7 @@ export class BottomSheetNativeComponent extends React.Component<
}
let extraStyles
if (IS_IOS15 && this.state.viewHeight) {
const screenHeight = Dimensions.get('screen').height
if (isIOS15 && this.state.viewHeight) {
const {viewHeight} = this.state
const cornerRadius = this.props.cornerRadius ?? 0
if (viewHeight < screenHeight / 2) {
@@ -114,7 +112,7 @@ export class BottomSheetNativeComponent extends React.Component<
onStateChange={this.onStateChange}
extraStyles={extraStyles}
onLayout={e => {
if (IS_IOS15) {
if (isIOS15) {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
}
@@ -154,9 +152,8 @@ function BottomSheetNativeComponentInner({
}) {
const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0
const {height: screenHeight} = useWindowDimensions()
const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight
const sheetHeight = isIOS ? screenHeight - insets.top : screenHeight
return (
<NativeView
@@ -178,7 +175,6 @@ function BottomSheetNativeComponentInner({
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
overflow: 'hidden',
},
extraStyles,
]}>
@@ -34,15 +34,12 @@ class NotificationPrefs(
is Boolean -> {
putBoolean(key, value)
}
is String -> {
putString(key, value)
}
is Array<*> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
is Map<*, *> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MCEmojiPicker'
s.dependency 'MCEmojiPicker', '1.2.3'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleImageIntents(
uris: List<Uri>,
text: String?,
text: String?
) {
var allParams = ""
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleVideoIntents(
uris: List<Uri>,
text: String?,
text: String?
) {
val uri = uris[0]
// If there is no extension for the file, substringAfterLast returns the original string - not
+74 -88
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.118.0",
"version": "1.112.0",
"private": true,
"engines": {
"node": ">=20"
@@ -16,13 +16,6 @@
"expo-image-picker"
]
}
},
"install": {
"exclude": [
"react-native-reanimated",
"@sentry/react-native",
"react-native-pager-view"
]
}
},
"scripts": {
@@ -48,15 +41,15 @@
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --quiet src",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start -c",
"e2e:run": "maestro test",
"e2e:start": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo start",
"e2e:run": "maestro test __e2e__",
"perf:test": "NODE_ENV=test maestro test",
"perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand \"yarn perf:test\" --duration 150000 --resultsFilePath .perf/results.json",
@@ -66,10 +59,9 @@
"intl:extract": "lingui extract --clean --locale en",
"intl:extract:all": "lingui extract --clean",
"intl:compile": "lingui compile",
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.ts ] || yarn intl:compile",
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || yarn intl:compile",
"intl:pull": "crowdin download translations --verbose -b main",
"intl:push": "crowdin push translations --verbose -b main",
"intl:push-sources": "crowdin push sources --verbose -b main",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
@@ -80,15 +72,12 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.19.3",
"@atproto/api": "^0.18.6",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/expo-translate-text": "^0.2.7",
"@bsky.app/alf": "^0.1.5",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.12.5",
"@expo/webpack-config": "^19.0.1",
@@ -102,19 +91,17 @@
"@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": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@lingui/react": "^4.14.1",
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.9.0",
"@react-navigation/native": "^7.1.26",
"@react-navigation/native-stack": "^7.9.0",
"@react-navigation/bottom-tabs": "^7.3.13",
"@react-navigation/drawer": "^7.3.12",
"@react-navigation/native": "^7.1.9",
"@react-navigation/native-stack": "^7.3.13",
"@sentry/react-native": "~6.20.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "5.25.0",
@@ -134,58 +121,57 @@
"@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^20.14.3",
"@zxing/text-encoding": "^0.9.0",
"array.prototype.findlast": "^1.2.3",
"await-lock": "^2.2.2",
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.6.0",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^54.0.27",
"expo-application": "~7.0.8",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-contacts": "^15.0.10",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.20",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.9",
"expo-intent-launcher": "~13.0.8",
"expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-localization": "~17.0.8",
"expo-location": "~19.0.8",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.14",
"expo-privacy-sensitive": "^0.1.0",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
"expo-sms": "^14.0.7",
"expo-splash-screen": "~31.0.12",
"expo-system-ui": "~6.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-video-thumbnails": "^10.0.8",
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"expo": "^54.0.22",
"expo-application": "~7.0.7",
"expo-blur": "~15.0.7",
"expo-build-properties": "~1.0.9",
"expo-camera": "~17.0.9",
"expo-clipboard": "~8.0.7",
"expo-dev-client": "~6.0.16",
"expo-device": "~8.0.9",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-image-crop-tool": "^0.4.0",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.8",
"expo-intent-launcher": "~13.0.7",
"expo-keep-awake": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-location": "~19.0.7",
"expo-media-library": "~18.2.0",
"expo-notifications": "~0.32.12",
"expo-screen-orientation": "~9.0.7",
"expo-sharing": "~14.0.7",
"expo-splash-screen": "~31.0.10",
"expo-system-ui": "~6.0.8",
"expo-task-manager": "~14.0.8",
"expo-updates": "~29.0.12",
"expo-video": "~3.0.13",
"expo-web-browser": "~15.0.9",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.6.2",
"idb-keyval": "^6.2.2",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.12.31",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"multiformats": "9.9.0",
@@ -205,18 +191,20 @@
"react-native-compressor": "^1.13.0",
"react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.1",
"react-native-drawer-layout": "^4.1.8",
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.20.7",
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "^3.19.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "^4.19.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.3",
"react-native-view-shot": "^4.0.3",
"react-native-web": "^0.21.0",
@@ -225,81 +213,79 @@
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^10.0.1",
"react-textarea-autosize": "^8.5.3",
"setimmediate": "^1.0.5",
"sonner": "^2.0.7",
"sonner-native": "^0.21.0",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
"tldts": "^6.1.46",
"unicode-segmenter": "^0.14.5",
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.209",
"@atproto/dev-env": "^0.3.193",
"@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/babel-plugin-lingui-macro": "^5.9.2",
"@lingui/cli": "^5.9.2",
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/babel-preset": "0.81.5",
"@react-native/eslint-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
"@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^29.7.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"babel-preset-expo": "~54.0.0",
"eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint": "^8.19.0",
"eslint-plugin-bsky-internal": "link:./eslint",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-lingui": "^0.11.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-ft-flow": "^2.0.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-lingui": "^0.2.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-native": "^5.0.0",
"eslint-plugin-react-native-a11y": "^3.5.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-react-native-a11y": "^3.3.0",
"eslint-plugin-simple-import-sort": "^12.0.0",
"file-loader": "6.2.0",
"globals": "^17.0.0",
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "~54.0.14",
"jest-expo": "^54.0.14",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"metro-react-native-babel-preset": "^0.77.0",
"prettier": "^3.6.0",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^3.3.2",
"ts-node": "^10.9.1",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"typescript": "^5.9.2",
"webpack-bundle-analyzer": "^4.10.1"
},
"resolutions": {
"@react-native/babel-preset": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"**/@expo/image-utils": "0.8.7",
"**/@react-native-async-storage/async-storage": "2.2.0",
"**/expo-constants": "18.0.8",
"**/expo-device": "7.1.4",
"**/multiformats": "9.9.0",
"unicode-segmenter": "0.14.5",
"@types/estree": "1.0.6"
"**/zod": "3.23.8",
"**/multiformats": "9.9.0"
},
"jest": {
"preset": "jest-expo/ios",
-136
View File
@@ -1,136 +0,0 @@
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
index b988d3f..7743421 100644
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
@@ -36,6 +36,7 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
+ consumerProguardFiles 'proguard-rules.pro'
}
lintOptions {
abortOnError false
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
new file mode 100644
index 0000000..3b5b864
--- /dev/null
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Keep FullscreenActivity from being stripped by R8/ProGuard
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
diff --git a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
index fdabd84..eda8c7c 100644
--- a/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
+++ b/node_modules/@haileyok/bluesky-video/android/src/main/java/expo/modules/blueskyvideo/BlueskyVideoView.kt
@@ -1,8 +1,11 @@
package expo.modules.blueskyvideo
+import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
+import android.util.Log
import android.graphics.Rect
import android.net.Uri
import android.view.ViewGroup
@@ -237,9 +240,44 @@ class BlueskyVideoView(
// Fullscreen handling
fun enterFullscreen(keepDisplayOn: Boolean) {
- val currentActivity = this.appContext.currentActivity ?: return
+ val tag = "BlueskyVideo"
+
+ Log.d(tag, "enterFullscreen() called - keepDisplayOn=$keepDisplayOn")
+ Log.d(tag, " isFullscreen=$isFullscreen, isPlaying=$isPlaying, isMuted=$isMuted")
+ Log.d(tag, " player=${player != null}, url=$url")
+ Log.d(tag, " isAttachedToWindow=$isAttachedToWindow, isShown=$isShown")
+ Log.d(tag, " Android SDK: ${Build.VERSION.SDK_INT}, Device: ${Build.MANUFACTURER} ${Build.MODEL}")
+
+ val currentActivity = this.appContext.currentActivity
+ if (currentActivity == null) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is null")
+ Log.e(tag, " appContext=$appContext")
+ onError(mapOf("error" to "Cannot enter fullscreen: no current activity"))
+ return
+ }
+
+ Log.d(tag, " currentActivity=$currentActivity")
+ Log.d(tag, " activity.isFinishing=${currentActivity.isFinishing}")
+ Log.d(tag, " activity.isDestroyed=${currentActivity.isDestroyed}")
+ Log.d(tag, " activity.lifecycle=${(currentActivity as? androidx.lifecycle.LifecycleOwner)?.lifecycle?.currentState}")
+ Log.d(tag, " activity.hasWindowFocus=${currentActivity.hasWindowFocus()}")
+ Log.d(tag, " activity.window.isActive=${currentActivity.window?.isActive}")
+
+ // Check if activity is in a valid state to start another activity
+ if (currentActivity.isFinishing) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is finishing")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is finishing"))
+ return
+ }
+
+ if (currentActivity.isDestroyed) {
+ Log.e(tag, "enterFullscreen() FAILED: currentActivity is destroyed")
+ onError(mapOf("error" to "Cannot enter fullscreen: activity is destroyed"))
+ return
+ }
this.enteredFullscreenMuteState = this.isMuted
+ Log.d(tag, " saved enteredFullscreenMuteState=$enteredFullscreenMuteState")
// We always want to start with unmuted state and playing. Fire those from here so the
// event dispatcher gets called
@@ -247,18 +285,51 @@ class BlueskyVideoView(
if (!this.isPlaying) {
this.play()
}
+ Log.d(tag, " after unmute/play: isPlaying=$isPlaying, isMuted=$isMuted")
// Remove the player from this view, but don't null the player!
this.playerView.player = null
+ Log.d(tag, " detached player from playerView")
// create the intent and give it a view
val intent = Intent(context, FullscreenActivity::class.java)
intent.putExtra("keepDisplayOn", keepDisplayOn)
FullscreenActivity.asscVideoView = WeakReference(this)
+ Log.d(tag, " intent created: $intent")
+ Log.d(tag, " intent.component=${intent.component}")
+ Log.d(tag, " intent.flags=${intent.flags} (0x${Integer.toHexString(intent.flags)})")
+ Log.d(tag, " context for intent=$context")
+ Log.d(tag, " FullscreenActivity.asscVideoView set to WeakReference(this)")
+
// fire the fullscreen event and launch the intent
- this.isFullscreen = true
- currentActivity.startActivity(intent)
+ try {
+ Log.d(tag, " calling startActivity()...")
+ currentActivity.startActivity(intent)
+ this.isFullscreen = true
+ Log.d(tag, " startActivity() SUCCESS - isFullscreen set to true")
+ } catch (e: Exception) {
+ Log.e(tag, "enterFullscreen() FAILED: startActivity() threw exception", e)
+ Log.e(tag, " exception class: ${e.javaClass.name}")
+ Log.e(tag, " exception message: ${e.message}")
+ Log.e(tag, " exception cause: ${e.cause}")
+ e.printStackTrace()
+
+ // Restore state since fullscreen failed
+ this.playerView.player = this.player
+ Log.d(tag, " restored player to playerView after failure")
+
+ if (this.enteredFullscreenMuteState) {
+ this.mute()
+ Log.d(tag, " restored mute state after failure")
+ }
+
+ onError(mapOf(
+ "error" to "Failed to enter fullscreen: ${e.message}",
+ "exceptionClass" to e.javaClass.name,
+ "exceptionMessage" to (e.message ?: "unknown")
+ ))
+ }
}
fun onExitFullscreen() {
+10
View File
@@ -0,0 +1,10 @@
diff --git a/node_modules/@lingui/core/dist/index.mjs b/node_modules/@lingui/core/dist/index.mjs
index 9759736..881f67b 100644
--- a/node_modules/@lingui/core/dist/index.mjs
+++ b/node_modules/@lingui/core/dist/index.mjs
@@ -1,4 +1,4 @@
-import unraw from 'unraw';
+import { unraw } from 'unraw';
import { compileMessage } from '@lingui/message-utils/compileMessage';
const isString = (s) => typeof s === "string";
-16
View File
@@ -1,16 +0,0 @@
diff --git a/node_modules/expo-font/ios/FontLoaderModule.swift b/node_modules/expo-font/ios/FontLoaderModule.swift
index 183480f..7b64f6e 100644
--- a/node_modules/expo-font/ios/FontLoaderModule.swift
+++ b/node_modules/expo-font/ios/FontLoaderModule.swift
@@ -2,10 +2,9 @@ import ExpoModulesCore
public final class FontLoaderModule: Module {
// could be a Set, but to be able to pass to JS we keep it as an array
- private var registeredFonts: [String]
+ private lazy var registeredFonts: [String] = queryCustomNativeFonts()
public required init(appContext: AppContext) {
- self.registeredFonts = queryCustomNativeFonts()
super.init(appContext: appContext)
}
-103
View File
@@ -1,103 +0,0 @@
diff --git a/node_modules/expo-image/build/Image.types.d.ts b/node_modules/expo-image/build/Image.types.d.ts
index 022ae48..416504f 100644
--- a/node_modules/expo-image/build/Image.types.d.ts
+++ b/node_modules/expo-image/build/Image.types.d.ts
@@ -152,6 +152,16 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
* @default 'normal'
*/
priority?: 'low' | 'normal' | 'high' | null;
+ /**
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
+ *
+ * - `'lazy'` - Defers loading until the image is near the viewport.
+ * - `'eager'` - Loads the image immediately.
+ *
+ * @default undefined
+ * @platform web
+ */
+ loading?: 'lazy' | 'eager' | null;
/**
* Determines whether to cache the image and where: on the disk, in the memory or both.
*
diff --git a/node_modules/expo-image/src/ExpoImage.web.tsx b/node_modules/expo-image/src/ExpoImage.web.tsx
index 2a49ff0..1c3de93 100644
--- a/node_modules/expo-image/src/ExpoImage.web.tsx
+++ b/node_modules/expo-image/src/ExpoImage.web.tsx
@@ -70,6 +70,7 @@ export default function ExpoImage({
onLoadEnd,
onDisplay,
priority,
+ loading,
blurRadius,
recyclingKey,
style,
@@ -118,6 +119,7 @@ export default function ExpoImage({
accessibilityLabel={accessibilityLabel ?? alt}
cachePolicy={cachePolicy}
priority={priority}
+ loading={loading}
tintColor={tintColor}
/>
),
@@ -149,6 +151,7 @@ export default function ExpoImage({
className={className}
cachePolicy={cachePolicy}
priority={priority}
+ loading={loading}
contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }}
hashPlaceholderContentPosition={contentPosition}
hashPlaceholderStyle={imageHashStyle}
diff --git a/node_modules/expo-image/src/Image.types.ts b/node_modules/expo-image/src/Image.types.ts
index 9dec0e7..61c1621 100644
--- a/node_modules/expo-image/src/Image.types.ts
+++ b/node_modules/expo-image/src/Image.types.ts
@@ -178,6 +178,17 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
*/
priority?: 'low' | 'normal' | 'high' | null;
+ /**
+ * The loading behavior for the image. Maps to the native HTML `loading` attribute on web.
+ *
+ * - `'lazy'` - Defers loading until the image is near the viewport.
+ * - `'eager'` - Loads the image immediately.
+ *
+ * @default undefined
+ * @platform web
+ */
+ loading?: 'lazy' | 'eager' | null;
+
/**
* Determines whether to cache the image and where: on the disk, in the memory or both.
*
diff --git a/node_modules/expo-image/src/web/ImageWrapper.tsx b/node_modules/expo-image/src/web/ImageWrapper.tsx
index e8f891d..89a5cb1 100644
--- a/node_modules/expo-image/src/web/ImageWrapper.tsx
+++ b/node_modules/expo-image/src/web/ImageWrapper.tsx
@@ -30,6 +30,7 @@ const ImageWrapper = React.forwardRef(
contentPosition,
hashPlaceholderContentPosition,
priority,
+ loading,
style,
hashPlaceholderStyle,
tintColor,
@@ -82,6 +83,7 @@ const ImageWrapper = React.forwardRef(
// @ts-ignore
// eslint-disable-next-line react/no-unknown-property
fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')}
+ loading={loading || undefined}
{...getImageWrapperEventHandler(events, sourceWithHeaders)}
{...getImgPropsFromSource(source)}
{...props}
diff --git a/node_modules/expo-image/src/web/ImageWrapper.types.ts b/node_modules/expo-image/src/web/ImageWrapper.types.ts
index 19bbe2f..179837f 100644
--- a/node_modules/expo-image/src/web/ImageWrapper.types.ts
+++ b/node_modules/expo-image/src/web/ImageWrapper.types.ts
@@ -29,6 +29,7 @@ export type ImageWrapperProps = {
contentPosition?: ImageContentPositionObject;
hashPlaceholderContentPosition?: ImageContentPositionObject;
priority?: string | null;
+ loading?: 'lazy' | 'eager' | null;
style: CSSProperties;
tintColor?: string | null;
hashPlaceholderStyle?: CSSProperties;

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