diff --git a/.github/actions/compile-i18n/action.yml b/.github/actions/compile-i18n/action.yml new file mode 100644 index 0000000000..9324ff17bf --- /dev/null +++ b/.github/actions/compile-i18n/action.yml @@ -0,0 +1,15 @@ +--- +name: Compile translations +description: Compile i18n translations and fail on compilation errors. + +runs: + using: composite + steps: + - name: πŸ”€ Compile translations + shell: bash + run: pnpm intl:build 2>&1 | tee i18n.log + + - name: Check for i18n compilation errors + shell: bash + 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 diff --git a/.github/actions/eas-local-build/action.yml b/.github/actions/eas-local-build/action.yml new file mode 100644 index 0000000000..ae62b0d572 --- /dev/null +++ b/.github/actions/eas-local-build/action.yml @@ -0,0 +1,72 @@ +--- +name: Local EAS Build +description: Build an Expo app locally with a selected EAS profile. + +inputs: + platform: + description: EAS platform to build (ios or android) + required: true + profile: + description: EAS build profile + required: true + output: + description: Output path for the local build artifact + required: true + log-path: + description: Optional path to tee build output into + required: false + default: "" + bump-build-number: + description: Run the build through use-build-number-with-bump + required: false + default: "false" + sentry-auth-token: + description: Optional Sentry authentication token + required: false + default: "" + sentry-release: + description: Optional Sentry release + required: false + default: "" + sentry-dist: + description: Optional Sentry distribution + required: false + default: "" + +runs: + using: composite + steps: + - name: Build locally with EAS + shell: bash + env: + PLATFORM: ${{ inputs.platform }} + PROFILE: ${{ inputs.profile }} + OUTPUT: ${{ inputs.output }} + LOG_PATH: ${{ inputs.log-path }} + BUMP_BUILD_NUMBER: ${{ inputs.bump-build-number }} + SENTRY_AUTH_TOKEN: ${{ inputs.sentry-auth-token }} + SENTRY_RELEASE: ${{ inputs.sentry-release }} + SENTRY_DIST: ${{ inputs.sentry-dist }} + run: | + set -o pipefail + build_command=( + pnpm eas build + --platform "$PLATFORM" + --profile "$PROFILE" + --local + --output "$OUTPUT" + --non-interactive + ) + + if [ -n "$LOG_PATH" ]; then + mkdir -p "$(dirname "$LOG_PATH")" + if [ "$BUMP_BUILD_NUMBER" = "true" ]; then + pnpm use-build-number-with-bump "${build_command[@]}" 2>&1 | tee "$LOG_PATH" + else + "${build_command[@]}" 2>&1 | tee "$LOG_PATH" + fi + elif [ "$BUMP_BUILD_NUMBER" = "true" ]; then + pnpm use-build-number-with-bump "${build_command[@]}" + else + "${build_command[@]}" + fi diff --git a/.github/actions/setup-expo-project/action.yml b/.github/actions/setup-expo-project/action.yml new file mode 100644 index 0000000000..1369ba0cb6 --- /dev/null +++ b/.github/actions/setup-expo-project/action.yml @@ -0,0 +1,47 @@ +--- +name: Setup Expo Project +description: Install dependencies and set up the Expo/EAS CLI for a build. Does not check out the repo. + +inputs: + expo-token: + description: Expo token (EXPO_TOKEN secret) + required: true + eas-version: + description: EAS CLI version to install + required: false + default: '19.0.5' + +runs: + using: composite + steps: + - name: Check for EXPO_TOKEN + shell: bash + env: + EXPO_TOKEN: ${{ inputs.expo-token }} + run: > + if [ -z "$EXPO_TOKEN" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: πŸ”§ Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: package.json + cache: pnpm + + - name: πŸͺ› Setup jq + uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1 + + - name: βš™οΈ Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: πŸ”¨ Setup Expo CLI + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 + with: + eas-version: ${{ inputs.eas-version }} + packager: 'pnpm --allow-build=dtrace-provider' + token: ${{ inputs.expo-token }} diff --git a/.github/actions/write-env/action.yml b/.github/actions/write-env/action.yml new file mode 100644 index 0000000000..7cb3464188 --- /dev/null +++ b/.github/actions/write-env/action.yml @@ -0,0 +1,63 @@ +--- +name: Write Environment Variables +description: Write the .env file and google-services.json used by the build. + +inputs: + env-token: + description: Base .env contents (ENV_TOKEN secret) + required: true + sentry-dsn: + description: Sentry DSN (SENTRY_DSN secret) + required: true + bitdrift-api-key: + description: Bitdrift API key (BITDRIFT_API_KEY secret) + required: true + gcp-project-id: + description: GCP project ID (EXPO_PUBLIC_GCP_PROJECT_ID secret) + required: true + google-services-token: + description: google-services.json contents (GOOGLE_SERVICES_TOKEN secret) + required: true + expo-public-env: + description: > + EXPO_PUBLIC_ENV value. Only set for OTA deploys where eas.json isn't used; + for regular builds this is normally handled in eas.json. + required: false + default: '' + +outputs: + release-version: + description: Version from package.json + value: ${{ steps.env.outputs.release-version }} + bundle-identifier: + description: git SHA of HEAD + value: ${{ steps.env.outputs.bundle-identifier }} + +runs: + using: composite + steps: + - name: ✏️ Write environment variables + id: env + shell: bash + env: + ENV_TOKEN: ${{ inputs.env-token }} + SENTRY_DSN: ${{ inputs.sentry-dsn }} + BITDRIFT_API_KEY: ${{ inputs.bitdrift-api-key }} + GCP_PROJECT_ID: ${{ inputs.gcp-project-id }} + GOOGLE_SERVICES_TOKEN: ${{ inputs.google-services-token }} + EXPO_PUBLIC_ENV: ${{ inputs.expo-public-env }} + run: | + echo "$ENV_TOKEN" > .env + # EXPO_PUBLIC_ENV is normally handled in eas.json; only written here for OTA deploys. + if [ -n "$EXPO_PUBLIC_ENV" ]; then + echo "EXPO_PUBLIC_ENV=$EXPO_PUBLIC_ENV" >> .env + fi + echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env + echo "release-version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT + echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env + echo "bundle-identifier=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env + echo "EXPO_PUBLIC_SENTRY_DSN=$SENTRY_DSN" >> .env + echo "EXPO_PUBLIC_BITDRIFT_API_KEY=$BITDRIFT_API_KEY" >> .env + echo "EXPO_PUBLIC_GCP_PROJECT_ID=$GCP_PROJECT_ID" >> .env + echo "$GOOGLE_SERVICES_TOKEN" > google-services.json diff --git a/.github/scripts/cleanup-nightly-e2e.sh b/.github/scripts/cleanup-nightly-e2e.sh new file mode 100755 index 0000000000..c37974de92 --- /dev/null +++ b/.github/scripts/cleanup-nightly-e2e.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set +e + +platform="${1:?usage: cleanup-nightly-e2e.sh }" +device_id="${2:-}" +artifact_dir="${GITHUB_WORKSPACE:-$PWD}/artifacts/$platform" +mkdir -p "$artifact_dir" + +if [[ -f i18n.log ]]; then + cp i18n.log "$artifact_dir/i18n.log" +fi + +stop_process_tree() { + local pid="$1" + local child + while read -r child; do + [[ -n "$child" ]] && stop_process_tree "$child" + done < <(pgrep -P "$pid" 2>/dev/null || true) + kill -TERM "$pid" >/dev/null 2>&1 || true +} + +stop_pid_file() { + [[ -f "$1" ]] || return 0 + local pid + pid="$(cat "$1")" + stop_process_tree "$pid" +} + +stop_pid_file "$artifact_dir/logcat.pid" +stop_pid_file "$artifact_dir/metro.pid" +stop_pid_file "$artifact_dir/mock-server.pid" +stop_pid_file "$artifact_dir/emulator.pid" + +if [[ "$platform" == "ios" ]]; then + if [[ -f "$artifact_dir/redis-bin.txt" ]]; then + "$(cat "$artifact_dir/redis-bin.txt")/redis-cli" \ + -h 127.0.0.1 -p 6380 shutdown nosave >/dev/null 2>&1 || true + fi + if [[ -f "$artifact_dir/postgres-bin.txt" ]]; then + "$(cat "$artifact_dir/postgres-bin.txt")/pg_ctl" \ + -D "${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres" -m fast stop >/dev/null 2>&1 || true + fi + [[ -n "$device_id" ]] && xcrun simctl shutdown "$device_id" >/dev/null 2>&1 || true +else + docker compose -f dev-env/dev-infra/docker-compose.yaml logs --no-color \ + >>"$artifact_dir/docker-services.log" 2>&1 || true + docker compose -f dev-env/dev-infra/docker-compose.yaml down --volumes --remove-orphans >/dev/null 2>&1 || true + [[ -n "$device_id" ]] && adb -s "$device_id" emu kill >/dev/null 2>&1 || true +fi diff --git a/.github/scripts/run-nightly-e2e.sh b/.github/scripts/run-nightly-e2e.sh new file mode 100755 index 0000000000..fe4fb7e355 --- /dev/null +++ b/.github/scripts/run-nightly-e2e.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +platform="${1:?usage: run-nightly-e2e.sh }" +device_id="${2:?usage: run-nightly-e2e.sh }" + +if [[ "$platform" != "ios" && "$platform" != "android" ]]; then + echo "Unsupported platform: $platform" >&2 + exit 2 +fi + +artifact_dir="${GITHUB_WORKSPACE:-$PWD}/artifacts/$platform" +maestro_dir="$artifact_dir/maestro" +mkdir -p "$maestro_dir" + +phase() { + printf '%s\n' "$1" >"$artifact_dir/phase.txt" +} + +wait_for_port() { + local port="$1" + local label="$2" + local attempts="${3:-120}" + + for ((i = 1; i <= attempts; i++)); do + if nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + echo "Timed out waiting for $label on port $port" >&2 + return 1 +} + +# shellcheck disable=SC2329 # Invoked through the cleanup trap call chain. +stop_process_tree() { + local pid="$1" + local child + while read -r child; do + [[ -n "$child" ]] && stop_process_tree "$child" + done < <(pgrep -P "$pid" 2>/dev/null || true) + kill -TERM "$pid" >/dev/null 2>&1 || true +} + +# shellcheck disable=SC2329 # Invoked by cleanup, which is registered as a trap. +stop_pid_file() { + local pid_file="$1" + [[ -f "$pid_file" ]] || return 0 + + local pid + pid="$(cat "$pid_file")" + [[ -n "$pid" ]] || return 0 + + # pnpm and Expo both spawn multiple generations of children. + stop_process_tree "$pid" +} + +# shellcheck disable=SC2329 # Invoked by the EXIT/INT/TERM trap below. +cleanup() { + set +e + stop_pid_file "$artifact_dir/logcat.pid" + stop_pid_file "$artifact_dir/metro.pid" + stop_pid_file "$artifact_dir/mock-server.pid" + + if [[ "$platform" == "ios" ]]; then + if [[ -f "$artifact_dir/redis.pid" ]]; then + redis_bin="$(cat "$artifact_dir/redis-bin.txt")" + "$redis_bin/redis-cli" -h 127.0.0.1 -p 6380 shutdown nosave >/dev/null 2>&1 || true + fi + if [[ -f "$artifact_dir/postgres-bin.txt" && -d "${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres" ]]; then + postgres_bin="$(cat "$artifact_dir/postgres-bin.txt")" + "$postgres_bin/pg_ctl" -D "${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres" -m fast stop >/dev/null 2>&1 || true + fi + else + docker compose -f dev-env/dev-infra/docker-compose.yaml logs --no-color \ + >>"$artifact_dir/docker-services.log" 2>&1 || true + docker compose -f dev-env/dev-infra/docker-compose.yaml down --volumes --remove-orphans >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT INT TERM + +if [[ "$platform" == "android" ]]; then + adb -s "$device_id" logcat -c + adb -s "$device_id" logcat -v threadtime >"$artifact_dir/logcat.log" 2>&1 & + printf '%s\n' "$!" >"$artifact_dir/logcat.pid" +fi + +phase "Starting PostgreSQL, Redis, and mock server" +if [[ "$platform" == "ios" ]]; then + brew install postgresql@14 2>&1 | tee "$artifact_dir/native-dependencies.log" + + postgres_bin="$(brew --prefix postgresql@14)/bin" + redis_version="7.4.7" + redis_archive="${RUNNER_TEMP:-/tmp}/redis-${redis_version}.tar.gz" + redis_source="${RUNNER_TEMP:-/tmp}/redis-${redis_version}" + curl -fsSL -o "$redis_archive" \ + "https://download.redis.io/releases/redis-${redis_version}.tar.gz" + echo "c97e57b0df330a9e091cacff012bebe763c275398cf36ff44cdba876814b595b $redis_archive" \ + | shasum -a 256 --check | tee -a "$artifact_dir/native-dependencies.log" + rm -rf "$redis_source" + tar -xzf "$redis_archive" -C "${RUNNER_TEMP:-/tmp}" + make -C "$redis_source" -j "$(sysctl -n hw.ncpu)" \ + 2>&1 | tee -a "$artifact_dir/native-dependencies.log" + redis_bin="$redis_source/src" + "$redis_bin/redis-server" --version | tee -a "$artifact_dir/native-dependencies.log" + printf '%s\n' "$redis_bin" >"$artifact_dir/redis-bin.txt" + + postgres_data="${RUNNER_TEMP:-/tmp}/nightly-e2e-postgres" + rm -rf "$postgres_data" + "$postgres_bin/initdb" -D "$postgres_data" --auth=trust --username=pg --no-locale \ + >"$artifact_dir/postgres-init.log" 2>&1 + "$postgres_bin/pg_ctl" -D "$postgres_data" \ + -o "-p 5433 -h 127.0.0.1" -l "$artifact_dir/postgres.log" start + printf '%s\n' "$postgres_bin" >"$artifact_dir/postgres-bin.txt" + + "$redis_bin/redis-server" \ + --bind 127.0.0.1 \ + --port 6380 \ + --save "" \ + --appendonly no \ + --daemonize yes \ + --pidfile "$artifact_dir/redis.pid" \ + --logfile "$artifact_dir/redis.log" + + wait_for_port 5433 "PostgreSQL" + wait_for_port 6380 "Redis" + pnpm --dir dev-env start:external >"$artifact_dir/mock-server.log" 2>&1 & +else + pnpm --dir dev-env start >"$artifact_dir/mock-server.log" 2>&1 & +fi +printf '%s\n' "$!" >"$artifact_dir/mock-server.pid" +wait_for_port 1986 "the E2E mock-server manager" + +phase "Starting Metro" +EXPO_PUBLIC_ENV=e2e \ + NODE_ENV=test \ + RN_SRC_EXT=e2e.ts,e2e.tsx \ + pnpm exec expo start --dev-client --clear --port 8081 \ + >"$artifact_dir/metro.log" 2>&1 & +printf '%s\n' "$!" >"$artifact_dir/metro.pid" +wait_for_port 8081 "Metro" + +# Pre-warm Metro bundle so the first Maestro flow doesn't hit a cold-start delay +phase "Pre-warming Metro bundle" +bundle_platform="$platform" +curl -s -o /dev/null "http://localhost:8081/index.bundle?platform=${bundle_platform}&dev=true&minify=false" +echo "Metro bundle pre-warmed for $bundle_platform" + +if [[ "$platform" == "android" ]]; then + phase "Configuring Android localhost routing" + adb -s "$device_id" reverse tcp:3000 tcp:3000 + adb -s "$device_id" reverse tcp:8081 tcp:8081 +fi + +phase "Running Maestro flows" +set +e +maestro test \ + --udid "$device_id" \ + --format JUNIT \ + --output "$artifact_dir/report.xml" \ + --config __e2e__/config.yml \ + --debug-output "$maestro_dir" \ + --test-output-dir "$maestro_dir" \ + --flatten-debug-output \ + __e2e__ \ + 2>&1 | tee "$artifact_dir/maestro-cli.log" +maestro_status=${PIPESTATUS[0]} +set -e + +if [[ "$maestro_status" -eq 0 ]]; then + phase "Completed" +else + phase "Maestro flow failure" +fi + +exit "$maestro_status" diff --git a/.github/scripts/summarize-maestro.mjs b/.github/scripts/summarize-maestro.mjs new file mode 100644 index 0000000000..7154f5b0ee --- /dev/null +++ b/.github/scripts/summarize-maestro.mjs @@ -0,0 +1,356 @@ +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const ENTITY_REPLACEMENTS = { + '&': '&', + ''': "'", + '>': '>', + '<': '<', + '"': '"', +} + +function decodeXml(value = '') { + return value + .replace(/&(amp|apos|gt|lt|quot);/g, entity => ENTITY_REPLACEMENTS[entity]) + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&#x([\da-f]+);/gi, (_, code) => + String.fromCodePoint(Number.parseInt(code, 16)), + ) +} + +function attributes(source = '') { + const result = {} + for (const match of source.matchAll(/([\w:.-]+)\s*=\s*(["'])(.*?)\2/gs)) { + result[match[1]] = decodeXml(match[3]) + } + return result +} + +function concise(value, limit = 300) { + const normalized = decodeXml(value) + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + return normalized.length > limit + ? `${normalized.slice(0, limit - 1)}…` + : normalized +} + +export function parseJUnit(xml) { + const failures = [] + const testcasePattern = /]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/gi + + for (const match of xml.matchAll(testcasePattern)) { + const testcase = attributes(match[1]) + const body = match[2] || '' + const failure = body.match(/<(failure|error)\b([^>]*)>([\s\S]*?)<\/\1>/i) + const selfClosingFailure = body.match(/<(failure|error)\b([^>]*)\/>/i) + const failureMatch = failure || selfClosingFailure + if (!failureMatch) continue + + const failureAttributes = attributes(failureMatch[2]) + const name = testcase.name || testcase.classname || 'Unnamed Maestro flow' + const message = concise( + failureAttributes.message || (failure ? failureMatch[3] : '') || 'Failed', + ) + failures.push({name, message}) + } + + if (failures.length === 0) { + const suite = xml.match(/]*)>/i) + const suiteAttributes = attributes(suite?.[1]) + if ( + Number(suiteAttributes.failures || 0) + + Number(suiteAttributes.errors || 0) > + 0 + ) { + failures.push({ + name: suiteAttributes.name || 'Maestro test suite', + message: 'JUnit reported a failure without testcase details', + }) + } + } + + return failures +} + +export function parseMaestroCli(log) { + const failures = [] + const failurePattern = /^\[Failed\]\s+(.+?)\s+\([^)]*\)\s+\((.+)\)\s*$/gm + for (const match of log.matchAll(failurePattern)) { + failures.push({ + name: concise(match[1], 120), + message: concise(match[2]), + }) + } + return failures +} + +function walk(root) { + if (!root || !fs.existsSync(root)) return [] + const entries = fs.readdirSync(root, {withFileTypes: true}) + return entries.flatMap(entry => { + const candidate = path.join(root, entry.name) + return entry.isDirectory() ? walk(candidate) : [candidate] + }) +} + +function readPhase(root) { + const phaseFile = walk(root).find(file => path.basename(file) === 'phase.txt') + return phaseFile ? fs.readFileSync(phaseFile, 'utf8').trim() : '' +} + +function platformResult({name, status, root, artifactUrl}) { + const files = walk(root) + const reports = files.filter(file => /(?:report|junit).*\.xml$/i.test(file)) + const junitFailures = reports.flatMap(report => + parseJUnit(fs.readFileSync(report, 'utf8')), + ) + const maestroLogs = files.filter( + file => path.basename(file) === 'maestro-cli.log', + ) + const cliFailures = maestroLogs.flatMap(log => + parseMaestroCli(fs.readFileSync(log, 'utf8')), + ) + // A cancelled or timed-out Maestro run may never flush JUnit. Its CLI log is + // streamed continuously, so use those failure lines when JUnit has no detail. + const failures = junitFailures.length > 0 ? junitFailures : cliFailures + // A skipped platform (e.g. iOS while temporarily disabled) is not a failure + // as long as it produced no flow failures. + const failed = + (status !== 'success' && status !== 'skipped') || failures.length > 0 + return { + name, + status, + failed, + failures, + phase: readPhase(root), + hasJUnit: reports.length > 0, + artifactUrl, + } +} + +function statusEmoji(status) { + if (status === 'success') return ':white_check_mark:' + if (status === 'skipped') return ':fast_forward:' + return ':x:' +} + +function slackEscape(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') +} + +function platformBlock(platform) { + const lines = [ + `${statusEmoji(platform.status)} *${platform.name}* β€” job status: \`${platform.status}\``, + ] + if (platform.failures.length > 0) { + for (const failure of platform.failures.slice(0, 8)) { + lines.push( + `β€’ *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`, + ) + } + if (platform.failures.length > 8) { + lines.push(`β€’ …and ${platform.failures.length - 8} more failed flows`) + } + } else if (platform.failed && !platform.hasJUnit) { + lines.push( + `β€’ *Setup phase:* ${slackEscape(platform.phase || 'No phase metadata was captured')}`, + ) + } else if (platform.failed) { + lines.push( + `β€’ Job failed after JUnit was written; latest phase: ${slackEscape(platform.phase || 'unknown')}`, + ) + } + if (platform.artifactUrl) { + lines.push( + `β€’ <${platform.artifactUrl}|Open ${platform.name} logs and artifacts>`, + ) + } + return lines.join('\n').slice(0, 3000) +} + +function githubSummary({notify, platforms, shortSha, runUrl, commitUrl}) { + const lines = [ + `# Nightly Maestro E2E ${notify ? 'failed' : 'passed'}`, + '', + `- Commit: [\`${shortSha}\`](${commitUrl})`, + `- Workflow run: [open run](${runUrl})`, + '', + ] + + for (const platform of platforms) { + const headerEmoji = + platform.status === 'success' + ? 'βœ…' + : platform.status === 'skipped' + ? '⏭️' + : '❌' + lines.push( + `## ${headerEmoji} ${platform.name}`, + '', + `Job status: \`${platform.status}\``, + '', + ) + if (platform.failures.length > 0) { + for (const failure of platform.failures.slice(0, 10)) { + lines.push(`- **${failure.name}:** ${failure.message}`) + } + if (platform.failures.length > 10) { + lines.push(`- …and ${platform.failures.length - 10} more failed flows`) + } + lines.push('') + } else if (platform.failed && !platform.hasJUnit) { + lines.push( + `- Setup phase: ${platform.phase || 'No phase metadata was captured'}`, + '', + ) + } else if (platform.failed) { + lines.push( + `- The job failed after JUnit was written (latest phase: ${platform.phase || 'unknown'})`, + '', + ) + } + if (platform.artifactUrl) { + lines.push( + `- [${platform.name} logs and artifacts](${platform.artifactUrl})`, + '', + ) + } + } + + return lines.join('\n').trim() +} + +export function buildSummary({ + iosStatus, + androidStatus, + iosRoot, + androidRoot, + artifactUrls = {}, + sha, + runUrl, + commitUrl, +}) { + const platforms = [ + platformResult({ + name: 'iOS', + status: iosStatus, + root: iosRoot, + artifactUrl: artifactUrls.ios, + }), + platformResult({ + name: 'Android', + status: androidStatus, + root: androidRoot, + artifactUrl: artifactUrls.android, + }), + ] + const notify = platforms.some(platform => platform.failed) + const shortSha = sha.slice(0, 12) + const lines = [ + ':rotating_light: *Nightly Maestro E2E failed*', + `*Commit:* <${commitUrl}|\`${shortSha}\`>`, + `*Workflow run:* <${runUrl}|open run>`, + '', + ] + + for (const platform of platforms) { + lines.push( + `${statusEmoji(platform.status)} *${platform.name}* β€” job status: \`${platform.status}\``, + ) + if (platform.failures.length > 0) { + for (const failure of platform.failures.slice(0, 10)) { + lines.push( + `β€’ *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`, + ) + } + if (platform.failures.length > 10) { + lines.push(`β€’ …and ${platform.failures.length - 10} more failed flows`) + } + } else if (platform.failed && !platform.hasJUnit) { + lines.push( + `β€’ Setup phase: ${platform.phase || 'No phase metadata was captured'}`, + ) + } else if (platform.failed) { + lines.push( + `β€’ The job failed after JUnit was written (latest phase: ${platform.phase || 'unknown'})`, + ) + } + if (platform.artifactUrl) { + lines.push( + `β€’ <${platform.artifactUrl}|${platform.name} logs and artifacts>`, + ) + } + lines.push('') + } + + const text = lines.join('\n').trim() + const blocks = [ + { + type: 'header', + text: {type: 'plain_text', text: 'Nightly Maestro E2E failed'}, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*Commit:* <${commitUrl}|\`${shortSha}\`>\n*Workflow run:* <${runUrl}|open run>`, + }, + }, + {type: 'divider'}, + ...platforms.flatMap((platform, index) => [ + {type: 'section', text: {type: 'mrkdwn', text: platformBlock(platform)}}, + ...(index < platforms.length - 1 ? [{type: 'divider'}] : []), + ]), + ] + return { + notify, + platforms, + githubSummary: githubSummary({ + notify, + platforms, + shortSha, + runUrl, + commitUrl, + }), + payload: {text, blocks}, + } +} + +function parseArgs(argv) { + const result = {} + for (let i = 0; i < argv.length; i += 2) { + const key = argv[i] + if (!key?.startsWith('--') || argv[i + 1] === undefined) { + throw new Error(`Invalid argument: ${key || ''}`) + } + result[key.slice(2)] = argv[i + 1] + } + return result +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(import.meta.filename) +) { + const args = parseArgs(process.argv.slice(2)) + const artifactUrls = args['artifact-urls'] + ? JSON.parse(fs.readFileSync(args['artifact-urls'], 'utf8')) + : {} + const summary = buildSummary({ + iosStatus: args['ios-status'], + androidStatus: args['android-status'], + iosRoot: args['ios-root'], + androidRoot: args['android-root'], + artifactUrls, + sha: args.sha, + runUrl: args['run-url'], + commitUrl: args['commit-url'], + }) + process.stdout.write(`${JSON.stringify(summary)}\n`) +} diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 87c7457706..a2261536e6 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -10,12 +10,25 @@ on: options: - testflight-android - production + submit: + type: boolean + description: Submit the build to Google Play (disable to only produce the APK artifact) + default: true workflow_call: inputs: profile: type: string description: Build profile to use required: true + submit: + type: boolean + description: Submit the build to Google Play (disable to only produce the APK artifact) + default: true + runner: + type: string + description: Runner for the build job (defaults to Linux-x64-32core) + required: false + default: '' outputs: package-version: description: Version from package.json @@ -56,93 +69,92 @@ permissions: jobs: build: if: github.repository == 'bluesky-social/social-app' - name: Build and Submit Android - runs-on: Linux-x64-32core + name: Build Android + runs-on: ${{ inputs.runner || 'Linux-x64-32core' }} concurrency: group: android-build cancel-in-progress: false outputs: package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }} - apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk steps: - - name: Check for EXPO_TOKEN - run: > - if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then - echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" - exit 1 - fi - - name: ⬇️ Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 5 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - name: πŸ”§ Setup Expo project + uses: ./.github/actions/setup-expo-project with: - node-version-file: package.json - cache: pnpm + expo-token: ${{ secrets.EXPO_TOKEN }} - - name: πŸͺ› Setup jq - uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1 - - - name: βš™οΈ Install dependencies - run: pnpm install --frozen-lockfile - - - name: πŸ”¨ Setup Expo CLI - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 - with: - eas-version: '19.0.5' - packager: 'pnpm --allow-build=dtrace-provider' - token: ${{ secrets.EXPO_TOKEN }} - - - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: "temurin" java-version: "17" - name: πŸ”€ Compile translations - run: pnpm 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 + uses: ./.github/actions/compile-i18n # EXPO_PUBLIC_ENV is handled in eas.json - - name: Env + - name: ✏️ Write environment variables id: env - run: | - export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' - echo "${{ secrets.ENV_TOKEN }}" > .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 - echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env - echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env - echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env - echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env - echo "$json" > google-services.json + uses: ./.github/actions/write-env + with: + env-token: ${{ secrets.ENV_TOKEN }} + sentry-dsn: ${{ secrets.SENTRY_DSN }} + bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }} + gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }} + google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }} - 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 - --local --output build.aab --non-interactive + uses: ./.github/actions/eas-local-build + with: + platform: android + profile: ${{ inputs.profile || 'testflight-android' }} + output: build.aab + bump-build-number: "true" + sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }} + sentry-release: ${{ steps.env.outputs.release-version }} + sentry-dist: ${{ steps.env.outputs.bundle-identifier }} - name: πŸ“š Get version from package.json id: get-build-info run: bash scripts/setGitHubOutput.sh + # Hands the built bundle off to the submit / universalApk jobs. Retention is + # deliberately short (1 day) since it's only an intra-run handoff artifact. + - name: πŸš€ Upload AAB artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: android-aab-${{ github.run_id }} + retention-days: 1 + if-no-files-found: error + path: build.aab + + submit: + name: Submit to Google Play + runs-on: ubuntu-latest + needs: [build] + # Submit unless explicitly disabled; on events where inputs is empty this still submits. + if: ${{ inputs.submit != false }} + steps: + # eas submit reads app config from the repo, so we need a checkout. + - name: ⬇️ Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 5 + + - name: πŸ”§ Setup Expo project + uses: ./.github/actions/setup-expo-project + with: + expo-token: ${{ secrets.EXPO_TOKEN }} + + - name: ⬇️ Download AAB artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: android-aab-${{ github.run_id }} + - name: πŸš€ Submit to Google Play env: PROFILE: ${{ inputs.profile || 'testflight-android' }} @@ -150,13 +162,49 @@ jobs: - name: πŸ”” Notify Slack of Play Store Submission if: ${{ inputs.profile == 'production' }} - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook-type: incoming-webhook payload-templated: true payload: | - {"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"} + {"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"} + + # Record the commit only after a successful submit, so a failed submit doesn't + # advance the "most recent testflight" marker. + - name: ⬇️ Restore Cache + id: get-base-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + if: ${{ inputs.profile == 'testflight-android' }} + with: + path: most-recent-testflight-commit.txt + key: most-recent-testflight-commit + + - 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 + + # Runs in parallel with submit: the QA APK shouldn't be blocked by a Play submission failure. + universalApk: + name: Build universal APK + runs-on: ubuntu-latest + needs: [build] + outputs: + apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk + steps: + - name: ⬇️ Download AAB artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: android-aab-${{ github.run_id }} + + # bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly + # like the build job so the toolchain is deterministic. + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + with: + distribution: "temurin" + java-version: "17" - name: πŸ”§ Setup bundletool uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1 @@ -164,19 +212,24 @@ jobs: version: "1.18.3" - name: πŸ”‘ Decode keystore - run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > - keystore.jks + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > keystore.jks - name: πŸ“¦ Build signed universal APK + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | bundletool build-apks \ --bundle=build.aab \ --output=universal.apks \ --mode=universal \ --ks=keystore.jks \ - --ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \ - --ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \ - --key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }} + --ks-pass=pass:"$ANDROID_KEYSTORE_PASSWORD" \ + --ks-key-alias="$ANDROID_KEY_ALIAS" \ + --key-pass=pass:"$ANDROID_KEY_PASSWORD" - name: πŸ“‹ Rename to .zip for extraction run: mv universal.apks universal.zip @@ -198,27 +251,13 @@ jobs: path: build.apk - name: πŸ”” Notify Slack of APK Artifact - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook-type: incoming-webhook payload-templated: true payload: | - {"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"} - - - name: ⬇️ Restore Cache - id: get-base-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - if: ${{ inputs.profile == 'testflight-android' }} - with: - path: most-recent-testflight-commit.txt - key: most-recent-testflight-commit - - - 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 + {"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"} # Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production # build is dispatched against such a tag we attach the APK to the matching release. This runs @@ -226,7 +265,7 @@ jobs: attachToRelease: name: Attach APK to GitHub Release runs-on: ubuntu-latest - needs: [build] + needs: [build, universalApk] if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }} permissions: contents: write @@ -254,7 +293,7 @@ jobs: if: ${{ steps.release-check.outputs.exists == 'true' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ needs.build.outputs.apk-artifact-name }} + name: ${{ needs.universalApk.outputs.apk-artifact-name }} - name: 🏷️ Rename APK for release if: ${{ steps.release-check.outputs.exists == 'true' }} @@ -265,6 +304,7 @@ jobs: if: ${{ steps.release-check.outputs.exists == 'true' }} env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} TAG: ${{ github.ref_name }} APK: Bluesky-${{ needs.build.outputs.package-version }}.apk run: | @@ -274,7 +314,7 @@ jobs: - name: πŸ”” Notify Slack of Release Attachment if: ${{ steps.release-check.outputs.exists == 'true' }} - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook-type: incoming-webhook diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index c78d22d388..2085aabaa4 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -28,6 +28,11 @@ on: type: string description: TestFlight group to assign the build to after submitting ("none" to skip) default: none + runner: + type: string + description: Runner for the build job (defaults to macos-26-xlarge) + required: false + default: '' outputs: package-version: description: Version from package.json @@ -66,8 +71,8 @@ permissions: jobs: build: if: github.repository == 'bluesky-social/social-app' - name: Build and Submit iOS - runs-on: macos-26-xlarge + name: Build iOS + runs-on: ${{ inputs.runner || 'macos-26-xlarge' }} concurrency: group: ios-build cancel-in-progress: false @@ -75,38 +80,15 @@ jobs: package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} build-number: ${{ steps.ipa-build-number.outputs.build-number }} steps: - - name: Check for EXPO_TOKEN - run: > - if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then - echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" - exit 1 - fi - - name: ⬇️ Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 5 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - name: πŸ”§ Setup Expo project + uses: ./.github/actions/setup-expo-project with: - node-version-file: package.json - cache: pnpm - - - name: πŸͺ› Setup jq - uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1 - - - name: βš™οΈ Install dependencies - run: pnpm install --frozen-lockfile - - - name: πŸ”¨ Setup Expo CLI - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 - with: - eas-version: '19.0.5' - packager: 'pnpm --allow-build=dtrace-provider' - token: ${{ secrets.EXPO_TOKEN }} + expo-token: ${{ secrets.EXPO_TOKEN }} - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: @@ -114,7 +96,7 @@ jobs: - name: β˜•οΈ Assert Cocoapods version run: | - EXPECTED=1.16.2 + EXPECTED=1.17.0 ACTUAL=$(pod --version) if [ "$ACTUAL" != "$EXPECTED" ]; then echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL." @@ -133,38 +115,29 @@ jobs: key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }} - name: πŸ”€ Compile translations - run: pnpm 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 + uses: ./.github/actions/compile-i18n # EXPO_PUBLIC_ENV is handled in eas.json - name: ✏️ Write environment variables id: env - run: | - echo "${{ secrets.ENV_TOKEN }}" > .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 - echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env - echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env - echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env - echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env - echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json + uses: ./.github/actions/write-env + with: + env-token: ${{ secrets.ENV_TOKEN }} + sentry-dsn: ${{ secrets.SENTRY_DSN }} + bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }} + gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }} + google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }} - 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 - --local --output build.tar.gz --non-interactive + uses: ./.github/actions/eas-local-build + with: + platform: ios + profile: ${{ inputs.profile || 'testflight' }} + output: build.tar.gz + bump-build-number: "true" + sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }} + sentry-release: ${{ steps.env.outputs.release-version }} + sentry-dist: ${{ steps.env.outputs.bundle-identifier }} - name: πŸ“‚ Extract build artifact run: | @@ -201,16 +174,6 @@ jobs: exit 1 fi - - 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 - - name: πŸ“š Get version from package.json id: get-build-info run: bash scripts/setGitHubOutput.sh @@ -220,6 +183,7 @@ jobs: # number that actually lands in App Store Connect. `eas build:version:get` reads the # remote counter, which a --local build does not advance, so it can be off by one β€” # using it here would make distribute_only poll for a nonexistent build. + # PlistBuddy is macOS-only, which is why this stays in the build job. - name: πŸ”’ Read build number from IPA id: ipa-build-number run: | @@ -235,18 +199,98 @@ jobs: echo "IPA build number: $build_number" echo "build-number=$build_number" >> "$GITHUB_OUTPUT" + # Hand the IPA and dSYM off to the submit job. Retention is deliberately short since + # this artifact only exists to bridge the two jobs within a single run. + - name: πŸš€ Upload build artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ios-build-${{ github.run_id }} + retention-days: 1 + if-no-files-found: error + path: | + ${{ env.BUILD_DIR }}/Bluesky.ipa + ${{ env.BUILD_DIR }}/Bluesky.app.dSYM.zip + + submit: + name: Submit iOS + # Submission and dSYM upload are I/O bound and don't need the xlarge builder. + runs-on: macos-26 + needs: [build] + steps: + - name: ⬇️ Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # eas submit reads the app config from the repo + fetch-depth: 5 + + - name: πŸ”§ Setup Expo project + uses: ./.github/actions/setup-expo-project + with: + expo-token: ${{ secrets.EXPO_TOKEN }} + + - name: ⬇️ Download build artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ios-build-${{ github.run_id }} + path: ios-build + + - name: πŸš€ Deploy + run: pnpm eas submit -p ios --non-interactive --path ios-build/Bluesky.ipa + + - name: πŸͺ² Upload dSYM to Sentry + env: + SENTRY_ORG: blueskyweb + SENTRY_PROJECT: app + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: pnpm sentry-cli debug-files upload ios-build/Bluesky.app.dSYM.zip --include-sources + + - name: πŸ”” Notify Slack of Production Build + if: ${{ inputs.profile == 'production' }} + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 + 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: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.build-number }}```"} + + # Record the commit only after a successful submit, so a failed submit doesn't advance + # the baseline used for the next testflight build's changelog. + - name: ⬇️ Restore Cache + id: get-base-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + 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 + + distribute: + name: Assign build to TestFlight group + # fastlane and jq ship preinstalled on the macOS runner image, and this step mostly idles + # polling Apple processing, so it runs on a normal-size runner. + runs-on: macos-26 + needs: [build, submit] + # testFlightGroup defaults to 'none' on both workflow_call and dispatch; guard against the + # empty string too, since `!= 'none'` alone would be true for ''. + if: ${{ inputs.testFlightGroup && inputs.testFlightGroup != 'none' }} + steps: # eas submit only uploads to App Store Connect; it can't assign a build to a # TestFlight group. fastlane's distribute_only mode skips the upload and assigns the # already-submitted build to the group, polling until Apple finishes processing it. - name: πŸ§ͺ Assign build to TestFlight group - if: ${{ inputs.testFlightGroup != 'none' }} env: TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} - APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} - BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }} + APP_VERSION: ${{ needs.build.outputs.package-version }} + BUILD_NUMBER: ${{ needs.build.outputs.build-number }} run: | # Ensure the API key material is removed even if fastlane exits non-zero # (the step runs under `bash -e`, which would otherwise abort before cleanup). @@ -271,27 +315,3 @@ jobs: build_number:"$BUILD_NUMBER" \ groups:"$TESTFLIGHT_GROUP" \ notify_external_testers:true - - - name: πŸ”” Notify Slack of Production Build - if: ${{ inputs.profile == 'production' }} - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 - 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.ipa-build-number.outputs.build-number }}```"} - - - name: ⬇️ Restore Cache - id: get-base-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - 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 diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index a60821f928..eab0851c5b 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -86,7 +86,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json cache: pnpm @@ -99,11 +99,7 @@ jobs: previous-commit-tag: ${{ inputs.runtimeVersion }} - name: πŸ”€ Compile translations - run: pnpm 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 + uses: ./.github/actions/compile-i18n - name: Lint check run: pnpm lint @@ -128,35 +124,26 @@ jobs: !steps.version.outputs.version-changed }} uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1 - # eas.json not used here, set EXPO_PUBLIC_ENV - - name: Env - env: - CHANNEL: ${{ inputs.channel || 'testflight' }} - GITHUB_SHA: ${{ github.sha }} + # eas.json not used here, so EXPO_PUBLIC_ENV must be written explicitly + - name: ✏️ Write environment variables id: env - if: ${{ !steps.fingerprint.outputs.includes-changes && - !steps.version.outputs.version-changed }} - run: | - export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' - echo "${{ secrets.ENV_TOKEN }}" > .env - echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .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 - echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env - echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env - echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env - echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env - echo "$json" > google-services.json + if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }} + uses: ./.github/actions/write-env + with: + env-token: ${{ secrets.ENV_TOKEN }} + sentry-dsn: ${{ secrets.SENTRY_DSN }} + bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }} + gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }} + google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }} + expo-public-env: ${{ inputs.channel || 'testflight' }} - name: πŸ—οΈ Create Bundle if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }} 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 }} + SENTRY_RELEASE=${{ steps.env.outputs.release-version }} + SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }} pnpm export - name: πŸ“¦ Package Bundle and πŸš€ Deploy @@ -182,305 +169,64 @@ jobs: !steps.version.outputs.version-changed }} 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 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 if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == 'bluesky-social/social-app' }} - steps: - - name: Check for EXPO_TOKEN - run: > - if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then - echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" - exit 1 - fi - - - name: ⬇️ Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 5 - - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: package.json - cache: pnpm - - - name: πŸ”¨ Setup EAS - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 - with: - eas-version: '19.0.5' - packager: 'pnpm --allow-build=dtrace-provider' - token: ${{ secrets.EXPO_TOKEN }} - - - name: βš™οΈ Install dependencies - run: pnpm install --frozen-lockfile - - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - with: - xcode-version: "26.4" - - - name: β˜•οΈ Assert Cocoapods version - run: | - EXPECTED=1.16.2 - ACTUAL=$(pod --version) - if [ "$ACTUAL" != "$EXPECTED" ]; then - echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL." - echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md" - echo "If the runner image changed, update EXPECTED here or reinstall the pinned version." - exit 1 - fi - - - name: πŸ’Ύ Cache Pods - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - 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') }} - - - name: πŸ”€ Compile translations - run: pnpm intl:build - - # EXPO_PUBLIC_ENV is handled in eas.json - - name: Env - id: env - run: | - echo "${{ secrets.ENV_TOKEN }}" > .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 - echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env - echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env - echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env - echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env - echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json - - - name: πŸ—οΈ EAS Build - 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 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 - - - 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 - - - name: ⬇️ Restore Cache - id: get-base-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - if: ${{ inputs.channel == 'testflight' }} - with: - path: most-recent-testflight-commit.txt - key: most-recent-testflight-commit - - - name: ✏️ Write commit hash to cache - if: ${{ inputs.channel == 'testflight' }} - env: - GITHUB_SHA: ${{ github.sha }} - run: echo $GITHUB_SHA > most-recent-testflight-commit.txt + uses: ./.github/workflows/build-submit-ios.yml + with: + profile: testflight + testFlightGroup: none + # OTA rebuilds don't need the xlarge builder used for releases + runner: macos-26 + # Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`, + # so this workflow never hands the reusable workflow the entire repo secret store. + secrets: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + ENV_TOKEN: ${{ secrets.ENV_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }} + EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }} + GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} + SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} buildIfNecessaryAndroid: name: Build and Submit Android - runs-on: ubuntu-latest - concurrency: - group: android-build - 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 # available here if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.changes-detected && github.repository == - 'bluesky-social/social-app'}} - - steps: - - name: Check for EXPO_TOKEN - run: > - if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then - echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" - exit 1 - fi - - - name: ⬇️ Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 5 - - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - - - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: package.json - cache: pnpm - - - name: πŸ”¨ Setup EAS - uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 - with: - eas-version: '19.0.5' - packager: 'pnpm --allow-build=dtrace-provider' - token: ${{ secrets.EXPO_TOKEN }} - - - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 - with: - distribution: "temurin" - java-version: "17" - - - name: βš™οΈ Install dependencies - run: pnpm install --frozen-lockfile - - - name: πŸ”€ Compile translations - run: pnpm intl:build - - # EXPO_PUBLIC_ENV is handled in eas.json - - name: Env - id: env - run: | - export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' - echo "${{ secrets.ENV_TOKEN }}" > .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 - echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env - echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env - echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env - echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env - echo "$json" > google-services.json - - - name: πŸ—οΈ EAS Build - 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 testflight-android - --local --output build.aab --non-interactive - - - name: πŸ“š Get version from package.json - id: get-build-info - run: bash scripts/setGitHubOutput.sh - - - name: πŸš€ Submit to Google Play - run: pnpm eas submit -p android --profile testflight-android --non-interactive --path - build.aab - - - name: πŸ”§ Setup bundletool - uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1 - with: - version: "1.18.3" - - - name: πŸ”‘ Decode keystore - run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > - keystore.jks - - - name: πŸ“¦ Build signed universal APK - run: | - bundletool build-apks \ - --bundle=build.aab \ - --output=universal.apks \ - --mode=universal \ - --ks=keystore.jks \ - --ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \ - --ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \ - --key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }} - - - name: πŸ“‹ Rename to .zip for extraction - run: mv universal.apks universal.zip - - - name: πŸ“¦ Extract universal APK - run: unzip -p universal.zip universal.apk > build.apk - - - name: ⏰ Get a timestamp - id: timestamp - run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT" - - - name: πŸš€ Upload Artifact - id: upload-artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - retention-days: 30 - compression-level: 0 - name: build-${{ steps.timestamp.outputs.time }}.apk - path: build.apk - - - name: πŸ”” Notify Slack - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 - 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 }}"} - - - name: ⬇️ Restore Cache - id: get-base-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - 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 + 'bluesky-social/social-app' }} + # build-submit-android.yml contains an attachToRelease job that requests contents: write. + # That job is skipped here (it needs a production tag build), but GitHub statically + # validates the reusable-workflow permission ceiling, so the caller must grant it. + permissions: + contents: write + uses: ./.github/workflows/build-submit-android.yml + with: + profile: testflight-android + runner: ubuntu-latest + # Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`, + # so this workflow never hands the reusable workflow the entire repo secret store. + secrets: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + ENV_TOKEN: ${{ secrets.ENV_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }} + EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }} + GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml index 4c4b6236f0..3e22d05f6c 100644 --- a/.github/workflows/claude-mention.yml +++ b/.github/workflows/claude-mention.yml @@ -59,13 +59,13 @@ jobs: fetch-depth: 1 - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} aws-region: us-east-2 - name: Claude - uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166 + uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index a66147a6e3..35587011c6 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -45,13 +45,13 @@ jobs: fetch-depth: 1 - name: Configure AWS credentials (OIDC) - uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1 + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} aws-region: us-east-2 - name: Claude review - uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166 + uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20b60c57d1..a3f43dd99c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,8 @@ jobs: strategy: fail-fast: false matrix: - job: [lint, prettier, typecheck] + job: + [lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web'] steps: - name: Check out Git repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -52,7 +53,7 @@ jobs: exit $rc - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Install node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json cache: pnpm @@ -90,7 +91,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Install node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json cache: pnpm diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index a90eb55fe5..53fc480e89 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -144,7 +144,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: πŸ”” Notify Slack - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }} webhook-type: incoming-webhook @@ -174,7 +174,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: πŸ”” Notify Slack - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 with: webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }} webhook-type: incoming-webhook diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml new file mode 100644 index 0000000000..1b2a5282a5 --- /dev/null +++ b/.github/workflows/nightly-e2e.yml @@ -0,0 +1,424 @@ +--- +name: Nightly Maestro E2E + +on: + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nightly-maestro-e2e-${{ github.ref }} + cancel-in-progress: false + +env: + CI: "1" + MAESTRO_VERSION: "2.6.1" + MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000" + MAESTRO_CLI_NO_ANALYTICS: "1" + MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true" + MAESTRO_DISABLE_UPDATE_CHECK: "1" + +jobs: + ios: + name: iOS Maestro E2E + if: github.repository == 'bluesky-social/social-app' + runs-on: macos-26-xlarge + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Select Xcode 26.4 + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: "26.4" + + - name: Prepare E2E configuration + run: | + mkdir -p artifacts/ios + echo "Installing dependencies" > artifacts/ios/phase.txt + cp .env.example .env.test + cp google-services.json.example google-services.json + + - name: Set up Expo project + uses: ./.github/actions/setup-expo-project + with: + expo-token: ${{ secrets.EXPO_TOKEN }} + + - name: Set up Java 17 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + with: + distribution: temurin + java-version: "17" + + - name: Install dev-env dependencies + run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/ios/dependencies.log + + - name: Compile translations + uses: ./.github/actions/compile-i18n + + - name: Install Maestro 2.6.1 + run: | + echo "Installing Maestro" > artifacts/ios/phase.txt + curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \ + "https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip" + echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \ + | shasum -a 256 --check + unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP" + echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/ios/maestro-version.log + test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION" + + - name: Boot one iOS simulator + run: | + echo "Booting iOS simulator" > artifacts/ios/phase.txt + device_name="iPhone 17" + runtime_name="iOS 26.5" + + runtime_id=$(xcrun simctl list runtimes available --json | jq -r \ + --arg name "$runtime_name" \ + '[.runtimes[] | select(.name == $name and .isAvailable != false)] | first | .identifier // empty') + if [ -z "$runtime_id" ]; then + echo "The $runtime_name simulator runtime is not installed. Available iOS runtimes:" >&2 + xcrun simctl list runtimes available --json | jq -r \ + '.runtimes[] | select(.name | startswith("iOS")) | "- \(.name)"' >&2 + exit 1 + fi + + device_type_id=$(xcrun simctl list devicetypes --json | jq -r \ + --arg name "$device_name" \ + '[.devicetypes[] | select(.name == $name)] | first | .identifier // empty') + if [ -z "$device_type_id" ]; then + echo "The $device_name simulator device type is not installed" >&2 + exit 1 + fi + + udid=$(xcrun simctl list devices available --json | jq -r \ + --arg runtime "$runtime_id" \ + --arg name "$device_name" \ + '[.devices[$runtime][]? | select(.name == $name)] | first | .udid // empty') + if [ -z "$udid" ]; then + udid=$(xcrun simctl create "$device_name" "$device_type_id" "$runtime_id") + fi + + echo "IOS_UDID=$udid" >> "$GITHUB_ENV" + xcrun simctl shutdown all || true + xcrun simctl boot "$udid" + xcrun simctl bootstatus "$udid" -b + echo "Using $device_name on $runtime_name ($udid)" + + - name: Mark iOS development client build phase + run: echo "Building the iOS development client" > artifacts/ios/phase.txt + + - name: Build iOS development client + uses: ./.github/actions/eas-local-build + with: + platform: ios + profile: e2e + output: ${{ runner.temp }}/nightly-e2e-ios.tar.gz + log-path: artifacts/ios/build.log + + - name: Install iOS development client + run: | + build_contents="$RUNNER_TEMP/nightly-e2e-ios-build" + mkdir -p "$build_contents" + tar -xzf "$RUNNER_TEMP/nightly-e2e-ios.tar.gz" -C "$build_contents" + app_path=$(find "$build_contents" -type d -name '*.app' -print -quit) + if [ -z "$app_path" ]; then + echo "The local EAS build did not contain an iOS simulator app" >&2 + exit 1 + fi + xcrun simctl install "$IOS_UDID" "$app_path" 2>&1 | tee -a artifacts/ios/build.log + + - name: Run iOS Maestro suite + run: .github/scripts/run-nightly-e2e.sh ios "$IOS_UDID" + + - name: Clean up iOS services and simulator + if: always() + run: .github/scripts/cleanup-nightly-e2e.sh ios "${IOS_UDID:-}" + + - name: Upload iOS E2E artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nightly-e2e-ios-${{ github.run_id }} + path: artifacts/ios + if-no-files-found: warn + retention-days: 7 + + android: + name: Android Maestro E2E + if: github.repository == 'bluesky-social/social-app' + # Linux-x64-32core is a repository-managed runner label. + runs-on: Linux-x64-32core + timeout-minutes: 120 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Prepare E2E configuration + run: | + mkdir -p artifacts/android + echo "Installing dependencies" > artifacts/android/phase.txt + cp .env.example .env.test + cp google-services.json.example google-services.json + + - name: Set up Expo project + uses: ./.github/actions/setup-expo-project + with: + expo-token: ${{ secrets.EXPO_TOKEN }} + + - name: Set up Java 17 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + with: + distribution: temurin + java-version: "17" + + - name: Install dev-env dependencies + run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/android/dependencies.log + + - name: Compile translations + uses: ./.github/actions/compile-i18n + + - name: Install Maestro 2.6.1 + run: | + echo "Installing Maestro" > artifacts/android/phase.txt + curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \ + "https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip" + echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \ + | shasum -a 256 --check + unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP" + echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/android/maestro-version.log + test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION" + + - name: Install and boot one Android emulator + run: | + echo "Booting Android emulator" > artifacts/android/phase.txt + android_sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/usr/local/lib/android/sdk}}" + sdkmanager_bin="$android_sdk/cmdline-tools/latest/bin/sdkmanager" + avdmanager_bin="$android_sdk/cmdline-tools/latest/bin/avdmanager" + # API 35 emulator images have known stability problems in headless CI + # (see flutter/flutter#153445); the qemu process died deterministically + # on the first native stack-screen push with the API 35 image. + system_image="system-images;android-34;google_apis;x86_64" + + if [ ! -x "$sdkmanager_bin" ] || [ ! -x "$avdmanager_bin" ]; then + echo "Android command-line tools were not found under $android_sdk" >&2 + find "$android_sdk/cmdline-tools" -maxdepth 3 -type f \( \ + -name sdkmanager -o -name avdmanager \ + \) -print >&2 || true + exit 1 + fi + + export ANDROID_HOME="$android_sdk" + export ANDROID_SDK_ROOT="$android_sdk" + export PATH="$android_sdk/platform-tools:$android_sdk/emulator:$PATH" + echo "ANDROID_HOME=$android_sdk" >> "$GITHUB_ENV" + echo "ANDROID_SDK_ROOT=$android_sdk" >> "$GITHUB_ENV" + echo "$android_sdk/platform-tools" >> "$GITHUB_PATH" + echo "$android_sdk/emulator" >> "$GITHUB_PATH" + echo "Using Android SDK at $android_sdk" + + yes | "$sdkmanager_bin" --sdk_root="$android_sdk" --licenses >/dev/null || true + "$sdkmanager_bin" --sdk_root="$android_sdk" \ + "platform-tools" "emulator" "$system_image" + + export ANDROID_AVD_HOME="$RUNNER_TEMP/.android/avd" + mkdir -p "$ANDROID_AVD_HOME" + echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV" + echo no | "$avdmanager_bin" create avd \ + --force \ + --name nightly-e2e \ + --package "$system_image" \ + --device pixel_6 + + # Reduce resolution to lighten the SwiftShader software-rendering + # workload, and raise RAM/cores/heap so the debug RN app has headroom. + # The default 2560MB RAM led to silent qemu crashes mid-flow. + printf 'hw.lcd.width=720\nhw.lcd.height=1600\nhw.lcd.density=280\nhw.ramSize=6144\nhw.cpu.ncore=4\nvm.heapSize=512\n' \ + >> "$ANDROID_AVD_HOME/nightly-e2e.avd/config.ini" + + if [ -e /dev/kvm ] && [ ! -w /dev/kvm ]; then + sudo chmod 666 /dev/kvm + fi + + # Disable the emulator's Vulkan feature so graphics goes through the + # plain GLES SwiftShader translator. gfxstream Vulkan via SwiftShader + # Subzero crashed qemu silently at a deterministic rendering step; + # GLES-only is sufficient since the guest renders with skiagl. + # + # Run the launch in a background subshell so the emulator's exit + # status is recorded when it dies (it is otherwise backgrounded and + # its death is invisible). Write the emulator's real PID - not the + # subshell's - to emulator.pid, since cleanup-nightly-e2e.sh kills the + # PID from that file directly; killing the subshell would not kill the + # emulator child. + ( + # wait returns the emulator's non-zero status on crash; set -e would + # abort the subshell before the status is logged. + set +e + "$android_sdk/emulator/emulator" @nightly-e2e \ + -port 5554 \ + -no-window \ + -gpu swiftshader_indirect \ + -feature -Vulkan \ + -no-snapshot \ + -noaudio \ + -no-boot-anim \ + -camera-back none \ + > artifacts/android/emulator.log 2>&1 & + emulator_pid=$! + echo "$emulator_pid" > artifacts/android/emulator.pid + wait "$emulator_pid" + echo "Emulator exited with status $?" >> artifacts/android/emulator.log + ) & + + adb -s emulator-5554 wait-for-device + booted=false + for _ in $(seq 1 120); do + if [ "$(adb -s emulator-5554 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then + booted=true + break + fi + sleep 5 + done + if [ "$booted" != "true" ]; then + echo "Android emulator did not finish booting" >&2 + exit 1 + fi + + adb -s emulator-5554 shell settings put global window_animation_scale 0 + adb -s emulator-5554 shell settings put global transition_animation_scale 0 + adb -s emulator-5554 shell settings put global animator_duration_scale 0 + + - name: Mark Android development client build phase + run: echo "Building the Android development client" > artifacts/android/phase.txt + + - name: Build Android development client + uses: ./.github/actions/eas-local-build + with: + platform: android + profile: e2e + output: ${{ runner.temp }}/nightly-e2e-android.apk + log-path: artifacts/android/build.log + + - name: Install Android development client + run: | + adb -s emulator-5554 install -r "$RUNNER_TEMP/nightly-e2e-android.apk" \ + 2>&1 | tee -a artifacts/android/build.log + + - name: Run Android Maestro suite + run: .github/scripts/run-nightly-e2e.sh android emulator-5554 + + - name: Capture emulator crash diagnostics + if: always() + run: | + { + echo "=== Emulator process status ===" + pgrep -fa "emulator.*nightly-e2e" || echo "Emulator process not found" + echo "=== Emulator exit status ===" + grep "Emulator exited" artifacts/android/emulator.log || echo "No emulator exit status recorded" + echo "=== OOM killer check (kernel) ===" + oom_lines=$(sudo dmesg 2>/dev/null | grep -iE "oom|killed process|out of memory" | tail -20) + echo "${oom_lines:-No kernel OOM evidence found (or dmesg unavailable)}" + echo "=== systemd-oomd check ===" + oomd_lines=$(journalctl -u systemd-oomd --no-pager 2>/dev/null | tail -20) + echo "${oomd_lines:-No systemd-oomd journal entries (or journalctl unavailable)}" + echo "=== journal kernel tail ===" + journalctl -k --no-pager 2>/dev/null | tail -30 || echo "journalctl -k unavailable" + echo "=== Emulator crash database ===" + ls -la /tmp/android-runner/emu-crash-*.db 2>/dev/null || echo "No crash database found" + } > artifacts/android/emulator-diagnostics.log 2>&1 + + - name: Clean up Android services and emulator + if: always() + run: .github/scripts/cleanup-nightly-e2e.sh android emulator-5554 + + - name: Upload Android E2E artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nightly-e2e-android-${{ github.run_id }} + path: artifacts/android + if-no-files-found: warn + retention-days: 7 + + report: + name: Report E2E failures + needs: [ios, android] + if: ${{ always() && github.repository == 'bluesky-social/social-app' }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download iOS artifacts + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nightly-e2e-ios-${{ github.run_id }} + path: downloaded-artifacts/ios + + - name: Download Android artifacts + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nightly-e2e-android-${{ github.run_id }} + path: downloaded-artifacts/android + + - name: Resolve artifact links + env: + GH_TOKEN: ${{ github.token }} + run: | + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}#artifacts" + jq -n --arg run "$run_url" '{ios: $run, android: $run}' > artifact-links.json + if gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" \ + > artifact-response.json; then + jq --arg base "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts" \ + --arg run "$run_url" \ + '{ + ios: (.artifacts | map(select(.name | startswith("nightly-e2e-ios-"))) | first | if . then ($base + "/" + (.id | tostring)) else $run end), + android: (.artifacts | map(select(.name | startswith("nightly-e2e-android-"))) | first | if . then ($base + "/" + (.id | tostring)) else $run end) + }' artifact-response.json > artifact-links.json + fi + + - name: Summarize platform results + id: summary + env: + IOS_STATUS: ${{ needs.ios.result }} + ANDROID_STATUS: ${{ needs.android.result }} + run: | + node .github/scripts/summarize-maestro.mjs \ + --ios-status "$IOS_STATUS" \ + --android-status "$ANDROID_STATUS" \ + --ios-root downloaded-artifacts/ios \ + --android-root downloaded-artifacts/android \ + --artifact-urls artifact-links.json \ + --sha "$GITHUB_SHA" \ + --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \ + > e2e-summary.json + echo "notify=$(jq -r .notify e2e-summary.json)" >> "$GITHUB_OUTPUT" + echo "payload=$(jq -c .payload e2e-summary.json)" >> "$GITHUB_OUTPUT" + jq -r .githubSummary e2e-summary.json >> "$GITHUB_STEP_SUMMARY" + + - name: Notify Slack of E2E failures + if: steps.summary.outputs.notify == 'true' + uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5 + with: + webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload: ${{ steps.summary.outputs.payload }} diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml index 52e0f2c2c7..32ba5e811f 100644 --- a/.github/workflows/nightly-update-source-languages.yaml +++ b/.github/workflows/nightly-update-source-languages.yaml @@ -21,7 +21,7 @@ jobs: ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}} - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Install node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json cache: pnpm diff --git a/.github/workflows/pull-request-comment.yml b/.github/workflows/pull-request-comment.yml index 46b169e541..944801326a 100644 --- a/.github/workflows/pull-request-comment.yml +++ b/.github/workflows/pull-request-comment.yml @@ -132,7 +132,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json cache: pnpm diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index aa20b8d417..264ee5d311 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -19,8 +19,47 @@ concurrency: permissions: {} jobs: + # Populate this from main so every PR can restore the same trusted baseline. + webpack-analyzer-base: + runs-on: ubuntu-24.04 + if: ${{ github.event_name == 'push' }} + permissions: + contents: read + steps: + - name: ⬇️ Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: πŸ”§ Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + cache: pnpm + + - name: ⬇️ Get base stats from cache + id: get-base-stats + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: stats.json + key: stats-base-main-${{ github.sha }} + + - name: πŸ”¦ Generate stats file for base commit + if: ${{ !steps.get-base-stats.outputs.cache-hit }} + run: | + pnpm install + pnpm intl:build + pnpm generate-webpack-stats-file + + - name: ⬆️ Save base stats to cache + if: ${{ !steps.get-base-stats.outputs.cache-hit }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: stats.json + key: stats-base-main-${{ github.sha }} + webpack-analyzer: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}} permissions: contents: read @@ -34,7 +73,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json cache: pnpm @@ -71,15 +110,18 @@ jobs: - name: ⬇️ Get base stats from cache id: get-base-stats - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + # Restore-only prevents PR-scoped fallback builds from creating caches. + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: stats-base.json - key: stats-base-${{ steps.base-commit.outputs.base-commit }} + path: stats.json + key: stats-base-main-${{ steps.base-commit.outputs.base-commit }} - name: Restore to base commit if: ${{ !steps.get-base-stats.outputs.cache-hit }} + env: + BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }} run: | - git reset HEAD~ + git reset "$BASE_COMMIT" git restore . - name: πŸ”¦ Generate stats file from base commit @@ -88,13 +130,12 @@ jobs: pnpm install pnpm intl:build pnpm generate-webpack-stats-file - mv stats.json stats-base.json - name: οΌ… Get diff id: get-diff uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0 with: - base_path: "stats-base.json" + base_path: "stats.json" pr_path: "../stats-new.json" excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico" @@ -127,7 +168,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: πŸ”§ Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json cache: pnpm diff --git a/.github/workflows/verify-pnpm-lock.yml b/.github/workflows/verify-pnpm-lock.yml index 8483e5fdb0..407ca6a345 100644 --- a/.github/workflows/verify-pnpm-lock.yml +++ b/.github/workflows/verify-pnpm-lock.yml @@ -27,7 +27,7 @@ jobs: - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Install node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: package.json diff --git a/__e2e__/flows/composer.yml b/__e2e__/flows/composer.yml index 62f195de75..e348ec1b25 100644 --- a/__e2e__/flows/composer.yml +++ b/__e2e__/flows/composer.yml @@ -44,6 +44,12 @@ appId: xyz.blueskyweb.app id: "e2eRefreshHome" - tapOn: id: "replyBtn" +# Wait for the composer to fully open before typing. Tapping replyBtn right +# after the previous publish can race the closing composer on Android. +- extendedWaitUntil: + visible: + id: "composerPublishBtn" + timeout: 10000 - inputText: "Reply text only" - tapOn: id: "composerPublishBtn" @@ -51,6 +57,11 @@ appId: xyz.blueskyweb.app id: "composeFAB" - tapOn: id: "replyBtn" +# Wait for the composer to fully open before typing. +- extendedWaitUntil: + visible: + id: "composerPublishBtn" + timeout: 10000 - inputText: "Reply with an image" - tapOn: id: "openMediaBtn" @@ -63,6 +74,11 @@ appId: xyz.blueskyweb.app id: "composeFAB" - tapOn: id: "replyBtn" +# Wait for the composer to fully open before typing. +- extendedWaitUntil: + visible: + id: "composerPublishBtn" + timeout: 10000 - inputText: "Reply with a https://example.com link card" - tapOn: id: "composerPublishBtn" diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml index 42abc3295e..1e2fe70d62 100644 --- a/__e2e__/flows/feed-reorder.yml +++ b/__e2e__/flows/feed-reorder.yml @@ -29,51 +29,88 @@ appId: xyz.blueskyweb.app id: "homeScreenFeedTabs-selector-1" text: "alice-favs" -# Set alice-favs first -- tapOn: "Open drawer menu" -- tapOn: - id: "menuItemButton-Feeds" -- tapOn: - id: "editFeedsBtn" -- swipe: - label: "Drag feed down" - from: - id: "feed-drag-handle" - direction: "DOWN" - duration: 1000 -- tapOn: - label: "Save button" - id: "saveChangesBtn" -- tapOn: "Go back" -- assertVisible: - id: "homeScreenFeedTabs-selector-0" - text: "alice-favs" -- assertVisible: - id: "homeScreenFeedTabs-selector-1" - text: "Following" +# Reordering feeds is driven by a drag on the feed-drag-handle. Maestro cannot +# activate the RNGH Pan gesture from a synthetic swipe on Android (proven +# twice - coordinate swipes never register the pan), so the reorder +# verification below runs on iOS only. If Android drag coverage is needed, +# revisit with the SavedFeedsA11y move buttons rather than a swipe. +- runFlow: + when: + platform: iOS + commands: + # Set alice-favs first + - tapOn: "Open drawer menu" + - tapOn: + id: "menuItemButton-Feeds" + - tapOn: + id: "editFeedsBtn" + - swipe: + label: "Drag feed down" + from: + id: "feed-drag-handle" + direction: "DOWN" + duration: 1000 + - assertVisible: + id: "saveChangesBtn" + enabled: true + - tapOn: + label: "Save button" + id: "saveChangesBtn" + - tapOn: "Go back" + - assertVisible: + id: "homeScreenFeedTabs-selector-0" + text: "alice-favs" + - assertVisible: + id: "homeScreenFeedTabs-selector-1" + text: "Following" -# Set following first -- tapOn: "Open drawer menu" -- tapOn: - id: "menuItemButton-Feeds" -- tapOn: - id: "editFeedsBtn" -- swipe: - label: "Drag feed down" - from: - id: "feed-drag-handle" - direction: "DOWN" - duration: 1000 -- tapOn: - label: "Save button" - id: "saveChangesBtn" -- tapOn: "Go back" -- assertVisible: - id: "homeScreenFeedTabs-selector-0" - text: "Following" -- assertVisible: - id: "homeScreenFeedTabs-selector-1" - text: "alice-favs" + # Set following first + - tapOn: "Open drawer menu" + - tapOn: + id: "menuItemButton-Feeds" + - tapOn: + id: "editFeedsBtn" + - swipe: + label: "Drag feed down" + from: + id: "feed-drag-handle" + direction: "DOWN" + duration: 1000 + - assertVisible: + id: "saveChangesBtn" + enabled: true + - tapOn: + label: "Save button" + id: "saveChangesBtn" + - tapOn: "Go back" + - assertVisible: + id: "homeScreenFeedTabs-selector-0" + text: "Following" + - assertVisible: + id: "homeScreenFeedTabs-selector-1" + text: "alice-favs" + +# On Android, the reorder path above is skipped. Smoke-test that the feeds +# edit screen opens and the pinned feeds render, then return to a valid state. +- runFlow: + when: + platform: Android + commands: + - tapOn: "Open drawer menu" + - tapOn: + id: "menuItemButton-Feeds" + - tapOn: + id: "editFeedsBtn" + - assertVisible: "Following" + - assertVisible: "alice-favs" + # Two back presses to reach Home: the first pops the saved-feeds editor + # back to the Feeds screen, the second pops Feeds back to Home. On iOS the + # equivalent path saves changes first (saveChangesBtn calls + # navigation.goBack), so a single "Go back" there already lands on Home. + # This Android smoke branch never saves, so it needs the extra pop to + # leave the screen state on Home, which the shared steps below expect. + - tapOn: "Go back" + - tapOn: "Go back" # Remove following - tapOn: "Open drawer menu" diff --git a/__e2e__/flows/onboarding-avatar-creator.yml b/__e2e__/flows/onboarding-avatar-creator.yml index 2204abb9df..7b65c405bb 100644 --- a/__e2e__/flows/onboarding-avatar-creator.yml +++ b/__e2e__/flows/onboarding-avatar-creator.yml @@ -15,6 +15,24 @@ appId: xyz.blueskyweb.app - tapOn: id: "e2eStartOnboarding" - tapOn: "Open avatar creator" +# The avatar-creator bottom sheet (Dialog.Inner, non-scrollable) opens only +# half-expanded on the short E2E emulator (720x1600), a ~220px sliver with the +# emoji grid below the fold. It is NOT a scroll view, so scrollUntilVisible's +# swipe grabs the sheet's own drag gesture and flings it closed. Instead, drag +# the sheet upward to expand it to full height, which brings the picker into +# view. iOS opens the sheet fully already, so this is Android-only. +- runFlow: + when: + platform: Android + commands: + - swipe: + label: "Drag the bottom sheet up to expand it" + start: "50%, 90%" + end: "50%, 20%" + duration: 600 + - extendedWaitUntil: + visible: "Select an emoji" + timeout: 10000 - tapOn: "Select the zap emoji as your avatar" - tapOn: label: "Tap on yellow" @@ -22,6 +40,20 @@ appId: xyz.blueskyweb.app - tapOn: "Done" - waitForAnimationToEnd - tapOn: "Select an avatar" +# Reopening the creator sheet lands on the same half-expanded sliver on +# Android, so expand it again before reaching for the emoji grid. No-op on iOS. +- runFlow: + when: + platform: Android + commands: + - swipe: + label: "Drag the bottom sheet up to expand it" + start: "50%, 90%" + end: "50%, 20%" + duration: 600 + - extendedWaitUntil: + visible: "Select an emoji" + timeout: 10000 - tapOn: "Select the atom emoji as your avatar" - tapOn: "Done" - waitForAnimationToEnd diff --git a/__e2e__/flows/onboarding.yml b/__e2e__/flows/onboarding.yml index a5f4217455..4c0ffdd48f 100644 --- a/__e2e__/flows/onboarding.yml +++ b/__e2e__/flows/onboarding.yml @@ -16,13 +16,28 @@ appId: xyz.blueskyweb.app id: "e2eStartOnboarding" - tapOn: "Select an avatar" - waitForAnimationToEnd -- assertVisible: "Photos" -- assertVisible: "Collections" -- tapOn: - point: "50%,22%" -- waitForAnimationToEnd -- tapOn: "Done" -- waitForAnimationToEnd +- runFlow: + when: + platform: iOS + commands: + - assertVisible: "Photos" + - assertVisible: "Collections" + - tapOn: + point: "50%,22%" + - waitForAnimationToEnd + - tapOn: "Done" + - waitForAnimationToEnd +- runFlow: + when: + platform: Android + commands: + # The system photo picker opened here shows MediaStore photos, which + # the e2e run doesn't seed (media is seeded into app-scoped storage for + # the composer's mocked picker instead). With no photo to pick, dismiss + # the picker and continue - onContinue falls back to the generated + # placeholder avatar, and nothing later in the flow depends on the image. + - back + - waitForAnimationToEnd - tapOn: id: "onboardingContinue" - assertVisible: "What are your interests?" diff --git a/__e2e__/flows/profile-screen-edit.yml b/__e2e__/flows/profile-screen-edit.yml index f60ca01b42..f029ee691a 100644 --- a/__e2e__/flows/profile-screen-edit.yml +++ b/__e2e__/flows/profile-screen-edit.yml @@ -45,7 +45,7 @@ appId: xyz.blueskyweb.app id: "editProfileSaveBtn" - assertNotVisible: id: "editProfileModal" -- assertVisible: "Alicia" +- assertVisible: ".*Alicia.*" - assertVisible: "One cool hacker" # Remove display name and description via the edit profile modal @@ -64,7 +64,10 @@ appId: xyz.blueskyweb.app id: "editProfileSaveBtn" - assertNotVisible: id: "editProfileModal" -- assertVisible: "alice.test" +# The display-name node renders the handle as a Text with a nested badge View +# once the display name is cleared, so the a11y text is not the bare handle +# string on Android. Match it as a substring instead. +- assertVisible: ".*alice\\.test.*" - assertNotVisible: "One cool hacker" # Set avi and banner via the edit profile modal diff --git a/__e2e__/flows/report-dialog/account.default.yml b/__e2e__/flows/report-dialog/account.default.yml index 372fc31c7e..c5c5169cdc 100644 --- a/__e2e__/flows/report-dialog/account.default.yml +++ b/__e2e__/flows/report-dialog/account.default.yml @@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app text: "Send report to Dev-env Moderation" - tapOn: id: "report:submit" -- assertNotVisible: - id: "report:dialog" +- extendedWaitUntil: + notVisible: + id: "report:dialog" + timeout: 20000 diff --git a/__e2e__/flows/report-dialog/post.default.yml b/__e2e__/flows/report-dialog/post.default.yml index be3ac6b68a..212accc254 100644 --- a/__e2e__/flows/report-dialog/post.default.yml +++ b/__e2e__/flows/report-dialog/post.default.yml @@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app text: "Send report to Dev-env Moderation" - tapOn: id: "report:submit" -- assertNotVisible: - id: "report:dialog" +- extendedWaitUntil: + notVisible: + id: "report:dialog" + timeout: 20000 diff --git a/__e2e__/flows/report-dialog/post.edit-reason.yml b/__e2e__/flows/report-dialog/post.edit-reason.yml index eec5794c4f..a44e7f97d7 100644 --- a/__e2e__/flows/report-dialog/post.edit-reason.yml +++ b/__e2e__/flows/report-dialog/post.edit-reason.yml @@ -39,5 +39,7 @@ appId: xyz.blueskyweb.app text: Your report will be sent to Dev-env Moderation.* - tapOn: id: "report:submit" -- assertNotVisible: - id: "report:dialog" +- extendedWaitUntil: + notVisible: + id: "report:dialog" + timeout: 20000 diff --git a/__e2e__/flows/report-dialog/post.reason-other.yml b/__e2e__/flows/report-dialog/post.reason-other.yml index e1065ece4d..78f5a195ec 100644 --- a/__e2e__/flows/report-dialog/post.reason-other.yml +++ b/__e2e__/flows/report-dialog/post.reason-other.yml @@ -29,5 +29,7 @@ appId: xyz.blueskyweb.app - hideKeyboard - tapOn: id: "report:submit" -- assertNotVisible: - id: "report:dialog" +- extendedWaitUntil: + notVisible: + id: "report:dialog" + timeout: 20000 diff --git a/__e2e__/flows/thread-muting.yml b/__e2e__/flows/thread-muting.yml index 2724833feb..f00d097e27 100644 --- a/__e2e__/flows/thread-muting.yml +++ b/__e2e__/flows/thread-muting.yml @@ -20,6 +20,12 @@ appId: xyz.blueskyweb.app - inputText: "Test thread" - tapOn: id: "composerPublishBtn" +# Wait for the composer to close and the home feed to settle before signing +# out. Without a settle guard the next action can race the closing composer. +- extendedWaitUntil: + visible: + id: "composeFAB" + timeout: 10000 # Login, reply to the thread, and log out - tapOn: @@ -31,9 +37,19 @@ appId: xyz.blueskyweb.app id: "viewHeaderHomeFeedPrefsBtn" - tapOn: id: "replyBtn" +# Wait for the composer to fully open before typing. +- extendedWaitUntil: + visible: + id: "composerPublishBtn" + timeout: 10000 - inputText: "Reply 1" - tapOn: id: "composerPublishBtn" +# Wait for the composer to close before signing out. +- extendedWaitUntil: + visible: + id: "composeFAB" + timeout: 10000 # Login, confirm notification exists, mute thread, and log out - tapOn: @@ -45,10 +61,8 @@ appId: xyz.blueskyweb.app id: "viewHeaderHomeFeedPrefsBtn" - tapOn: id: "bottomBarNotificationsBtn" -- assertVisible: - id: "feedItem-by-bob.test" -- tapOn: - id: "feedItem-by-bob.test" +- assertVisible: ".*Reply 1.*" +- tapOn: ".*Reply 1.*" - tapOn: id: "postDropdownBtn" childOf: @@ -67,16 +81,78 @@ appId: xyz.blueskyweb.app id: "bottomBarProfileBtn" - tapOn: id: "profilePager-selector-1" -- tapOn: - id: "replyBtn" +# Both replies target the thread root ("Test thread" by alice), which sits at +# the top of bob's Replies tab. That tab renders each post in the thread with +# its own replyBtn, so scope the tap to the root post's card +# (feedItem-by-alice.test) rather than relying on which replyBtn Maestro picks +# first. This keeps both reply taps deterministic regardless of list order or +# how many posts have rendered. +# +# Even with the close-gating below, the replyBtn tap can land on a recycled list +# row while the author feed re-renders after a publish, and be swallowed so the +# composer never opens. Wrapping the tap + open-wait in retry makes opening the +# composer idempotent: a swallowed tap just re-taps until the publish button +# appears. A first-try success does not retry. +- retry: + maxRetries: 3 + commands: + - tapOn: + id: "replyBtn" + childOf: + id: "feedItem-by-alice.test" + # Wait for the composer to fully open before typing. + - extendedWaitUntil: + visible: + id: "composerPublishBtn" + timeout: 10000 - inputText: "Reply 2" - tapOn: id: "composerPublishBtn" -- tapOn: - id: "replyBtn" +# Wait for the composer to actually close before opening it again. replyBtn +# stays in the accessibility tree behind the open composer sheet, so waiting on +# its visibility returns immediately and does not gate on the close animation or +# the author-feed re-render that follows a post - the next replyBtn tap then +# fires mid-transition and is swallowed, so the composer never opens. Gate on +# the publish button disappearing (the composer is gone), then confirm the +# reply button underneath is back and let animations settle. +- extendedWaitUntil: + notVisible: + id: "composerPublishBtn" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "replyBtn" + timeout: 10000 +- waitForAnimationToEnd +# As with Reply 2: even after gating on the composer close, this tap can hit a +# recycled row during the post-publish feed re-render and be swallowed, so wrap +# the open in retry to make it idempotent. +- retry: + maxRetries: 3 + commands: + - tapOn: + id: "replyBtn" + childOf: + id: "feedItem-by-alice.test" + # Wait for the composer to fully open before typing. + - extendedWaitUntil: + visible: + id: "composerPublishBtn" + timeout: 10000 - inputText: "Reply 3" - tapOn: id: "composerPublishBtn" +# Wait for the composer to actually close before signing out. As above, +# replyBtn stays visible behind the sheet, so gate on the publish button +# disappearing first, then confirm the reply button underneath has returned. +- extendedWaitUntil: + notVisible: + id: "composerPublishBtn" + timeout: 15000 +- extendedWaitUntil: + visible: + id: "replyBtn" + timeout: 10000 # Login, confirm notifications dont exist, unmute the thread, ~~confirm notifications exist~~ # Mute thread behaviour no longer change old notifications after muting/unmuting a thread -sfn @@ -92,10 +168,7 @@ appId: xyz.blueskyweb.app - assertVisible: ".*Reply 1.*" - assertNotVisible: ".*Reply 2.*" - assertNotVisible: ".*Reply 3.*" -- assertVisible: - id: "feedItem-by-bob.test" -- tapOn: - id: "feedItem-by-bob.test" +- tapOn: ".*Reply 1.*" - tapOn: id: "postDropdownBtn" childOf: diff --git a/__e2e__/setupApp.yml b/__e2e__/setupApp.yml index dc784e661f..ab9623ffba 100644 --- a/__e2e__/setupApp.yml +++ b/__e2e__/setupApp.yml @@ -9,23 +9,27 @@ appId: xyz.blueskyweb.app 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 + - extendedWaitUntil: + visible: "http://localhost:8081" + timeout: 60000 + - tapOn: "http://localhost:8081" - runFlow: when: platform: Android commands: - - tapOn: 'http://localhost:8081' - - runFlow: - label: "Dismiss Expo dev menu" - when: - visible: "Continue" - commands: - - back + - extendedWaitUntil: + visible: "http://10.0.2.2:8081" + timeout: 60000 + - tapOn: "http://10.0.2.2:8081" + - extendedWaitUntil: + visible: "Continue" + timeout: 180000 + - tapOn: "Continue" + - back +- extendedWaitUntil: + visible: + id: e2eProxyHeaderInput + timeout: 180000 - tapOn: id: e2eProxyHeaderInput - inputText: ${output.result} diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 7373dc5cdc..eb59adf40f 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -328,6 +328,7 @@ func serve(cctx *cli.Context) error { e.GET("/settings/interests", server.WebGenericNoindex) e.GET("/settings/about", server.WebGenericNoindex) e.GET("/settings/notifications", server.WebGenericNoindex) + e.GET("/settings/notifications/activity", server.WebGenericNoindex) e.GET("/sys/debug", server.WebGenericNoindex) e.GET("/sys/debug-mod", server.WebGenericNoindex) e.GET("/sys/log", server.WebGenericNoindex) diff --git a/dev-env/package.json b/dev-env/package.json index e77ad1dfab..c65ca9cd64 100644 --- a/dev-env/package.json +++ b/dev-env/package.json @@ -3,7 +3,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts" + "start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts", + "start:external": "NODE_ENV=development PGPORT=5433 PGHOST=localhost PGUSER=pg PGPASSWORD=password PGDATABASE=postgres DB_POSTGRES_URL=postgresql://pg:password@127.0.0.1:5433/postgres REDIS_HOST=127.0.0.1:6380 node ./mock-server.ts" }, "dependencies": { "@atproto/api": "^0.20.22", diff --git a/docs/testing.md b/docs/testing.md index b84d966112..1bc2af46c5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,6 +29,49 @@ adb reverse tcp:3000 tcp:3000 - In a second tab, run `pnpm e2e:build` - In a third tab, run `pnpm e2e:run __e2e__` +## Nightly Maestro CI + +The `Nightly Maestro E2E` GitHub Actions workflow runs every day at 04:00 UTC +and can also be started from the Actions tab with **Run workflow**. It runs iOS +and Android concurrently, but each platform runs all of `__e2e__/config.yml` +sequentially on one explicitly selected simulator or emulator. The flows share a +stateful mock-server manager, so the suite must not be sharded. + +The jobs run Maestro CLI 2.6.1 locally on GitHub Actions; Maestro Cloud is not +used. iOS runs on `macos-26-xlarge` with Xcode 26.4. Android runs on +`Linux-x64-32core`. Both use Java 17 and the Node and pnpm versions declared in +`package.json`. The iOS job selects an iPhone 17 simulator running iOS 26.5; +Android directly provisions and boots a Pixel 6 AVD with the API 35 Google APIs +x86_64 image using the Android SDK command-line tools. +Both development clients use the `e2e` EAS profile and the same reusable local +EAS build action as the release build workflows; the resulting simulator app +and APK are installed directly on the selected devices. + +The mock-server manager listens on host port 1986 and creates test services on +port 3000. Metro listens on 8081. Android reverses ports 3000 and 8081 into the +emulator; port 1986 remains host-side because Maestro JavaScript calls it from +the runner. Android uses the existing Docker Compose PostgreSQL 14 and Redis 7 +services on ports 5433 and 6380. GitHub-hosted macOS cannot run nested Docker +virtualization, so iOS provisions ephemeral native PostgreSQL 14.x and Redis +7.4.7 on those same ports and starts `pnpm --dir dev-env start:external`. + +Each platform uploads a `nightly-e2e--` artifact for 14 days. +It contains JUnit at `report.xml`, Maestro screenshots, videos, command metadata +and `maestro.log` under `maestro/`, plus Metro, native build, mock-server, service, +dependency, and translation logs. The workflow always uploads what was captured, +including when setup or the native build fails before Maestro starts. + +Add the repository secret `E2E_FAILURES_SLACK_WEBHOOK` before enabling the +schedule. The aggregation job runs even when either platform fails and posts one +detailed Slack notification containing both job statuses, failed flow details or +the failed setup phase, the commit and workflow links, and links to both artifact +sets. Successful runs do not post to Slack. + +Before relying on the schedule, manually dispatch the workflow and verify both +platforms against live Metro and `dev-env`, Android localhost routing, artifact +uploads on success and failure, one Slack message for a forced failure, and no +Slack message for an all-green run. + ## Using Flashlight for Performance Testing 1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above 2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/) diff --git a/eas.json b/eas.json index 7ab73ab4c8..5bf4e25513 100644 --- a/eas.json +++ b/eas.json @@ -21,6 +21,17 @@ "EXPO_PUBLIC_ENV": "production" } }, + "e2e": { + "extends": "development", + "android": { + "buildType": "apk" + }, + "env": { + "EXPO_PUBLIC_ENV": "e2e", + "NODE_ENV": "test", + "RN_SRC_EXT": "e2e.ts,e2e.tsx" + } + }, "preview": { "extends": "base", "distribution": "internal", diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index 0c3c700e90..f2955395e6 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -146,6 +146,7 @@ function BottomSheetNativeComponentInner({ const insets = useSafeAreaInsets() const cornerRadius = rest.cornerRadius ?? 0 const {height: screenHeight} = useWindowDimensions() + const isHeightConstrained = maxHeight != null || rest.fullHeight === true // sigh... on older Android versions, screenHeight does not include safe area insets // on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset @@ -182,7 +183,7 @@ function BottomSheetNativeComponentInner({ ]}> + style={isHeightConstrained ? {flex: 1} : undefined}> {children} diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.web.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.web.tsx index 8149561a3a..2edea6ca96 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.web.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.web.tsx @@ -1,5 +1,14 @@ +import {Component} from 'react' + import {type BottomSheetViewProps} from './BottomSheet.types' -export function BottomSheetNativeComponent(_: BottomSheetViewProps) { - throw new Error('BottomSheetNativeComponent is not available on web') +export class BottomSheetNativeComponent extends Component { + /* + * Native sheets do not exist on web; there is nothing to dismiss. + */ + static dismissAll = async () => {} + + render(): never { + throw new Error('BottomSheetNativeComponent is not available on web') + } } diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts index 1bef0f74e9..5b0e566abd 100644 --- a/modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ios.ts @@ -6,7 +6,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise { + return Promise.resolve(getReferrerInfoSync()) +} + +function getReferrerInfoSync(): ReferrerInfo | null { const referrer = SharedPrefs.getString('referrer') if (referrer) { SharedPrefs.removeValue('referrer') diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts index 89ed4f58a3..3771e071de 100644 --- a/modules/expo-bluesky-swiss-army/src/Referrer/index.ts +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.ts @@ -5,6 +5,6 @@ export function getGooglePlayReferrerInfoAsync(): Promise { throw new NotImplementedError() } diff --git a/modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts b/modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts index 3f8671ac31..d0510ef89f 100644 --- a/modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts +++ b/modules/expo-bluesky-swiss-army/src/Referrer/index.web.ts @@ -7,7 +7,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise { + return Promise.resolve(getReferrerInfoSync()) +} + +function getReferrerInfoSync(): ReferrerInfo | null { if ( Platform.OS === 'web' && // for ssr diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index bf1ed59cf4..f533d7cb0d 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1617,14 +1617,6 @@ "count": 1 } }, - "src/view/com/composer/text-input/TextInput.tsx": { - "typescript/no-floating-promises": { - "count": 1 - }, - "typescript/no-misused-promises": { - "count": 1 - } - }, "src/view/com/composer/text-input/TextInput.web.tsx": { "typescript/no-misused-promises": { "count": 1 diff --git a/package.json b/package.json index 77e2ddab86..ae08413d33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.128.0", + "version": "1.129.0", "private": true, "engines": { "node": ">=24.18.0" @@ -62,7 +62,10 @@ "lint": "oxlint --quiet src modules", "lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", - "typecheck": "tsgo --project ./tsconfig.check.json", + "typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web", + "typecheck:ios": "tsc --project ./tsconfig.check.ios.json", + "typecheck:android": "tsc --project ./tsconfig.check.android.json", + "typecheck:web": "tsc --project ./tsconfig.check.web.json", "e2e:mock-server": "cd dev-env && pnpm start", "e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android", @@ -98,7 +101,7 @@ "@atproto/syntax": "0.7.2", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", - "@bsky.app/alf": "^0.1.14", + "@bsky.app/alf": "^0.1.15", "@bsky.app/expo-dynamic-app-icon": "^1.8.5", "@bsky.app/expo-guess-language": "^0.2.8", "@bsky.app/expo-image-crop-tool": "^0.5.1", @@ -158,6 +161,7 @@ "expo": "54.0.34", "expo-age-range": "0.2.18", "expo-application": "~7.0.8", + "expo-asset": "~12.0.13", "expo-blur": "~15.0.8", "expo-build-properties": "~1.0.10", "expo-camera": "~17.0.10", @@ -270,7 +274,7 @@ "@types/psl": "1.1.1", "@types/react": "^19.1.17", "@types/react-dom": "^19.1.11", - "@typescript/native-preview": "^7.0.0-dev.20260428.1", + "@typescript/native": "npm:typescript@^7.0.2", "babel-jest": "^29.7.0", "babel-plugin-module-resolver": "^5.0.2", "babel-plugin-react-compiler": "19.1.0-rc.3", @@ -295,7 +299,7 @@ "react-refresh": "^0.14.0", "svgo": "^4.0.2", "ts-plugin-sort-import-suggestions": "^1.0.4", - "typescript": "^6.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2", "webpack-bundle-analyzer": "^4.10.1" }, "jest": { diff --git a/patches/react-native-svg@15.12.1.patch b/patches/react-native-svg@15.12.1.patch index 299ac13106..25dbc13e8c 100644 --- a/patches/react-native-svg@15.12.1.patch +++ b/patches/react-native-svg@15.12.1.patch @@ -55,3 +55,15 @@ index 06829bd00dbded262e1871aaf6db3ae0cfa9d1b1..1b158185a6d7e907aa18ddc806902fcc invalidate(); } +diff --git a/lib/typescript/ReactNativeSVG.web.d.ts b/lib/typescript/ReactNativeSVG.web.d.ts +deleted file mode 100644 +index e2001975fa97398fb159d2f85f45d067200f575b..0000000000000000000000000000000000000000 +diff --git a/lib/typescript/ReactNativeSVG.web.d.ts.map b/lib/typescript/ReactNativeSVG.web.d.ts.map +deleted file mode 100644 +index c81af4416af5ee3eaca77b51bfc2952b30d49c8a..0000000000000000000000000000000000000000 +diff --git a/lib/typescript/elements.web.d.ts b/lib/typescript/elements.web.d.ts +deleted file mode 100644 +index cf4083fbf749b2b002fb649d80b33ac74763847b..0000000000000000000000000000000000000000 +diff --git a/lib/typescript/elements.web.d.ts.map b/lib/typescript/elements.web.d.ts.map +deleted file mode 100644 +index 29602aa47ad903bc7aaa111462a61dd862c4476d..0000000000000000000000000000000000000000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe409d6149..b64d2b20cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,7 +231,7 @@ patchedDependencies: react-native-keyboard-controller@1.21.8: ac52bf7502ff3ad34a8594b5e1a916b96bf87a2523b6abc87c31f03607d52271 react-native-pager-view@6.8.0: 0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108 react-native-reanimated@3.19.1: 97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6 - react-native-svg@15.12.1: 31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9 + react-native-svg@15.12.1: ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310 react-native-uitextview@1.4.0: 62de26caadfc39d1bdff204cdc03a705c0cd343999825e06d8c0c120de06cc99 react-native-view-shot@4.0.3: a2457dc30a82cc8c21f371a6f860d9396d450a2a1b4265b1a4ee13bdb24d3268 react-native@0.81.5: 2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194 @@ -257,8 +257,8 @@ importers: specifier: ^6.0.2 version: 6.0.4 '@bsky.app/alf': - specifier: ^0.1.14 - version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + specifier: ^0.1.15 + version: 0.1.15(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@bsky.app/expo-dynamic-app-icon': specifier: ^1.8.5 version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -330,10 +330,10 @@ importers: version: 9.2.7 '@lingui/core': specifier: ^5.9.2 - version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2)) '@lingui/react': specifier: ^5.9.2 - version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0) + version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))(react@19.1.0) '@react-native-async-storage/async-storage': specifier: 2.2.0 version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)) @@ -436,6 +436,9 @@ importers: expo-application: specifier: ~7.0.8 version: 7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) + expo-asset: + specifier: ~12.0.13 + version: 12.0.13(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) expo-blur: specifier: ~15.0.8 version: 15.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -642,10 +645,10 @@ importers: version: 6.8.0(patch_hash=0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-progress: specifier: bluesky-social/react-native-progress - version: https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4(react-native-svg@15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) + version: https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4(react-native-svg@15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)) react-native-qrcode-styled: specifier: ^0.3.3 - version: 0.3.3(react-native-svg@15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 0.3.3(react-native-svg@15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-reanimated: specifier: 3.19.1 version: 3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -657,7 +660,7 @@ importers: version: 4.24.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-svg: specifier: 15.12.1 - version: 15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + version: 15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-uitextview: specifier: ^1.4.0 version: 1.4.0(patch_hash=62de26caadfc39d1bdff204cdc03a705c0cd343999825e06d8c0c120de06cc99)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -696,7 +699,7 @@ importers: version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) sonner-native: specifier: 0.21.0 - version: 0.21.0(patch_hash=4bcd0da0fec32e943460e6e788a9e4adf601b507d2dfd0c8246a6c8c74d8f638)(4e42d15c94c2c56af69d343a007eb893) + version: 0.21.0(patch_hash=4bcd0da0fec32e943460e6e788a9e4adf601b507d2dfd0c8246a6c8c74d8f638)(0ed429160839218d22ac63d892397351) tippy.js: specifier: ^6.3.7 version: 6.3.7 @@ -727,10 +730,10 @@ importers: version: 4.14.2 '@lingui/babel-plugin-lingui-macro': specifier: ^5.9.2 - version: 5.9.5(typescript@6.0.3) + version: 5.9.5(@typescript/typescript6@6.0.2) '@lingui/cli': specifier: ^5.9.2 - version: 5.9.5(typescript@6.0.3) + version: 5.9.5(@typescript/typescript6@6.0.2) '@pmmmwh/react-refresh-webpack-plugin': specifier: ^0.5.15 version: 0.5.17(react-refresh@0.14.2)(type-fest@1.4.0)(webpack-dev-server@4.15.2(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)) @@ -767,9 +770,9 @@ importers: '@types/react-dom': specifier: ^19.1.11 version: 19.1.11(@types/react@19.1.17) - '@typescript/native-preview': - specifier: ^7.0.0-dev.20260428.1 - version: 7.0.0-dev.20260512.1 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 babel-jest: specifier: ^29.7.0 version: 29.7.0(@babel/core@7.29.0) @@ -846,8 +849,8 @@ importers: specifier: ^1.0.4 version: 1.0.4 typescript: - specifier: ^6.0.2 - version: 6.0.3 + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' webpack-bundle-analyzer: specifier: ^4.10.1 version: 4.10.2 @@ -1603,8 +1606,8 @@ packages: '@braintree/sanitize-url@6.0.4': resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} - '@bsky.app/alf@0.1.14': - resolution: {integrity: sha512-c/KK6avyjEnYzhXDsN0rQaTFSbi8BPg4sJCW8aUk8AtKsO34GTaY6FU1adQ2i8xVfe5UIRu9FJjnBoGpuXj21A==} + '@bsky.app/alf@0.1.15': + resolution: {integrity: sha512-e6blt+oZ2klv+Cp7u11FeZckoILdXOtUZ11ATERgjZgb4zX3y7rt4e2fI03JkJO31wTvXHG8A1LnM96Az+ndJg==} peerDependencies: react: '*' react-native: '*' @@ -3715,51 +3718,128 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-l9AJi/TIVMPx5R1c7fxZCSA7eUaHeA0C9Mxdxx/oQJo1K/GtbI3mzYe/SiKNltko1KSdKUmWVhPwxTOS289REg==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-oABZLQrfB8JN2Ct2CiLK5PyE28Em3sIJlZsAMD45/A2ymtIaa5826dwv8vapE5Wjp54ao0LXxCSuKFm1A8zzCQ==} + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-xvbwzpTe+5N6bnBI/t9n4zsGzXxz3V6rVbvDUoJmRLfav5fz+ck0QDkGQGUPrQEEIp0KEzQvx7c+AEZnzdvTQA==} + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-0Hs1Gqa/t9cthoPdqHud1pFGUr9DgJivBTjwquTUh8jt/6PI2bQxoMNZLiN/bhqeDFDTzdxoMBfCaytsTMcXqw==} + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-qr5h6FPo74bN/U+EwRuayBhUbxaji8xzFbIbhMOA2oYSc/qozp5ia2g1+9xGw67MXxPPw/IPT+UGvrNK7K1NeQ==} + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-meNWxhNEfaqos2U0JXvfxWvy4JWrKE9fZepCndDZi+t04X+AIiLYp5s6crWnKP67nzVAzMNgTAc8mu8CnGM+/A==} + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-Hp6vBnxJSKEEAVWgIoWMmfqkZXCdkhm6XTivrwgRzBwWfiTVe2ZyZ7byWegIKeNnBbffg/K2KvoM8JAHl059GQ==} + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-KIzYPGuxZnyiiYkYrozDT94Af2nwbdLXoY1cgGY66RRa9HSEw13RH9WHg8wA8fZhT4wYzF5uF7WY3hz0QhaxGg==} - engines: {node: '>=16.20.0'} + '@typescript/typescript6@6.0.2': + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} hasBin: true '@ungap/structured-clone@1.3.1': @@ -8655,6 +8735,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ua-parser-js@0.7.41: resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} hasBin: true @@ -10069,7 +10154,7 @@ snapshots: '@braintree/sanitize-url@6.0.4': {} - '@bsky.app/alf@0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': + '@bsky.app/alf@0.1.15(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': dependencies: react: 19.1.0 react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) @@ -10902,19 +10987,19 @@ snapshots: '@lingui/babel-plugin-extract-messages@5.9.5': {} - '@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)': + '@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2)': dependencies: '@babel/core': 7.29.0 '@babel/runtime': 7.29.2 '@babel/types': 7.29.0 - '@lingui/conf': 5.9.5(typescript@6.0.3) - '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)) + '@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2)) '@lingui/message-utils': 5.9.5 transitivePeerDependencies: - supports-color - typescript - '@lingui/cli@5.9.5(typescript@6.0.3)': + '@lingui/cli@5.9.5(@typescript/typescript6@6.0.2)': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -10922,10 +11007,10 @@ snapshots: '@babel/runtime': 7.29.2 '@babel/types': 7.29.0 '@lingui/babel-plugin-extract-messages': 5.9.5 - '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3) - '@lingui/conf': 5.9.5(typescript@6.0.3) - '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)) - '@lingui/format-po': 5.9.5(typescript@6.0.3) + '@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2) + '@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2)) + '@lingui/format-po': 5.9.5(@typescript/typescript6@6.0.2) '@lingui/message-utils': 5.9.5 chokidar: 3.5.1 cli-table: 0.3.11 @@ -10948,26 +11033,26 @@ snapshots: - supports-color - typescript - '@lingui/conf@5.9.5(typescript@6.0.3)': + '@lingui/conf@5.9.5(@typescript/typescript6@6.0.2)': dependencies: '@babel/runtime': 7.29.2 - cosmiconfig: 8.3.6(typescript@6.0.3) + cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2) jest-validate: 29.7.0 jiti: 2.7.0 picocolors: 1.1.1 transitivePeerDependencies: - typescript - '@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))': + '@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))': dependencies: '@babel/runtime': 7.29.2 '@lingui/message-utils': 5.9.5 optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3) + '@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2) - '@lingui/format-po@5.9.5(typescript@6.0.3)': + '@lingui/format-po@5.9.5(@typescript/typescript6@6.0.2)': dependencies: - '@lingui/conf': 5.9.5(typescript@6.0.3) + '@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2) '@lingui/message-utils': 5.9.5 date-fns: 3.6.0 pofile: 1.1.4 @@ -10979,13 +11064,13 @@ snapshots: '@messageformat/parser': 5.1.1 js-sha256: 0.10.1 - '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)': + '@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))(react@19.1.0)': dependencies: '@babel/runtime': 7.29.2 - '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)) + '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2)) react: 19.1.0 optionalDependencies: - '@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3) + '@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2) '@messageformat/parser@5.1.1': dependencies: @@ -12563,36 +12648,69 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1': + '@typescript/typescript-aix-ppc64@7.0.2': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1': + '@typescript/typescript-darwin-arm64@7.0.2': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1': + '@typescript/typescript-darwin-x64@7.0.2': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1': + '@typescript/typescript-freebsd-arm64@7.0.2': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1': + '@typescript/typescript-freebsd-x64@7.0.2': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1': + '@typescript/typescript-linux-arm64@7.0.2': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1': + '@typescript/typescript-linux-arm@7.0.2': optional: true - '@typescript/native-preview@7.0.0-dev.20260512.1': - optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260512.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260512.1 + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@typescript/typescript6@6.0.2': + dependencies: + '@typescript/old': typescript@6.0.3 '@ungap/structured-clone@1.3.1': {} @@ -13434,14 +13552,14 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig@8.3.6(typescript@6.0.3): + cosmiconfig@8.3.6(@typescript/typescript6@6.0.2): dependencies: import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' create-jest@29.7.0(@types/node@24.12.4): dependencies: @@ -17082,18 +17200,18 @@ snapshots: react: 19.1.0 react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-progress@https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4(react-native-svg@15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): + react-native-progress@https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4(react-native-svg@15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)): dependencies: prop-types: 15.8.1 - react-native-svg: 15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-svg: 15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-qrcode-styled@0.3.3(react-native-svg@15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-qrcode-styled@0.3.3(react-native-svg@15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: qrcode: 1.5.4 react: 19.1.0 react-fast-compare: 3.2.2 react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) - react-native-svg: 15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-svg: 15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: @@ -17127,7 +17245,7 @@ snapshots: react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) warn-once: 0.1.1 - react-native-svg@15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): + react-native-svg@15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: css-select: 5.2.2 css-tree: 1.1.3 @@ -17677,7 +17795,7 @@ snapshots: uuid: 8.3.2 websocket-driver: 0.7.4 - sonner-native@0.21.0(patch_hash=4bcd0da0fec32e943460e6e788a9e4adf601b507d2dfd0c8246a6c8c74d8f638)(4e42d15c94c2c56af69d343a007eb893): + sonner-native@0.21.0(patch_hash=4bcd0da0fec32e943460e6e788a9e4adf601b507d2dfd0c8246a6c8c74d8f638)(0ed429160839218d22ac63d892397351): dependencies: react: 19.1.0 react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) @@ -17685,7 +17803,7 @@ snapshots: react-native-reanimated: 3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-safe-area-context: 5.6.2(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) react-native-screens: 4.24.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) - react-native-svg: 15.12.1(patch_hash=31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-svg: 15.12.1(patch_hash=ae309e9542214f5ae54935e253dedd236765feedac7be343f81d532d7fe09310)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: @@ -18110,6 +18228,29 @@ snapshots: typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ua-parser-js@0.7.41: {} ua-parser-js@1.0.41: {} diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 76fa7187b2..ab596876aa 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -117,6 +117,7 @@ import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings' import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings' import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' +import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings' import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings' import {SettingsScreen} from '#/screens/Settings/Settings' import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' @@ -451,6 +452,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => NotificationSettingsScreen} options={{title: title(msg`Notification settings`), requireAuth: true}} /> + ActivityNotificationSettingsScreen} + options={{ + title: title(msg`Activity notifications`), + requireAuth: true, + }} + /> ContentAndMediaSettingsScreen} @@ -1003,14 +1012,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { }) if (IS_WEB) { - const referrerInfo = Referrer.getReferrerInfo() - if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { - ax.metric('deepLink:referrerReceived', { - to: window.location.href, - referrer: referrerInfo?.referrer, - hostname: referrerInfo?.hostname, - }) - } + void Referrer.getReferrerInfo().then(referrerInfo => { + if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { + ax.metric('deepLink:referrerReceived', { + to: window.location.href, + referrer: referrerInfo?.referrer, + hostname: referrerInfo?.hostname, + }) + } + }) } // temp, just testing diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index d93c7bb91a..f4a0302ff7 100644 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -286,7 +286,7 @@ export function Composer({ ref={IS_WEB ? sift.refs.setAnchor : undefined} style={ node.type === 'facet' && { - color: t.palette.primary_500, + color: t.atoms.text_link.color, } }> {node.raw} diff --git a/src/components/ContextMenu/index.web.tsx b/src/components/ContextMenu/index.web.tsx index 3dfc39ffaa..2d45f79db4 100644 --- a/src/components/ContextMenu/index.web.tsx +++ b/src/components/ContextMenu/index.web.tsx @@ -2,8 +2,9 @@ import {type StyleProp, type ViewStyle} from 'react-native' import {atoms as a, useTheme} from '#/alf' import * as Menu from '#/components/Menu' +import {type TriggerProps as MenuTriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' -import {type AuxiliaryViewProps} from './types' +import {type AuxiliaryViewProps, type TriggerProps} from './types' export { ContainerItem, @@ -16,7 +17,6 @@ export { ItemText, LabelText, Root, - Trigger, useMenuContext as useContextMenuContext, useMenuControl as useContextMenuControl, } from '#/components/Menu' @@ -30,14 +30,37 @@ export function AuxiliaryView({}: AuxiliaryViewProps) { return null } +/* + * On web the context menu is just a Menu; contentLabel, onTap, style, and + * swipeGesture only apply to the native press-and-hold presentation. + */ +export function Trigger({children, label, hint, role}: TriggerProps) { + return ( + + {/* + * Menu supplies the same web-arm child props shape as ContextMenu's + * TriggerChildProps; only the native arms of the two unions differ, + * and those never occur here. + */} + {children as unknown as MenuTriggerProps['children']} + + ) +} + export function Outer({ children, label, + align: _align, style, onCloseAutoFocus, }: { children: React.ReactNode label?: string + /** + * Native positions the menu against the message bubble explicitly; the web + * dropdown is anchored by radix, so this is accepted only for parity. + */ + align?: 'left' | 'right' style?: StyleProp onCloseAutoFocus?: (event: Event) => void }) { diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index bd23e566ad..c9f23175de 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -157,7 +157,8 @@ export function Outer({ [open, close], ) - const isHeightConstrained = nativeOptions?.maxHeight != null + const isHeightConstrained = + nativeOptions?.maxHeight != null || nativeOptions?.fullHeight === true const context = useMemo( () => ({ diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index 37c893be1a..3bb9e478a7 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -12,6 +12,7 @@ import { type GestureResponderEvent, type LayoutChangeEvent, Pressable, + type ScrollView, type StyleProp, View, type ViewStyle, @@ -24,6 +25,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar' import {logger} from '#/logger' import {useA11y} from '#/state/a11y' import {useDialogStateControlContext} from '#/state/dialogs' +import {type ListMethods} from '#/view/com/util/List' import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {Context} from '#/components/Dialog/context' @@ -224,11 +226,21 @@ export function Inner({ ) } -export const ScrollableInner = Inner +/* + * There is no inner ScrollView on web - the ref is accepted for parity with + * the native variant and never attached, so native-only scrolling code in + * shared callers stays a no-op here. + */ +export function ScrollableInner({ + ref: _ref, + ...props +}: DialogInnerProps & {ref?: React.Ref}) { + return +} export const InnerFlatList = forwardRef< - FlatList, - FlatListProps & {label: string} & { + ListMethods, + FlatListProps & {label?: string} & { webInnerStyle?: StyleProp webInnerContentContainerStyle?: StyleProp footer?: React.ReactNode @@ -247,7 +259,11 @@ export const InnerFlatList = forwardRef< const {gtMobile} = useBreakpoints() return ( } style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]} {...props} /> @@ -321,7 +343,11 @@ export function Close() { ) } -export function Handle() { +/* + * The drag handle only exists on the native bottom sheet; props are accepted + * for parity with the native variant. + */ +export function Handle(_props: {difference?: boolean; fill?: string}) { return null } diff --git a/src/components/DraggableList/index.web.tsx b/src/components/DraggableList/index.web.tsx index 237a11be35..7dd02cb76c 100644 --- a/src/components/DraggableList/index.web.tsx +++ b/src/components/DraggableList/index.web.tsx @@ -1,5 +1,7 @@ import {useState} from 'react' import {View} from 'react-native' +import type Animated from 'react-native-reanimated' +import {type AnimatedRef, type SharedValue} from 'react-native-reanimated' import {useTheme} from '#/alf' import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' @@ -18,6 +20,10 @@ interface SortableListProps { onDragEnd?: () => void /** Fixed row height used for position math. */ itemHeight: number + /** Ref to the parent Animated.ScrollView for auto-scroll. Ignored on web. */ + scrollRef?: AnimatedRef + /** Scroll offset shared value from useScrollViewOffset. Ignored on web. */ + scrollOffset?: SharedValue } export function SortableList({ diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 02dee2fd6e..7f8820f36f 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -481,11 +481,11 @@ export function ProfileGrid({ See more diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index f2f5ba122f..a0bd45c8fc 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -4,7 +4,6 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {HITSLOP_30} from '#/lib/constants' import {type NavigationProp} from '#/lib/routes/types' import {useSetDrawerOpen} from '#/state/shell' import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context' @@ -33,6 +32,8 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_IOS} from '#/env' +const HEADER_BUTTON_HITSLOP = {top: 30, bottom: 10, left: 30, right: 30} + export function Outer({ children, noBottomBorder, @@ -136,7 +137,7 @@ export function BackButton({onPress, style, ...props}: Partial) { color="secondary" shape="round" onPress={onPressBack} - hitSlop={HITSLOP_30} + hitSlop={HEADER_BUTTON_HITSLOP} style={[ {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, a.bg_transparent, @@ -170,7 +171,7 @@ export function MenuButton() { color="secondary" shape="square" onPress={onPress} - hitSlop={HITSLOP_30} + hitSlop={HEADER_BUTTON_HITSLOP} style={[ {marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, a.bg_transparent, diff --git a/src/components/Lightbox/chrome/ImageMenu.tsx b/src/components/Lightbox/chrome/ImageMenu.tsx index 92b5c47749..632f5eb362 100644 --- a/src/components/Lightbox/chrome/ImageMenu.tsx +++ b/src/components/Lightbox/chrome/ImageMenu.tsx @@ -78,6 +78,7 @@ export function ImageMenu({onPressShare, onPressSave}: Props) { visible={isMounted} animationType="none" onRequestClose={close} + supportedOrientations={['portrait', 'landscape']} statusBarTranslucent> { diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx index 8664aae142..10e8c73236 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -1,3 +1,10 @@ -export function VideoEmbedInnerWeb() { +import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared' + +export { + HLSUnsupportedError, + VideoNotFoundError, +} from './VideoEmbedInnerWeb.shared' + +export function VideoEmbedInnerWeb(_props: VideoEmbedInnerWebProps): never { throw new Error('VideoEmbedInnerWeb may not be used on native.') } diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts new file mode 100644 index 0000000000..06abf40408 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.shared.ts @@ -0,0 +1,21 @@ +import {type AppBskyEmbedVideo} from '@atproto/api' + +export type VideoEmbedInnerWebProps = { + embed: AppBskyEmbedVideo.View + active: boolean + setActive: () => void + onScreen: boolean + lastKnownTime: React.RefObject +} + +export class HLSUnsupportedError extends Error { + constructor() { + super('HLS is not supported') + } +} + +export class VideoNotFoundError extends Error { + constructor() { + super('Video not found') + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 3d5affc8d4..6973429c76 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,6 +1,5 @@ import {useCallback, useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' -import {type AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import type * as HlsTypes from 'hls.js' @@ -10,21 +9,25 @@ import {atoms as a} from '#/alf' import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog' import {useFullscreen} from '#/components/hooks/useFullscreen' import * as BandwidthEstimate from './bandwidth-estimate' +import { + HLSUnsupportedError, + type VideoEmbedInnerWebProps, + VideoNotFoundError, +} from './VideoEmbedInnerWeb.shared' import {Controls} from './web-controls/VideoControls' +export { + HLSUnsupportedError, + VideoNotFoundError, +} from './VideoEmbedInnerWeb.shared' + export function VideoEmbedInnerWeb({ embed, active, setActive, onScreen, lastKnownTime, -}: { - embed: AppBskyEmbedVideo.View - active: boolean - setActive: () => void - onScreen: boolean - lastKnownTime: React.RefObject -}) { +}: VideoEmbedInnerWebProps) { const containerRef = useRef(null) const videoRef = useRef(null) const [focused, setFocused] = useState(false) @@ -104,12 +107,6 @@ export function VideoEmbedInnerWeb({ ) } -export class HLSUnsupportedError extends Error { - constructor() { - super('HLS is not supported') - } -} - // Bluesky serves HLS as MPEG-TS with H.264 + AAC. `Hls.isSupported()` is loose // (true if MSE supports *any* of {H.264, AV1, VP9} OR *any* of {AAC, FLAC}), // so on Linux boxes missing H.264 (e.g. no ubuntu-restricted-extras, sandboxed @@ -138,12 +135,6 @@ function canPlayBskyVideoCodecs(): boolean { ) } -export class VideoNotFoundError extends Error { - constructor() { - super('Video not found') - } -} - /** * Fatal hls.js playback error. `detail` is the hls.js error details code * (e.g. bufferAppendError), which buckets failures more usefully than the diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx index e2e24ed367..9535d0fc1f 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx @@ -1,3 +1,5 @@ -export function Controls() { +import {type ControlsProps} from './VideoControls.shared' + +export function Controls(_props: ControlsProps): never { throw new Error('VideoWebControls may not be used on native.') } diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.shared.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.shared.ts new file mode 100644 index 0000000000..173aa9f1fb --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.shared.ts @@ -0,0 +1,17 @@ +import type Hls from 'hls.js' + +export type ControlsProps = { + videoRef: React.RefObject + hlsRef: React.RefObject + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + fullscreenRef: React.RefObject + hlsLoading: boolean + hasSubtitleTrack: boolean + isGif: boolean + altText?: string + updateCuePositions: (controlsVisible?: boolean) => void +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx index 2e5822589e..57f52e2f22 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -3,7 +3,6 @@ import {Pressable, View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import type Hls from 'hls.js' import {clamp} from '#/lib/numbers' import { @@ -33,6 +32,7 @@ import {TimeIndicator} from '../TimeIndicator' import {ControlButton} from './ControlButton' import {Scrubber} from './Scrubber' import {formatTime, useVideoElement} from './utils' +import {type ControlsProps} from './VideoControls.shared' import {VolumeControl} from './VolumeControl' export function Controls({ @@ -49,21 +49,7 @@ export function Controls({ isGif, altText, updateCuePositions, -}: { - videoRef: React.RefObject - hlsRef: React.RefObject - active: boolean - setActive: () => void - focused: boolean - setFocused: (focused: boolean) => void - onScreen: boolean - fullscreenRef: React.RefObject - hlsLoading: boolean - hasSubtitleTrack: boolean - isGif: boolean - altText?: string - updateCuePositions: (controlsVisible?: boolean) => void -}) { +}: ControlsProps) { const { play, pause, diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts index 77319b0e86..0ef5569c7d 100644 --- a/src/components/Post/Embed/types.ts +++ b/src/components/Post/Embed/types.ts @@ -4,7 +4,6 @@ import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api' export enum PostEmbedViewContext { ThreadHighlighted = 'ThreadHighlighted', Feed = 'Feed', - FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia', ChatMessage = 'ChatMessage', } diff --git a/src/components/Post/ShowMoreTextButton.tsx b/src/components/Post/ShowMoreTextButton.tsx index 1e4e13cd5d..7765fbdd07 100644 --- a/src/components/Post/ShowMoreTextButton.tsx +++ b/src/components/Post/ShowMoreTextButton.tsx @@ -1,8 +1,6 @@ import {useCallback, useMemo} from 'react' import {LayoutAnimation, type TextStyle} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {HITSLOP_10} from '#/lib/constants' import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf' @@ -14,7 +12,7 @@ export function ShowMoreTextButton({ style, }: TextStyleProp & {onPress: () => void}) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const onPress = useCallback(() => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) @@ -30,7 +28,7 @@ export function ShowMoreTextButton({ return ( diff --git a/src/components/Post/Translated/index.tsx b/src/components/Post/Translated/index.tsx index 9aff552bbf..94e0ea697a 100644 --- a/src/components/Post/Translated/index.tsx +++ b/src/components/Post/Translated/index.tsx @@ -150,10 +150,10 @@ function TranslationLink({ label={l`Translate`} hoverStyle={[ native({opacity: 0.5}), - web([a.underline, {textDecorationColor: t.palette.primary_500}]), + web([a.underline, {textDecorationColor: t.atoms.text_link.color}]), ]} hitSlop={HITSLOP_30}> - + Translate @@ -229,7 +229,7 @@ function TranslationError({ label={l`Try Google Translate`} hoverStyle={[ native({opacity: 0.5}), - web([a.underline, {textDecorationColor: t.palette.primary_500}]), + web([a.underline, {textDecorationColor: t.atoms.text_link.color}]), ]} hitSlop={HITSLOP_30}> Try Google Translate diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx index 9e02d5a3cc..c087f5fa46 100644 --- a/src/components/StarterPack/Main/ProfilesList.tsx +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -23,7 +23,7 @@ import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {Default as ProfileCard} from '#/components/ProfileCard' import {IS_NATIVE, IS_WEB} from '#/env' -function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { +function keyExtractor(item: AppBskyActorDefs.ProfileView, index: number) { return `${item.did}-${index}` } @@ -87,7 +87,7 @@ export const ProfilesList = forwardRef( const renderItem = ({ item, index, - }: ListRenderItemInfo) => { + }: ListRenderItemInfo) => { return ( | React.ReactElement } -function keyExtractor(item: AppBskyGraphDefs.StarterPackView) { +function keyExtractor(item: AppBskyGraphDefs.StarterPackViewBasic) { return item.uri } @@ -138,13 +138,16 @@ export function ProfileStarterPacks({ useEffect(() => { if (IS_IOS && enabled && scrollElRef.current) { - const nativeTag = findNodeHandle(scrollElRef.current) + const nativeTag = findListNativeTag(scrollElRef.current) setScrollViewTag(nativeTag) } }, [enabled, scrollElRef, setScrollViewTag]) const renderItem = useCallback( - ({item, index}: ListRenderItemInfo) => { + ({ + item, + index, + }: ListRenderItemInfo) => { return ( void - close: () => void - } + control: WelcomeModalControl } export function WelcomeModal({control}: WelcomeModalProps) { diff --git a/src/components/contacts/FindContactsFlow.web.tsx b/src/components/contacts/FindContactsFlow.web.tsx index 9def7df16b..f1c720087b 100644 --- a/src/components/contacts/FindContactsFlow.web.tsx +++ b/src/components/contacts/FindContactsFlow.web.tsx @@ -1,3 +1,11 @@ -export function FindContactsFlow() { +import {type Action, type State} from './state' + +export function FindContactsFlow(_props: { + state: State + dispatch: React.ActionDispatch<[Action]> + onBack?: () => void + onCancel: () => void + context: 'Onboarding' | 'Standalone' +}): never { throw new Error('FindContactsFlow is not available on web') } diff --git a/src/components/hooks/useWelcomeModal.native.ts b/src/components/hooks/useWelcomeModal.native.ts index f5bc1aa4ed..bdd0805d32 100644 --- a/src/components/hooks/useWelcomeModal.native.ts +++ b/src/components/hooks/useWelcomeModal.native.ts @@ -1,3 +1,5 @@ -export function useWelcomeModal() { +import {type WelcomeModalControl} from './useWelcomeModal.shared' + +export function useWelcomeModal(): WelcomeModalControl { throw new Error('useWelcomeModal is web only') } diff --git a/src/components/hooks/useWelcomeModal.shared.ts b/src/components/hooks/useWelcomeModal.shared.ts new file mode 100644 index 0000000000..1151475e28 --- /dev/null +++ b/src/components/hooks/useWelcomeModal.shared.ts @@ -0,0 +1,5 @@ +export type WelcomeModalControl = { + isOpen: boolean + open: () => void + close: () => void +} diff --git a/src/components/hooks/useWelcomeModal.ts b/src/components/hooks/useWelcomeModal.ts index d0a55a0c18..3e33084b96 100644 --- a/src/components/hooks/useWelcomeModal.ts +++ b/src/components/hooks/useWelcomeModal.ts @@ -2,8 +2,9 @@ import {useEffect, useState} from 'react' import {useSession} from '#/state/session' import {IS_WEB} from '#/env' +import {type WelcomeModalControl} from './useWelcomeModal.shared' -export function useWelcomeModal() { +export function useWelcomeModal(): WelcomeModalControl { const {hasSession} = useSession() const [isOpen, setIsOpen] = useState(false) diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 5b4ba608b2..f69de63bd1 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -5,7 +5,7 @@ import {type AppBskyEmbedImages} from '@atproto/api' import {atoms as a, useBreakpoints} from '#/alf' import {type Dimensions} from '#/components/Lightbox/types' -import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {type PostEmbedViewContext} from '#/components/Post/Embed/types' import {GalleryItem} from './ImageLayoutGridItem' interface ImageLayoutGridProps { @@ -28,9 +28,7 @@ export function ImageLayoutGrid({ ...props }: ImageLayoutGridProps) { const {gtMobile} = useBreakpoints() - const isWithinQuote = - isWithinQuoteProp ?? - props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + const isWithinQuote = isWithinQuoteProp const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs return ( diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx index b603c738d4..cfdc6c7e12 100644 --- a/src/components/moderation/ContentHider.tsx +++ b/src/components/moderation/ContentHider.tsx @@ -6,9 +6,7 @@ import { type ViewStyle, } from 'react-native' import {type ModerationUI} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import { ADULT_CONTENT_LABELS, @@ -78,7 +76,7 @@ function ContentHiderActive({ children?: React.ReactNode }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const {gtMobile} = useBreakpoints() const [override, setOverride] = useState(false) const control = useModerationDetailsDialogControl() @@ -97,7 +95,7 @@ function ContentHiderActive({ (blur.type === 'label' && blur.source.type !== 'user') ) { if (desc.isSubjectAccount) { - return _(msg`${desc.name} (Account)`) + return l`${desc.name} (Account)` } else { return desc.name } @@ -128,7 +126,7 @@ function ContentHiderActive({ const def = cause.labelDef || getDefinition(labelDefs, cause.label) if (def.identifier === 'porn' || def.identifier === 'sexual') { - return _(msg`Adult Content`) + return l`Adult Content` } return getLabelStrings(i18n.locale, globalLabelStrings, def).name }) @@ -138,7 +136,7 @@ function ContentHiderActive({ } return [...new Set(selfBlurNames)].join(', ') }, [ - _, + l, modui.blurs, blur, desc.name, @@ -151,7 +149,6 @@ function ContentHiderActive({ return ( - - {desc.source && blur.type === 'label' && !override && ( )} - {override && {children}} ) diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx index 4379c76cb3..af8329bec5 100644 --- a/src/components/moderation/PostAlerts.tsx +++ b/src/components/moderation/PostAlerts.tsx @@ -54,7 +54,15 @@ export function PostAlerts({ const isOwnPost = !!post && post.author.did === currentAccount?.did const allLabels: ComAtprotoLabelDefs.Label[] = isOwnPost && view === 'expanded' - ? [...(post.labels ?? []), ...(post.author.labels ?? [])] + ? [ + ...(post.labels ?? []), + /* + * Account labels appear on Profile. We don't show them here unless the + * user's mod settings are configured such that the labels land in the + * modui handling. + */ + // ...(post.author.labels ?? []) + ] : [] /* * Labels that the moderation system already surfaces in this context - diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx index cb3d522274..3c0d4584b8 100644 --- a/src/components/moderation/ScreenHider.tsx +++ b/src/components/moderation/ScreenHider.tsx @@ -6,9 +6,7 @@ import { type ViewStyle, } from 'react-native' import {type ModerationUI} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' @@ -38,7 +36,7 @@ export function ScreenHider({ containerStyle?: StyleProp }>) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const [override, setOverride] = useState(false) const navigation = useNavigation() const {isMobile} = useWebMediaQueries() @@ -131,15 +129,13 @@ export function ScreenHider({ control.open() }} accessibilityRole="button" - accessibilityLabel={_(msg`Learn more about this warning`)} + accessibilityLabel={l`Learn more about this warning`} accessibilityHint=""> { if (navigation.canGoBack()) { navigation.goBack() @@ -176,7 +172,7 @@ export function ScreenHider({ color="secondary" size="large" style={[a.rounded_full]} - label={_(msg`Show anyway`)} + label={l`Show anyway`} onPress={() => setOverride(v => !v)}> Show anyway diff --git a/src/lib/custom-animations/GestureActionView.shared.ts b/src/lib/custom-animations/GestureActionView.shared.ts new file mode 100644 index 0000000000..c987d61ffb --- /dev/null +++ b/src/lib/custom-animations/GestureActionView.shared.ts @@ -0,0 +1,15 @@ +import {type ColorValue} from 'react-native' + +export interface GestureAction { + color: ColorValue + action: () => void + threshold: number + icon: React.ElementType +} + +export interface GestureActions { + leftFirst?: GestureAction + leftSecond?: GestureAction + rightFirst?: GestureAction + rightSecond?: GestureAction +} diff --git a/src/lib/custom-animations/GestureActionView.tsx b/src/lib/custom-animations/GestureActionView.tsx index cadc9389da..71b7864df2 100644 --- a/src/lib/custom-animations/GestureActionView.tsx +++ b/src/lib/custom-animations/GestureActionView.tsx @@ -16,20 +16,7 @@ import Animated, { } from 'react-native-reanimated' import {useHaptics} from '#/lib/haptics' - -interface GestureAction { - color: ColorValue - action: () => void - threshold: number - icon: React.ElementType -} - -interface GestureActions { - leftFirst?: GestureAction - leftSecond?: GestureAction - rightFirst?: GestureAction - rightSecond?: GestureAction -} +import {type GestureActions} from './GestureActionView.shared' const MAX_WIDTH = Dimensions.get('screen').width const ICON_SIZE = 32 diff --git a/src/lib/custom-animations/GestureActionView.web.tsx b/src/lib/custom-animations/GestureActionView.web.tsx index 9fed076a03..b27a90614e 100644 --- a/src/lib/custom-animations/GestureActionView.web.tsx +++ b/src/lib/custom-animations/GestureActionView.web.tsx @@ -1,3 +1,13 @@ -export function GestureActionView({children}: {children: React.ReactNode}) { +import {type GestureActions} from './GestureActionView.shared' + +/* + * Swipe gestures only exist on native; children render as-is on web. + */ +export function GestureActionView({ + children, +}: { + children: React.ReactNode + actions: GestureActions +}) { return children } diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 5a9d0b39fe..eaa6482830 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -40,7 +40,7 @@ export function useIntentHandler() { await WebBrowser.dismissBrowser().catch(() => {}) } - const referrerInfo = Referrer.getReferrerInfo() + const referrerInfo = await Referrer.getReferrerInfo() if (referrerInfo && referrerInfo.hostname !== 'bsky.app') { ax.metric('deepLink:referrerReceived', { to: url, diff --git a/src/lib/hooks/useNavigationTabState.web.ts b/src/lib/hooks/useNavigationTabState.web.ts index 03dcbbb038..6af96fed58 100644 --- a/src/lib/hooks/useNavigationTabState.web.ts +++ b/src/lib/hooks/useNavigationTabState.web.ts @@ -8,6 +8,8 @@ export function useNavigationTabState() { return { isAtHome: currentRoute === 'Home', isAtSearch: currentRoute === 'Search', + isAtFeeds: currentRoute === 'Feeds', + isAtBookmarks: currentRoute === 'Bookmarks', isAtNotifications: currentRoute === 'Notifications', isAtMyProfile: currentRoute === 'MyProfile', isAtMessages: currentRoute === 'Messages', diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts index 2783a04dd1..30cb2c6dba 100644 --- a/src/lib/hooks/useOTAUpdates.web.ts +++ b/src/lib/hooks/useOTAUpdates.web.ts @@ -1,7 +1,7 @@ export function useOTAUpdates() {} export function useApplyPullRequestOTAUpdate() { return { - tryApplyUpdate: () => {}, + tryApplyUpdate: async (_channel: string) => {}, revertToEmbedded: () => {}, isCurrentlyRunningPullRequestDeployment: false, currentChannel: 'web-build', diff --git a/src/lib/hotkeys/index.native.tsx b/src/lib/hotkeys/index.native.tsx index a0fe0e105a..58bfb1fef8 100644 --- a/src/lib/hotkeys/index.native.tsx +++ b/src/lib/hotkeys/index.native.tsx @@ -4,7 +4,7 @@ export function Provider({children}: {children: React.ReactNode}) { return children } -const noop = () => {} +const noop = (_scope: string) => {} export function useHotkeysContext() { return useMemo( diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 94732687ef..690dc32023 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -168,10 +168,14 @@ function createResizedImage( export async function saveBytesToDisk( filename: string, - bytes: Uint8Array, + bytes: Uint8Array, type: string, ) { - const blob = new Blob([bytes], {type}) + /* + * Bytes handed to us are never SharedArrayBuffer-backed, but the broader + * Uint8Array parameter type matches the native variant. + */ + const blob = new Blob([bytes as Uint8Array], {type}) const url = URL.createObjectURL(blob) downloadUrl(url, filename) // Firefox requires a small delay diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index 7aaa69c47d..4fa17ecf37 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -1,3 +1,4 @@ +import {Asset} from 'expo-asset' import { documentDirectory, getInfoAsync, @@ -9,10 +10,15 @@ import ExpoImageCropTool, { } from '@bsky.app/expo-image-crop-tool' import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' +import {IS_ANDROID} from '#/env' import {compressIfNeeded} from './manip' import {type PickerImage} from './picker.shared' async function getFile() { + if (IS_ANDROID) { + return await getAndroidFile() + } + const imagesDir = documentDirectory! .split('/') .slice(0, -6) @@ -41,6 +47,44 @@ async function getFile() { ) } +/* + * The Android emulator can't reach the iOS simulator's sample photo library, + * so we load a jpg bundled with the app instead. It is bundled via require() + * (resolved by Metro), so it survives `pm clear`, which Maestro's clearState + * runs at the start of every flow. An adb-seeded file in app-scoped external + * storage does not survive: pm clear wipes that directory each flow, so the + * seeded file is gone before the picker mock ever reads it. + */ +async function getAndroidFile() { + const asset = Asset.fromModule( + require('../../../assets/images/welcome-modal-bg.jpg'), + ) + await asset.downloadAsync() + + const path = asset.localUri! + const fileInfo = await getInfoAsync(path) + + if (!fileInfo.exists) { + throw new Error('Failed to get file info') + } + + /* + * Dimensions of the bundled asset (assets/images/welcome-modal-bg.jpg). Only + * used for downstream aspect-ratio display; the actual bytes are read from + * disk by compressIfNeeded. + */ + return await compressIfNeeded( + { + path, + mime: 'image/jpeg', + size: fileInfo.size, + width: 1432, + height: 1025, + }, + IMAGE_SIZE_CONFIG_2K_1MB, + ) +} + export async function openPicker(): Promise { return [await getFile()] } diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index 060169ff89..1cd99a05c7 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -1,11 +1,13 @@ +import {type ImagePickerOptions} from 'expo-image-picker' import {type OpenCropperOptions} from '@bsky.app/expo-image-crop-tool' import {type PickerImage} from './picker.shared' -import {type CameraOpts} from './types' export {openPicker, openUnifiedPicker} from './picker.shared' -export async function openCamera(_opts: CameraOpts): Promise { +export async function openCamera( + _opts: ImagePickerOptions, +): Promise { throw new Error('openCamera is not supported on web') } diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts index c083093ac7..c4d53ffedd 100644 --- a/src/lib/media/types.ts +++ b/src/lib/media/types.ts @@ -8,10 +8,3 @@ export interface PickerOpts { multiple?: boolean maxFiles?: number } - -export interface CameraOpts { - width: number - height: number - freeStyleCropEnabled?: boolean - cropperCircleOverlay?: boolean -} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 6eb112a381..9108b01e07 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -70,6 +70,7 @@ export type CommonNavigatorParams = { ActivityPrivacySettings: undefined ContentAndMediaSettings: undefined NotificationSettings: undefined + ActivityNotificationSettings: undefined InterestsSettings: undefined AboutSettings: undefined AppIconSettings: undefined diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 90cdfb0e89..e87e5bf40b 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -98,12 +98,12 @@ msgid "{0, plural, one {# hour} other {# hours}}" msgstr "" #. placeholder {0}: labels.length -#: src/components/moderation/PostAlerts.tsx:149 +#: src/components/moderation/PostAlerts.tsx:157 msgid "{0, plural, one {# label applied to your post} other {# labels applied to your post}}" msgstr "{0, plural, one {# label applied to your post} other {# labels applied to your post}}" #. placeholder {0}: labels.length -#: src/components/moderation/PostAlerts.tsx:156 +#: src/components/moderation/PostAlerts.tsx:164 msgid "{0, plural, one {# label applied} other {# labels applied}}" msgstr "{0, plural, one {# label applied} other {# labels applied}}" @@ -219,7 +219,7 @@ msgid "{0} Β· {1}" msgstr "" #. placeholder {0}: desc.name -#: src/components/moderation/ContentHider.tsx:100 +#: src/components/moderation/ContentHider.tsx:98 msgid "{0} (Account)" msgstr "" @@ -837,7 +837,7 @@ msgstr "" #. Number of images beyond the first 3 #. placeholder {0}: labels.length #. placeholder {0}: totalNumber - 3 -#: src/components/moderation/PostAlerts.tsx:155 +#: src/components/moderation/PostAlerts.tsx:163 #: src/view/com/composer/ComposerReplyTo.tsx:278 msgid "+{0}" msgstr "+{0}" @@ -966,7 +966,7 @@ msgstr "" msgid "7 days" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:17 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:27 msgid "A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens" msgstr "" @@ -1333,7 +1333,7 @@ msgstr "" msgid "Add to lists" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:144 +#: src/components/PostControls/BookmarkButton.tsx:121 msgid "Add to saved posts" msgstr "" @@ -1395,7 +1395,7 @@ msgstr "" msgid "Adult content" msgstr "" -#: src/components/moderation/ContentHider.tsx:131 +#: src/components/moderation/ContentHider.tsx:129 #: src/lib/moderation/useGlobalLabelStrings.ts:34 #: src/lib/moderation/useModerationCauseDescription.ts:149 #: src/view/com/composer/labels/LabelsBtn.tsx:123 @@ -1555,7 +1555,7 @@ msgstr "" msgid "Already have a code?" msgstr "" -#: src/components/WelcomeModal.tsx:192 +#: src/components/WelcomeModal.tsx:189 msgid "Already have an account?" msgstr "" @@ -1609,7 +1609,7 @@ msgstr "" msgid "An error has occurred" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:448 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:434 msgid "An error occurred" msgstr "" @@ -1626,7 +1626,7 @@ msgstr "" msgid "An error occurred while fetching the feed." msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:374 +#: src/components/StarterPack/ProfileStarterPacks.tsx:377 msgid "An error occurred while generating your starter pack. Want to try again?" msgstr "" @@ -1679,7 +1679,7 @@ msgstr "" msgid "An illustration depicting user avatars flowing from a contact book into the Bluesky app" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:35 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:45 msgid "An illustration of several Bluesky posts alongside repost, like, and comment icons" msgstr "" @@ -2114,8 +2114,8 @@ msgid "Before creating a post or replying, you must first verify your email." msgstr "" #: src/components/dialogs/StarterPackDialog.tsx:72 -#: src/components/StarterPack/ProfileStarterPacks.tsx:264 -#: src/components/StarterPack/ProfileStarterPacks.tsx:274 +#: src/components/StarterPack/ProfileStarterPacks.tsx:267 +#: src/components/StarterPack/ProfileStarterPacks.tsx:277 #: src/view/screens/Profile.tsx:350 msgid "Before creating a starter pack, you must first verify your email." msgstr "" @@ -2130,10 +2130,10 @@ msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:155 #: src/components/dms/MessageProfileButton.tsx:60 -#: src/screens/Messages/ChatList.tsx:169 -#: src/screens/Messages/ChatList.tsx:187 -#: src/screens/Messages/ChatList.tsx:288 -#: src/screens/Messages/ChatList.tsx:544 +#: src/screens/Messages/ChatList.tsx:168 +#: src/screens/Messages/ChatList.tsx:186 +#: src/screens/Messages/ChatList.tsx:287 +#: src/screens/Messages/ChatList.tsx:543 #: src/screens/Messages/Conversation.tsx:203 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:99 msgid "Before you can message another user, you must first verify your email." @@ -2277,7 +2277,7 @@ msgstr "" msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." msgstr "" -#: src/screens/Profile/Sections/Labels.tsx:203 +#: src/screens/Profile/Sections/Labels.tsx:204 msgid "Blocking does not prevent this labeler from placing labels on your account." msgstr "" @@ -2353,7 +2353,7 @@ msgstr "" msgid "Bluesky stores your contacts as encoded data. Removing your contacts will immediately delete this data." msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:340 +#: src/components/StarterPack/ProfileStarterPacks.tsx:343 msgid "Bluesky will choose a set of recommended accounts from people in your network." msgstr "" @@ -2503,7 +2503,7 @@ msgstr "" msgid "By you" msgstr "" -#: src/view/com/composer/photos/OpenCameraBtn.tsx:69 +#: src/view/com/composer/photos/OpenCameraBtn.tsx:65 msgid "Camera" msgstr "" @@ -2580,7 +2580,7 @@ msgstr "" msgid "Cancel reply" msgstr "Cancel reply" -#: src/screens/Search/Shell.tsx:595 +#: src/screens/Search/Shell.tsx:584 msgid "Cancel search" msgstr "" @@ -2738,9 +2738,9 @@ msgstr "" msgid "Chat not found." msgstr "Chat not found." -#: src/screens/Messages/ChatList.tsx:570 -#: src/screens/Messages/ChatList.tsx:605 -#: src/screens/Messages/ChatList.tsx:652 +#: src/screens/Messages/ChatList.tsx:569 +#: src/screens/Messages/ChatList.tsx:604 +#: src/screens/Messages/ChatList.tsx:651 msgid "Chat options" msgstr "Chat options" @@ -2763,10 +2763,10 @@ msgstr "" #: src/components/dms/ConvoMenu.tsx:88 #: src/Navigation.tsx:515 -#: src/screens/Messages/ChatList.tsx:98 -#: src/screens/Messages/ChatList.tsx:102 -#: src/screens/Messages/ChatList.tsx:672 -#: src/screens/Messages/ChatList.tsx:682 +#: src/screens/Messages/ChatList.tsx:97 +#: src/screens/Messages/ChatList.tsx:101 +#: src/screens/Messages/ChatList.tsx:671 +#: src/screens/Messages/ChatList.tsx:681 #: src/screens/Messages/Settings.tsx:48 msgid "Chat settings" msgstr "" @@ -2793,9 +2793,9 @@ msgctxt "toast" msgid "Chat unmuted" msgstr "" -#: src/screens/Messages/ChatList.tsx:92 -#: src/screens/Messages/ChatList.tsx:556 -#: src/screens/Messages/ChatList.tsx:596 +#: src/screens/Messages/ChatList.tsx:91 +#: src/screens/Messages/ChatList.tsx:555 +#: src/screens/Messages/ChatList.tsx:595 msgid "Chats" msgstr "" @@ -2832,7 +2832,7 @@ msgstr "" msgid "Choose Feeds" msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:349 +#: src/components/StarterPack/ProfileStarterPacks.tsx:352 msgid "Choose for me" msgstr "" @@ -3022,8 +3022,8 @@ msgstr "" msgid "Close" msgstr "" -#: src/components/Dialog/index.web.tsx:127 -#: src/components/Dialog/index.web.tsx:317 +#: src/components/Dialog/index.web.tsx:129 +#: src/components/Dialog/index.web.tsx:339 msgid "Close active dialog" msgstr "" @@ -3085,7 +3085,7 @@ msgstr "Close the incoming requests banner" msgid "Close this dialog" msgstr "" -#: src/components/WelcomeModal.tsx:217 +#: src/components/WelcomeModal.tsx:214 msgid "Close welcome modal" msgstr "" @@ -3278,7 +3278,7 @@ msgid "Content promoting or depicting self-harm" msgstr "" #: src/components/moderation/ModerationDetailsDialog.tsx:70 -#: src/components/moderation/ScreenHider.tsx:100 +#: src/components/moderation/ScreenHider.tsx:98 #: src/lib/moderation/useGlobalLabelStrings.ts:22 #: src/lib/moderation/useModerationCauseDescription.ts:46 msgid "Content Warning" @@ -3288,7 +3288,7 @@ msgstr "" msgid "Content warnings" msgstr "" -#: src/components/Menu/index.web.tsx:91 +#: src/components/Menu/index.web.tsx:93 msgid "Context menu backdrop, click to close the menu." msgstr "" @@ -3363,7 +3363,7 @@ msgstr "Conversation not found." msgid "Copied build version to clipboard" msgstr "" -#: src/screens/Search/Shell.tsx:515 +#: src/screens/Search/Shell.tsx:504 msgid "Copied link to clipboard" msgstr "Copied link to clipboard" @@ -3602,7 +3602,7 @@ msgstr "cows pigs" #: src/components/dialogs/StarterPackDialog.tsx:113 #: src/components/dialogs/StarterPackDialog.tsx:210 #: src/components/dms/InitiateChatFlow.tsx:502 -#: src/components/StarterPack/ProfileStarterPacks.tsx:329 +#: src/components/StarterPack/ProfileStarterPacks.tsx:332 msgid "Create" msgstr "" @@ -3619,8 +3619,8 @@ msgstr "" msgid "Create a QR code for a starter pack" msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:207 -#: src/components/StarterPack/ProfileStarterPacks.tsx:316 +#: src/components/StarterPack/ProfileStarterPacks.tsx:210 +#: src/components/StarterPack/ProfileStarterPacks.tsx:319 #: src/Navigation.tsx:551 msgid "Create a starter pack" msgstr "" @@ -3630,12 +3630,12 @@ msgstr "" msgid "Create a Starter Pack" msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:303 +#: src/components/StarterPack/ProfileStarterPacks.tsx:306 msgid "Create a starter pack for me" msgstr "" -#: src/components/WelcomeModal.tsx:158 -#: src/components/WelcomeModal.tsx:166 +#: src/components/WelcomeModal.tsx:155 +#: src/components/WelcomeModal.tsx:163 #: src/screens/Messages/JoinRequest.tsx:310 #: src/screens/Signup/index.tsx:141 #: src/view/com/auth/SplashScreen.tsx:106 @@ -3665,7 +3665,7 @@ msgstr "" msgid "Create an avatar instead" msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:214 +#: src/components/StarterPack/ProfileStarterPacks.tsx:217 msgid "Create another" msgstr "" @@ -3997,7 +3997,7 @@ msgstr "Disable" msgid "Disable 2FA" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:414 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:400 msgid "Disable captions" msgstr "Disable captions" @@ -4089,7 +4089,7 @@ msgstr "" msgid "Discover New Feeds" msgstr "" -#: src/components/Dialog/index.tsx:413 +#: src/components/Dialog/index.tsx:414 #: src/features/inviteFriends/components/FollowersPromoBanner.tsx:83 msgid "Dismiss" msgstr "" @@ -4137,7 +4137,7 @@ msgstr "" msgid "Display name" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:23 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:33 msgid "Ditch the trolls and clickbait. Find real people and conversations that matter to you." msgstr "" @@ -4215,7 +4215,7 @@ msgstr "" msgid "Double tap or long press the message to add a reaction" msgstr "" -#: src/components/Dialog/index.tsx:414 +#: src/components/Dialog/index.tsx:415 msgid "Double tap to close the dialog" msgstr "" @@ -4504,7 +4504,7 @@ msgstr "" msgid "Embed this post in your website. Simply copy the following snippet and paste it into the HTML code of your website." msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx:61 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx:64 msgid "Embedded video player" msgstr "" @@ -4528,7 +4528,7 @@ msgstr "" msgid "Enable beta features" msgstr "Enable beta features" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:415 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:401 msgid "Enable captions" msgstr "Enable captions" @@ -4576,7 +4576,7 @@ msgstr "" msgid "Enabled" msgstr "" -#: src/screens/Profile/Sections/Feed.tsx:131 +#: src/screens/Profile/Sections/Feed.tsx:132 msgid "End of feed" msgstr "" @@ -4603,7 +4603,7 @@ msgstr "" msgid "Enter code" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:433 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:419 msgid "Enter fullscreen" msgstr "" @@ -4733,7 +4733,7 @@ msgstr "" msgid "Excludes users you follow" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:432 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:418 msgid "Exit fullscreen" msgstr "" @@ -4750,7 +4750,7 @@ msgstr "" msgid "Expand or collapse the full post you are replying to" msgstr "" -#: src/components/Post/ShowMoreTextButton.tsx:33 +#: src/components/Post/ShowMoreTextButton.tsx:31 msgid "Expand post text" msgstr "" @@ -4798,14 +4798,14 @@ msgid "Explicit sexual images." msgstr "" #: src/Navigation.tsx:760 -#: src/screens/Search/Shell.tsx:552 +#: src/screens/Search/Shell.tsx:541 #: src/view/shell/desktop/LeftNav.tsx:677 #: src/view/shell/Drawer.tsx:473 msgid "Explore" msgstr "" -#: src/components/WelcomeModal.tsx:171 -#: src/components/WelcomeModal.tsx:180 +#: src/components/WelcomeModal.tsx:168 +#: src/components/WelcomeModal.tsx:177 msgid "Explore the app" msgstr "" @@ -4881,7 +4881,7 @@ msgstr "" #: src/components/Lightbox/Lightbox.web.tsx:302 #: src/features/inviteFriends/InviteFriendsDialogInner.tsx:130 -#: src/screens/Search/Shell.tsx:516 +#: src/screens/Search/Shell.tsx:505 msgid "Failed to copy link" msgstr "Failed to copy link" @@ -4969,7 +4969,7 @@ msgctxt "toast" msgid "Failed to leave group chat" msgstr "Failed to leave group chat" -#: src/screens/Messages/ChatList.tsx:421 +#: src/screens/Messages/ChatList.tsx:420 #: src/screens/Messages/Inbox.tsx:202 msgid "Failed to load conversations" msgstr "" @@ -5022,12 +5022,12 @@ msgstr "" msgid "Failed to lock group chat" msgstr "Failed to lock group chat" -#: src/screens/Messages/ChatList.tsx:637 +#: src/screens/Messages/ChatList.tsx:636 #: src/view/shell/bottom-bar/BottomBar.tsx:447 msgid "Failed to mark all chats as read" msgstr "Failed to mark all chats as read" -#: src/screens/Messages/ChatList.tsx:646 +#: src/screens/Messages/ChatList.tsx:645 #: src/screens/Messages/Inbox.tsx:304 #: src/view/shell/bottom-bar/BottomBar.tsx:456 msgid "Failed to mark all requests as read" @@ -5369,7 +5369,7 @@ msgstr "" msgid "Find people you know" msgstr "Find people you know" -#: src/screens/Search/Shell.tsx:765 +#: src/screens/Search/Shell.tsx:753 msgid "Find posts, users, and feeds on Bluesky" msgstr "" @@ -5382,7 +5382,7 @@ msgstr "" msgid "Find your friends on Bluesky by verifying your phone number and matching with your contacts. We protect your information and you control what happens next. <0>Learn more" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:21 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:31 msgid "Find your people" msgstr "" @@ -5623,7 +5623,7 @@ msgstr "" msgid "Forever" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:30 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:40 msgid "Forget the noise" msgstr "" @@ -5642,7 +5642,7 @@ msgstr "" msgid "Four message bubbles representing a group chat. First message: \"Did you hear the news? Bluesky has group chats now!\" Second message: \"omg, no way\" Third message: \"Wow, 50 people in one chat!\" Fourth message: \"You can send invite links too!\"" msgstr "Four message bubbles representing a group chat. First message: \"Did you hear the news? Bluesky has group chats now!\" Second message: \"omg, no way\" Third message: \"Wow, 50 people in one chat!\" Fourth message: \"You can send invite links too!\"" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:12 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:22 msgid "Free your feed" msgstr "" @@ -5669,7 +5669,7 @@ msgstr "" msgid "From these people" msgstr "From these people" -#: src/components/StarterPack/ProfileStarterPacks.tsx:337 +#: src/components/StarterPack/ProfileStarterPacks.tsx:340 msgid "Generate a starter pack" msgstr "" @@ -5808,9 +5808,9 @@ msgstr "" #: src/components/dialogs/LinkWarning.tsx:127 #: src/components/dialogs/LinkWarning.tsx:133 -#: src/components/Layout/Header/index.tsx:133 -#: src/components/moderation/ScreenHider.tsx:161 -#: src/components/moderation/ScreenHider.tsx:170 +#: src/components/Layout/Header/index.tsx:134 +#: src/components/moderation/ScreenHider.tsx:157 +#: src/components/moderation/ScreenHider.tsx:166 #: src/screens/Login/components/AuthLayout/Header/index.tsx:74 #: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:160 #: src/screens/Login/components/ConfirmHostingProviderDialog.tsx:163 @@ -6145,7 +6145,7 @@ msgstr "" #: src/components/interstitials/Trending.tsx:133 #: src/components/interstitials/TrendingVideos.tsx:139 -#: src/components/moderation/ContentHider.tsx:220 +#: src/components/moderation/ContentHider.tsx:217 #: src/components/moderation/LabelPreference.tsx:141 #: src/components/moderation/PostHider.tsx:140 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:820 @@ -6235,7 +6235,7 @@ msgstr "" msgid "Hide verification badges" msgstr "" -#: src/components/moderation/ContentHider.tsx:171 +#: src/components/moderation/ContentHider.tsx:168 #: src/components/moderation/PostHider.tsx:94 msgid "Hides the content" msgstr "" @@ -6543,7 +6543,7 @@ msgstr "In-app, push, everyone" msgid "In-app, push, people you follow" msgstr "In-app, push, people you follow" -#: src/screens/Messages/ChatList.tsx:438 +#: src/screens/Messages/ChatList.tsx:437 msgid "Inbox empty" msgstr "Inbox empty" @@ -6826,11 +6826,11 @@ msgid "KWS website" msgstr "" #. placeholder {0}: sanitizeDisplayName(desc.source!) -#: src/components/moderation/ContentHider.tsx:251 +#: src/components/moderation/ContentHider.tsx:245 msgid "Labeled by {0}." msgstr "" -#: src/components/moderation/ContentHider.tsx:249 +#: src/components/moderation/ContentHider.tsx:243 msgid "Labeled by the author." msgstr "" @@ -6847,7 +6847,7 @@ msgstr "" msgid "Labels applied to this post" msgstr "Labels applied to this post" -#: src/screens/Profile/Sections/Labels.tsx:194 +#: src/screens/Profile/Sections/Labels.tsx:195 msgid "Labels are annotations on users and content. They can be used to hide, warn, and categorize the network." msgstr "" @@ -6899,7 +6899,7 @@ msgctxt "english-only-resource" msgid "Learn more" msgstr "" -#: src/components/moderation/ScreenHider.tsx:147 +#: src/components/moderation/ScreenHider.tsx:143 msgid "Learn More" msgstr "" @@ -6932,8 +6932,8 @@ msgstr "" msgid "Learn more about self hosting your PDS." msgstr "" -#: src/components/moderation/ContentHider.tsx:169 -#: src/components/moderation/ContentHider.tsx:235 +#: src/components/moderation/ContentHider.tsx:166 +#: src/components/moderation/ContentHider.tsx:230 msgid "Learn more about the moderation applied to this content" msgstr "" @@ -6946,7 +6946,7 @@ msgid "Learn more about these changes and how to share your thoughts with us by msgstr "" #: src/components/moderation/PostHider.tsx:116 -#: src/components/moderation/ScreenHider.tsx:134 +#: src/components/moderation/ScreenHider.tsx:132 msgid "Learn more about this warning" msgstr "" @@ -6970,7 +6970,7 @@ msgid "Learn more in your <0>account settings." msgstr "" #: src/components/dialogs/ServerInput.tsx:222 -#: src/components/moderation/ContentHider.tsx:259 +#: src/components/moderation/ContentHider.tsx:253 #: src/screens/Login/components/HostingProviderDialog.tsx:243 msgid "Learn more." msgstr "" @@ -7037,7 +7037,7 @@ msgstr "Left group chat." msgid "left to go." msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:354 +#: src/components/StarterPack/ProfileStarterPacks.tsx:357 msgid "Let me choose" msgstr "" @@ -7288,7 +7288,7 @@ msgid "Load new notifications" msgstr "" #: src/screens/Profile/ProfileFeed/index.tsx:206 -#: src/screens/Profile/Sections/Feed.tsx:117 +#: src/screens/Profile/Sections/Feed.tsx:118 #: src/screens/ProfileList/FeedSection.tsx:113 #: src/view/com/feeds/FeedPage.tsx:168 msgid "Load new posts" @@ -7382,7 +7382,7 @@ msgstr "Love GIFs" msgid "Make adjustments to email settings for your account" msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:311 +#: src/components/StarterPack/ProfileStarterPacks.tsx:314 msgid "Make one for me" msgstr "" @@ -7413,15 +7413,15 @@ msgstr "Manual" msgid "Mark all as read" msgstr "" -#: src/screens/Messages/ChatList.tsx:656 -#: src/screens/Messages/ChatList.tsx:660 +#: src/screens/Messages/ChatList.tsx:655 +#: src/screens/Messages/ChatList.tsx:659 #: src/view/shell/bottom-bar/BottomBar.tsx:465 #: src/view/shell/bottom-bar/BottomBar.tsx:469 msgid "Mark all chats as read" msgstr "Mark all chats as read" -#: src/screens/Messages/ChatList.tsx:664 -#: src/screens/Messages/ChatList.tsx:668 +#: src/screens/Messages/ChatList.tsx:663 +#: src/screens/Messages/ChatList.tsx:667 #: src/view/shell/bottom-bar/BottomBar.tsx:473 #: src/view/shell/bottom-bar/BottomBar.tsx:477 msgid "Mark all requests as read" @@ -7436,12 +7436,12 @@ msgstr "" msgid "Marked all as read" msgstr "" -#: src/screens/Messages/ChatList.tsx:634 +#: src/screens/Messages/ChatList.tsx:633 #: src/view/shell/bottom-bar/BottomBar.tsx:444 msgid "Marked all chats as read" msgstr "Marked all chats as read" -#: src/screens/Messages/ChatList.tsx:643 +#: src/screens/Messages/ChatList.tsx:642 #: src/view/shell/bottom-bar/BottomBar.tsx:453 msgid "Marked all requests as read" msgstr "Marked all requests as read" @@ -7888,11 +7888,11 @@ msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:169 #: src/components/dms/dialogs/NewChatDialog.tsx:184 +#: src/screens/Messages/ChatList.tsx:231 #: src/screens/Messages/ChatList.tsx:232 -#: src/screens/Messages/ChatList.tsx:233 +#: src/screens/Messages/ChatList.tsx:455 #: src/screens/Messages/ChatList.tsx:456 -#: src/screens/Messages/ChatList.tsx:457 -#: src/screens/Messages/ChatList.tsx:581 +#: src/screens/Messages/ChatList.tsx:580 msgid "New chat" msgstr "" @@ -8052,7 +8052,7 @@ msgstr "" msgid "Night" msgstr "Night" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:32 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:42 msgid "No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention." msgstr "" @@ -8138,7 +8138,7 @@ msgstr "" msgid "No messages yet" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:14 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:24 msgid "No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you." msgstr "" @@ -8179,12 +8179,12 @@ msgstr "No one can send messages" msgid "No posts here" msgstr "" -#: src/screens/Profile/Sections/Feed.tsx:81 +#: src/screens/Profile/Sections/Feed.tsx:82 #: src/view/screens/Profile.tsx:430 msgid "No posts yet" msgstr "" -#: src/view/com/post-thread/PostQuotes.tsx:107 +#: src/view/com/post-thread/PostQuotes.tsx:114 msgid "No quotes yet" msgstr "" @@ -8273,7 +8273,7 @@ msgstr "" msgid "Nobody has liked this yet. Maybe you should be the first!" msgstr "" -#: src/view/com/post-thread/PostQuotes.tsx:109 +#: src/view/com/post-thread/PostQuotes.tsx:116 msgid "Nobody has quoted this yet. Maybe you should be the first!" msgstr "" @@ -8514,7 +8514,7 @@ msgstr "Only posts with videos" msgid "Only replies" msgstr "Only replies" -#: src/view/com/composer/videos/SubtitleFilePicker.tsx:41 +#: src/view/com/composer/videos/SubtitleFilePicker.tsx:39 msgid "Only WebVTT (.vtt) files are supported" msgstr "" @@ -8527,8 +8527,8 @@ msgid "Oops, this profile doesn't exist. Try scanning another one." msgstr "Oops, this profile doesn't exist. Try scanning another one." #: src/components/Lists.tsx:184 -#: src/components/StarterPack/ProfileStarterPacks.tsx:363 -#: src/components/StarterPack/ProfileStarterPacks.tsx:372 +#: src/components/StarterPack/ProfileStarterPacks.tsx:366 +#: src/components/StarterPack/ProfileStarterPacks.tsx:375 #: src/screens/Settings/AppPasswords.tsx:59 #: src/screens/Settings/components/ChangeHandleDialog.tsx:108 #: src/view/screens/Profile.tsx:132 @@ -8565,7 +8565,7 @@ msgstr "" msgid "Open draft" msgstr "" -#: src/components/Layout/Header/index.tsx:167 +#: src/components/Layout/Header/index.tsx:168 msgid "Open drawer menu" msgstr "" @@ -8694,7 +8694,7 @@ msgstr "" msgid "Opens alt text dialog" msgstr "" -#: src/view/com/composer/photos/OpenCameraBtn.tsx:70 +#: src/view/com/composer/photos/OpenCameraBtn.tsx:66 msgid "Opens camera on device" msgstr "" @@ -8912,7 +8912,7 @@ msgid "Password updated!" msgstr "" #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:168 -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:395 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:381 msgid "Pause" msgstr "" @@ -8920,7 +8920,7 @@ msgstr "" msgid "Pause GIF" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:346 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:332 msgid "Pause video" msgstr "" @@ -9058,7 +9058,7 @@ msgid "Pinned to your feeds" msgstr "" #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:168 -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:396 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:382 msgid "Play" msgstr "" @@ -9072,7 +9072,7 @@ msgid "Play GIF" msgstr "" #: src/components/Post/Embed/VideoEmbed/index.tsx:169 -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:347 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:333 msgid "Play video" msgstr "" @@ -9364,10 +9364,6 @@ msgctxt "toast" msgid "Post pinned" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:67 -msgid "Post saved" -msgstr "" - #: src/state/queries/pinned-post.ts:61 msgctxt "toast" msgid "Post unpinned" @@ -9682,11 +9678,11 @@ msgstr "" msgid "Read the Bluesky Terms of Service" msgstr "" -#: src/components/WelcomeModal.tsx:149 +#: src/components/WelcomeModal.tsx:146 msgid "Real conversations." msgstr "" -#: src/components/WelcomeModal.tsx:147 +#: src/components/WelcomeModal.tsx:144 msgid "Real people." msgstr "" @@ -9743,7 +9739,7 @@ msgstr "" msgid "Reject join request" msgstr "Reject join request" -#: src/screens/Messages/ChatList.tsx:425 +#: src/screens/Messages/ChatList.tsx:424 #: src/screens/Messages/Inbox.tsx:206 msgid "Reload conversations" msgstr "" @@ -9857,7 +9853,7 @@ msgstr "" msgid "Remove from saved feeds" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:143 +#: src/components/PostControls/BookmarkButton.tsx:120 #: src/screens/Bookmarks.tsx:252 msgid "Remove from saved posts" msgstr "" @@ -9938,7 +9934,7 @@ msgstr "" msgid "Removed from saved feeds" msgstr "" -#: src/components/PostControls/BookmarkButton.tsx:107 +#: src/components/PostControls/BookmarkButton.tsx:88 #: src/screens/Bookmarks.tsx:211 msgid "Removed from saved posts" msgstr "" @@ -10353,10 +10349,10 @@ msgstr "" #: src/components/moderation/ReportDialog/index.tsx:297 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:56 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:59 -#: src/components/StarterPack/ProfileStarterPacks.tsx:377 +#: src/components/StarterPack/ProfileStarterPacks.tsx:380 #: src/screens/Login/LoginForm.tsx:531 #: src/screens/Login/LoginForm.tsx:537 -#: src/screens/Messages/ChatList.tsx:430 +#: src/screens/Messages/ChatList.tsx:429 #: src/screens/Messages/components/MessageListError.tsx:24 #: src/screens/Messages/Inbox.tsx:211 #: src/screens/Messages/JoinRequests.tsx:361 @@ -10515,8 +10511,8 @@ msgstr "" msgid "Say hello!" msgstr "" -#: src/screens/Messages/ChatList.tsx:223 -#: src/screens/Messages/ChatList.tsx:447 +#: src/screens/Messages/ChatList.tsx:222 +#: src/screens/Messages/ChatList.tsx:446 msgid "Say hi to someone" msgstr "Say hi to someone" @@ -10559,9 +10555,9 @@ msgstr "" #: src/components/forms/SearchInput.tsx:53 #: src/screens/Search/components/AdvancedSearchDialog/index.tsx:218 #: src/screens/Search/components/AdvancedSearchDialog/index.tsx:224 -#: src/screens/Search/Shell.tsx:552 -#: src/screens/Search/Shell.tsx:616 -#: src/screens/Search/Shell.tsx:753 +#: src/screens/Search/Shell.tsx:541 +#: src/screens/Search/Shell.tsx:604 +#: src/screens/Search/Shell.tsx:741 #: src/view/shell/bottom-bar/BottomBar.tsx:216 msgid "Search" msgstr "" @@ -10775,8 +10771,8 @@ msgstr "" msgid "Select app language" msgstr "" -#: src/view/com/composer/videos/SubtitleFilePicker.tsx:60 -#: src/view/com/composer/videos/SubtitleFilePicker.tsx:67 +#: src/view/com/composer/videos/SubtitleFilePicker.tsx:58 +#: src/view/com/composer/videos/SubtitleFilePicker.tsx:65 msgid "Select caption file (.vtt)" msgstr "Select caption file (.vtt)" @@ -11168,8 +11164,8 @@ msgstr "" msgid "Share this feed" msgstr "" -#: src/screens/Search/Shell.tsx:568 -#: src/screens/Search/Shell.tsx:638 +#: src/screens/Search/Shell.tsx:557 +#: src/screens/Search/Shell.tsx:626 msgid "Share this search" msgstr "Share this search" @@ -11214,7 +11210,7 @@ msgstr "Sharing your age range reveals only the range associated with your Apple msgid "Sharing your age range reveals only the range associated with your Google Account – for example, that you’re at least 18. <0>Your exact age and birthday are never shared, and this data never leaves this device. Therefore, it only enables access on this device. Alternatively, <1>you can use our trusted partner, KWS, to complete your verification and enable access on all platforms." msgstr "Sharing your age range reveals only the range associated with your Google Account – for example, that you’re at least 18. <0>Your exact age and birthday are never shared, and this data never leaves this device. Therefore, it only enables access on this device. Alternatively, <1>you can use our trusted partner, KWS, to complete your verification and enable access on all platforms." -#: src/components/moderation/ContentHider.tsx:220 +#: src/components/moderation/ContentHider.tsx:217 #: src/components/moderation/LabelPreference.tsx:143 #: src/components/moderation/PostHider.tsx:140 msgid "Show" @@ -11224,8 +11220,8 @@ msgstr "" msgid "Show alt text" msgstr "" -#: src/components/moderation/ScreenHider.tsx:179 -#: src/components/moderation/ScreenHider.tsx:182 +#: src/components/moderation/ScreenHider.tsx:175 +#: src/components/moderation/ScreenHider.tsx:178 #: src/features/liveNow/components/LiveStatusDialog.tsx:318 #: src/features/liveNow/components/LiveStatusDialog.tsx:322 #: src/screens/List/ListHiddenScreen.tsx:194 @@ -11269,9 +11265,9 @@ msgstr "" msgid "Show lists of users to select from" msgstr "" -#: src/components/Post/ShowMoreTextButton.tsx:52 -msgid "Show More" -msgstr "" +#: src/components/Post/ShowMoreTextButton.tsx:50 +msgid "Show more" +msgstr "Show more" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:594 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:596 @@ -11336,15 +11332,15 @@ msgstr "" msgid "Shows other accounts you can switch to" msgstr "" -#: src/components/moderation/ContentHider.tsx:172 +#: src/components/moderation/ContentHider.tsx:169 #: src/components/moderation/PostHider.tsx:94 msgid "Shows the content" msgstr "" #: src/components/dialogs/Signin.tsx:98 #: src/components/dialogs/Signin.tsx:100 -#: src/components/WelcomeModal.tsx:197 -#: src/components/WelcomeModal.tsx:209 +#: src/components/WelcomeModal.tsx:194 +#: src/components/WelcomeModal.tsx:206 #: src/screens/Hashtag.tsx:231 #: src/screens/Login/index.tsx:143 #: src/screens/Login/index.tsx:165 @@ -11431,7 +11427,7 @@ msgstr "" msgid "Sign up" msgstr "Sign up" -#: src/components/moderation/ScreenHider.tsx:98 +#: src/components/moderation/ScreenHider.tsx:96 #: src/lib/moderation/useGlobalLabelStrings.ts:28 msgid "Sign-in Required" msgstr "" @@ -11494,7 +11490,7 @@ msgstr "" msgid "So many thoughts, you should post one" msgstr "" -#: src/components/WelcomeModal.tsx:151 +#: src/components/WelcomeModal.tsx:148 msgid "Social media you control." msgstr "" @@ -11588,7 +11584,7 @@ msgid "Something went wrong, please try again" msgstr "" #: src/screens/Moderation/index.tsx:112 -#: src/screens/Profile/Sections/Labels.tsx:184 +#: src/screens/Profile/Sections/Labels.tsx:185 #: src/screens/Settings/BetaFeaturesSettings.tsx:80 #: src/screens/Settings/components/BetaFeaturesFeedbackDialog.tsx:84 msgid "Something went wrong, please try again." @@ -11715,7 +11711,7 @@ msgstr "" msgid "Starter Packs" msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:295 +#: src/components/StarterPack/ProfileStarterPacks.tsx:298 msgid "Starter packs let you easily share your favorite feeds and people with your friends." msgstr "" @@ -11806,7 +11802,7 @@ msgid "Subscribe to {publicationTitle} on {0}" msgstr "Subscribe to {publicationTitle} on {0}" #. placeholder {0}: labelerInfo.creator.handle -#: src/screens/Profile/Sections/Labels.tsx:231 +#: src/screens/Profile/Sections/Labels.tsx:232 msgid "Subscribe to @{0} to use these labels:" msgstr "" @@ -12108,7 +12104,7 @@ msgstr "" msgid "That username is already taken" msgstr "" -#: src/view/com/post-thread/PostQuotes.tsx:135 +#: src/view/com/post-thread/PostQuotes.tsx:142 msgid "That's all, folks!" msgstr "" @@ -12387,7 +12383,7 @@ msgstr "They own this chat" msgid "They won’t be able to rejoin unless you invite them again." msgstr "They won’t be able to rejoin unless you invite them again." -#: src/components/moderation/ScreenHider.tsx:118 +#: src/components/moderation/ScreenHider.tsx:116 msgid "This {screenDescription} has been flagged:" msgstr "" @@ -12403,7 +12399,7 @@ msgstr "This account has been marked as automated by its owner." msgid "This account has one or more attempted verifications, but it is not currently verified." msgstr "" -#: src/components/moderation/ScreenHider.tsx:113 +#: src/components/moderation/ScreenHider.tsx:111 msgid "This account has requested that users sign in to view their profile." msgstr "" @@ -12591,7 +12587,7 @@ msgstr "" msgid "This label was applied to the entire user account and will appear on all posts." msgstr "This label was applied to the entire user account and will appear on all posts." -#: src/screens/Profile/Sections/Labels.tsx:218 +#: src/screens/Profile/Sections/Labels.tsx:219 msgid "This labeler hasn't declared what labels it publishes, and may not be active." msgstr "" @@ -13187,7 +13183,7 @@ msgstr "Unmute this group chat" msgid "Unmute thread" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:344 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:330 msgid "Unmute video" msgstr "" @@ -13253,7 +13249,7 @@ msgstr "" msgid "Unsubscribed from list" msgstr "" -#: src/view/com/composer/text-input/TextInput.tsx:128 +#: src/view/com/composer/text-input/TextInput.tsx:127 msgid "Unsupported clipboard content" msgstr "Unsupported clipboard content" @@ -13725,7 +13721,7 @@ msgid "View details" msgstr "" #: src/view/com/posts/ViewFullThread.tsx:31 -#: src/view/com/posts/ViewFullThread.tsx:64 +#: src/view/com/posts/ViewFullThread.tsx:65 msgid "View full thread" msgstr "" @@ -14156,7 +14152,7 @@ msgid "Who can verify?" msgstr "" #: src/screens/Home/NoFeedsPinned.tsx:80 -#: src/screens/Messages/ChatList.tsx:410 +#: src/screens/Messages/ChatList.tsx:409 #: src/screens/Messages/Inbox.tsx:191 msgid "Whoops!" msgstr "" @@ -14558,7 +14554,7 @@ msgstr "" msgid "You have unsaved changes. Would you like to save them before viewing your drafts?" msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:292 +#: src/components/StarterPack/ProfileStarterPacks.tsx:295 msgid "You haven't created a starter pack yet!" msgstr "" @@ -14617,7 +14613,7 @@ msgstr "You must be a chat owner to remove a member." msgid "You must be at least 13 years old to use Bluesky. Read our <0>Terms of Service for more information." msgstr "" -#: src/components/StarterPack/ProfileStarterPacks.tsx:365 +#: src/components/StarterPack/ProfileStarterPacks.tsx:368 msgid "You must be following at least seven other people to generate a starter pack." msgstr "" @@ -14975,11 +14971,11 @@ msgid "Your preferred language" msgstr "" #: src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx:102 -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx:53 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx:54 msgid "Your profile picture" msgstr "" -#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:26 +#: src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx:36 msgid "Your profile picture surrounded by concentric circles of other users' profile pictures" msgstr "" diff --git a/src/platform/misc.android-check.d.ts b/src/platform/misc.android-check.d.ts new file mode 100644 index 0000000000..9c6361b5fb --- /dev/null +++ b/src/platform/misc.android-check.d.ts @@ -0,0 +1,13 @@ +/* + * Used ONLY by the Android typecheck pass (tsconfig.check.android.json) - it + * is excluded from the other passes and has no runtime effect. + * + * react-native-device-attest's Android variant implements only the Play + * Integrity statics, so the iOS-only getDeviceCheckToken (whose app call + * site is IS_IOS-guarded) fails to resolve under .android module suffixes. + * Pin the module to its platform-neutral base class, which declares the + * full API surface. + */ +declare module 'react-native-device-attest' { + export {DeviceAttestBase as default} from 'react-native-device-attest/build/DeviceAttestBase' +} diff --git a/src/platform/misc.web-check.d.ts b/src/platform/misc.web-check.d.ts new file mode 100644 index 0000000000..a592015d56 --- /dev/null +++ b/src/platform/misc.web-check.d.ts @@ -0,0 +1,102 @@ +/* + * Used ONLY by the web typecheck pass (tsconfig.check.web.json) - it is + * excluded from the other passes and has no runtime effect. + * + * Some packages publish .web.d.ts declarations that diverge from the API + * the app is written against (or, worse, raw TypeScript sources that break + * under `moduleSuffixes: [".web", ""]`), so this pins them to their native + * declarations. react-native-svg has the same problem, handled by deleting + * its divergent web declarations in patches/react-native-svg@15.12.1.patch + * so resolution falls through to the native ones. + */ + +/* + * Side-effect CSS imports in .web files are handled by the bundler. + */ +declare module '*.css' + +/* + * expo-file-system's declarations build File/Directory on top of + * `./ExpoFileSystem`, which remaps to a web shim whose classes are empty. + * The fully-typed base classes live in ExpoFileSystem.types (no .web + * sibling), so mirror the FileSystem.d.ts wrapper classes on top of those. + */ +declare module 'expo-file-system' { + import { + Directory as ExpoFileSystemDirectory, + File as ExpoFileSystemFile, + } from 'expo-file-system/build/ExpoFileSystem.types' + export { + type DirectoryCreateOptions, + type DirectoryInfo, + type DownloadOptions, + EncodingType, + type FileCreateOptions, + FileHandle, + type FileInfo, + type FileWriteOptions, + type InfoOptions, + type PathInfo, + } from 'expo-file-system/build/ExpoFileSystem.types' + import {type PathInfo as ExpoPathInfo} from 'expo-file-system/build/ExpoFileSystem.types' + import {PathUtilities} from 'expo-file-system/build/pathUtilities' + + export class Paths extends PathUtilities { + static get cache(): Directory + static get bundle(): Directory + static get document(): Directory + static get appleSharedContainers(): Record + static get totalDiskSpace(): number + static get availableDiskSpace(): number + static info(...uris: string[]): ExpoPathInfo + } + + export class File extends ExpoFileSystemFile implements Blob { + constructor(...uris: (string | File | Directory)[]) + get parentDirectory(): Directory + get extension(): string + get name(): string + readableStream(): ReadableStream> + writableStream(): WritableStream> + arrayBuffer(): Promise + stream(): ReadableStream> + slice(start?: number, end?: number, contentType?: string): Blob + } + + export class Directory extends ExpoFileSystemDirectory { + constructor(...uris: (string | File | Directory)[]) + get parentDirectory(): Directory + list(): (Directory | File)[] + get name(): string + createFile(name: string, mimeType: string | null): File + createDirectory(name: string): Directory + } +} + +/* + * expo-image-manipulator's web declarations export the module class where + * the native ones export an instance, hiding instance methods such as + * `manipulate` from `typeof ImageManipulator`. Mirror the native surface. + */ +declare module 'expo-image-manipulator' { + import {type ImageManipulator as ImageManipulatorModule} from 'expo-image-manipulator/build/ImageManipulator.types' + export const ImageManipulator: ImageManipulatorModule + export { + manipulateAsync, + useImageManipulator, + } from 'expo-image-manipulator/build/ImageManipulator' + export { + type Action, + type ActionCrop, + type ActionExtent, + type ActionFlip, + type ActionResize, + type ActionRotate, + FlipType, + type ImageResult, + SaveFormat, + type SaveOptions, + } from 'expo-image-manipulator/build/ImageManipulator.types' + export {type ImageManipulatorContext} from 'expo-image-manipulator/build/ImageManipulatorContext' + export {type ImageRef} from 'expo-image-manipulator/build/ImageRef' +} diff --git a/src/routes.ts b/src/routes.ts index 3e45f1b379..c996dfde9b 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -61,6 +61,7 @@ export const router = new Router({ AboutSettings: '/settings/about', AppIconSettings: '/settings/app-icon', NotificationSettings: '/settings/notifications', + ActivityNotificationSettings: '/settings/notifications/activity', FindContactsSettings: '/settings/find-contacts', // support Support: '/support', diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 26e225cbd2..b7eb113a49 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -1,6 +1,5 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {View} from 'react-native' -import {useAnimatedRef} from 'react-native-reanimated' import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import { @@ -275,7 +274,7 @@ export function ChatList({ const t = useTheme() const {t: l} = useLingui() const aa = useAgeAssurance() - const scrollElRef: ListRef = useAnimatedRef() + const scrollElRef: ListRef = useRef(null) const {isWithinSplitView} = useIsWithinSplitView() const openChatControl = useCallback(() => { diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index a3468878a5..794740d0b8 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -17,7 +17,6 @@ import Animated, { runOnJS, type ScrollEvent, type SharedValue, - useAnimatedRef, useAnimatedStyle, useDerivedValue, useSharedValue, @@ -154,7 +153,7 @@ export function MessagesList({ const t = useTheme() const textInputId = 'chat-input-' + useId() - const flatListRef = useAnimatedRef() + const flatListRef = useRef(null) const [expandedGroups, setExpandedGroups] = useState>( () => new Set(), diff --git a/src/screens/Onboarding/StepFindContacts/index.web.tsx b/src/screens/Onboarding/StepFindContacts/index.web.tsx index dc9b4d0ef7..f062156839 100644 --- a/src/screens/Onboarding/StepFindContacts/index.web.tsx +++ b/src/screens/Onboarding/StepFindContacts/index.web.tsx @@ -1,3 +1,8 @@ -export function StepFindContacts() { +import {type Action, type State} from '#/components/contacts/state' + +export function StepFindContacts(_props: { + flowState: State + flowDispatch: React.ActionDispatch<[Action]> +}): never { throw new Error('StepFindContacts is not available on web') } diff --git a/src/screens/Onboarding/StepFindContactsIntro/index.web.tsx b/src/screens/Onboarding/StepFindContactsIntro/index.web.tsx index bb58f202b9..64194890f4 100644 --- a/src/screens/Onboarding/StepFindContactsIntro/index.web.tsx +++ b/src/screens/Onboarding/StepFindContactsIntro/index.web.tsx @@ -1,3 +1,3 @@ -export function StepFindContactsIntro() { +export function StepFindContactsIntro(): never { throw new Error('StepFindContactsIntro is not available on web') } diff --git a/src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx b/src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx index 479657bb3a..5b0da24ccf 100644 --- a/src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx +++ b/src/screens/Onboarding/StepFinished/ValuePropositionPager.shared.tsx @@ -4,6 +4,16 @@ import {useLingui} from '@lingui/react' import {atoms as a, useTheme} from '#/alf' +export type ValuePropositionPagerProps = { + step: 0 | 1 | 2 + /** + * Only the native pager changes pages itself (by swiping); the web pager + * is driven entirely by the step prop. + */ + setStep: (step: 0 | 1 | 2) => void + avatarUri?: string +} + export function useValuePropText(step: 0 | 1 | 2) { const {_} = useLingui() diff --git a/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx b/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx index 1641547bb2..f617c01888 100644 --- a/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx +++ b/src/screens/Onboarding/StepFinished/ValuePropositionPager.tsx @@ -8,17 +8,17 @@ import {useLingui} from '@lingui/react' import {atoms as a, tokens, useTheme} from '#/alf' import {Text} from '#/components/Typography' import {PROP_1, PROP_2, PROP_3} from './images' -import {Dot, useValuePropText} from './ValuePropositionPager.shared' +import { + Dot, + useValuePropText, + type ValuePropositionPagerProps, +} from './ValuePropositionPager.shared' export function ValuePropositionPager({ step, setStep, avatarUri, -}: { - step: 0 | 1 | 2 - setStep: (step: 0 | 1 | 2) => void - avatarUri?: string -}) { +}: ValuePropositionPagerProps) { const t = useTheme() const [activePage, setActivePage] = useState(step) const ref = useRef(null) diff --git a/src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx b/src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx index a7c685506b..ba71a5681f 100644 --- a/src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx +++ b/src/screens/Onboarding/StepFinished/ValuePropositionPager.web.tsx @@ -6,15 +6,16 @@ import {useLingui} from '@lingui/react' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' import {PROP_1, PROP_2, PROP_3} from './images' -import {Dot, useValuePropText} from './ValuePropositionPager.shared' +import { + Dot, + useValuePropText, + type ValuePropositionPagerProps, +} from './ValuePropositionPager.shared' export function ValuePropositionPager({ step, avatarUri, -}: { - step: 0 | 1 | 2 - avatarUri?: string -}) { +}: ValuePropositionPagerProps) { const t = useTheme() const {_} = useLingui() diff --git a/src/screens/Profile/KnownFollowers.tsx b/src/screens/Profile/KnownFollowers.tsx index 32a5076829..09c1034b07 100644 --- a/src/screens/Profile/KnownFollowers.tsx +++ b/src/screens/Profile/KnownFollowers.tsx @@ -34,7 +34,7 @@ function renderItem({ ) } -function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { +function keyExtractor(item: AppBskyActorDefs.ProfileView) { return item.did } diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx index 37ced06bbc..1f6863ff36 100644 --- a/src/screens/Profile/Sections/Feed.tsx +++ b/src/screens/Profile/Sections/Feed.tsx @@ -1,5 +1,5 @@ import {useCallback, useEffect, useImperativeHandle, useState} from 'react' -import {findNodeHandle, View} from 'react-native' +import {View} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -17,6 +17,7 @@ import { type EmptyStateButtonProps, } from '#/view/com/util/EmptyState' import {type ListRef} from '#/view/com/util/List' +import {findListNativeTag} from '#/view/com/util/listNativeTag' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {atoms as a, ios, useTheme} from '#/alf' import {EditBig_Stroke1_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' @@ -87,7 +88,7 @@ export function ProfileFeedSection({ useEffect(() => { if (IS_IOS && isFocused && scrollElRef.current) { - const nativeTag = findNodeHandle(scrollElRef.current) + const nativeTag = findListNativeTag(scrollElRef.current) setScrollViewTag(nativeTag) } }, [isFocused, scrollElRef, setScrollViewTag]) diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx index e4af8c4a86..8c70d40392 100644 --- a/src/screens/Profile/Sections/Labels.tsx +++ b/src/screens/Profile/Sections/Labels.tsx @@ -1,5 +1,5 @@ import {useCallback, useEffect, useImperativeHandle, useMemo} from 'react' -import {findNodeHandle, type ListRenderItemInfo, View} from 'react-native' +import {type ListRenderItemInfo, View} from 'react-native' import { type AppBskyLabelerDefs, type InterpretedLabelValueDefinition, @@ -12,6 +12,7 @@ import {Trans} from '@lingui/react/macro' import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' import {List, type ListRef} from '#/view/com/util/List' +import {findListNativeTag} from '#/view/com/util/listNativeTag' import {atoms as a, ios, tokens, useTheme} from '#/alf' import {Divider} from '#/components/Divider' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' @@ -61,7 +62,7 @@ export function ProfileLabelsSection({ useEffect(() => { if (IS_IOS && isFocused && scrollElRef.current) { - const nativeTag = findNodeHandle(scrollElRef.current) + const nativeTag = findListNativeTag(scrollElRef.current) setScrollViewTag(nativeTag) } }, [isFocused, scrollElRef, setScrollViewTag]) diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index 0afc72e1a6..95f58645ad 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -1169,7 +1169,7 @@ export function Explore({ ) } -function keyExtractor(item: FeedPreviewItem) { +function keyExtractor(item: ExploreScreenItems) { return item.key } diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index 951d415a87..681ee25273 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -474,17 +474,6 @@ export function SearchScreenShell({ } }, [setShowAutocomplete]) - const onSearchInputBlur = useCallback(() => { - /* - * Bind autocomplete visibility to focus state on native. On web this - * doesn't work because of focus management, which would render the - * autocomplete results uninteractable. - */ - if (IS_NATIVE) { - setShowAutocomplete(false) - } - }, []) - const focusSearchInput = useCallback( (tab?: TabParam) => { textInput.current?.focus() @@ -609,7 +598,6 @@ export function SearchScreenShell({ ref={textInput} value={searchText} onFocus={onSearchInputFocus} - onBlur={onSearchInputBlur} onChangeText={onChangeText} onClearText={onPressClearQuery} onSubmitEditing={onSubmit('typed')} diff --git a/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx b/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx index c7707d23fd..4c2585bccc 100644 --- a/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx +++ b/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx @@ -1 +1,3 @@ -export function SettingsListItem() {} +export function SettingsListItem() { + return null +} diff --git a/src/screens/Settings/AppIconSettings/index.web.tsx b/src/screens/Settings/AppIconSettings/index.web.tsx index d8c4d9bea7..cff23d49c9 100644 --- a/src/screens/Settings/AppIconSettings/index.web.tsx +++ b/src/screens/Settings/AppIconSettings/index.web.tsx @@ -1,3 +1,3 @@ -export function AppIconSettingsScreen() { +export function AppIconSettingsScreen(): never { throw new Error('Not supported on web') } diff --git a/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx new file mode 100644 index 0000000000..168593ebe8 --- /dev/null +++ b/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx @@ -0,0 +1,264 @@ +import {useCallback, useMemo} from 'react' +import {type ListRenderItemInfo, Text as RNText, View} from 'react-native' +import {type ModerationOpts} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {Trans} from '@lingui/react/macro' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import { + type AllNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActivitySubscriptionsQuery} from '#/state/queries/activity-subscriptions' +import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings' +import {List} from '#/view/com/util/List' +import {atoms as a, useTheme} from '#/alf' +import {SubscribeProfileDialog} from '#/components/activity-notifications/SubscribeProfileDialog' +import * as Admonition from '#/components/Admonition' +import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import { + BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon, + BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon, +} from '#/components/icons/BellRinging' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import {ListFooter} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import * as SettingsList from '../components/SettingsList' +import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle' +import {PreferenceControls} from './components/PreferenceControls' + +type Props = NativeStackScreenProps< + AllNavigatorParams, + 'ActivityNotificationSettings' +> + +export function ActivityNotificationSettingsScreen({}: Props) { + const t = useTheme() + const {t: l} = useLingui() + const {data: preferences, isError: isPreferencesError} = + useNotificationSettingsQuery() + const moderationOpts = useModerationOpts() + + const { + data: subscriptions, + isPending, + isError: isSubscriptionsError, + error, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useActivitySubscriptionsQuery() + + const items = useMemo(() => { + if (!subscriptions) return [] + return subscriptions.pages.flatMap(page => page.subscriptions) + }, [subscriptions]) + + const renderItem = useCallback( + ({item}: ListRenderItemInfo) => { + if (!moderationOpts) return null + return ( + + ) + }, + [moderationOpts], + ) + + const onEndReached = useCallback(() => { + if (isFetchingNextPage || !hasNextPage || isSubscriptionsError) return + void fetchNextPage().catch(err => { + logger.error('Failed to load more activity subscriptions', { + message: err, + }) + }) + }, [isFetchingNextPage, hasNextPage, isSubscriptionsError, fetchNextPage]) + + return ( + + + + + + Notifications + + + + + + + + + + {isPreferencesError ? ( + + + Failed to load notification settings. + + + ) : ( + + + + )} + + } + data={items} + keyExtractor={keyExtractor} + renderItem={renderItem} + onEndReached={onEndReached} + onEndReachedThreshold={4} + ListEmptyComponent={ + error ? null : ( + + {!isPending ? ( + + + + + + + Enable notifications for an account by visiting their + profile and pressing the{' '} + + bell icon + {' '} + + . + + + + + If you want to restrict who can receive notifications + for your account's activity, you can change this in{' '} + + Settings → Privacy and Security + + . + + + + + + ) : ( + + + + )} + + ) + } + ListFooterComponent={ + + } + windowSize={11} + /> + + ) +} + +function keyExtractor(item: bsky.profile.AnyProfileView) { + return item.did +} + +function ActivitySubscriptionCard({ + profile: profileUnshadowed, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts +}) { + const profile = useProfileShadow(profileUnshadowed) + const control = useDialogControl() + const {t: l} = useLingui() + const t = useTheme() + + const preview = useMemo(() => { + const actSub = profile.viewer?.activitySubscription + if (actSub?.post && actSub?.reply) { + return l`Posts, Replies` + } else if (actSub?.post) { + return l`Posts` + } else if (actSub?.reply) { + return l`Replies` + } + return l`None` + }, [l, profile.viewer?.activitySubscription]) + + return ( + + + + + + + + {preview} + + + + + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx index 00318a9180..16671508b2 100644 --- a/src/screens/Settings/NotificationSettings/index.tsx +++ b/src/screens/Settings/NotificationSettings/index.tsx @@ -57,7 +57,6 @@ export function NotificationSettingsScreen({}: Props) { const mentionDialogControl = Dialog.useDialogControl() const quoteDialogControl = Dialog.useDialogControl() const repostDialogControl = Dialog.useDialogControl() - const activityDialogControl = Dialog.useDialogControl() const likeRepostDialogControl = Dialog.useDialogControl() const repostRepostDialogControl = Dialog.useDialogControl() const chatDialogControl = Dialog.useDialogControl() @@ -217,9 +216,9 @@ export function NotificationSettingsScreen({}: Props) { showSkeleton={!settings} /> - - + Get notifications when people repost your posts. } /> - Activity from others} - subtitleText={ - - Get notifications when there's activity on posts you're subscribed - to. - - } - allowDisableInApp={false} - /> {label} diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.shared.ts b/src/screens/Signup/StepCaptcha/CaptchaWebView.shared.ts new file mode 100644 index 0000000000..7a4c02b45d --- /dev/null +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.shared.ts @@ -0,0 +1,10 @@ +import {type SignupState} from '#/screens/Signup/state' + +export type CaptchaWebViewProps = { + url: string + stateParam: string + state?: SignupState + onComplete: () => void + onSuccess: (code: string) => void + onError: (error: unknown) => void +} diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx index fa7b3064ee..bf0b9a2483 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -2,7 +2,7 @@ import {useEffect, useMemo, useRef} from 'react' import {WebView, type WebViewNavigation} from 'react-native-webview' import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' -import {type SignupState} from '#/screens/Signup/state' +import {type CaptchaWebViewProps} from './CaptchaWebView.shared' const ALLOWED_HOSTS = [ 'bsky.social', @@ -24,14 +24,7 @@ export function CaptchaWebView({ onComplete, onSuccess, onError, -}: { - url: string - stateParam: string - state?: SignupState - onComplete: () => void - onSuccess: (code: string) => void - onError: (error: unknown) => void -}) { +}: CaptchaWebViewProps) { const startedAt = useRef(Date.now()) const successTo = useRef(undefined) diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx index 3518400779..b8c3832607 100644 --- a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx @@ -1,6 +1,8 @@ import {useCallback, useEffect} from 'react' import {StyleSheet} from 'react-native' +import {type CaptchaWebViewProps} from './CaptchaWebView.shared' + // @ts-ignore web only, we will always redirect to the app on web (CORS) const REDIRECT_HOST = new URL(window.location.href).host @@ -9,12 +11,7 @@ export function CaptchaWebView({ stateParam, onSuccess, onError, -}: { - url: string - stateParam: string - onSuccess: (code: string) => void - onError: (error: unknown) => void -}) { +}: CaptchaWebViewProps) { useEffect(() => { const timeout = setTimeout(() => { onError({ diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx index 61a4f45ef4..4aef058b27 100644 --- a/src/screens/StarterPack/Wizard/StepProfiles.tsx +++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx @@ -1,7 +1,7 @@ import {useState} from 'react' import {type ListRenderItemInfo, View} from 'react-native' import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' -import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' +import {type ModerationOpts} from '@atproto/api' import {Trans} from '@lingui/react/macro' import {useA11y} from '#/state/a11y' @@ -18,7 +18,7 @@ import {Text} from '#/components/Typography' import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' -function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { +function keyExtractor(item: bsky.profile.AnyProfileView) { return item?.did ?? '' } diff --git a/src/view/com/composer/photos/EditImageDialog.shared.ts b/src/view/com/composer/photos/EditImageDialog.shared.ts new file mode 100644 index 0000000000..8c9af6a098 --- /dev/null +++ b/src/view/com/composer/photos/EditImageDialog.shared.ts @@ -0,0 +1,10 @@ +import {type ComposerImage} from '#/state/gallery' +import type * as Dialog from '#/components/Dialog' + +export type EditImageDialogProps = { + control: Dialog.DialogOuterProps['control'] + image?: ComposerImage + onChange: (next: ComposerImage) => void + aspectRatio?: number + circularCrop?: boolean +} diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx index 9a0eb90848..d81c0c0b33 100644 --- a/src/view/com/composer/photos/EditImageDialog.tsx +++ b/src/view/com/composer/photos/EditImageDialog.tsx @@ -1,13 +1,6 @@ -import {type ComposerImage} from '#/state/gallery' -import type * as Dialog from '#/components/Dialog' +import {type EditImageDialogProps} from './EditImageDialog.shared' -export type EditImageDialogProps = { - control: Dialog.DialogOuterProps['control'] - image?: ComposerImage - onChange: (next: ComposerImage) => void - aspectRatio?: number - circularCrop?: boolean -} +export type {EditImageDialogProps} from './EditImageDialog.shared' export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => { return null diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index 60f4fc1fa2..6a3f65602c 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -16,7 +16,7 @@ import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Loader} from '#/components/Loader' -import {type EditImageDialogProps} from './EditImageDialog' +import {type EditImageDialogProps} from './EditImageDialog.shared' export function EditImageDialog(props: EditImageDialogProps) { return ( diff --git a/src/view/com/composer/photos/OpenCameraBtn.shared.ts b/src/view/com/composer/photos/OpenCameraBtn.shared.ts new file mode 100644 index 0000000000..513db09140 --- /dev/null +++ b/src/view/com/composer/photos/OpenCameraBtn.shared.ts @@ -0,0 +1,6 @@ +import {type ComposerImage} from '#/state/gallery' + +export type OpenCameraBtnProps = { + disabled?: boolean + onAdd: (next: ComposerImage[]) => void +} diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 320a1b7d03..6365765fc9 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -6,18 +6,14 @@ import {useLingui} from '@lingui/react' import {useCameraPermission} from '#/lib/hooks/usePermissions' import {openCamera} from '#/lib/media/picker' import {logger} from '#/logger' -import {type ComposerImage, createComposerImage} from '#/state/gallery' +import {createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' import {IS_NATIVE, IS_WEB_MOBILE} from '#/env' +import {type OpenCameraBtnProps} from './OpenCameraBtn.shared' -type Props = { - disabled?: boolean - onAdd: (next: ComposerImage[]) => void -} - -export function OpenCameraBtn({disabled, onAdd}: Props) { +export function OpenCameraBtn({disabled, onAdd}: OpenCameraBtnProps) { const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const [mediaPermissionRes, requestMediaPermission] = diff --git a/src/view/com/composer/photos/OpenCameraBtn.web.tsx b/src/view/com/composer/photos/OpenCameraBtn.web.tsx index 226de1f60b..5ca2d54120 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.web.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.web.tsx @@ -1,3 +1,5 @@ -export function OpenCameraBtn() { +import {type OpenCameraBtnProps} from './OpenCameraBtn.shared' + +export function OpenCameraBtn(_props: OpenCameraBtnProps) { return null } diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 313f0becbf..d40e06a215 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -6,10 +6,9 @@ import { useState, } from 'react' import { - type NativeSyntheticEvent, Text as RNText, TextInput as RNTextInput, - type TextInputSelectionChangeEventData, + type TextInputSelectionChangeEvent, View, } from 'react-native' import {type PasteEventPayload, TextInputWrapper} from 'expo-paste-input' @@ -141,7 +140,7 @@ export function TextInput({ ) const onSelectionChange = useCallback( - (evt: NativeSyntheticEvent) => { + (evt: TextInputSelectionChangeEvent) => { // NOTE we track the input selection using a ref to avoid excessive renders -prf textInputSelection.current = evt.nativeEvent.selection }, @@ -150,7 +149,7 @@ export function TextInput({ const onSelectAutocompleteItem = useCallback( (item: string) => { - onChangeText( + void onChangeText( insertMentionAt( richtext.text, textInputSelection.current?.start || 0, @@ -201,7 +200,9 @@ export function TextInput({ style={[ inputTextStyle, { - color: segment.facet ? t.palette.primary_500 : t.atoms.text.color, + color: segment.facet + ? t.atoms.text_link.color + : t.atoms.text.color, marginTop: -1, }, ]}> @@ -217,7 +218,7 @@ export function TextInput({ void onChangeText(newText)} onSelectionChange={onSelectionChange} placeholder={placeholder} placeholderTextColor={t.atoms.text_contrast_low.color} diff --git a/src/view/com/composer/videos/SubtitleFilePicker.native.tsx b/src/view/com/composer/videos/SubtitleFilePicker.native.tsx index f2b9a7b04a..b799d2dd84 100644 --- a/src/view/com/composer/videos/SubtitleFilePicker.native.tsx +++ b/src/view/com/composer/videos/SubtitleFilePicker.native.tsx @@ -1,3 +1,5 @@ -export function SubtitleFilePicker() { +import {type SubtitleFilePickerProps} from './SubtitleFilePicker.shared' + +export function SubtitleFilePicker(_props: SubtitleFilePickerProps): never { throw new Error('SubtitleFilePicker is a web-only component') } diff --git a/src/view/com/composer/videos/SubtitleFilePicker.shared.ts b/src/view/com/composer/videos/SubtitleFilePicker.shared.ts new file mode 100644 index 0000000000..b4f6f7591c --- /dev/null +++ b/src/view/com/composer/videos/SubtitleFilePicker.shared.ts @@ -0,0 +1,4 @@ +export type SubtitleFilePickerProps = { + onSelectFile: (file: File) => void + disabled?: boolean +} diff --git a/src/view/com/composer/videos/SubtitleFilePicker.tsx b/src/view/com/composer/videos/SubtitleFilePicker.tsx index 44fa582ddf..23277c8681 100644 --- a/src/view/com/composer/videos/SubtitleFilePicker.tsx +++ b/src/view/com/composer/videos/SubtitleFilePicker.tsx @@ -9,14 +9,12 @@ import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC' import * as Toast from '#/components/Toast' +import {type SubtitleFilePickerProps} from './SubtitleFilePicker.shared' export function SubtitleFilePicker({ onSelectFile, disabled, -}: { - onSelectFile: (file: File) => void - disabled?: boolean -}) { +}: SubtitleFilePickerProps) { const {_} = useLingui() const ref = useRef(null) diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx index cb8eb52bec..0a5550f265 100644 --- a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx @@ -1,6 +1,8 @@ +import {type QueryClient} from '@tanstack/react-query' + import {atoms as a, flatten} from '#/alf' -export function clearThumbnailCache() { +export function clearThumbnailCache(_queryClient: QueryClient) { // no-op on web } diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 6eedcd8622..effa9ad430 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -6,7 +6,6 @@ import { useState, } from 'react' import { - findNodeHandle, type ListRenderItemInfo, type StyleProp, useWindowDimensions, @@ -26,6 +25,7 @@ import {useSession} from '#/state/session' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {List, type ListRef} from '#/view/com/util/List' +import {findListNativeTag} from '#/view/com/util/listNativeTag' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {atoms as a, ios, useTheme} from '#/alf' @@ -219,7 +219,7 @@ export function ProfileFeedgens({ useEffect(() => { if (IS_IOS && enabled && scrollElRef.current) { - const nativeTag = findNodeHandle(scrollElRef.current) + const nativeTag = findListNativeTag(scrollElRef.current) setScrollViewTag(nativeTag) } }, [enabled, scrollElRef, setScrollViewTag]) diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index c175793624..afc4c63791 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -6,7 +6,6 @@ import { useState, } from 'react' import { - findNodeHandle, type ListRenderItemInfo, type StyleProp, useWindowDimensions, @@ -26,6 +25,7 @@ import {useSession} from '#/state/session' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {List, type ListRef} from '#/view/com/util/List' +import {findListNativeTag} from '#/view/com/util/listNativeTag' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {atoms as a, ios, useTheme} from '#/alf' @@ -218,7 +218,7 @@ export function ProfileLists({ useEffect(() => { if (IS_IOS && enabled && scrollElRef.current) { - const nativeTag = findNodeHandle(scrollElRef.current) + const nativeTag = findListNativeTag(scrollElRef.current) setScrollViewTag(nativeTag) } }, [enabled, scrollElRef, setScrollViewTag]) diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index ebe5432a33..cf15639791 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react' import {View} from 'react-native' +import {type SharedValue, useSharedValue} from 'react-native-reanimated' import {flushSync} from 'react-dom' import {s} from '#/lib/styles' @@ -19,7 +20,9 @@ export interface PagerRef { export interface RenderTabBarFnProps { selectedPage: number onSelect?: (index: number) => void - tabBarAnchor?: JSX.Element + tabBarAnchor?: JSX.Element | null | undefined // Ignored on native. + dragProgress: SharedValue // Ignored on web. + dragState: SharedValue<'idle' | 'dragging' | 'settling'> // Ignored on web. } export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element @@ -27,7 +30,17 @@ interface Props { ref?: React.Ref initialPage?: number renderTabBar: RenderTabBarFn + // tab pressed, yet to scroll to page + onTabPressed?: (index: number) => void + // scroll settled onPageSelected?: (index: number) => void + /** + * Never fires on web - pages switch instantly, there is no drag gesture. + */ + onPageScrollStateChanged?: ( + scrollState: 'idle' | 'dragging' | 'settling', + ) => void + testID?: string } export function Pager({ @@ -35,12 +48,22 @@ export function Pager({ children, initialPage = 0, renderTabBar, + onTabPressed, onPageSelected, + onPageScrollStateChanged: _onPageScrollStateChanged, + testID, }: React.PropsWithChildren) { const [selectedPage, setSelectedPage] = useState(initialPage) const scrollYs = useRef>([]) const anchorRef = useRef(null) + /* + * There is no drag gesture on web; these exist to satisfy the shared + * RenderTabBarFnProps contract and never change. + */ + const dragProgress = useSharedValue(selectedPage) + const dragState = useSharedValue<'idle' | 'dragging' | 'settling'>('idle') + useImperativeHandle(ref, () => ({ setPage: (index: number) => { onTabBarSelect(index) @@ -59,6 +82,7 @@ export function Pager({ : -scrollY // If there's no anchor, treat the top of the page as one. const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable. + onTabPressed?.(index) if (isSticking) { scrollYs.current[selectedPage] = window.scrollY } else { @@ -77,15 +101,17 @@ export function Pager({ } } }, - [selectedPage, setSelectedPage, onPageSelected], + [selectedPage, setSelectedPage, onPageSelected, onTabPressed], ) return ( - + {renderTabBar({ selectedPage, tabBarAnchor: , onSelect: e => onTabBarSelect(e), + dragProgress, + dragState, })} {Children.map(children, (child, i) => ( void + setMinimumHeight: (height: number) => void }) => JSX.Element initialPage?: number onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void + allowHeaderOverScroll?: boolean // Ignored on web. } export const PagerWithHeader = forwardRef( function PageWithHeaderImpl( @@ -127,7 +128,7 @@ let PagerTabBar = ({ renderHeader?: ({ setMinimumHeight, }: { - setMinimumHeight: () => void + setMinimumHeight: (height: number) => void }) => JSX.Element isHeaderReady: boolean onCurrentPageSelected?: (index: number) => void diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx index bacfaedabd..395e0dce07 100644 --- a/src/view/com/pager/TabBar.web.tsx +++ b/src/view/com/pager/TabBar.web.tsx @@ -1,5 +1,6 @@ import {useCallback, useEffect, useRef} from 'react' import {type ScrollView, StyleSheet, View} from 'react-native' +import {type SharedValue} from 'react-native-reanimated' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {Text} from '#/components/Typography' @@ -15,6 +16,14 @@ export interface TabBarProps { onSelect?: (index: number) => void onPressSelected?: (index: number) => void + + /** + * The drag-following indicator and transparent background only exist in + * the native tab bar - accepted here so shared callers typecheck. + */ + dragProgress?: SharedValue // Ignored on web. + dragState?: SharedValue<'idle' | 'dragging' | 'settling'> // Ignored on web. + transparent?: boolean // Ignored on web. } // How much of the previous/next item we're showing diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx index 671334ad3c..b16281f9e0 100644 --- a/src/view/com/post-thread/PostQuotes.tsx +++ b/src/view/com/post-thread/PostQuotes.tsx @@ -17,6 +17,7 @@ import {usePostQuotesQuery} from '#/state/queries/post-quotes' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {Post} from '#/view/com/post/Post' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' +import * as bsky from '#/types/bsky' import {List} from '../util/List' function renderItem({ @@ -70,7 +71,13 @@ export function PostQuotes({uri}: {uri: string}) { data?.pages .flatMap(page => page.posts.map(post => { - if (!AppBskyFeedPost.isRecord(post.record) || !moderationOpts) { + if ( + !bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) || + !moderationOpts + ) { return null } const moderation = moderatePost(post, moderationOpts) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 611eb7c43a..4c2db9eda0 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -28,7 +28,7 @@ function renderItem({ ) } -function keyExtractor(item: ActorDefs.ProfileViewBasic) { +function keyExtractor(item: ActorDefs.ProfileView) { return item.did } diff --git a/src/view/com/posts/ViewFullThread.tsx b/src/view/com/posts/ViewFullThread.tsx index 2a0eb5135f..f346a34396 100644 --- a/src/view/com/posts/ViewFullThread.tsx +++ b/src/view/com/posts/ViewFullThread.tsx @@ -58,7 +58,8 @@ export function ViewFullThread({uri}: {uri: string}) { {/* HACKFIX: Trans isn't working after SDK 53 upgrade -sfn */} {l`View full thread`} diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 32ec6d6deb..07092caed6 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -43,7 +43,7 @@ function renderItem({ ) } -function keyExtractor(item: ActorDefs.ProfileViewBasic) { +function keyExtractor(item: ActorDefs.ProfileView) { return item.did } diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index b2c1908673..9371038aa6 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -39,7 +39,7 @@ function renderItem({ ) } -function keyExtractor(item: ActorDefs.ProfileViewBasic) { +function keyExtractor(item: ActorDefs.ProfileView) { return item.did } diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index 2110c9540e..f3f7743b7e 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -18,12 +18,25 @@ LogBox.ignoreAllLogs() const BTN = {height: 1, width: 1, backgroundColor: 'red'} +/* + * This component is mounted inside in + * App.tsx, so it fully remounts whenever the account changes (sign-in / + * sign-out). If the "proxy configured" flag lived only in React state it would + * reset to false on every remount, hiding the sign-in buttons. Keeping it at + * module level lets it survive remounts so the sign-in buttons stay visible + * across sign-out during multi-account flows. Module state still resets when + * the app relaunches with cleared state at the start of each flow, which is the + * desired gating behavior. + */ +let hasConfiguredProxy = false + export function TestCtrls() { const agent = useAgent() const queryClient = useQueryClient() const {logoutEveryAccount, login} = useSessionApi() const onboardingDispatch = useOnboardingDispatch() const {setShowLoggedOut} = useLoggedOutViewControls() + const [isProxyConfigured, setIsProxyConfigured] = useState(hasConfiguredProxy) const onPressSignInAlice = async () => { console.info('[E2E] Signing in as Alice') await login( @@ -63,21 +76,27 @@ export function TestCtrls() { const header = `${proxyHeader}#bsky_appview` BLUESKY_PROXY_HEADER.set(header) agent.configureProxy(header as any) + hasConfiguredProxy = true + setIsProxyConfigured(true) }} style={BTN} /> - - + {isProxyConfigured && ( + <> + + + + )} logoutEveryAccount('Settings')} diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 441297f120..bc3d3de27a 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -28,8 +28,10 @@ import * as Layout from '#/components/Layout' export type ListMethods = { scrollToTop: () => void - scrollToOffset: (options: {animated: boolean; offset: number}) => void - scrollToEnd: (options?: {animated?: boolean}) => void + // Signature kept compatible with FlatList's scrollToOffset (the native + // ListMethods type) so callers stay platform-agnostic. + scrollToOffset: (options: {animated?: boolean | null; offset: number}) => void + scrollToEnd: (options?: {animated?: boolean | null}) => void // Signature kept compatible with FlatList's scrollToIndex (the native // ListMethods type) so callers stay platform-agnostic. viewOffset is // accepted for parity but not currently used by the web implementation. @@ -40,7 +42,8 @@ export type ListMethods = { viewPosition?: number }) => void } -export type ListProps = Omit< +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ListProps = Omit< FlatListProps, | 'onScroll' // Use ScrollContext instead. | 'refreshControl' // Pass refreshing and/or onRefresh instead. @@ -59,7 +62,7 @@ export type ListProps = Omit< */ sideBorders?: boolean } -export type ListRef = React.RefObject +export type ListRef = React.RefObject const ON_ITEM_SEEN_WAIT_DURATION = 0.5e3 // when we consider post to be "seen" const ON_ITEM_SEEN_INTERSECTION_OPTS = { @@ -248,7 +251,13 @@ function ListImpl( getScrollableNode()?.scrollTo({top: 0}) }, - scrollToOffset({animated, offset}: {animated: boolean; offset: number}) { + scrollToOffset({ + animated, + offset, + }: { + animated?: boolean | null + offset: number + }) { getScrollableNode()?.scrollTo({ left: 0, top: offset, diff --git a/src/view/com/util/listNativeTag.ts b/src/view/com/util/listNativeTag.ts new file mode 100644 index 0000000000..c4580a9ae0 --- /dev/null +++ b/src/view/com/util/listNativeTag.ts @@ -0,0 +1,12 @@ +import {findNodeHandle} from 'react-native' + +import {type ListMethods} from './List' + +/** + * Returns the native view tag backing a List, for handing to native code + * (e.g. the pager's scrollViewTag). Always null on web. + */ +export function findListNativeTag(list: ListMethods | null): number | null { + if (!list) return null + return findNodeHandle(list) +} diff --git a/src/view/com/util/listNativeTag.web.ts b/src/view/com/util/listNativeTag.web.ts new file mode 100644 index 0000000000..e8149cf4e7 --- /dev/null +++ b/src/view/com/util/listNativeTag.web.ts @@ -0,0 +1,9 @@ +import {type ListMethods} from './List' + +/** + * Returns the native view tag backing a List, for handing to native code + * (e.g. the pager's scrollViewTag). Always null on web. + */ +export function findListNativeTag(_list: ListMethods | null): number | null { + return null +} diff --git a/tsconfig.check.android.json b/tsconfig.check.android.json new file mode 100644 index 0000000000..3aa14b5615 --- /dev/null +++ b/tsconfig.check.android.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.check.json", + "compilerOptions": { + /* + * Mirrors Metro's resolution order for platform=android. + */ + "moduleSuffixes": [".android", ".native", ""] + }, + /* + * Narrows the base exclude so the *.android-check.d.ts ambient shims + * apply to this pass. + */ + "exclude": ["src/platform/*.web-check.d.ts"] +} diff --git a/tsconfig.check.ios.json b/tsconfig.check.ios.json new file mode 100644 index 0000000000..c91d6f804e --- /dev/null +++ b/tsconfig.check.ios.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.check.json", + "compilerOptions": { + /* + * Mirrors Metro's resolution order for platform=ios. + */ + "moduleSuffixes": [".ios", ".native", ""] + } +} diff --git a/tsconfig.check.json b/tsconfig.check.json index f9f1370f6d..6a3a0ed03d 100644 --- a/tsconfig.check.json +++ b/tsconfig.check.json @@ -1,4 +1,12 @@ { "extends": "./tsconfig.json", - "include": ["src", "modules", "app.config.js"] + "include": ["src", "modules", "app.config.js"], + /* + * Ambient check shims apply only to the platform pass that includes them + * back (tsconfig.check.android.json, tsconfig.check.web.json). + */ + "exclude": [ + "src/platform/*.android-check.d.ts", + "src/platform/*.web-check.d.ts" + ] } diff --git a/tsconfig.check.web.json b/tsconfig.check.web.json new file mode 100644 index 0000000000..156e51f648 --- /dev/null +++ b/tsconfig.check.web.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "moduleSuffixes": [".web", ""], + "paths": { + "#/*": ["./src/*"], + "crypto": ["./src/platform/crypto.ts"], + /* + * expo-file-system/legacy resolves to the package's raw TypeScript + * source, whose internals break under .web module suffixes. Point it + * at the compiled declarations, where skipLibCheck applies. + */ + "expo-file-system/legacy": [ + "./node_modules/expo-file-system/build/legacy/index.d.ts" + ] + } + }, + "include": [ + "index.web.ts", + "src/**/*.web.ts", + "src/**/*.web.tsx", + "src/platform/*.web-check.d.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json index c3f559d917..2f69ed70af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "jsx": "react-jsx", "module": "esnext", + "moduleSuffixes": [".ios", ".android", ".native", ""], "lib": ["dom", "esnext"], "types": ["node", "jest"], "paths": {