Compare commits

..

1 Commits

Author SHA1 Message Date
Samuel Newman 90fa98b777 replace lodash.isequal with fast-deep-equal 2025-12-11 12:22:57 +02:00
1502 changed files with 227239 additions and 419822 deletions
-1
View File
@@ -1 +0,0 @@
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
-9
View File
@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash(pnpm typecheck *)",
"Bash(pnpm lint *)",
"Bash(pnpm test *)"
]
}
}
+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 -3
View File
@@ -1,5 +1,5 @@
name: "Bug Report"
description: "Create a report for an issue you have experienced in the app."
description: "Create a report for an issue you have experience in the app."
labels: ["bug"]
body:
- type: markdown
@@ -19,14 +19,13 @@ body:
4. See error
validations:
required: true
- type: upload
- type: textarea
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: dropdown
attributes:
label: What platform(s) does this occur on?
+1 -2
View File
@@ -26,14 +26,13 @@ body:
4. See error
validations:
required: true
- type: upload
- type: textarea
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: dropdown
attributes:
label: What platform(s) does this occur on?
+1 -2
View File
@@ -15,7 +15,7 @@ body:
implement it in a timely manner.
validations:
required: true
- type: upload
- type: textarea
attributes:
label: Attachments
description: |
@@ -24,7 +24,6 @@ body:
in or is missing from.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.webp,.mp4,.mov,.webm"
- type: textarea
attributes:
label: Describe Alternatives
-44
View File
@@ -1,44 +0,0 @@
version: 2
# Dependabot auto-update config.
#
# Cooldown (7 days) is the point of this config: it delays version-update
# PRs until a newly-published version has aged. Supply-chain attacks like
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
# before the registry yanks them; a 7-day cooldown keeps poisoned
# versions out of our lockfiles.
#
# Security updates bypass cooldown and continue to flow immediately. See:
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown
#
# Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
# review.
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
production:
dependency-type: production
update-types: [minor, patch]
development:
dependency-type: development
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 3
groups:
actions:
patterns: ["*"]
update-types: [minor, patch]
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -1,9 +1,11 @@
name: build-and-push-link-aws
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
paths:
- "bskylink/**"
- "Dockerfile.bskylink"
- ".github/workflows/build-and-push-link-aws.yaml"
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -22,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
@@ -1,9 +1,11 @@
name: build-and-push-ogcard-aws
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
paths:
- "bskyogcard/**"
- "Dockerfile.bskyogcard"
- ".github/workflows/build-and-push-ogcard-aws.yaml"
env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
@@ -22,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v1
+46 -46
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: >
@@ -28,38 +25,39 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
@@ -81,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 }}
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile $PROFILE
yarn use-build-number-with-bump
eas build -p android
--profile ${{ inputs.profile || 'testflight-android' }}
--local --output build.aab --non-interactive
- name: ✍️ Rename Testflight bundle
@@ -105,7 +101,7 @@ jobs:
- name: 🚀 Upload Production Artifact
id: upload-artifact-production
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
@@ -115,7 +111,7 @@ jobs:
- name: 🚀 Upload Testflight Artifact
id: upload-artifact-testflight
if: ${{ inputs.profile != 'production' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
@@ -128,23 +124,27 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
{
"text": "Android production build for Google Play Store submission is ready!\n```Artifact: ${{ steps.upload-artifact-production.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"}
{
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact-testflight.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: 🧹 Clear Metro cache
if: ${{ inputs.profile == 'production' }}
@@ -157,15 +157,15 @@ jobs:
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 }}
pnpm use-build-number-with-bump
pnpm eas build -p android
yarn use-build-number-with-bump
eas build -p android
--profile production-apk
--local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
if: ${{ inputs.profile == 'production' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 6
@@ -174,17 +174,19 @@ jobs:
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
{
"text": "Android production build for GitHub/Obtanium is ready!\n```Artifact: ${{ steps.upload-artifact-production-apk.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.profile == 'testflight-android' }}
with:
path: most-recent-testflight-commit.txt
@@ -192,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
+34 -61
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: >
@@ -28,34 +25,35 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@v9
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.4"
xcode-version: "26.0"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
@@ -63,16 +61,16 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v5
uses: actions/cache@v4
id: pods-cache
with:
path: ./ios/Pods
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
@@ -93,61 +91,36 @@ 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 }}
pnpm use-build-number-with-bump
pnpm eas build -p ios
--profile $PROFILE
yarn use-build-number-with-bump
eas build -p ios
--profile ${{ inputs.profile || 'testflight' }}
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
mkdir ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
run: eas submit -p ios --non-interactive --path ios-build/ios/build/Bluesky.ipa
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
yarn sentry-cli debug-files upload ios-build/ios/build/Bluesky.app.dSYM.zip --include-sources
- name: 📚 Get version from package.json
id: get-build-info
@@ -155,24 +128,24 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"}
{
"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_IOS_BUILD_NUMBER }}```"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
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
+77 -119
View File
@@ -39,16 +39,14 @@ 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
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -56,13 +54,11 @@ jobs:
if: ${{ github.ref != 'refs/heads/main' }}
run: git fetch origin main:main --depth 100
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
@@ -71,44 +67,48 @@ jobs:
profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: Prettier check
run: yarn prettier --check .
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: Type check
run: pnpm typecheck
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
if: ${{ !steps.fingerprint.outputs.includes-changes }}
uses: dcarbone/install-jq-action@v2
# 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
@@ -125,11 +125,11 @@ jobs:
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 }}
pnpm export
yarn export
- name: 📦 Package Bundle and 🚀 Deploy
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: pnpm use-build-number bash scripts/bundleUpdate.sh
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
@@ -137,7 +137,7 @@ jobs:
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
path: most-recent-testflight-commit.txt
@@ -145,15 +145,15 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ !steps.fingerprint.outputs.includes-changes }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-26
concurrency:
group: ios-build
cancel-in-progress: false
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
@@ -167,31 +167,32 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
run: yarn install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.4"
xcode-version: "26.0"
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
@@ -199,16 +200,16 @@ jobs:
version: 1.16.2
- name: 💾 Cache Pods
uses: actions/cache@v5
uses: actions/cache@v4
id: pods-cache
with:
path: ./ios/Pods
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: 🔤 Compile translations
run: pnpm intl:build
run: yarn intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
@@ -230,59 +231,17 @@ jobs:
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 }}
pnpm use-build-number-with-bump
pnpm eas build -p ios
yarn use-build-number-with-bump
eas build -p ios
--profile testflight
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
--local --output build.ipa --non-interactive
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
run: eas submit -p ios --non-interactive --path build.ipa
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.channel == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
@@ -290,15 +249,13 @@ jobs:
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel == 'testflight' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: android-build
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-android
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
@@ -314,35 +271,36 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 5
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@v5
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
run: yarn install --frozen-lockfile
- name: 🔤 Compile translations
run: pnpm intl:build
run: yarn intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
@@ -365,8 +323,8 @@ jobs:
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 }}
pnpm use-build-number-with-bump
pnpm eas build -p android
yarn use-build-number-with-bump
eas build -p android
--profile testflight-android
--local --output build.apk --non-interactive
@@ -378,7 +336,7 @@ jobs:
- name: 🚀 Upload Artifact
id: upload-artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
retention-days: 30
compression-level: 0
@@ -386,24 +344,24 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v3.0.3
uses: slackapi/slack-github-action@v1.25.0
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"}
{
"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with:
path: most-recent-testflight-commit.txt
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@v5
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-7
+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
+22 -88
View File
@@ -6,122 +6,56 @@ on:
branches:
- main
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
cancel-in-progress: true
# These jobs only check out the repo and run checks, so read access to the
# repo contents is all the GITHUB_TOKEN needs.
permissions:
contents: read
jobs:
linting:
name: Run linters
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
job: [lint, prettier, typecheck]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Verify Node version pins match package.json
run: |
set -euo pipefail
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
rc=0
check() {
if [ "$2" != "$expected" ]; then
echo "::error file=$1::Node version mismatch: $1 pins '$2' but package.json is '$expected'"
rc=1
fi
}
# FROM node:X.Y.Z - service runtime images
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# ENV NODE_VERSION=X.Y.Z - Go images that nvm-install Node for the JS build stage
for f in Dockerfile.embedr; do
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# eas.json "node": "X.Y.Z"
v=$(grep -oE '"node":[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' eas.json | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "eas.json" "$v"
exit $rc
- uses: pnpm/action-setup@v6
uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
command: yarn --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: Prettier check
run: yarn prettier --check .
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks
run: pnpm ${{ matrix.job }}
# Aggregates the matrix results into a single stable check name so branch
# protection can require "Run linters" regardless of how many matrix jobs run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
linting-summary:
name: Run linters
if: always()
needs: [linting]
runs-on: ubuntu-latest
steps:
- name: Require linting to have succeeded
env:
RESULT: ${{ needs.linting.result }}
run: |
echo "linting result: $RESULT"
test "$RESULT" = "success"
run: yarn intl:build
- name: Type check
run: yarn typecheck
testing:
name: Run tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
command: yarn --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
run: yarn intl:build
- name: Run tests
run: |
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
# Aggregates the sharded test results into a single stable check name so branch
# protection can require "Run tests" regardless of how many shards run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
testing-summary:
name: Run tests
if: always()
needs: [testing]
runs-on: ubuntu-latest
steps:
- name: Require testing to have succeeded
env:
RESULT: ${{ needs.testing.result }}
run: |
echo "testing result: $RESULT"
test "$RESULT" = "success"
NODE_ENV=test yarn test --forceExit
@@ -16,23 +16,22 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
node-version-file: .nvmrc
cache: yarn
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
command: yarn --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Extract language strings
run: pnpm intl:extract
run: yarn intl:extract
- name: Create commit
uses: stefanzweifel/git-auto-commit-action@v5
with:
+26 -26
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}`);
@@ -94,7 +92,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
@@ -113,40 +111,44 @@ jobs:
fi
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ steps.pr-info.outputs.head-sha }}
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: yarn install --frozen-lockfile
- name: Lint check
run: yarn lint
- name: Lint lockfile
run: yarn lockfile-lint
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
run: yarn intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check
run: pnpm lint
- name: Type check
run: pnpm typecheck
run: yarn typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v9
uses: expo/expo-github-action@main
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup Expo
run: yarn global add eas-cli-local-build-plugin
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@v2
@@ -171,26 +173,24 @@ jobs:
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 }}
pnpm export
yarn export
- name: 📦 Package Bundle and 🚀 Deploy
run: pnpm use-build-number bash scripts/bundleUpdate.sh
run: yarn use-build-number bash scripts/bundleUpdate.sh
env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
CHANNEL_NAME: pull-request-${{ github.event.issue.number }}
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
uses: marocchino/sticky-pull-request-comment@v2
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 }}`
---
@@ -198,7 +198,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+21 -32
View File
@@ -24,51 +24,42 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
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
pnpm install
pnpm intl:build
git merge --no-edit ${{ github.head_ref }}
yarn install
yarn intl:build
- name: 🔦 Generate stats file for PR
run: |
pnpm generate-webpack-stats-file
yarn generate-webpack-stats-file
mv stats.json ../stats-new.json
- name: ⬇️ Get base stats from cache
id: get-base-stats
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: stats-base.json
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
@@ -82,9 +73,9 @@ jobs:
- name: 🔦 Generate stats file from base commit
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
run: |
pnpm install
pnpm intl:build
pnpm generate-webpack-stats-file
yarn install
yarn intl:build
yarn generate-webpack-stats-file
mv stats.json stats-base.json
- name: Get diff
@@ -93,10 +84,10 @@ 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@v3
uses: marocchino/sticky-pull-request-comment@v2
with:
header: bundle-diff
message: |
@@ -110,7 +101,7 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 100
@@ -118,13 +109,11 @@ jobs:
run: git fetch origin main:main --depth 100
if: github.event_name == 'pull_request'
- uses: pnpm/action-setup@v6
- name: 🔧 Setup Node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
node-version-file: .nvmrc
cache: yarn
- name: 📷 Check fingerprint and install dependencies
id: fingerprint
@@ -133,7 +122,7 @@ jobs:
profile: pull-request
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
@@ -151,7 +140,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@v3
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
+2 -2
View File
@@ -10,12 +10,12 @@ jobs:
if: github.repository == 'bluesky-social/social-app'
steps:
- name: Checkout public repo
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v3
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
@@ -7,44 +7,38 @@ concurrency:
cancel-in-progress: true
jobs:
verify-pnpm-lock:
name: No manual pnpm-lock.yaml edits
verify-yarn-lock:
name: No manual yarn.lock edits
runs-on: ubuntu-latest
steps:
- name: Check out PR HEAD
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch base branch
env:
BASE_REF: ${{ github.base_ref }}
run: git fetch origin $BASE_REF --depth=1
- uses: pnpm/action-setup@v6
run: git fetch origin ${{ github.base_ref }} --depth=1
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
node-version-file: .nvmrc
- name: Reset pnpm-lock.yaml to base
env:
BASE_REF: ${{ github.base_ref }}
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: Reset yarn.lock to base
run: git show "origin/${{ github.base_ref }}:yarn.lock" > yarn.lock
- name: pnpm install
- name: Yarn install
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
command: yarn install --ignore-scripts
attempt_limit: 3
attempt_delay: 2000
- name: Verify pnpm-lock.yaml
- name: Verify yarn.lock
run: |
git diff --quiet --exit-code || {
echo '::error::`pnpm-lock.yaml` does not match what pnpm would generate given the base `pnpm-lock.yaml` and the head `package.json`.'
echo '::error::`yarn.lock` does not match what Yarn would generate given the base `yarn.lock` and the head `package.json`.'
echo '::error:: - If this is intentional, you can ignore this check.'
echo '::error:: - If this is unintentional, apply the following diff:'
git --no-pager diff
+1 -10
View File
@@ -86,9 +86,6 @@ stats.json
# VSCode
.vscode
# Zed
.zed
# gitignore and github actions
!.gitignore
!.github
@@ -113,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
@@ -129,7 +124,3 @@ bskyogcard/src/assets/fonts/noto-*
bskyweb/static/media/*.webp
bskyweb/static/media/*.jpg
bskyweb/static/media/*.png
bskyweb/static/media/*.svg
# superpowers plugin plans/specs — local-only workspace
docs/superpowers/
+4 -1
View File
@@ -1 +1,4 @@
pnpm exec lint-staged --concurrent false
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged --concurrent false
-236
View File
@@ -1,236 +0,0 @@
/**
* Codemod to update Lingui's useLingui() hook to use v5.
*
* Before:
* import {msg} from '@lingui/core/macro'
* import {useLingui} from '@lingui/react'
* const {_} = useLingui()
* _(msg`Lorem ipsum`)
*
* After:
* import {useLingui} from '@lingui/react/macro'
* const {t: l} = useLingui()
* l`Lorem ipsum`
*
* Usage: jscodeshift -t .jscodeshift/file/lingui-v5.js <file-path>
* Example: jscodeshift -t .jscodeshift/file/lingui-v5.js modules/bottom-sheet/src/lib/Portal.tsx
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Remove msg imports, including aliased imports
root
.find(j.ImportDeclaration)
.filter(path => path.node.source.value === '@lingui/core/macro')
.forEach(path => {
const newSpecifiers = path.node.specifiers.filter(specifier => {
if (j.ImportSpecifier.check(specifier)) {
const importedName = specifier.imported
? specifier.imported.name
: specifier.local.name
const localName = specifier.local
? specifier.local.name
: importedName
return !(
importedName === 'msg' &&
(localName === 'msg' || localName === 'msgLingui')
)
}
return true
})
if (newSpecifiers.length > 0) {
path.node.specifiers = newSpecifiers
} else {
j(path).remove()
}
})
// Update React imports
root
.find(j.ImportDeclaration)
.filter(path => path.node.source.value === '@lingui/react')
.forEach(path => {
path.node.source = j.stringLiteral('@lingui/react/macro')
})
// Replace {_} with {t: l}
root
.find(j.VariableDeclarator, {
init: {
type: 'CallExpression',
callee: {name: 'useLingui'},
},
})
.replaceWith(nodePath => {
const {node} = nodePath
for (let i = 0; i < node.id.properties.length; i++) {
if (node.id.properties[i].key.name === '_') {
node.id.properties[i].key = j.identifier('t')
node.id.properties[i].shorthand = false
node.id.properties[i].value = j.identifier('l')
}
}
return node
})
// Replace {_: _l} with {t: l}
root
.find(j.VariableDeclarator, {
init: {callee: {name: 'useLingui'}},
id: {
properties: properties =>
properties.some(p => p.key.name === '_' && p.value.name === '_l'),
},
})
.forEach(path => {
const prop = path.node.id.properties.find(
p => p.key.name === '_' && p.value.name === '_l',
)
if (prop) {
prop.key.name = 't'
prop.value.name = 'l'
}
})
// Replace _(msg``) with l``
root
.find(j.CallExpression, {
callee: {name: '_'},
arguments: [
{
type: 'TaggedTemplateExpression',
tag: {name: 'msg'},
},
],
})
.replaceWith(path => {
const quasis = path.node.arguments[0].quasi
return j.taggedTemplateExpression(j.identifier('l'), quasis)
})
// Replace _l(msgLingui``) with l``
root
.find(j.CallExpression, {
callee: {name: '_l'},
arguments: [
{
type: 'TaggedTemplateExpression',
tag: {name: 'msgLingui'},
},
],
})
.replaceWith(path => {
const quasis = path.node.arguments[0].quasi
return j.taggedTemplateExpression(j.identifier('l'), quasis)
})
// Replace _(msg()) with l()
root
.find(j.CallExpression, {
callee: {name: '_'},
arguments: [
{
type: 'CallExpression',
callee: {name: 'msg'},
},
],
})
.replaceWith(path => {
return j.callExpression(j.identifier('l'), [
path.node.arguments[0].arguments[0],
])
})
// Replace _l(msgLingui()) with l()
root
.find(j.CallExpression, {
callee: {name: '_l'},
arguments: [
{
type: 'CallExpression',
callee: {name: 'msgLingui'},
},
],
})
.replaceWith(path => {
return j.callExpression(j.identifier('l'), [
path.node.arguments[0].arguments[0],
])
})
// Replace _(plural()) with plural()
root
.find(j.CallExpression, {
callee: {
type: 'Identifier',
name: '_',
},
})
.forEach(path => {
const callExpression = path.node
if (
callExpression.arguments.length === 1 &&
callExpression.arguments[0].type === 'CallExpression'
) {
const innerCall = callExpression.arguments[0]
if (
innerCall.callee.type === 'Identifier' &&
innerCall.callee.name === 'plural'
) {
j(path).replaceWith(innerCall)
}
}
})
// Update hook dependency arrays
root
.find(j.CallExpression, {
callee: callee => {
if (
callee.type === 'Identifier' ||
callee.type === 'MemberExpression'
) {
return true
}
if (
['useCallback', 'useEffect', 'useMemo'].includes(callee.name) &&
callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'React' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'useCallback'
) {
return true
}
return false
},
})
.forEach(path => {
const dependencyArray = path.node.arguments[1]
if (dependencyArray && dependencyArray.type === 'ArrayExpression') {
dependencyArray.elements = dependencyArray.elements.map(element => {
if (
element.type === 'Identifier' &&
(element.name === '_' || element.name === '_l')
) {
return j.identifier('l')
}
return element
})
}
})
return root.toSource()
}
-135
View File
@@ -1,135 +0,0 @@
/**
* Codemod to replace namespaced React calls with named imports
*
* Before:
* import React from 'react'
* React.useEffect(() => {}, [])
*
* After:
* import { useEffect } from 'react'
* useEffect(() => {}, [])
*
* Usage: jscodeshift -t .jscodeshift/repo/react-import.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/react-import.js src/App.native.tsx
*/
/* eslint-disable */
export const parser = 'tsx'
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Find the React import
let reactImportPath = null
const reactMembers = new Set()
root.find(j.ImportDeclaration).forEach(path => {
const node = path.value
if (node.source.value === 'react') {
node.specifiers.forEach(spec => {
// Check if this is a default import of React
if (
spec.type === 'ImportDefaultSpecifier' &&
spec.local.name === 'React'
) {
reactImportPath = path
}
})
}
})
if (!reactImportPath) {
// No React import found, nothing to do
return file.source
}
// Find all React.* member expressions
root
.find(j.MemberExpression)
.filter(path => {
const node = path.value
return (
node.object.type === 'Identifier' &&
node.object.name === 'React' &&
node.property.type === 'Identifier'
)
})
.forEach(path => {
const propertyName = path.value.property.name
reactMembers.add(propertyName)
})
// Find all React.* JSX member expressions (e.g., <React.Fragment>)
root
.find(j.JSXMemberExpression)
.filter(path => {
const node = path.value
return node.object.name === 'React' && node.property.name
})
.forEach(path => {
const propertyName = path.value.property.name
reactMembers.add(propertyName)
})
// If no React members are used, remove the import
if (reactMembers.size === 0) {
reactImportPath.prune()
return root.toSource()
}
// Sort the members for consistent output
const sortedMembers = Array.from(reactMembers).sort()
// Create new import specifiers
const newSpecifiers = sortedMembers.map(name =>
j.importSpecifier(j.identifier(name), j.identifier(name)),
)
// Get the existing import specifiers
const sortedImports = Array.from(reactImportPath.value.specifiers).sort()
const existingSpecifiers = sortedImports.filter(
specifier => specifier.type !== 'ImportDefaultSpecifier',
)
const allSpecifiers = [
...new Map(
[...existingSpecifiers, ...newSpecifiers].map(item => [
item.imported.name,
item,
]),
).values(),
]
// Update the import declaration
reactImportPath.value.specifiers = allSpecifiers
// Replace all React.* member expressions with just the identifier
root
.find(j.MemberExpression)
.filter(path => {
const node = path.value
return (
node.object.type === 'Identifier' &&
node.object.name === 'React' &&
node.property.type === 'Identifier'
)
})
.replaceWith(path => {
return j.identifier(path.value.property.name)
})
// Replace all React.* JSX member expressions with just the identifier
root
.find(j.JSXMemberExpression)
.filter(path => {
const node = path.value
return node.object.name === 'React' && node.property.name
})
.replaceWith(path => {
return j.jsxIdentifier(path.value.property.name)
})
return root.toSource()
}
-106
View File
@@ -1,106 +0,0 @@
/**
* Codemod to replace namespaced React calls with named imports
*
* Before:
* import * as Toast from '#/view/com/util/Toast'
* Toast.show(message, 'xmark')
*
* After:
* import * as Toast from '#/components/Toast'
* Toast.show(message, {type: 'error'})
*
* Usage: jscodeshift -t .jscodeshift/repo/toast-v2.js <file-path>
* Example: jscodeshift -t .jscodeshift/repo/toast-v2.js src/App.native.tsx
*/
/* eslint-disable */
export const parser = 'tsx'
const OLD_IMPORT = '#/view/com/util/Toast'
const NEW_IMPORT = '#/components/Toast'
const convertLegacyToastType = type => {
switch (type) {
// these ones are fine
case 'default':
case 'success':
case 'error':
case 'warning':
case 'info':
return type
// legacy ones need conversion
case 'xmark':
return 'error'
case 'exclamation-circle':
return 'warning'
case 'check':
return 'success'
case 'clipboard-check':
return 'success'
case 'circle-exclamation':
case 'exclamation-circle':
return 'warning'
default:
return 'default'
}
}
export default function transformer(file, api) {
const j = api.jscodeshift
const root = j(file.source)
// Find Toast import declarations using the old path
const toastImports = root
.find(j.ImportDeclaration)
.filter(path => path.value.source.value === OLD_IMPORT)
if (toastImports.length === 0) {
return file.source
}
// Update import path
toastImports.forEach(path => {
path.value.source.value = NEW_IMPORT
})
// Collect all local names the Toast namespace is bound to
const toastLocalNames = new Set()
toastImports.forEach(path => {
path.value.specifiers.forEach(spec => {
if (spec.type === 'ImportNamespaceSpecifier') {
toastLocalNames.add(spec.local.name)
}
})
})
// Transform Toast.show(message, type) calls
root.find(j.CallExpression).forEach(path => {
const {callee, arguments: args} = path.value
// Match <ToastName>.show(...)
if (
callee.type !== 'MemberExpression' ||
callee.object.type !== 'Identifier' ||
!toastLocalNames.has(callee.object.name) ||
callee.property.name !== 'show'
) {
return
}
// Only transform 2-arg calls where the second arg is a string literal
if (args.length !== 2) return
const typeArg = args[1]
if (typeArg.type !== 'StringLiteral' && typeArg.type !== 'Literal') return
const legacyType = typeArg.value
const newType = convertLegacyToastType(legacyType)
// Replace the second argument with an options object: {type: 'newType'}
args[1] = j.objectExpression([
j.property('init', j.identifier('type'), j.stringLiteral(newType)),
])
})
return root.toSource()
}
+1 -1
View File
@@ -1 +1 @@
24.15.0
20
-4
View File
@@ -5,14 +5,10 @@
!**/*.jsx
!**/*.ts
!**/*.tsx
!**/*.mjs
!**/*.cjs
!*/
!**/*.css
!**/pnpm-workspace.yaml
# More specific ignores go below.
.expo
android
+1 -5
View File
@@ -1,6 +1,4 @@
import {type Config} from 'prettier'
const config: Config = {
module.exports = {
semi: false,
arrowParens: 'avoid',
bracketSameLine: true,
@@ -8,5 +6,3 @@ const config: Config = {
singleQuote: true,
trailingComma: 'all',
}
export default config
-894
View File
@@ -1,894 +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 19.1
- React Native 0.81 with Expo 54
- TypeScript 6
- React Navigation 7 for routing
- TanStack Query (React Query) for data fetching
- Lingui 5 for internationalization
- Custom design system called ALF (Application Layout Framework)
Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses.
## Essential Commands
```bash
# Development
pnpm start # Start Expo dev server
pnpm web # Start web version
pnpm android # Run on Android
pnpm ios # Run on iOS
# Testing & Quality
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
pnpm test # Run Jest tests
pnpm lint # Run ESLint
pnpm typecheck # Run TypeScript type checking
# Internationalization
# DO NOT run these commands - extraction and compilation are handled by CI
pnpm intl:extract # Extract translation strings (nightly CI job)
pnpm intl:compile # Compile translations for runtime (nightly CI job)
# Build
pnpm build-web # Build web version
pnpm 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
Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`.
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
Generally, order atoms by:
- Flexbox configuration, e.g., `a.flex_row`
- Spacing, e.g., `a.px_md`
- Text styles, e.g., `a.font_bold`
- Themes, e.g., `t.atoms.text`,
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
```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_contrast_high]}>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
- Prefer fragment shorthand over `Fragment` unless a `key` is needed.
- Prefer functions over arrow functions for component declarations.
- Prefer prop destructuring via parameters over a const within the component.
- Prefer inline types over `Props` types or interfaces.
- Set reasonable defaults for optional props.
```tsx
import {Fragment} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {Text} from '#/components/Typography'
function MyComponent({foo = []}: {foo?: string[]}) {
const {t: l} = useLingui()
return (
<>
<View><Text><Trans>Example</Trans><Text></View>
<View>
{foo.map((foo, index) => (
<Fragment key={foo}>
<Text>{index}</Text>
<Text>{foo}</Text>
</Fragment>
))}
</View>
</>
);
}
```
### 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 */}
<DialogInner />
</Dialog.Outer>
</>
)
}
function DialogInner() {
return (
<>
<Dialog.Handle /> {/* Native-only drag handle */}
<Dialog.ScrollableInner label={l`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>
</>
)
}
```
### 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_md, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
<Text emoji>Hello! 👋</Text>
```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.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. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb.
Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``.
Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one  two` over `one—two`.
```tsx
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
function MyComponent() {
const {t: l} = useLingui()
// Simple strings - use the l macro
const title = l`Settings`
const errorMessage = l({
message: 'Something went wrong',
comment: 'Generic error message for unknown/unhandled errors.',
context: 'Toast',
})
// Strings with variables
const greeting = l`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>, {name}!
</Trans>
</Text>
)
}
```
Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`.
```tsx
import {useLingui} from '@lingui/react/macro'
function MyComponent() {
const {i18n} = useLingui()
const createdAt = new Date()
return i18n.date(createdAt, {
dateStyle: 'medium',
timeStyle: 'medium',
})
}
```
**Commands:**
```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
pnpm intl:extract # Extract new strings to locale files
pnpm 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'
import {createQueryKey} from '#/state/queries/util'
/*
* Query key name should match the query hook name for consistency
*/
const profileQueryKeyRoot = 'profile'
/*
* Use object params and createQueryKey helper for better readability and to
* avoid bugs with parameter order or types.
*/
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args)
/*
* Query hook should be named use[Name]Query, where [Name] describes the data
* being fetched. This is not a strict requirement, but it's a helpful
* convention for discoverability
*/
export function useProfileQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
queryKey: createProfileQueryKey({did}),
queryFn: async () => {
const res = await agent.getProfile({actor: did})
return res.data
},
staleTime: STALE.MINUTES.FIVE,
enabled: !!did,
})
}
/*
* Mutation hook should match the name of the query hook, but with "Mutation"
* suffix. This is not a strict requirement, but it's a helpful convention for
* discoverability and consistency.
*/
export function useProfileMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async data => {
// Update logic
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: createProfileQueryKey({did: 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})
}
},
})
}
/*
* If cache mutation is needed, include specific interfaces for the specific
* mutations you require adjacent to the source queries. Naming should be
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
* not a strict requirement, but it's a helpful convention for discoverability
* and consistency.
*/
export function useProfileCacheMutation() {
const queryClient = useQueryClient()
return (data: Partial<Profile>) => {
queryClient.setQueryData(
createProfileQueryKey({did: data.did}),
oldData => {
if (!oldData) return oldData
return {...oldData, ...data}
},
)
}
}
```
**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: createQueryKey('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) ?? []`
**Persisted Queries**
To persist query data across app restarts, `createQueryKey` supports a third
parameter called `options`, which has a `persistedVersion` property. When this
property is set to a number, the query will be persisted.
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
```tsx
export const createProfileQueryKey = (args: {did: string}) =>
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
```
### 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 ` `l` `` 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` |
+41 -49
View File
@@ -1,21 +1,23 @@
#
# Stage 1: build the web bundle with pnpm.
#
# Uses the official pnpm image. Node is auto-downloaded by pnpm using the
# `devEngines.runtime` field in package.json (onFail: "download").
#
FROM ghcr.io/pnpm/pnpm:11 AS web-build
FROM golang:1.25-bookworm AS build-env
WORKDIR /app
WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
#
# pnpm config
# Node
#
ENV CI=1
# use the pnpm version specified in package.json
ENV pnpm_config_pm_on_fail=download
ENV NODE_VERSION=20
ENV NVM_DIR=/usr/share/nvm
#
# Go
#
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
# The latest git hash of the preview branch on render.com
# https://render.com/docs/docker-secrets#environment-variables-in-docker-builds
@@ -40,48 +42,41 @@ ENV SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN:-unknown}
ARG EXPO_PUBLIC_SENTRY_DSN
ENV EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN
#
# Copy everything into the container
#
COPY . .
RUN echo "Using bundle identifier: $EXPO_PUBLIC_BUNDLE_IDENTIFIER" && \
#
# Generate the JavaScript webpack.
#
RUN mkdir --parents $NVM_DIR && \
wget \
--output-document=/tmp/nvm-install.sh \
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
bash /tmp/nvm-install.sh
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
echo "Using bundle identifier: $EXPO_PUBLIC_BUNDLE_IDENTIFIER" && \
echo "EXPO_PUBLIC_ENV=$EXPO_PUBLIC_ENV" >> .env && \
echo "EXPO_PUBLIC_RELEASE_VERSION=$EXPO_PUBLIC_RELEASE_VERSION" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$EXPO_PUBLIC_BUNDLE_IDENTIFIER" >> .env && \
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env && \
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi
RUN SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN \
SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION \
SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER \
pnpm build-web
#
# Stage 2: build the bskyweb Go binary, embedding the assets from stage 1.
#
# post-web-build.js (run by `pnpm build-web`) writes the bundled JS/CSS/media
# into bskyweb/static/* and regenerates bskyweb/templates/scripts.html, so
# copying the bskyweb/ tree from stage 1 is enough for go:embed to find
# everything.
#
FROM golang:1.26-bookworm AS go-build
WORKDIR /usr/src/social-app
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
COPY --from=web-build /app/bskyweb ./bskyweb
echo "EXPO_PUBLIC_SENTRY_DSN=$EXPO_PUBLIC_SENTRY_DSN" >> .env && \
npm install --global yarn && \
yarn && \
yarn intl:build 2>&1 | tee i18n.log && \
if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compile errors!\n\n"; fi && \
SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN SENTRY_RELEASE=$EXPO_PUBLIC_RELEASE_VERSION SENTRY_DIST=$EXPO_PUBLIC_BUNDLE_IDENTIFIER yarn build-web
# DEBUG
RUN find ./bskyweb/static
RUN find ./bskyweb/static && find ./web-build/static
#
# Generate the bskyweb Go binary.
#
RUN cd bskyweb/ && \
go mod download && \
go mod verify
@@ -94,9 +89,6 @@ RUN cd bskyweb/ && \
-o /bskyweb \
./cmd/bskyweb
#
# Stage 3: runtime image.
#
FROM debian:bookworm-slim
ENV GODEBUG=netdns=go
@@ -110,7 +102,7 @@ RUN apt-get update && apt-get install --yes \
ENTRYPOINT ["dumb-init", "--"]
WORKDIR /bskyweb
COPY --from=go-build /bskyweb /usr/bin/bskyweb
COPY --from=build-env /bskyweb /usr/bin/bskyweb
CMD ["/usr/bin/bskyweb"]
+4 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.15.0-alpine3.22 AS build
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
@@ -14,7 +14,7 @@ RUN yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:24.15.0-alpine3.22
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
@@ -29,6 +29,8 @@ VOLUME /app/data
EXPOSE 3000
ENV LINK_PORT=3000
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+8 -11
View File
@@ -1,25 +1,20 @@
FROM node:24.15.0-alpine3.22 AS build
# Tells pnpm to run non-interactively (needed for install/script steps)
ENV CI=true
FROM node:20.11-alpine3.18 as build
# Move files into the image and install
WORKDIR /app
COPY ./bskyogcard/package.json ./
COPY ./bskyogcard/pnpm-lock.yaml ./
COPY ./bskyogcard/pnpm-workspace.yaml ./
RUN npm install --global pnpm@11.1.3
RUN pnpm install --frozen-lockfile
COPY ./bskyogcard/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./
# build then prune dev deps
RUN pnpm install-fonts && pnpm build
RUN pnpm install --prod --ignore-scripts --prefer-offline
RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size
FROM node:24.15.0-alpine3.22
FROM node:20.11-alpine3.18
RUN apk add --update dumb-init
@@ -35,6 +30,8 @@ EXPOSE 3000 3001
ENV CARD_PORT=3000
ENV CARD_METRICS_PORT=3001
ENV NODE_ENV=production
# potential perf issues w/ io_uring on this version of node
ENV UV_USE_IO_URING=0
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
+8 -12
View File
@@ -1,24 +1,20 @@
FROM golang:1.26-bookworm AS build-env
FROM golang:1.25-bookworm AS build-env
WORKDIR /usr/src/social-app
ENV DEBIAN_FRONTEND=noninteractive
# Node
ENV NODE_VERSION=24.15.0
ENV NODE_VERSION=20
ENV NVM_DIR=/usr/share/nvm
# Go
ENV GODEBUG="netdns=go"
ENV GOOS="linux"
ARG TARGETARCH
ENV GOARCH=${TARGETARCH:-amd64}
ENV GOARCH="amd64"
ENV CGO_ENABLED=1
ENV GOEXPERIMENT="loopvar"
# CI environment for pnpm
ENV CI=true
COPY . .
#
@@ -33,11 +29,11 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.5.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
pnpm build-embed
npm install --global yarn && \
yarn && \
cd bskyembed && yarn install --frozen-lockfile && cd .. && \
yarn intl:build && \
yarn build-embed
# DEBUG
RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bskyembed/dist
+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:
+11 -11
View File
@@ -10,33 +10,33 @@ help: ## Print info about all commands
.PHONY: build-web
build-web: ## Compile web bundle, copy to bskyweb directory
pnpm intl:build
pnpm build-web
yarn intl:build
yarn build-web
.PHONY: build-web-embed
build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directories
pnpm intl:build
pnpm build-embed
yarn intl:build
yarn build-embed
.PHONY: test
test: ## Run all tests
NODE_ENV=test pnpm test
NODE_ENV=test yarn test
.PHONY: lint
lint: ## Run style checks and verify syntax
pnpm run lint
yarn run lint
#.PHONY: fmt
#fmt: ## Run syntax re-formatting
# pnpm prettier
# yarn prettier
.PHONY: deps
deps: ## Installs dependent libs using 'pnpm install'
pnpm install --frozen-lockfile
deps: ## Installs dependent libs using 'yarn install'
yarn install --frozen-lockfile
cd bskyembed && yarn install --frozen-lockfile
.PHONY: nvm-setup
nvm-setup: ## Use NVM to install and activate node+pnpm
nvm-setup: ## Use NVM to install and activate node+yarn
nvm install 20
nvm use 20
npm install --global pnpm
npm install --global yarn
+3 -4
View File
@@ -39,6 +39,7 @@ appId: xyz.blueskyweb.app
id: "editListNameInput"
- eraseText
- inputText: "Bad Ppl"
- hideKeyboard
- tapOn:
id: "editListDescriptionInput"
- eraseText
@@ -91,8 +92,7 @@ appId: xyz.blueskyweb.app
- tapOn: "Add user to list"
- swipe:
direction: DOWN
- assertVisible:
id: "profileCard-bob.test-link"
- assertVisible: "View Bob's profile"
- tapOn: "Posts"
- assertVisible:
@@ -124,8 +124,7 @@ appId: xyz.blueskyweb.app
- tapOn: "Good Ppl"
- tapOn: "People"
- assertVisible:
id: "profileCard-bob.test-link"
- assertVisible: "View Bob's profile"
- tapOn:
point: "90%,43%"
- tapOn:
+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:
+529
View File
@@ -0,0 +1,529 @@
import {createServer as createHTTPServer} from 'node:http'
import {parse} from 'node:url'
import {createServer, type TestPDS} from '../jest/test-pds'
async function main() {
let server: TestPDS
createHTTPServer(async (req, res) => {
const url = parse(req.url || '/', true)
if (req.method !== 'POST') {
return res.writeHead(200).end()
}
try {
console.log('Closing old server')
await server?.close()
console.log('Starting new server')
const inviteRequired = url?.query && 'invite' in url.query
server = await createServer({inviteRequired})
console.log('Listening at', server.pdsUrl)
if (url?.query) {
if ('users' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
if (inviteRequired) {
await server.mocker.createInvite(server.mocker.users.alice.did)
}
}
if ('follows' in url.query) {
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
await server.mocker.follow('bob', 'alice')
await server.mocker.follow('bob', 'carla')
await server.mocker.follow('carla', 'alice')
await server.mocker.follow('carla', 'bob')
}
if ('posts' in url.query) {
console.log('Generating mock posts')
for (let user in server.mocker.users) {
await server.mocker.users[user].agent.post({text: 'Post'})
}
}
if ('feeds' in url.query) {
console.log('Generating mock feed')
await server.mocker.createFeed('alice', 'alice-favs', [])
}
if ('thread' in url.query) {
console.log('Generating mock posts')
const res = await server.mocker.users.bob.agent.post({
text: 'Thread root',
})
await server.mocker.users.carla.agent.post({
text: 'Thread reply',
reply: {
parent: {cid: res.cid, uri: res.uri},
root: {cid: res.cid, uri: res.uri},
},
})
}
if ('mergefeed' in url.query) {
console.log('Generating mock users')
await server.mocker.createUser('alice')
await server.mocker.createUser('bob')
await server.mocker.createUser('carla')
await server.mocker.createUser('dan')
await server.mocker.users.alice.agent.upsertProfile(() => ({
displayName: 'Alice',
description: 'Test user 1',
}))
await server.mocker.users.bob.agent.upsertProfile(() => ({
displayName: 'Bob',
description: 'Test user 2',
}))
await server.mocker.users.carla.agent.upsertProfile(() => ({
displayName: 'Carla',
description: 'Test user 3',
}))
await server.mocker.users.dan.agent.upsertProfile(() => ({
displayName: 'Dan',
description: 'Test user 4',
}))
console.log('Generating mock follows')
await server.mocker.follow('alice', 'bob')
await server.mocker.follow('alice', 'carla')
console.log('Generating mock posts')
let posts: Record<string, any[]> = {
alice: [],
bob: [],
carla: [],
dan: [],
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
posts[user].push(
await server.mocker.createPost(user, `Post ${i}`),
)
}
}
for (let i = 0; i < 10; i++) {
for (let user in server.mocker.users) {
if (user === 'alice') continue
if (i % 5 === 0) {
await server.mocker.createReply(user, 'Self reply', {
cid: posts[user][i].cid,
uri: posts[user][i].uri,
})
}
if (i % 5 === 1) {
await server.mocker.createReply(user, 'Reply to bob', {
cid: posts.bob[i].cid,
uri: posts.bob[i].uri,
})
}
if (i % 5 === 2) {
await server.mocker.createReply(user, 'Reply to dan', {
cid: posts.dan[i].cid,
uri: posts.dan[i].uri,
})
}
await server.mocker.users[user].agent.post({text: `Post ${i}`})
}
}
console.log('Generating mock feeds')
await server.mocker.createFeed(
'alice',
'alice-favs',
posts.dan.map(p => p.uri),
)
await server.mocker.createFeed(
'alice',
'alice-favs2',
posts.dan.map(p => p.uri),
)
}
if ('labels' in url.query) {
console.log('Generating naughty users with labels')
const anchorPost = await server.mocker.createPost(
'alice',
'Anchor post',
)
for (const user of [
'dmca-account',
'dmca-profile',
'dmca-posts',
'porn-account',
'porn-profile',
'porn-posts',
'nudity-account',
'nudity-profile',
'nudity-posts',
'scam-account',
'scam-profile',
'scam-posts',
'unknown-account',
'unknown-profile',
'unknown-posts',
'hide-account',
'hide-profile',
'hide-posts',
'no-promote-account',
'no-promote-profile',
'no-promote-posts',
'warn-account',
'warn-profile',
'warn-posts',
'muted-account',
'muted-by-list-acc',
'blocking-account',
'blockedby-account',
'mutual-block-acc',
]) {
await server.mocker.createUser(user)
await server.mocker.follow('alice', user)
await server.mocker.follow(user, 'alice')
await server.mocker.createPost(user, `Unlabeled post from ${user}`)
await server.mocker.createReply(
user,
`Unlabeled reply from ${user}`,
anchorPost,
)
await server.mocker.like(user, anchorPost)
}
await server.mocker.labelAccount('dmca-violation', 'dmca-account')
await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createPost('dmca-posts', 'dmca post'),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createQuotePost(
'dmca-posts',
'dmca quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'dmca-violation',
await server.mocker.createReply(
'dmca-posts',
'dmca reply',
anchorPost,
),
)
await server.mocker.labelAccount('porn', 'porn-account')
await server.mocker.labelProfile('porn', 'porn-profile')
await server.mocker.labelPost(
'porn',
await server.mocker.createImagePost('porn-posts', 'porn post'),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createQuotePost(
'porn-posts',
'porn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'porn',
await server.mocker.createReply(
'porn-posts',
'porn reply',
anchorPost,
),
)
await server.mocker.labelAccount('nudity', 'nudity-account')
await server.mocker.labelProfile('nudity', 'nudity-profile')
await server.mocker.labelPost(
'nudity',
await server.mocker.createImagePost('nudity-posts', 'nudity post'),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createQuotePost(
'nudity-posts',
'nudity quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'nudity',
await server.mocker.createReply(
'nudity-posts',
'nudity reply',
anchorPost,
),
)
await server.mocker.labelAccount('scam', 'scam-account')
await server.mocker.labelProfile('scam', 'scam-profile')
await server.mocker.labelPost(
'scam',
await server.mocker.createPost('scam-posts', 'scam post'),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createQuotePost(
'scam-posts',
'scam quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'scam',
await server.mocker.createReply(
'scam-posts',
'scam reply',
anchorPost,
),
)
await server.mocker.labelAccount(
'not-a-real-label',
'unknown-account',
)
await server.mocker.labelProfile(
'not-a-real-label',
'unknown-profile',
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createPost('unknown-posts', 'unknown post'),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createQuotePost(
'unknown-posts',
'unknown quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'not-a-real-label',
await server.mocker.createReply(
'unknown-posts',
'unknown reply',
anchorPost,
),
)
await server.mocker.labelAccount('!hide', 'hide-account')
await server.mocker.labelProfile('!hide', 'hide-profile')
await server.mocker.labelPost(
'!hide',
await server.mocker.createPost('hide-posts', 'hide post'),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createQuotePost(
'hide-posts',
'hide quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!hide',
await server.mocker.createReply(
'hide-posts',
'hide reply',
anchorPost,
),
)
await server.mocker.labelAccount('!no-promote', 'no-promote-account')
await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createPost(
'no-promote-posts',
'no-promote post',
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createQuotePost(
'no-promote-posts',
'no-promote quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!no-promote',
await server.mocker.createReply(
'no-promote-posts',
'no-promote reply',
anchorPost,
),
)
await server.mocker.labelAccount('!warn', 'warn-account')
await server.mocker.labelProfile('!warn', 'warn-profile')
await server.mocker.labelPost(
'!warn',
await server.mocker.createPost('warn-posts', 'warn post'),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createQuotePost(
'warn-posts',
'warn quote post',
anchorPost,
),
)
await server.mocker.labelPost(
'!warn',
await server.mocker.createReply(
'warn-posts',
'warn reply',
anchorPost,
),
)
await server.mocker.users.alice.agent.mute('muted-account.test')
await server.mocker.createPost('muted-account', 'muted post')
await server.mocker.createQuotePost(
'muted-account',
'muted quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-account',
'muted reply',
anchorPost,
)
const list = await server.mocker.createMuteList(
'alice',
'Muted Users',
)
await server.mocker.addToMuteList(
'alice',
list,
server.mocker.users['muted-by-list-acc'].did,
)
await server.mocker.createPost('muted-by-list-acc', 'muted post')
await server.mocker.createQuotePost(
'muted-by-list-acc',
'account quote post',
anchorPost,
)
await server.mocker.createReply(
'muted-by-list-acc',
'account reply',
anchorPost,
)
await server.mocker.createPost('blocking-account', 'blocking post')
await server.mocker.createQuotePost(
'blocking-account',
'blocking quote post',
anchorPost,
)
await server.mocker.createReply(
'blocking-account',
'blocking reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['blocking-account'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost('blockedby-account', 'blockedby post')
await server.mocker.createQuotePost(
'blockedby-account',
'blockedby quote post',
anchorPost,
)
await server.mocker.createReply(
'blockedby-account',
'blockedby reply',
anchorPost,
)
await server.mocker.users[
'blockedby-account'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['blockedby-account'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.createPost(
'mutual-block-acc',
'mutual-block post',
)
await server.mocker.createQuotePost(
'mutual-block-acc',
'mutual-block quote post',
anchorPost,
)
await server.mocker.createReply(
'mutual-block-acc',
'mutual-block reply',
anchorPost,
)
await server.mocker.users.alice.agent.app.bsky.graph.block.create(
{
repo: server.mocker.users.alice.did,
},
{
subject: server.mocker.users['mutual-block-acc'].did,
createdAt: new Date().toISOString(),
},
)
await server.mocker.users[
'mutual-block-acc'
].agent.app.bsky.graph.block.create(
{
repo: server.mocker.users['mutual-block-acc'].did,
},
{
subject: server.mocker.users.alice.did,
createdAt: new Date().toISOString(),
},
)
// flush caches
await server.mocker.testNet.processAll()
}
}
console.log('Ready')
return res
.writeHead(200, {
'content-type': 'application/json',
})
.end(
JSON.stringify({
pdsUrl: server.pdsUrl,
appviewDid: server.appviewDid,
}),
)
} catch (e) {
console.error('Error!', e)
return res.writeHead(500).end()
}
}).listen(1986)
console.log('Mock server manager listening on 1986')
}
main()
+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(() => {})
+7 -86
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,8 +6,7 @@ import {
createStarterPackLinkFromAndroidReferrer,
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -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,20 +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',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
'https://static.klipy.com',
]
const outputs = [
@@ -835,52 +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,
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
// With video slug params — on native (test env), keeps gif filename,
// strips mp4/webm params. On web, would swap to video filename.
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
@@ -1071,30 +1005,17 @@ describe('createStarterPackGooglePlayUri', () => {
})
})
describe('klipyUrlToBskyGifUrl', () => {
describe('tenorUrlToBskyGifUrl', () => {
const inputs = [
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://media.tenor.com/someID_AAAAC/someName.gif',
'https://media.tenor.com/someID/someName.gif',
]
it.each(inputs)(
'returns url with k.gifs.bsky.app as hostname for input url',
'returns url with t.gifs.bsky.app as hostname for input url',
input => {
const out = klipyUrlToBskyGifUrl(input)
expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true)
const out = tenorUrlToBskyGifUrl(input)
expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
},
)
it('preserves the path and query params when rewriting', () => {
const out = klipyUrlToBskyGifUrl(
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
expect(out).toEqual(
'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
)
})
it('returns empty string for invalid URLs', () => {
expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('')
})
})
+34 -69
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,
@@ -54,7 +42,7 @@ module.exports = function (_config) {
},
icon: './assets/app-icons/ios_icon_default_next.png',
userInterfaceStyle: 'automatic',
primaryColor: '#006AFF',
primaryColor: '#1083fe',
newArchEnabled: false,
ios: {
supportsTablet: false,
@@ -62,11 +50,12 @@ 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: {
CADisableMinimumFrameDurationOnPhone: true,
UIBackgroundModes: ['remote-notification'],
NSUserActivityTypes: ['INSendMessageIntent'],
NSCameraUsageDescription:
'Used for profile pictures, posts, and other kinds of content.',
NSMicrophoneUsageDescription:
@@ -118,14 +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.usernotifications.communication': true,
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
NSPrivacyCollectedDataTypes: [
@@ -204,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'],
},
@@ -243,36 +227,23 @@ 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,
cxxLanguageStandard: 'c++23',
extraPods: [
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
},
],
},
android: {
compileSdkVersion: 36,
compileSdkVersion: 35,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
buildReactNativeFromSource: IS_PRODUCTION,
@@ -300,6 +271,7 @@ module.exports = function (_config) {
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidManifestIntentQueriesPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidDayNightThemePlugin.js',
'./plugins/withAndroidNoJitpackPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js',
@@ -325,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,
},
},
},
@@ -417,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="M17 3a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4h-2a1 1 0 1 1 0-2h2a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h2Zm-6.707 4.793a1 1 0 0 1 1.414 0l3.5 3.5a1 1 0 0 1 0 1.414l-3.5 3.5a1 1 0 1 1-1.414-1.414L12.086 13H4a1 1 0 1 1 0-2h8.086l-1.793-1.793a1 1 0 0 1 0-1.414Z"/></svg>

Before

Width:  |  Height:  |  Size: 360 B

@@ -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
View File
@@ -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="M12 0a2 2 0 0 1 1 3.73V5h4.2c1.68 0 2.52 0 3.162.327a3 3 0 0 1 1.31 1.31C22 7.28 22 8.12 22 9.8v.25a2.501 2.501 0 0 1 0 4.9V15c0 2.8 0 4.2-.545 5.27a5 5 0 0 1-2.185 2.185C18.2 23 16.8 23 14 23h-4c-2.8 0-4.2 0-5.27-.545a5 5 0 0 1-2.185-2.185C2 19.2 2 17.8 2 15v-.05a2.5 2.5 0 0 1 0-4.9V9.8c0-1.68 0-2.52.327-3.162a3 3 0 0 1 1.31-1.31C4.28 5 5.12 5 6.8 5H11V3.73A2 2 0 0 1 12 0M8 10a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2m8 0a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 621 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a1 1 0 0 1 1 1v2h3.2l1.113.005c.975.015 1.568.077 2.05.322a3 3 0 0 1 1.31 1.31C21 7.28 21 8.12 21 9.8v.287a1.498 1.498 0 0 1-.005 2.827c-.006 2.204-.058 3.41-.54 4.356l-.093.174a5 5 0 0 1-2.092 2.011l-.205.096c-.766.33-1.72.417-3.21.44L13 20h-2l-1.854-.009c-1.49-.023-2.445-.11-3.211-.44l-.205-.096a5 5 0 0 1-2.185-2.185c-.409-.803-.51-1.79-.536-3.415l-.005-.94A1.498 1.498 0 0 1 3 10.086V9.8c0-1.575 0-2.412.27-3.04l.057-.122a3 3 0 0 1 1.105-1.196l.206-.115C5.279 5 6.12 5 7.8 5H11V3a1 1 0 0 1 1-1M7.8 7c-.873 0-1.408.002-1.808.034a3 3 0 0 0-.367.051l-.063.018-.016.006a1 1 0 0 0-.437.437l-.006.016-.018.063a3 3 0 0 0-.05.367C5.001 8.392 5 8.927 5 9.8V12c0 1.433.002 2.388.062 3.121.058.71.16 1.036.265 1.241a3 3 0 0 0 1.31 1.31c.207.106.532.209 1.242.267.733.06 1.688.061 3.121.061h2c1.433 0 2.388-.002 3.121-.061.71-.058 1.036-.161 1.241-.266a3 3 0 0 0 1.31-1.31c.106-.206.209-.532.267-1.242.06-.733.061-1.688.061-3.121V9.8c0-.873-.002-1.408-.034-1.808a2.5 2.5 0 0 0-.051-.367l-.017-.063-.007-.016a1 1 0 0 0-.437-.437l-.015-.006-.064-.018a3 3 0 0 0-.367-.05C17.608 7.001 17.073 7 16.2 7zM9 10a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1m6 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0v-2a1 1 0 0 1 1-1"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#111822" d="M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32c0 3.463.765 6.745 2.134 9.688.358.769.479 1.658.267 2.525l-2.076 8.51a1.667 1.667 0 0 0 1.987 2.02l8.876-2.005a3.84 3.84 0 0 1 2.412.26A22.9 22.9 0 0 0 32 55c12.703 0 23-10.297 23-23Zm2 0c0 13.807-11.193 25-25 25-3.638 0-7.098-.778-10.219-2.177a1.84 1.84 0 0 0-1.152-.133l-8.877 2.004c-2.655.6-5.015-1.802-4.37-4.446l2.077-8.51a1.84 1.84 0 0 0-.139-1.207A24.9 24.9 0 0 1 7 32C7 18.193 18.193 7 32 7s25 11.193 25 25Z"/><path fill="#111822" d="M17.667 32a2.333 2.333 0 1 0 4.667 0 2.333 2.333 0 0 0-4.667 0Zm24 0a2.334 2.334 0 1 0 4.667 0 2.334 2.334 0 0 0-4.667 0Z"/><path fill="#000" d="M35.137 37.215a1 1 0 0 1 1.414 1.414 6.143 6.143 0 0 1-8.687 0 1 1 0 0 1 1.415-1.414 4.14 4.14 0 0 0 5.858 0Z"/></svg>

Before

Width:  |  Height:  |  Size: 847 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M14.3 23v-1.1a1 1 0 0 1 2 0V23a1 1 0 1 1-2 0Zm5.243-3.457a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM4.788 9.298a1 1 0 0 1 1.424 1.404l-.742.752-.004.005a5.003 5.003 0 1 0 7.075 7.075l.005-.004.752-.742a1 1 0 0 1 1.404 1.424l-.747.736a7.003 7.003 0 1 1-9.904-9.904l.737-.746ZM23 14.3a1 1 0 0 1 0 2h-1.1a1 1 0 1 1 0-2H23ZM10.044 4.05a7.005 7.005 0 0 1 9.905 9.906h0l-.737.746a1 1 0 0 1-1.424-1.404l.742-.752.004-.005a5.003 5.003 0 1 0-7.075-7.075l-.005.004-.752.742a1 1 0 0 1-1.404-1.424l.746-.737ZM2.1 7.7a1 1 0 1 1 0 2H1a1 1 0 0 1 0-2h1.1Zm-.157-5.757a1 1 0 0 1 1.414 0l1.1 1.1a1 1 0 1 1-1.414 1.414l-1.1-1.1a1 1 0 0 1 0-1.414ZM7.7 2.1V1a1 1 0 1 1 2 0v1.1a1 1 0 0 1-2 0Z"/></svg>

Before

Width:  |  Height:  |  Size: 807 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M17.058 1.001c.466-.012 1.294.05 1.828.782.099.136.172.274.224.405.385-.036.92-.08 1.416-.039.48.04 1.448.197 2.012 1.09.497.787.372 1.592.13 2.14-.14.315-.345.602-.505.807.172.15.366.354.518.613.366.622.383 1.312.22 1.962-.285 1.143-1.24 1.642-1.82 1.865-.597.23-1.296.354-1.818.448l-.06.01-.121.022c.128.445.101.918-.048 1.367-.41 1.242-1.349 1.831-2.24 2.08-.764.214-1.618.213-2.225.212h-.218c.152.615-.022 1.221-.192 1.631-.63 1.514-2.053 2.08-3.203 2.319s-2.484.247-3.591.253h-.076c-.607.004-1.145.007-1.616.034-.417.806-.763 1.702-1.163 2.75l-.002.002q-.158.414-.327.85c-.446 1.14-1.709 1.693-2.82 1.235-1.11-.459-1.65-1.755-1.204-2.897.571-1.462 1.164-2.48 1.954-4.046.212-.42.39-.76.547-1.075 0-.951.07-1.624.207-2.476.23-1.432.423-1.984.707-3.29.26-.903.644-2.115 1.247-2.783.346-.384.88-.945 1.685-.983.489-.023.794.38 1.052.553.749-1.04 1.384-2.427 2.202-3.503.657-.863 1.363-1.618 2.503-1.438.588.093 1.423.547 1.424 1.438.217-.249.883-1.204 1.162-1.438.532-.445 1.27-.874 2.21-.9m-.42 1.154c-1.003.028-1.71.904-2.303 1.64-.43.532-.8.991-1.18 1.001-.373.01-.345-.247-.31-.572.05-.464.115-1.066-.945-1.234-.879-.14-1.805 1.531-2.61 2.981-.551.993-1.046 1.883-1.428 2.019-.584.208-.854-.037-1.085-.245-.28-.252-.5-.451-1.147.265-.681.755-1.146 2.82-1.596 5.068-.057.287.342.472.525.247 1.036-1.28 2.72-3.039 4.73-4.34 1.848-1.196 3.63-2.385 5.647-2.983.297-.088.38.253.096.377-2.273.994-4.226 2.304-6.272 4.053-2.299 1.923-4.474 4.483-5.577 6.57a27 27 0 0 0-1.829 3.786.724.724 0 0 0 .388.933.693.693 0 0 0 .91-.398q.162-.418.316-.825c.473-1.234.915-2.391 1.539-3.443a.25.25 0 0 1 .187-.123c.678-.082 1.485-.087 2.32-.092 2.277-.013 4.764-.028 5.431-1.632.348-.838-.071-.959-.45-1.067-.31-.089-.593-.17-.406-.629.323-.792 1.377-.793 2.494-.793 1.301 0 2.687 0 3.103-1.254.177-.537-.14-.748-.42-.934-.235-.157-.444-.296-.317-.596.201-.477 1.078-.636 2.007-.803 1.11-.2 2.294-.412 2.49-1.198.186-.744-.16-.983-.52-1.232-.218-.151-.44-.307-.55-.582-.133-.337.158-.697.461-1.071.358-.442.731-.903.438-1.367-.334-.527-1.327-.434-2.15-.358-.512.048-.959.089-1.136-.03-.18-.12-.173-.325-.166-.528.011-.317.022-.63-.685-.611"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.54729 11.8415C6.66801 12.836 7.11067 14.8647 8.11671 16.5751C8.23744 16.8138 8.55937 16.774 8.63986 16.5751L14.4346 6.07367C14.5553 5.87478 14.5151 5.59634 14.3139 5.43722C13.4688 4.72121 12.3823 4.28365 11.0946 4.32343C6.46681 4.44276 6.42657 10.2106 6.54729 11.8415ZM12.9054 19.638C17.4125 19.5187 17.5734 13.7907 17.4125 12.1995C17.332 11.1653 16.8894 9.09681 15.8431 7.3068C15.7626 7.14769 15.4809 7.14769 15.4004 7.3068L9.56541 17.8878C9.48492 18.1265 9.52517 18.3652 9.68613 18.5243C10.5312 19.2403 11.6177 19.6778 12.9054 19.638ZM17.8149 2.93119L17.5734 3.36875C17.4125 3.6472 17.493 3.96543 17.7345 4.08476C20.9537 6.11345 22 9.61393 22 11.9211C22 14.7851 19.5856 21.4678 12.2213 21.4678C10.7727 21.4678 9.52517 21.2689 8.3984 20.9109C8.1972 20.8314 7.9155 20.9109 7.79478 21.1098L7.47284 21.7463C7.35212 21.9849 7.07043 22.0645 6.82898 21.9452L6.38632 21.7065C6.14488 21.5872 6.0644 21.3088 6.18512 21.0701L6.46681 20.5529C6.58753 20.3143 6.50705 20.0358 6.30584 19.9165C3.08652 17.848 2 14.3078 2 12.0802C2 9.21615 4.21328 2.53341 11.6177 2.53341C13.1469 2.53341 14.4346 2.7323 15.5614 3.0903C15.8028 3.16986 16.0846 3.05053 16.2053 2.85163L16.5674 2.21518C16.6881 2.01629 16.9699 1.93673 17.1711 2.05607L17.654 2.29474C17.8552 2.41407 17.9357 2.69252 17.8149 2.93119Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M4.686 8.752c1.117 0 1.88.517 2.246 1.52q.247.678.249 1.643l-.001.097q-.014.94-.267 1.6l-.074.176c-.394.858-1.123 1.303-2.153 1.303a2.32 2.32 0 0 1-1.408-.455l-.035-.028v1.966a1 1 0 0 1-.008.074c-.029.178-.146.267-.359.267H.854q-.273 0-.335-.216l-.01-.035a1 1 0 0 1-.009-.09v-7.34a.7.7 0 0 1 .02-.149.27.27 0 0 1 .155-.18l.027-.01.005-.002a.5.5 0 0 1 .126-.02h2.066q.04 0 .076.007h.007l.048.013a.27.27 0 0 1 .204.231l.005.028a1 1 0 0 1 .004.07 2 2 0 0 1 .185-.131l.039-.024a2 2 0 0 1 .289-.144q.413-.17.93-.171m5.367.041c.46 0 .958.048 1.655.289l.015.005.021.008.06.021.02.007.018.008.017.007.033.014.024.012.015.008.02.01.003.004.01.005.006.004.01.007.003.003.01.008.005.003.009.008.003.003.009.007.011.012.008.009.004.004.006.008.005.005.005.007.013.023.005.009.004.007.002.003a.4.4 0 0 1 .036.134l.004.05.083 1.389a1 1 0 0 1 0 .083v.01l-.004.03-.007.043-.002.008q0 .006-.004.012v.005l-.005.014-.005.014-.002.003-.005.011-.002.004-.006.01-.002.004-.005.008q0 .003-.003.005l-.003.003-.006.009-.003.004-.018.016-.023.018-.005.003-.01.005h-.002l-.01.006q-.004 0-.008.003l-.007.002-.007.002-.009.003-.007.001-.01.003-.005.001-.013.002H12l-.012.002h-.007l-.013.001h-.048l-.013-.003-.057-.008-.124-.02a9 9 0 0 0-1.272-.112c-.804 0-1.146.265-1.146.857 0 .495.212.857 1.134.857a10 10 0 0 0 1.396-.12c.271-.049.366.06.354.326l-.083 1.388a1 1 0 0 1-.01.093l-.001.008-.003.013-.002.006-.004.012-.018.047-.007.016-.002.002-.006.011-.003.006c-.068.107-.192.153-.358.21a4.5 4.5 0 0 1-1.655.3c-1.24 0-2.135-.377-2.706-.996.306-.61.445-1.365.445-2.219 0-.828-.13-1.562-.418-2.16.574-.6 1.464-.963 2.69-.963m11.419-1.227q.354 0 .354.363v1.073c.386.048.796.116 1.265.21.2.041.327.242.285.446l-.307 1.477a.374.374 0 0 1-.438.293 17 17 0 0 0-.805-.142v.603c0 .724.425.82.71.821.117 0 .33-.024.52-.048.271-.036.39.072.378.338l-.082 1.46c-.012.29-.166.46-.415.52a5.5 5.5 0 0 1-1.24.145c-1.869 0-2.614-.978-2.614-2.982v-.985l-.165.008a.4.4 0 0 1-.262-.085l-.002-.002a.4.4 0 0 1-.04-.039l-.004-.003a.4.4 0 0 1-.05-.071l-.003-.005a.4.4 0 0 1-.023-.052l-.002-.005-.008-.025v-.006a.4.4 0 0 1-.01-.057l-.001-.011v-.006l-.001-.019v-.784c0-.165.07-.322.193-.43.191-.168.493-.435.759-.673.154-.139.296-.268.397-.363l.003-.003.744-.683c.201-.181.438-.278.71-.278zM14.052 7c.249 0 .368.12.368.362v2.52c.375-.199.75-.37 1.143-.512.91-.328 1.852-.47 3.017-.482l-.283.25a1.14 1.14 0 0 0-.385.858v.784q.002.214.083.394c-.294.02-.56.052-.806.096l.01.029c.436 1.35 1.099 3.357 1.099 3.357.082.254-.013.386-.273.386h-2.01a.524.524 0 0 1-.532-.386l-.77-2.338q-.142.09-.293.188v2.174q.002.362-.367.362h-1.665a.95.95 0 0 0 .23-.6l.083-1.39v-.007c.008-.165-.001-.484-.243-.718-.248-.24-.564-.225-.748-.194l-.033.005v-.355l.033.006c.197.035.521.046.77-.208.23-.234.24-.544.233-.713v-.009l-.083-1.388a.93.93 0 0 0-.317-.685 1.3 1.3 0 0 0-.382-.209l-.093-.032-.161-.054V7.362q0-.362.354-.362zM3.919 10.868c-.556 0-.74.423-.74 1.132s.184 1.132.74 1.132.738-.423.738-1.132-.182-1.132-.738-1.132"/></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M13.004 1.646c1.983 0 3.668.678 4.824 2.135q.17.215.32.446.694.46 1.24 1.146C20.512 6.787 21 8.767 21 11.103c0 1.856-.31 3.49-.999 4.795q.105.073.201.163c.306.288.483.648.595.967l.007.019.006.02c.144.46.24 1.13-.137 1.75a1.8 1.8 0 0 1-.263.335q.031.196.017.413c-.054.732-.522 1.223-.926 1.516H19.5c-.3.218-.73.458-1.268.458-.311 0-.58-.078-.807-.201-.23.123-.5.201-.81.201-.515 0-.955-.24-1.256-.458l-.013-.01c-.333-.249-.735-.648-.873-1.224a2.6 2.6 0 0 1-.673-.357l-.014-.01a2.6 2.6 0 0 1-.617-.64 6 6 0 0 1-.48-.128v2.168c0 .439-.108.995-.543 1.428-.429.427-.972.53-1.4.53H6.48c-.43 0-.968-.111-1.39-.541a1.87 1.87 0 0 1-.506-1.074 1.8 1.8 0 0 1-1.053-.517c-.421-.43-.53-.978-.53-1.417V3.86c0-.44.109-.987.53-1.418s.96-.54 1.389-.54h4.267c.348 0 .767.067 1.14.315.798-.376 1.7-.57 2.678-.57m0 1.193c-1.223 0-2.246.357-3.045.993 0-.483-.25-.738-.773-.738H4.918q-.748 0-.75.763V19.29q.002.763.75.764h4.268c.523 0 .773-.255.773-.764v-4.1c.81.625 1.814 1.005 2.985 1.018.007-.19.048-.377.107-.552.086-.277.218-.548.432-.753a1.17 1.17 0 0 1 .954-.318 1.25 1.25 0 0 1 .267-.548c.304-.355.749-.455 1.165-.455.411 0 .858.1 1.16.458q.064.076.112.156c.744-1.115 1.129-2.685 1.129-4.684 0-4.38-1.847-6.672-5.266-6.672m2.865 11.34c-.684 0-.955.332-.873 1.008l.047.248-.224-.119a1.2 1.2 0 0 0-.508-.142c-.341 0-.566.214-.707.676-.213.628.024.995.696 1.114l.248.036-.177.201c-.483.474-.448.9.106 1.316.212.153.413.237.578.237.271 0 .507-.19.696-.557l.118-.225.118.225c.189.367.414.557.685.557.189 0 .377-.083.59-.237.554-.403.578-.842.106-1.328L17.203 17l.236-.036c.684-.119.896-.486.696-1.126-.154-.438-.378-.651-.697-.651-.153 0-.318.047-.518.141l-.236.107.035-.248c.106-.676-.177-1.008-.85-1.008m-4.485-6.877c1.173 0 1.56.893 1.56 2.386s-.387 2.388-1.56 2.388-1.56-.894-1.56-2.388.387-2.386 1.56-2.386"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M11.815.056a.33.33 0 0 1 .37 0c.13.088.2.95.34 2.673l.047.599h3.312a4.745 4.745 0 0 1 4.745 4.745v.818h-1.637v-.818a3.11 3.11 0 0 0-3.109-3.11h-3.175c.147 1.658.295 2.622.726 3.41a5.24 5.24 0 0 0 2.026 2.05c.969.545 2.214.667 4.702.91l1.366.133c1.553.151 2.33.227 2.417.357a.32.32 0 0 1-.003.364c-.088.128-.866.192-2.422.32l-.891.073v3.347a4.745 4.745 0 0 1-4.746 4.746h-3.315l-.044.56c-.139 1.748-.21 2.622-.34 2.711a.33.33 0 0 1-.369 0c-.13-.088-.2-.963-.339-2.711l-.045-.56H8.03a4.746 4.746 0 0 1-4.746-4.746v-.818H4.92v.818a3.11 3.11 0 0 0 3.11 3.11h3.269c-.15-1.72-.297-2.713-.743-3.52a5.24 5.24 0 0 0-2.064-2.057c-.987-.543-2.254-.646-4.786-.853l-1.227-.1c-1.555-.128-2.333-.192-2.422-.32a.325.325 0 0 1-.003-.364c.087-.13.864-.206 2.417-.357l.812-.08V8.073a4.746 4.746 0 0 1 4.746-4.745h3.399l.048-.599c.14-1.722.209-2.585.34-2.673m7.177 12.66c-1.697.152-2.682.303-3.484.744a5.24 5.24 0 0 0-2.065 2.057c-.445.807-.592 1.8-.741 3.52h3.182a3.11 3.11 0 0 0 3.108-3.11zM8.029 4.964a3.11 3.11 0 0 0-3.11 3.109v3.152c1.79-.184 2.803-.342 3.62-.803a5.24 5.24 0 0 0 2.027-2.048c.432-.79.58-1.753.727-3.41z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -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="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M19 14h-2.417a5 5 0 0 1-9.166 0H5v4a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-4Zm0-8a1 1 0 0 0-.898-.995L18 5H6a1 1 0 0 0-1 1v6h3.126a1 1 0 0 1 .969.751 3.001 3.001 0 0 0 5.81 0l.056-.16a1 1 0 0 1 .913-.591H19V6Zm2 12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h12l.154.004A3 3 0 0 1 21 6v12Z"/></svg>

Before

Width:  |  Height:  |  Size: 387 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#111822" d="M57.666 33.416q-.001-.21-.022-.419L57.6 33H44a4.33 4.33 0 0 0-3.466 1.734l-.8 1.066a6.34 6.34 0 0 1-5.068 2.533h-5.333a6.33 6.33 0 0 1-5.066-2.533l-.8-1.066A4.33 4.33 0 0 0 20 33H6.4l-.045-.003a4 4 0 0 0-.022.42v11.916a4.333 4.333 0 0 0 4.333 4.333h42.667a4.333 4.333 0 0 0 4.333-4.333V33.416Zm-38.57-19.083a4.33 4.33 0 0 0-3.764 2.184L7.057 31H20c1.994 0 3.87.939 5.067 2.533l.8 1.067a4.33 4.33 0 0 0 3.466 1.733h5.333a4.33 4.33 0 0 0 3.467-1.733l.8-1.067A6.34 6.34 0 0 1 44 31h12.944l-8.277-14.483a4.33 4.33 0 0 0-3.762-2.184h-25.81Zm40.57 31a6.333 6.333 0 0 1-6.333 6.333H10.666a6.333 6.333 0 0 1-6.333-6.333V33.416c0-1.102.288-2.185.835-3.142l8.428-14.75a6.33 6.33 0 0 1 5.5-3.19h25.809a6.33 6.33 0 0 1 5.499 3.19l8.427 14.75h.001a6.33 6.33 0 0 1 .834 3.142v11.917Z"/></svg>

Before

Width:  |  Height:  |  Size: 875 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"><path fill="#000" fill-rule="evenodd" d="M14.5 8a1 1 0 0 1 .912.59l4.5 10a1 1 0 0 1-1.824.82L16.554 16h-4.108l-1.534 3.41a1 1 0 0 1-1.824-.82l4.5-10A1 1 0 0 1 14.5 8m-1.153 6h2.306L14.5 11.437zM5.053.106a1 1 0 0 1 1.342.447L7.118 2H11a1 1 0 0 1 0 2H9.92c-.28 1.765-1.27 3.24-2.393 4.398a17 17 0 0 0 2.605 1.562q.129.062.197.092l.005.002.049.022.01.005a1 1 0 0 1-.787 1.838h-.002l-.027-.013-.068-.03-.242-.113A19 19 0 0 1 6 9.75a19 19 0 0 1-3.509 2.125l-.068.03-.027.013h-.002a1 1 0 0 1-.788-1.838l.011-.005.049-.022.005-.002q.069-.03.197-.092a17 17 0 0 0 2.604-1.562C3.348 7.239 2.36 5.764 2.08 4H1a1 1 0 0 1 0-2h3.882l-.277-.553A1 1 0 0 1 5.053.106M4.118 4c.27 1.151.98 2.193 1.882 3.1.901-.907 1.612-1.95 1.882-3.1z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 819 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 24 24"><path fill="#000" d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z"/></svg>

Before

Width:  |  Height:  |  Size: 448 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 24 24"><path fill="#000" d="M10.655 3.718c.55-1.116 2.14-1.116 2.69 0l7.548 15.317c.578 1.172-.515 2.471-1.768 2.103L13 19.336V15a1 1 0 0 0-2 0v4.336l-6.124 1.802c-1.254.369-2.346-.93-1.769-2.103l7.548-15.317Z"/></svg>

Before

Width:  |  Height:  |  Size: 284 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

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>

Before

Width:  |  Height:  |  Size: 502 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>

Before

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

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 -4
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',
@@ -47,9 +47,6 @@ module.exports = function (api) {
production: {
plugins: ['transform-remove-console'],
},
test: {
plugins: ['@babel/plugin-transform-class-static-block'],
},
},
}
}

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