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 53f1097251..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: @@ -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 acfce80f48..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.17.0 - 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 49bc67ada9..a3f43dd99c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -53,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 @@ -91,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 5f568dde78..264ee5d311 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -32,7 +32,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 @@ -73,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 @@ -168,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/oxlint-suppressions.json b/oxlint-suppressions.json index bf1ed59cf4..ddcd835fb7 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -244,14 +244,6 @@ "count": 3 } }, - "src/components/Post/Embed/ImageEmbed.tsx": { - "typescript/no-explicit-any": { - "count": 2 - }, - "typescript/no-floating-promises": { - "count": 1 - } - }, "src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": { "typescript/no-floating-promises": { "count": 2 @@ -290,19 +282,6 @@ "count": 1 } }, - "src/components/PostControls/ShareMenu/ShareMenuItems.tsx": { - "typescript/no-floating-promises": { - "count": 3 - }, - "typescript/no-misused-promises": { - "count": 1 - } - }, - "src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx": { - "typescript/no-floating-promises": { - "count": 3 - } - }, "src/components/PostControls/ShareMenu/index.tsx": { "typescript/no-floating-promises": { "count": 1 @@ -1055,11 +1034,6 @@ "count": 1 } }, - "src/screens/Profile/components/ProfileFeedHeader.tsx": { - "typescript/no-misused-promises": { - "count": 5 - } - }, "src/screens/ProfileList/FeedSection.tsx": { "typescript/no-floating-promises": { "count": 1 @@ -1406,11 +1380,6 @@ "count": 2 } }, - "src/state/queries/feed.ts": { - "typescript/no-floating-promises": { - "count": 1 - } - }, "src/state/queries/handle.ts": { "typescript/no-floating-promises": { "count": 1 @@ -1617,14 +1586,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 @@ -1764,11 +1725,6 @@ "count": 2 } }, - "src/view/com/posts/PostFeed.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "src/view/com/posts/PostFeedErrorMessage.tsx": { "typescript/no-explicit-any": { "count": 1 diff --git a/package.json b/package.json index 3122d2d7bb..244c8f46b4 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,9 @@ "lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", "typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web", - "typecheck:ios": "tsgo --project ./tsconfig.check.ios.json", - "typecheck:android": "tsgo --project ./tsconfig.check.android.json", - "typecheck:web": "tsgo --project ./tsconfig.check.web.json", + "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", @@ -96,12 +96,12 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.28", + "@atproto/api": "0.20.31", "@atproto/common-web": "0.5.6", "@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", @@ -161,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", @@ -273,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", @@ -292,13 +293,13 @@ "jest-junit": "^16.0.0", "lint-staged": "^17.0.8", "oxlint": "^1.73.0", - "oxlint-tsgolint": "^0.24.0", + "oxlint-tsgolint": "^7.0.2001", "prettier": "^3.8.3", "react-native-dotenv": "^3.4.11", "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/pnpm-lock.yaml b/pnpm-lock.yaml index 563652a1fb..3b4f945d0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.28 - version: 0.20.28 + specifier: 0.20.31 + version: 0.20.31 '@atproto/common-web': specifier: 0.5.6 version: 0.5.6 @@ -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) @@ -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) @@ -826,10 +829,10 @@ importers: version: runtime:24.18.0 oxlint: specifier: ^1.73.0 - version: 1.73.0(oxlint-tsgolint@0.24.0) + version: 1.73.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: - specifier: ^0.24.0 - version: 0.24.0 + specifier: ^7.0.2001 + version: 7.0.2001 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -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 @@ -862,8 +865,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.28': - resolution: {integrity: sha512-/Rvk8zt9mtRi9tlMD2Qg+NG2lMj3B0HDjmfswR5724pH7GsOEMDDHwleVlmOBSrgACIyVSa5tdCDJ+R+SEhwww==} + '@atproto/api@0.20.31': + resolution: {integrity: sha512-TovCQLQv5ti1jqh8UH6jJ0EFuWRjGdUtFyFR5xYC/IkIulwHDuyrVaXdCv7VLWiHftp92DevtZnFRm+BZsZZdw==} engines: {node: '>=22'} '@atproto/common-web@0.5.6': @@ -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: '*' @@ -2242,33 +2245,33 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxlint-tsgolint/darwin-arm64@0.24.0': - resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.24.0': - resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.24.0': - resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.24.0': - resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.24.0': - resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.24.0': - resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} cpu: [x64] os: [win32] @@ -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': @@ -7060,8 +7140,8 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxlint-tsgolint@0.24.0: - resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true oxlint@1.73.0: @@ -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 @@ -9123,7 +9208,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.28': + '@atproto/api@0.20.31': dependencies: '@atproto/common-web': 0.5.6 '@atproto/lexicon': 0.7.7 @@ -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: @@ -11003,22 +11088,22 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@oxlint-tsgolint/darwin-arm64@0.24.0': + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/darwin-x64@0.24.0': + '@oxlint-tsgolint/darwin-x64@7.0.2001': optional: true - '@oxlint-tsgolint/linux-arm64@0.24.0': + '@oxlint-tsgolint/linux-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/linux-x64@0.24.0': + '@oxlint-tsgolint/linux-x64@7.0.2001': optional: true - '@oxlint-tsgolint/win32-arm64@0.24.0': + '@oxlint-tsgolint/win32-arm64@7.0.2001': optional: true - '@oxlint-tsgolint/win32-x64@0.24.0': + '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true '@oxlint/binding-android-arm-eabi@1.73.0': @@ -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: @@ -16340,16 +16458,16 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxlint-tsgolint@0.24.0: + oxlint-tsgolint@7.0.2001: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.24.0 - '@oxlint-tsgolint/darwin-x64': 0.24.0 - '@oxlint-tsgolint/linux-arm64': 0.24.0 - '@oxlint-tsgolint/linux-x64': 0.24.0 - '@oxlint-tsgolint/win32-arm64': 0.24.0 - '@oxlint-tsgolint/win32-x64': 0.24.0 + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.73.0(oxlint-tsgolint@0.24.0): + oxlint@1.73.0(oxlint-tsgolint@7.0.2001): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.73.0 '@oxlint/binding-android-arm64': 1.73.0 @@ -16370,7 +16488,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.73.0 '@oxlint/binding-win32-ia32-msvc': 1.73.0 '@oxlint/binding-win32-x64-msvc': 1.73.0 - oxlint-tsgolint: 0.24.0 + oxlint-tsgolint: 7.0.2001 p-limit@2.3.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/pnpm-workspace.yaml b/pnpm-workspace.yaml index c917be629a..a90336d4b1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,3 +43,11 @@ patchedDependencies: minimumReleaseAgeExclude: - '@atproto/*' - '@bsky.app/*' + # todo: remove when old enough + - '@oxlint-tsgolint/darwin-arm64@7.0.2001' + - '@oxlint-tsgolint/darwin-x64@7.0.2001' + - '@oxlint-tsgolint/linux-arm64@7.0.2001' + - '@oxlint-tsgolint/linux-x64@7.0.2001' + - '@oxlint-tsgolint/win32-arm64@7.0.2001' + - '@oxlint-tsgolint/win32-x64@7.0.2001' + - oxlint-tsgolint@7.0.2001 diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 5207a3a123..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} diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 7792ec5e84..4fee04450b 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -20,6 +20,7 @@ export enum Features { PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable', VideoMultipartUploadEnable = 'video:multipart_upload:enable', + SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index dc80eadeb9..dd28a51799 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -513,7 +513,7 @@ export type Events = { | 'ProgressGuide' location: 'Card' | 'Profile' | 'FollowAll' recSource?: 'Search' - recId?: number | string + recId?: string position: number suggestedDid: string category: string | null @@ -526,7 +526,7 @@ export type Events = { | 'ProfileHeader' | 'Onboarding' | 'SeeMoreSuggestedUsers' - recId?: number | string + recId?: string position: number suggestedDid: string category: string | null @@ -541,7 +541,7 @@ export type Events = { | 'SeeMoreSuggestedUsers' | 'ProgressGuide' recSource?: 'Search' - recId?: number | string + recId?: string position: number suggestedDid: string category: string | null @@ -553,11 +553,11 @@ export type Events = { | 'ProfileInterstitial' | 'ProfileHeader' | 'Onboarding' - recId?: number | string + recId?: string } 'suggestedUser:dismiss': { logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader' - recId?: number | string + recId?: string position: number suggestedDid: string } @@ -608,7 +608,7 @@ export type Events = { // Group chat adoption 'groupchat:create': { - logContext: 'NewChatDialog' + logContext: 'NewChatDialog' | 'SendViaChatDialog' } 'groupchat:landingPage:view': { hasSession: boolean @@ -746,9 +746,7 @@ export type Events = { } 'trendingTopic:click': { context: 'sidebar' | 'interstitial' | 'explore' - } - 'recommendedTopic:click': { - context: 'explore' + recId?: string } 'trendingVideos:show': { context: 'settings' @@ -782,13 +780,13 @@ export type Events = { } 'search:results:loaded': { - tab: 'top' | 'latest' | 'people' | 'feeds' + tab: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks' initialCount: number } 'search:result:press': { - tab?: 'top' | 'latest' | 'people' | 'feeds' - resultType: 'post' | 'profile' | 'feed' + tab?: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks' + resultType: 'post' | 'profile' | 'feed' | 'starterPack' position: number uri: string } @@ -1349,6 +1347,26 @@ export type Events = { // user dismissed the empty-followers promo banner 'invite:followersPromo:dismiss': {} + /** + * Fired when a video fails terminally during playback: unreachable (404), + * undecodable, or the client lacks the required codecs. Complements the + * Sentry-only video.playback spans with a countable, unsampled event. + */ + 'video:playback:failed': { + surface: 'feed' | 'immersiveFeed' + presentation: 'video' | 'gif' + /** + * Coarse failure bucket: VideoNotFoundError, HLSUnsupportedError, an + * hls.js error details code (e.g. bufferAppendError), or PlayerError on + * native. + */ + errorClass: string + /** Truncated to 256 chars */ + errorMessage: string + /** HLS playlist URL, identifies the exact video for server-side lookup */ + playlist: string + } + // === Video upload funnel (Frontend Spec section D) === // Every event carries uploadId (client-generated UUID, ties one upload // session end-to-end) + engine (compression engine id, e.g. diff --git a/src/components/BetaBadge.tsx b/src/components/BetaBadge.tsx new file mode 100644 index 0000000000..115f8d0046 --- /dev/null +++ b/src/components/BetaBadge.tsx @@ -0,0 +1,104 @@ +import {useState} from 'react' +import {type Insets, Pressable, View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' +import * as Tooltip from '#/components/Tooltip' +import type * as bsky from '#/types/bsky' + +/** + * Whether to show the beta badge for a given profile. Only shown on the + * viewer's own profile, and only when the viewer has opted in to beta features. + */ +export function useIsBetaBadgeVisible( + profile: bsky.profile.AnyProfileView, +): boolean { + const {currentAccount} = useSession() + const {data: preferences} = usePreferencesQuery() + const isBetaUser = preferences?.bskyAppState?.isBetaUser ?? false + const isSelf = currentAccount?.did === profile.did + + return isSelf && isBetaUser +} + +export function BetaBadge({ + profile, + width, + padding, +}: { + profile: bsky.profile.AnyProfileView + width: number + padding: number +}) { + const t = useTheme() + const isVisible = useIsBetaBadgeVisible(profile) + + if (!isVisible) return null + + return ( + + + + ) +} + +export function BetaBadgeButton({ + profile, + width, + padding, + hitSlop, +}: { + profile: bsky.profile.AnyProfileView + width: number + padding: number + hitSlop: Insets +}) { + const t = useTheme() + const {t: l} = useLingui() + const isVisible = useIsBetaBadgeVisible(profile) + + const [tooltipVisible, setTooltipVisible] = useState(false) + + if (!isVisible) return null + + return ( + + + [ + a.rounded_full, + a.transition_transform, + { + backgroundColor: t.palette.primary_50, + padding, + transform: [ + { + scale: hovered ? 1.1 : 1, + }, + ], + }, + ]} + onPress={() => setTooltipVisible(v => !v)}> + + + + + Beta features enabled + + + ) +} diff --git a/src/components/BotBadge.tsx b/src/components/BotBadge.tsx index 83da49bc4e..b5e6f867b6 100644 --- a/src/components/BotBadge.tsx +++ b/src/components/BotBadge.tsx @@ -1,4 +1,4 @@ -import {View} from 'react-native' +import {type Insets, View} from 'react-native' import {type ComAtprotoLabelDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' @@ -44,9 +44,11 @@ export function BotBadge({ export function BotBadgeButton({ profile, width, + hitSlop, }: { profile: bsky.profile.AnyProfileView width: number + hitSlop: Insets }) { const t = useTheme() const ax = useAnalytics() @@ -61,7 +63,7 @@ export function BotBadgeButton({ <> 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/PostControls/ShareMenu/ShareMenuItems.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx index 627034f5d6..dc66340fd3 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.tsx @@ -1,9 +1,7 @@ import {memo, useMemo} from 'react' import * as ExpoClipboard from 'expo-clipboard' import {AtUri} 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 {useQueryClient} from '@tanstack/react-query' @@ -37,7 +35,7 @@ let ShareMenuItems = ({ }: ShareMenuItemsProps): React.ReactNode => { const ax = useAnalytics() const {hasSession} = useSession() - const {_} = useLingui() + const {t: l} = useLingui() const navigation = useNavigation() const sendViaChatControl = useDialogControl() const [devModeEnabled] = useDevMode() @@ -61,7 +59,7 @@ let ShareMenuItems = ({ const onSharePost = () => { ax.metric('share:press:nativeShare', {}) const url = toShareUrl(href) - shareUrl(url) + void shareUrl(url) onShareProp() } @@ -74,7 +72,7 @@ let ShareMenuItems = ({ } else { await ExpoClipboard.setStringAsync(url) } - Toast.show(_(msg`Copied to clipboard`), { + Toast.show(l`Copied to clipboard`, { type: 'success', }) onShareProp() @@ -93,11 +91,11 @@ let ShareMenuItems = ({ } const onShareATURI = () => { - shareText(postUri) + void shareText(postUri) } const onShareAuthorDID = () => { - shareText(postAuthor.did) + void shareText(postAuthor.did) } return ( @@ -113,13 +111,13 @@ let ShareMenuItems = ({ { ax.metric('share:press:openDmSearch', {}) sendViaChatControl.open() }}> - Send via direct message + Send via chat @@ -129,7 +127,7 @@ let ShareMenuItems = ({ Share via... @@ -139,8 +137,8 @@ let ShareMenuItems = ({ + label={l`Copy link to post`} + onPress={() => void onCopyLink()}> Copy link to post @@ -164,7 +162,7 @@ let ShareMenuItems = ({ Share post at:// URI @@ -173,7 +171,7 @@ let ShareMenuItems = ({ Share author DID @@ -183,7 +181,6 @@ let ShareMenuItems = ({ )} - () const embedPostControl = useDialogControl() const sendViaChatControl = useDialogControl() @@ -60,7 +58,7 @@ let ShareMenuItems = ({ const onCopyLink = () => { ax.metric('share:press:copyLink', {}) const url = toShareUrl(href) - shareUrl(url) + void shareUrl(url) onShareProp() } @@ -75,17 +73,17 @@ let ShareMenuItems = ({ const canEmbed = IS_WEB && gtMobile && !hideInPWI const onShareATURI = () => { - shareText(postUri) + void shareText(postUri) } const onShareAuthorDID = () => { - shareText(postAuthor.did) + void shareText(postAuthor.did) } const copyLinkItem = ( Copy link to post @@ -102,13 +100,13 @@ let ShareMenuItems = ({ {hasSession && aa.state.access === aa.Access.Full && ( { ax.metric('share:press:openDmSearch', {}) sendViaChatControl.open() }}> - Send via direct message + Send via chat @@ -117,12 +115,12 @@ let ShareMenuItems = ({ {canEmbed && ( { ax.metric('share:press:embed', {}) embedPostControl.open() }}> - {_(msg`Embed post`)} + {l`Embed post`} )} @@ -142,7 +140,7 @@ let ShareMenuItems = ({ Copy post at:// URI @@ -151,7 +149,7 @@ let ShareMenuItems = ({ Copy author DID @@ -161,7 +159,6 @@ let ShareMenuItems = ({ )} - {canEmbed && ( )} - = { xl: 23, } as const +const betaIconSizes: Record = { + xs: 8, + sm: 8, + md: 8, + lg: 10, + xl: 12, +} as const + +const betaBadgePadding: Record = { + xs: 1, + sm: 2, + md: 3, + lg: 4, + xl: 5, +} as const + export function ProfileBadges({ profile, interactive = false, @@ -41,13 +59,19 @@ export function ProfileBadges({ }) { const shadowed = useProfileShadow(profile) const verification = useSimpleVerificationState({profile}) + const badgeVisibility = [ + verification.showBadge, + useIsBetaBadgeVisible(profile), + isBotAccount(shadowed), + ] + const badgeCount = badgeVisibility.filter(Boolean).length const nativeScaleMultiplier = useNativeFontScale() const { fonts: {scaleMultiplier: alfScaleMultiplier}, } = useAlf() // if nothing to show, don't render the container at all - if (!verification.showBadge && !isBotAccount(shadowed)) return null + if (badgeCount < 1) return null const isOnTheSmallSide = size === 'xs' || size === 'sm' @@ -57,31 +81,57 @@ export function ProfileBadges({ const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier const botIconWidth = botIconSizes[size] * scaleMultiplier + const betaIconWidth = betaIconSizes[size] * scaleMultiplier + const betaBadgeScaledPadding = betaBadgePadding[size] * scaleMultiplier + + const gap = isOnTheSmallSide ? a.gap_2xs : a.gap_xs + const padding = gap.gap / 2 + let visibleBadgeIndex = 0 + const hitSlops = badgeVisibility.map(isVisible => { + if (!isVisible) return HITSLOP_20 + + const index = visibleBadgeIndex++ + return { + ...HITSLOP_20, + left: index === 0 ? HITSLOP_20.left : padding, + right: index === badgeCount - 1 ? HITSLOP_20.right : padding, + } + }) return ( - + {interactive ? ( <> + + - ) : ( <> - {verification.showBadge && ( + {verification.showBadge ? ( - )} + ) : null} + )} diff --git a/src/components/TrendingTopics.tsx b/src/components/TrendingTopics.tsx index 8d35e126d4..4b045c6e3e 100644 --- a/src/components/TrendingTopics.tsx +++ b/src/components/TrendingTopics.tsx @@ -1,143 +1,20 @@ import {useMemo} from 'react' -import {View} from 'react-native' -import {type AtUri} from '@atproto/api' +import {type AppBskyUnspeccedDefs, type AtUri} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {PressableScale} from '#/lib/custom-animations/PressableScale' // import {makeProfileLink} from '#/lib/routes/links' // import {feedUriToHref} from '#/lib/strings/url-helpers' -// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' -// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote' -// import {UserAvatar} from '#/view/com/util/UserAvatar' -import {type TrendingTopic} from '#/state/queries/trending/useTrendingTopics' -import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf' -import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack' +import {native} from '#/alf' import {Link as InternalLink, type LinkProps} from '#/components/Link' -import {Text} from '#/components/Typography' - -export function TrendingTopic({ - topic: raw, - size, - style, - hovered, -}: { - topic: TrendingTopic - size?: 'large' | 'small' - hovered?: boolean -} & ViewStyleProp) { - const topic = useTopic(raw) - - const isSmall = size === 'small' - const hasIcon = topic.type === 'starter-pack' && !isSmall - const iconSize = 20 - - return ( - - {hasIcon && topic.type === 'starter-pack' && ( - - )} - - {/* - - {topic.type === 'tag' ? ( - - ) : topic.type === 'topic' ? ( - - ) : topic.type === 'feed' ? ( - - ) : ( - - )} - - */} - - - {topic.displayName} - - - ) -} - -export function TrendingTopicSkeleton({ - size = 'large', - index = 0, -}: { - size?: 'large' | 'small' - index?: number -}) { - const t = useTheme() - const isSmall = size === 'small' - return ( - - ) -} export function TrendingTopicLink({ topic: raw, children, ...rest }: { - topic: TrendingTopic + topic: AppBskyUnspeccedDefs.TrendView } & Omit) { const topic = useTopic(raw) @@ -168,7 +45,9 @@ type ParsedTrendingTopic = uri: AtUri } -export function useTopic(raw: TrendingTopic): ParsedTrendingTopic { +export function useTopic( + raw: AppBskyUnspeccedDefs.TrendView, +): ParsedTrendingTopic { const {_} = useLingui() return useMemo(() => { const {topic: displayName, link} = raw diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx index ed4b9bc825..4e0e71c2f4 100644 --- a/src/components/dms/InitiateChatFlow.tsx +++ b/src/components/dms/InitiateChatFlow.tsx @@ -11,19 +11,33 @@ import {moderateProfile, type ModerationOpts} from '@atproto/api' import {Plural, Trans, useLingui} from '@lingui/react/macro' import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useChatActorStatusQuery} from '#/state/queries/messages/get-status' +import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useSession} from '#/state/session' import {type ListMethods} from '#/view/com/util/List' import {android, atoms as a, native, useTheme, web} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {canBeAddedToGroup, canBeMessaged} from '#/components/dms/util' +import {ChatProfileTabs} from '#/components/dms/ChatProfileTabs' +import {EmptyMemberList} from '#/components/dms/components/EmptyMemberList' +import {GroupChatProfileCard} from '#/components/dms/components/GroupChatProfileCard' +import {ProfileCardSkeleton} from '#/components/dms/components/ProfileCardSkeleton' +import {UserLabel} from '#/components/dms/components/UserLabel' +import {UserSearchInput} from '#/components/dms/components/UserSearchInput' +import { + canBeAddedToGroup, + canBeMessaged, + type ConvoWithDetails, + parseConvoView, +} from '#/components/dms/util' import * as TextField from '#/components/forms/TextField' import * as Toggle from '#/components/forms/Toggle' import { @@ -33,18 +47,13 @@ import { import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {ProfileBadges} from '#/components/ProfileBadges' import * as ProfileCard from '#/components/ProfileCard' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' import {IS_NATIVE, IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' -import {ChatProfileTabs} from './ChatProfileTabs' -import {EmptyMemberList} from './components/EmptyMemberList' -import {GroupChatProfileCard} from './components/GroupChatProfileCard' -import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' -import {UserLabel} from './components/UserLabel' -import {UserSearchInput} from './components/UserSearchInput' type NewGroupChatItem = { type: 'newGroupChat' @@ -63,6 +72,12 @@ type ProfileItem = { profile: bsky.profile.AnyProfileView } +type ExistingChatItem = { + type: 'existingChat' + key: string + convo: ConvoWithDetails +} + type EmptyItem = { type: 'empty' key: string @@ -83,6 +98,7 @@ type Item = | NewGroupChatItem | LabelItem | ProfileItem + | ExistingChatItem | EmptyItem | PlaceholderItem | ErrorItem @@ -212,11 +228,17 @@ export function InitiateChatFlow({ onSelectChat, onSelectGroupChat, startInGroupChat = false, + showRecentConvos = false, + onSelectExistingChat, + sortByMessageDeclaration = false, }: { title: string onSelectChat: (did: string) => void onSelectGroupChat: (dids: string[], groupName: string) => void startInGroupChat?: boolean + showRecentConvos?: boolean + onSelectExistingChat?: (convoId: string) => void + sortByMessageDeclaration?: boolean }) { const t = useTheme() const {t: l} = useLingui() @@ -230,6 +252,12 @@ export function InitiateChatFlow({ const inputRef = useRef(null) const accountTooNewPromptControl = Dialog.useDialogControl() + const {data: convos} = useListConvosQuery({ + enabled: showRecentConvos, + status: 'accepted', + lockStatus: 'unlocked', + }) + const {data: chatStatus} = useChatActorStatusQuery() const canCreateGroups = chatStatus?.canCreateGroups ?? true const groupMemberLimit = chatStatus?.groupMemberLimit @@ -281,6 +309,10 @@ export function InitiateChatFlow({ let _items: Item[] = [] const checker = chatState === ChatState.NEW_GROUP_CHAT ? canBeAddedToGroup : canBeMessaged + const messageDeclarationRank = (item: Item) => + item.type === 'profile' && checker(item.profile) ? 0 : 1 + const compareByMessageDeclaration = (a: Item, b: Item) => + messageDeclarationRank(a) - messageDeclarationRank(b) if (isError) { _items.push({ @@ -310,9 +342,9 @@ export function InitiateChatFlow({ }) } - _items = _items.sort(item => { - return item.type === 'profile' && checker(item.profile) ? -1 : 1 - }) + if (sortByMessageDeclaration) { + _items = _items.sort(compareByMessageDeclaration) + } } } else { const placeholders: Item[] = Array(10) @@ -322,7 +354,57 @@ export function InitiateChatFlow({ key: i + '', })) - if (follows) { + if ( + chatState === ChatState.NEW_CHAT && + showRecentConvos && + convos && + follows + ) { + const usedDids = new Set() + + for (const page of convos.pages) { + for (const convoView of page.convos) { + const convo = parseConvoView(convoView, currentAccount?.did) + + if (!convo) continue + + if (convo.kind === 'group') { + _items.push({ + type: 'existingChat', + key: convo.view.id, + convo, + }) + } else { + if (convo.primaryMember.handle === 'missing.invalid') continue + if (usedDids.has(convo.primaryMember.did)) continue + + usedDids.add(convo.primaryMember.did) + + _items.push({ + type: 'existingChat', + key: convo.view.id, + convo, + }) + } + } + } + + let followsItems: ProfileItem[] = [] + + for (const page of follows.pages) { + for (const profile of page.follows) { + if (usedDids.has(profile.did)) continue + if (!checker(profile)) continue + followsItems.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + + _items.push(...followsItems) + } else if (follows) { for (const page of follows.pages) { for (const profile of page.follows) { if (!checker(profile)) continue @@ -359,10 +441,19 @@ export function InitiateChatFlow({ _items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) } - return _items + const profileDids = new Set() + + return _items.filter(item => { + if (item.type !== 'profile') return true + if (profileDids.has(item.profile.did)) return false + + profileDids.add(item.profile.did) + return true + }) }, [ isError, chatState, + convos, searchText, l, groupChatProfiles, @@ -370,6 +461,8 @@ export function InitiateChatFlow({ currentAccount?.did, follows, aa.flags.groupChatDisabled, + showRecentConvos, + sortByMessageDeclaration, ]) if (searchText && !isFetching && !items.length && !isError) { @@ -429,6 +522,16 @@ export function InitiateChatFlow({ case 'label': { return } + case 'existingChat': { + return showRecentConvos && onSelectExistingChat ? ( + + ) : null + } case 'profile': { switch (chatState) { case ChatState.NEW_CHAT: @@ -474,6 +577,8 @@ export function InitiateChatFlow({ handlePressNewGroupChat, moderationOpts, onSelectChat, + onSelectExistingChat, + showRecentConvos, ], ) @@ -845,6 +950,114 @@ function NewGroupChatButton({ ) } +function ExistingChatCard({ + convo, + moderationOpts, + onPress, +}: { + convo: ConvoWithDetails + moderationOpts: ModerationOpts + onPress: (convoId: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const enabled = + convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true + const name = + convo.kind === 'group' + ? convo.details.name + : createSanitizedDisplayName( + convo.primaryMember, + true, + moderateProfile(convo.primaryMember, moderationOpts).ui( + 'displayName', + ), + ) + + const handleOnPress = useCallback(() => { + onPress(convo.view.id) + }, [onPress, convo.view.id]) + + return ( + + ) +} + function DefaultProfileCard({ profile, moderationOpts, diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 74ab6b4c0d..fd9eaa8880 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -89,7 +89,7 @@ export function NewChat({ }, onError: error => { logger.error('Failed to create groupchat', {safeMessage: error}) - let errorMessage = l`An issue occurred creating the group chat, please try again.` + let errorMessage = l`An issue occurred starting the group chat, please try again.` if (isNetworkError(error)) { errorMessage = l`A network error occurred. Please check your internet connection.` } else if ( @@ -184,6 +184,7 @@ export function NewChat({ title={l`New chat`} onSelectChat={onCreateChat} onSelectGroupChat={onCreateGroupChat} + sortByMessageDeclaration startInGroupChat={startInGroupChat} /> ) : ( diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index 30cd80862d..0463cc694d 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -1,11 +1,17 @@ -import {useCallback} from 'react' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useCallback, useState} from 'react' +import { + ChatBskyConvoGetConvoForMembers, + ChatBskyGroupCreateGroup, +} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' +import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import * as Dialog from '#/components/Dialog' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' +import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' @@ -16,26 +22,39 @@ export function SendViaChatDialog({ control: Dialog.DialogControlProps onSelectChat: (chatId: string) => void }) { + const [flowKey, setFlowKey] = useState(0) + const onClose = useCallback(() => setFlowKey(key => key + 1), []) + return ( + nativeOptions={{fullHeight: true}} + onClose={onClose}> - + ) } function SendViaChatDialogInner({ control, + flowKey, onSelectChat, }: { control: Dialog.DialogControlProps + flowKey: number onSelectChat: (chatId: string) => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() + + const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable) + const {mutate: createChat} = useGetConvoForMembers({ onSuccess: data => { onSelectChat(data.convo.id) @@ -46,8 +65,74 @@ function SendViaChatDialogInner({ ax.metric('chat:open', {logContext: 'SendViaChatDialog'}) }, onError: error => { - logger.error('Failed to share post to chat', {message: error}) - Toast.show(_(msg`An issue occurred while trying to open the chat`), { + logger.error('Failed to share post to chat', {safeMessage: error}) + let errorMessage = l`An issue occurred starting the chat, please try again.` + if (isNetworkError(error)) { + errorMessage = l`A network error occurred. Please check your internet connection.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.AccountSuspendedError + ) { + errorMessage = l`Suspended accounts cannot participate in chat.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.BlockedActorError + ) { + errorMessage = l`This user has blocked you and cannot be messaged.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.MessagesDisabledError + ) { + errorMessage = l`This user has disabled chat and cannot be messaged.` + } else if ( + error instanceof + ChatBskyConvoGetConvoForMembers.NotFollowedBySenderError + ) { + errorMessage = l`Chat recipient is not followed by the sender.` + } else if ( + error instanceof ChatBskyConvoGetConvoForMembers.RecipientNotFoundError + ) { + errorMessage = l`Unable to find the selected recipient.` + } + Toast.show(errorMessage, { + type: 'error', + }) + }, + }) + + const {mutate: createGroupChat} = useCreateGroupChat({ + onSuccess: data => { + onSelectChat(data.convo.id) + + ax.metric('groupchat:create', {logContext: 'SendViaChatDialog'}) + }, + onError: error => { + logger.error('Failed to share post to group chat', {safeMessage: error}) + let errorMessage = l`An issue occurred starting the group chat, please try again.` + if (isNetworkError(error)) { + errorMessage = l`A network error occurred. Please check your internet connection.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.AccountSuspendedError + ) { + errorMessage = l`Suspended accounts cannot participate in a group chat.` + } else if (error instanceof ChatBskyGroupCreateGroup.BlockedActorError) { + errorMessage = l`One of the selected recipients has blocked you and cannot be messaged.` + } else if ( + error instanceof + ChatBskyGroupCreateGroup.NewAccountCannotCreateGroupError + ) { + errorMessage = l`You cannot create a group chat yet.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.NotFollowedBySenderError + ) { + errorMessage = l`A selected recipient is not followed by the sender.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.RecipientNotFoundError + ) { + errorMessage = l`Unable to find a selected recipient.` + } else if ( + error instanceof ChatBskyGroupCreateGroup.UserForbidsGroupsError + ) { + errorMessage = l`One of the selected recipients does not allow group chats.` + } + Toast.show(errorMessage, { type: 'error', }) }, @@ -67,9 +152,28 @@ function SendViaChatDialogInner({ [control, createChat], ) - return ( + const onCreateGroupChat = useCallback( + (members: string[], name: string) => { + control.close(() => { + createGroupChat({members, name}) + }) + }, + [control, createGroupChat], + ) + + return isGroupChatEnabled ? ( + + ) : ( { if (chat.kind === 'user') { onCreateChat(chat.did) 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/interstitials/FeedTrendingTopics.tsx b/src/components/interstitials/FeedTrendingTopics.tsx new file mode 100644 index 0000000000..a9666a82be --- /dev/null +++ b/src/components/interstitials/FeedTrendingTopics.tsx @@ -0,0 +1,280 @@ +import {useMemo} from 'react' +import {Pressable, View} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' +import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useTrendingSettings} from '#/state/preferences/trending' +import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' +import {useTrendingConfig} from '#/state/service-config' +import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {formatCount} from '#/view/com/util/numeric/format' +import { + atoms as a, + useGutters, + useLayoutBreakpoints, + useTheme, + type ViewStyleProp, +} from '#/alf' +import {alpha} from '#/alf/utils' +import {AvatarStack} from '#/components/AvatarStack' +import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' +import {Link} from '#/components/Link' +import {SubtleHover} from '#/components/SubtleHover' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' + +const TOPIC_COUNT = 3 + +export function FeedTrendingTopicsInterstitial() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + const {rightNavVisible} = useLayoutBreakpoints() + + return enabled && !trendingDisabled && !rightNavVisible ? : null +} + +function Inner() { + const t = useTheme() + const {t: l} = useLingui() + const gutters = useGutters([0, 'base']) + const ax = useAnalytics() + const { + data: trending, + error, + isLoading, + isRefetching, + } = useGetTrendsQuery({limit: TOPIC_COUNT}) + const noTopics = !isLoading && !error && !trending?.trends?.length + + const shadowColor = alpha(t.palette.primary_100, 0.5) + + const gradient = { + values: [ + [0, t.atoms.bg.backgroundColor], + [0.1, t.palette.primary_25], + [0.9, t.palette.primary_25], + [1, t.atoms.bg.backgroundColor], + ], + hover_value: t.palette.white, + } + + if (error || noTopics) { + return null + } + + return ( + + c[1]) as [string, string, ...string[]]} + locations={ + gradient.values.map(c => c[0]) as [number, number, ...number[]] + } + style={[a.absolute, a.inset_0]} + /> + + + + + Trending + + + + + See more + + + + + {isLoading || isRefetching + ? Array.from({length: TOPIC_COUNT}).map((_, i) => ( + + )) + : trending?.trends?.map((trend, index) => ( + { + ax.metric('trendingTopic:click', {context: 'interstitial'}) + }} + /> + ))} + + + ) +} + +function TrendRow({ + trend, + rank, + onPress, +}: ViewStyleProp & { + trend: AppBskyUnspeccedDefs.TrendView + rank: number + children?: React.ReactNode + onPress?: () => void +}) { + const t = useTheme() + const {t: l, i18n} = useLingui() + + const actors = useModerateTrendingActors(trend.actors) + + return ( + + {({hovered, pressed}) => ( + <> + + + + + {rank}. + + + + + {trend.displayName} + + + {actors.length > 0 ? ( + + ) : null} + + {trend.postCount >= 1000 ? ( + 1K+ posts + ) : ( + + {formatCount(i18n, trend.postCount)}{' '} + {plural(trend.postCount, {one: 'post', other: 'posts'})} + + )} + + + + + + )} + + ) +} +function TrendingTopicRowSkeleton({rank}: {rank: number}) { + const t = useTheme() + + return ( + + + + + + + + + + + + ) +} + +function useModerateTrendingActors( + actors: AppBskyUnspeccedDefs.TrendView['actors'], +) { + const moderationOpts = useModerationOpts() + + return useMemo(() => { + if (!moderationOpts) return [] + + return actors + .filter(actor => { + const decision = moderateProfile(actor, moderationOpts) + return !decision.ui('avatar').filter && !decision.ui('avatar').blur + }) + .slice(0, 3) + }, [actors, moderationOpts]) +} diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx index 98e8f77b2a..94c4b37dd6 100644 --- a/src/components/interstitials/Trending.tsx +++ b/src/components/interstitials/Trending.tsx @@ -7,7 +7,7 @@ import { useTrendingSettings, useTrendingSettingsApi, } from '#/state/preferences/trending' -import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics' +import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' import {useTrendingConfig} from '#/state/service-config' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' @@ -20,6 +20,8 @@ import {TrendingTopicLink} from '#/components/TrendingTopics' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' +const TRENDING_LIMIT = 14 + export function TrendingInterstitial() { const {enabled} = useTrendingConfig() const {trendingDisabled} = useTrendingSettings() @@ -33,8 +35,15 @@ export function Inner() { const gutters = useGutters([0, 'base', 0, 'base']) const trendingPrompt = Prompt.usePromptControl() const {setTrendingDisabled} = useTrendingSettingsApi() - const {data: trending, error, isLoading} = useTrendingTopics() - const noTopics = !isLoading && !error && !trending?.topics?.length + const { + data: trending, + error, + isLoading, + } = useGetTrendsQuery({ + limit: TRENDING_LIMIT, + refetchOnWindowFocus: true, + }) + const noTopics = !isLoading && !error && !trending?.trends?.length const onConfirmHide = useCallback(() => { ax.metric('trendingTopics:hide', {context: 'interstitial'}) @@ -88,15 +97,16 @@ export function Inner() { {' '} - ) : !trending?.topics ? null : ( + ) : !trending?.trends ? null : ( <> - {trending.topics.map(topic => ( + {trending.trends.map(topic => ( { ax.metric('trendingTopic:click', { context: 'interstitial', + recId: trending.recId, }) }}> diff --git a/src/components/moderation/BlockDialog.tsx b/src/components/moderation/BlockDialog.tsx index dcca625163..a575cd7433 100644 --- a/src/components/moderation/BlockDialog.tsx +++ b/src/components/moderation/BlockDialog.tsx @@ -181,6 +181,7 @@ function BlockDialogInner({ const footer = ( - {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/components/verification/VerificationCheckButton.tsx b/src/components/verification/VerificationCheckButton.tsx index ceb9d24f2b..fe4a6ebe7e 100644 --- a/src/components/verification/VerificationCheckButton.tsx +++ b/src/components/verification/VerificationCheckButton.tsx @@ -1,6 +1,5 @@ -import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {type Insets, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' import {type Shadow} from '#/state/cache/types' import {atoms as a, useTheme} from '#/alf' @@ -52,16 +51,25 @@ export function shouldShowVerificationCheckButton( export function VerificationCheckButton({ profile, width, + hitSlop, }: { profile: Shadow width: number + hitSlop: Insets }) { const state = useFullVerificationState({ profile, }) if (shouldShowVerificationCheckButton(state)) { - return + return ( + + ) } return null @@ -71,14 +79,16 @@ function Badge({ profile, verificationState: state, width, + hitSlop, }: { profile: Shadow verificationState: FullVerificationState width: number + hitSlop: Insets }) { const t = useTheme() const ax = useAnalytics() - const {_} = useLingui() + const {t: l} = useLingui() const verificationsDialogControl = useDialogControl() const verifierDialogControl = useDialogControl() @@ -89,10 +99,10 @@ function Badge({ - - - } else { - // no feed, no trending - return null - } + // no feed + return null } // On desktop, we show in the sidebar diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 91d1319c6b..80d8639ff2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -146,6 +146,9 @@ export const BSKY_FEED_OWNER_DIDS = [ 'did:plc:q6gjnaw2blty4crticxkmujt', ] +export const TRENDING_DID = 'did:plc:qrz3lhbyuxbeilrc6nekdqme' +export const TRENDING_HANDLE = 'trending.bsky.app' + export const DISCOVER_FEED_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' export const VIDEO_FEED_URI = 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/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 cee2f855de..51d10b8cf8 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}}" @@ -114,6 +114,7 @@ msgstr "" #. placeholder {0}: convo.details.memberCount #: src/components/dialogs/SearchablePeopleList.tsx:555 +#: src/components/dms/InitiateChatFlow.tsx:1038 msgid "{0, plural, one {# member} other {# members}}" msgstr "{0, plural, one {# member} other {# members}}" @@ -191,7 +192,10 @@ msgid "{0, plural, one {following} other {following}}" msgstr "" #. placeholder {0}: profile.postsCount || 0 +#. placeholder {0}: trend.postCount +#: src/components/interstitials/FeedTrendingTopics.tsx:223 #: src/screens/Profile/Header/Metrics.tsx:58 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:153 msgid "{0, plural, one {post} other {posts}}" msgstr "" @@ -219,10 +223,20 @@ msgid "{0} Β· {1}" msgstr "" #. placeholder {0}: desc.name -#: src/components/moderation/ContentHider.tsx:100 +#: src/components/moderation/ContentHider.tsx:98 msgid "{0} (Account)" msgstr "" +#. '{postCount} {posts}', e.g., '1.2K posts' +#. '{postCount} {posts}', e.g., '1.2K posts' +#. placeholder {0}: formatCount(i18n, trend.postCount) +#. placeholder {1}: import {useMemo} from 'react' import {Pressable, View} from 'react-native' import {Image} from 'expo-image' import { type AppBskyUnspeccedDefs, moderateProfile, RichText as RichTextApi, } from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useTrendingSettings} from '#/state/preferences/trending' import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' import {useTrendingConfig} from '#/state/service-config' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {formatCount} from '#/view/com/util/numeric/format' import {atoms as a, useGutters, useTheme, type ViewStyleProp} from '#/alf' import {AvatarStack} from '#/components/AvatarStack' import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' import {Link} from '#/components/Link' import {RichText} from '#/components/RichText' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import * as ModuleHeader from '../components/ModuleHeader' const TOPIC_COUNT = 5 const IMAGE_SIZE = 56 export function ExploreTrendingTopics() { const {enabled} = useTrendingConfig() const {trendingDisabled} = useTrendingSettings() return enabled && !trendingDisabled ? : null } function Inner() { const ax = useAnalytics() const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery() const noTopics = !isLoading && !error && !trending?.trends?.length const showLoading = isLoading || isRefetching if (!showLoading && (error || !trending?.trends || noTopics)) return null return ( Trending {showLoading ? Array.from({length: TOPIC_COUNT}).map((__, i) => ( )) : trending?.trends.map((trend, index) => ( { ax.metric('trendingTopic:click', { context: 'explore', recId: trending.recId, }) }} /> ))} ) } export function TrendRow({ trend, rank, children, onPress, }: ViewStyleProp & { trend: AppBskyUnspeccedDefs.TrendView rank: number children?: React.ReactNode onPress?: () => void }) { const t = useTheme() const {t: l, i18n} = useLingui() const gutters = useGutters([0, 'base']) const actors = useModerateTrendingActors(trend.actors) const description = useMemo(() => { if (!trend.description) return const rt = new RichTextApi({text: trend.description}) rt.detectFacetsWithoutResolution() return rt }, [trend.description]) let imageUrl = null // TODO Image URL goes here when available. -dsb return ( {({hovered, pressed}) => ( <> {rank}. {trend.displayName} {description ? ( ) : null} {actors.length > 0 ? ( ) : null} {trend.postCount >= 1000 ? ( 1K+ posts ) : ( {formatCount(i18n, trend.postCount)}{' '} {plural(trend.postCount, {one: 'post', other: 'posts'})} )} {imageUrl ? ( {trend.topic} ) : null} {children} )} ) } // Unused atm, but leaving here so we don't lose localization. -dsb export function useCategoryDisplayName( category: AppBskyUnspeccedDefs.TrendView['category'], ) { const {t: l} = useLingui() switch (category) { case 'sports': return l`Sports` case 'politics': return l`Politics` case 'video-games': return l`Video Games` case 'pop-culture': return l`Entertainment` case 'news': return l`News` case 'other': default: return null } } export function TrendingTopicRowSkeleton() { const t = useTheme() const gutters = useGutters([0, 'base']) return ( {/* TODO Image placeholder goes here when images are available. -dsb */} ) } function useModerateTrendingActors( actors: AppBskyUnspeccedDefs.TrendView['actors'], ) { const moderationOpts = useModerationOpts() return useMemo(() => { if (!moderationOpts) return [] return actors .filter(actor => { const decision = moderateProfile(actor, moderationOpts) return !decision.ui('avatar').filter && !decision.ui('avatar').blur }) .slice(0, 3) }, [actors, moderationOpts]) } +#. placeholder {1}: import {useMemo} from 'react' import {Pressable, View} from 'react-native' import {LinearGradient} from 'expo-linear-gradient' import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useTrendingSettings} from '#/state/preferences/trending' import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' import {useTrendingConfig} from '#/state/service-config' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {formatCount} from '#/view/com/util/numeric/format' import { atoms as a, useGutters, useLayoutBreakpoints, useTheme, type ViewStyleProp, } from '#/alf' import {alpha} from '#/alf/utils' import {AvatarStack} from '#/components/AvatarStack' import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' import {Link} from '#/components/Link' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' const TOPIC_COUNT = 3 export function FeedTrendingTopicsInterstitial() { const {enabled} = useTrendingConfig() const {trendingDisabled} = useTrendingSettings() const {rightNavVisible} = useLayoutBreakpoints() return enabled && !trendingDisabled && !rightNavVisible ? : null } function Inner() { const t = useTheme() const {t: l} = useLingui() const gutters = useGutters([0, 'base']) const ax = useAnalytics() const { data: trending, error, isLoading, isRefetching, } = useGetTrendsQuery({limit: TOPIC_COUNT}) const noTopics = !isLoading && !error && !trending?.trends?.length const shadowColor = alpha(t.palette.primary_100, 0.5) const gradient = { values: [ [0, t.atoms.bg.backgroundColor], [0.1, t.palette.primary_25], [0.9, t.palette.primary_25], [1, t.atoms.bg.backgroundColor], ], hover_value: t.palette.white, } if (error || noTopics) { return null } return ( c[1]) as [string, string, ...string[]]} locations={ gradient.values.map(c => c[0]) as [number, number, ...number[]] } style={[a.absolute, a.inset_0]} /> Trending See more {isLoading || isRefetching ? Array.from({length: TOPIC_COUNT}).map((_, i) => ( )) : trending?.trends?.map((trend, index) => ( { ax.metric('trendingTopic:click', {context: 'interstitial'}) }} /> ))} ) } function TrendRow({ trend, rank, onPress, }: ViewStyleProp & { trend: AppBskyUnspeccedDefs.TrendView rank: number children?: React.ReactNode onPress?: () => void }) { const t = useTheme() const {t: l, i18n} = useLingui() const actors = useModerateTrendingActors(trend.actors) return ( {({hovered, pressed}) => ( <> {rank}. {trend.displayName} {actors.length > 0 ? ( ) : null} {trend.postCount >= 1000 ? ( 1K+ posts ) : ( {formatCount(i18n, trend.postCount)}{' '} {plural(trend.postCount, {one: 'post', other: 'posts'})} )} )} ) } function TrendingTopicRowSkeleton({rank}: {rank: number}) { const t = useTheme() return ( ) } function useModerateTrendingActors( actors: AppBskyUnspeccedDefs.TrendView['actors'], ) { const moderationOpts = useModerationOpts() return useMemo(() => { if (!moderationOpts) return [] return actors .filter(actor => { const decision = moderateProfile(actor, moderationOpts) return !decision.ui('avatar').filter && !decision.ui('avatar').blur }) .slice(0, 3) }, [actors, moderationOpts]) } +#: src/components/interstitials/FeedTrendingTopics.tsx:221 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:151 +msgid "{0} {1}" +msgstr "{0} {1}" + #. Pattern: {wordValue} in tags #. placeholder {0}: word.value #: src/components/dialogs/MutedWords.tsx:495 @@ -246,29 +260,6 @@ msgstr "{0} added to chat" msgid "{0} and {1} added to chat" msgstr "{0} and {1} added to chat" -#. Social proof on the likes stat; the bolded names are people the viewer follows who liked the post and are its only likes -#. placeholder {0}: nameLink(names[0]) -#. placeholder {0}: names[0].displayName -#. placeholder {1}: nameLink(names[1]) -#. placeholder {1}: names[1].displayName -#: src/screens/PostThread/components/LikesStat.tsx:135 -#: src/screens/PostThread/components/LikesStat.tsx:191 -msgid "{0} and {1} like this" -msgstr "{0} and {1} like this" - -#. Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post, and the count is the remaining number of likes -#. placeholder {0}: nameLink(names[0]) -#. placeholder {1}: formatPostStatCount(others) -#. placeholder {2}: formatPostStatCount(others) -#: src/screens/PostThread/components/LikesStat.tsx:196 -msgid "{0} and {others, plural, one {{1} other} other {{2} others}} like this" -msgstr "{0} and {others, plural, one {{1} other} other {{2} others}} like this" - -#. placeholder {0}: names[0].displayName -#: src/screens/PostThread/components/LikesStat.tsx:137 -msgid "{0} and {othersLabel} like this" -msgstr "{0} and {othersLabel} like this" - #. placeholder {0}: profile.followsCount || 0 #: src/screens/Profile/Header/Metrics.tsx:49 msgid "{0} following" @@ -319,14 +310,6 @@ msgstr "{0} left" msgid "{0} left the group" msgstr "{0} left the group" -#. Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post and is its only like -#. placeholder {0}: nameLink(names[0]) -#. placeholder {0}: names[0].displayName -#: src/screens/PostThread/components/LikesStat.tsx:138 -#: src/screens/PostThread/components/LikesStat.tsx:206 -msgid "{0} likes this" -msgstr "{0} likes this" - #. placeholder {0}: formatTime(currentTime) #. placeholder {1}: formatTime(duration) #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx:203 @@ -383,21 +366,6 @@ msgstr "{0}, " msgid "{0}, {1} and {memberCount, plural, one {# other} other {# others}} added to chat" msgstr "{0}, {1} and {memberCount, plural, one {# other} other {# others}} added to chat" -#. Social proof on the likes stat; the bolded names are people the viewer follows who liked the post, and the count is the remaining number of likes -#. placeholder {0}: nameLink(names[0]) -#. placeholder {1}: nameLink(names[1]) -#. placeholder {2}: formatPostStatCount(others) -#. placeholder {3}: formatPostStatCount(others) -#: src/screens/PostThread/components/LikesStat.tsx:181 -msgid "{0}, {1}, and {others, plural, one {{2} other} other {{3} others}} like this" -msgstr "{0}, {1}, and {others, plural, one {{2} other} other {{3} others}} like this" - -#. placeholder {0}: names[0].displayName -#. placeholder {1}: names[1].displayName -#: src/screens/PostThread/components/LikesStat.tsx:134 -msgid "{0}, {1}, and {othersLabel} like this" -msgstr "{0}, {1}, and {othersLabel} like this" - #. placeholder {0}: feed.displayName #. placeholder {1}: sanitizeHandle(feed.creatorHandle, '@') #. placeholder {2}: feed.likeCount || 0 @@ -685,7 +653,7 @@ msgid "{following} following" msgstr "" #: src/components/dms/components/GroupChatProfileCard.tsx:62 -#: src/components/dms/InitiateChatFlow.tsx:945 +#: src/components/dms/InitiateChatFlow.tsx:1158 msgid "{handle} can’t be added" msgstr "{handle} can’t be added" @@ -693,7 +661,7 @@ msgstr "{handle} can’t be added" msgid "{handle} can't be messaged" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:906 +#: src/components/dms/InitiateChatFlow.tsx:1119 msgid "{handle} can’t be messaged" msgstr "{handle} can’t be messaged" @@ -771,12 +739,6 @@ msgstr "" msgid "{numMatches, plural, one {# contact found} other {# contacts found}}" msgstr "" -#. placeholder {0}: formatPostStatCount(others) -#. placeholder {1}: formatPostStatCount(others) -#: src/screens/PostThread/components/LikesStat.tsx:127 -msgid "{others, plural, one {{0} other} other {{1} others}}" -msgstr "{others, plural, one {{0} other} other {{1} others}}" - #: src/components/NewskieDialog.tsx:115 msgid "{profileName} joined Bluesky {timeAgoString} ago" msgstr "" @@ -786,7 +748,9 @@ msgid "{profileName} joined Bluesky using a starter pack {timeAgoString} ago" msgstr "" #. The trending topic rank, i.e. "1. March Madness", "2. The Bachelor" -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:101 +#. The trending topic rank, i.e. "1. March Madness", "2. The Bachelor" +#: src/components/interstitials/FeedTrendingTopics.tsx:203 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:123 msgid "{rank}." msgstr "" @@ -816,11 +780,6 @@ msgstr "{requestCount, plural, one {# request} other {# requests}}" msgid "{requestCount}+ requests" msgstr "{requestCount}+ requests" -#. trending topic time spent trending. should be as short as possible to fit in a pill -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:191 -msgid "{type}h ago" -msgstr "" - #: src/components/verification/VerifierDialog.tsx:62 msgid "{userName} is a trusted verifier" msgstr "" @@ -837,13 +796,13 @@ 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}" #. Indicates the number of additional profiles are in the Starter Pack e.g. +12 -#: src/screens/Search/components/StarterPackCard.tsx:250 +#: src/screens/Search/components/StarterPackCard.tsx:258 msgid "+{computedTotal}" msgstr "" @@ -878,14 +837,14 @@ msgstr "" #. Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.quoteCount) #. placeholder {1}: post.quoteCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:470 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:469 msgid "<0>{0} {1, plural, one {quote} other {quotes}}" msgstr "" #. Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.repostCount) #. placeholder {1}: post.repostCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:449 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:448 msgid "<0>{0} {1, plural, one {repost} other {reposts}}" msgstr "" @@ -898,7 +857,7 @@ msgstr "" #. Like count display, the <0> tags enclose the number of likes in bold (will never be 0) #. placeholder {0}: formatPostStatCount(likeCount) -#: src/screens/PostThread/components/LikesStat.tsx:90 +#: src/screens/PostThread/components/LikesStat.tsx:44 msgid "<0>{0} {likeCount, plural, one {like} other {likes}}" msgstr "<0>{0} {likeCount, plural, one {like} other {likes}}" @@ -926,7 +885,7 @@ msgid "<0>{displayName}<1/><2> added you" msgstr "<0>{displayName}<1/><2> added you" #: src/screens/Hashtag.tsx:230 -#: src/screens/Search/SearchResults.tsx:385 +#: src/screens/Search/SearchResults.tsx:412 msgid "<0>Sign in<1> or <2>create an account<3> <4>to search for news, sports, politics, and everything else happening on Bluesky." msgstr "" @@ -944,6 +903,13 @@ msgstr "" msgid "⚠Invalid Handle" msgstr "" +#. Over 1,000 posts +#. Over 1,000 posts +#: src/components/interstitials/FeedTrendingTopics.tsx:219 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:149 +msgid "1K+ posts" +msgstr "1K+ posts" + #: src/components/dialogs/MutedWords.tsx:202 #: src/components/dialogs/MutedWords.tsx:551 #: src/components/dialogs/MutedWords.tsx:554 @@ -983,9 +949,11 @@ msgstr "" #: src/components/contacts/screens/VerifyNumber.tsx:155 #: src/components/dms/dialogs/NewChatDialog.tsx:56 #: src/components/dms/dialogs/NewChatDialog.tsx:94 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:71 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:110 #: src/components/dms/LeaveConvoPrompt.tsx:38 -#: src/components/moderation/BlockDialog.tsx:287 -#: src/components/moderation/BlockDialog.tsx:313 +#: src/components/moderation/BlockDialog.tsx:284 +#: src/components/moderation/BlockDialog.tsx:310 #: src/screens/Messages/JoinRequests.tsx:192 #: src/screens/Messages/JoinRequests.tsx:225 msgid "A network error occurred. Please check your internet connection." @@ -1023,10 +991,11 @@ msgid "A screenshot of the post composer with a new button next to the post butt msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:109 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:125 msgid "A selected recipient is not followed by the sender." msgstr "A selected recipient is not followed by the sender." -#: src/Navigation.tsx:474 +#: src/Navigation.tsx:483 #: src/screens/Settings/AboutSettings.tsx:85 #: src/screens/Settings/Settings.tsx:264 #: src/screens/Settings/Settings.tsx:267 @@ -1075,11 +1044,11 @@ msgstr "Access requested! The group owner will review your request." msgid "Accessibility" msgstr "" -#: src/Navigation.tsx:389 +#: src/Navigation.tsx:390 msgid "Accessibility Settings" msgstr "" -#: src/Navigation.tsx:405 +#: src/Navigation.tsx:406 #: src/screens/Settings/AccountSettings.tsx:54 #: src/screens/Settings/Settings.tsx:174 #: src/screens/Settings/Settings.tsx:177 @@ -1154,11 +1123,15 @@ msgid "Accounts with a scalloped blue check mark <0><1/> can verify others. msgstr "" #: src/lib/hooks/useNotificationHandler.ts:214 -#: src/screens/Settings/NotificationSettings/index.tsx:226 -#: src/screens/Settings/NotificationSettings/index.tsx:365 +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:105 +#: src/screens/Settings/NotificationSettings/index.tsx:225 msgid "Activity from others" msgstr "" +#: src/Navigation.tsx:459 +msgid "Activity notifications" +msgstr "Activity notifications" + #: src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx:191 #: src/components/dialogs/lists/UserAddRemoveListsDialog.tsx:319 #: src/components/dialogs/lists/UserAddRemoveListsDialog.tsx:326 @@ -1226,7 +1199,7 @@ msgstr "" msgid "Add another post to thread" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:435 +#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:439 msgid "Add another search filter" msgstr "Add another search filter" @@ -1248,7 +1221,7 @@ msgstr "Add automation label to account" msgid "Add emoji reaction" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:443 +#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:447 msgid "Add filter" msgstr "Add filter" @@ -1395,7 +1368,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 @@ -1635,11 +1608,11 @@ msgstr "" msgid "An error occurred while hiding suggestion. {0}" msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.tsx:188 +#: src/components/Post/Embed/VideoEmbed/index.tsx:197 msgid "An error occurred while loading the video. Please try again later." msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.web.tsx:239 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:308 msgid "An error occurred while loading the video. Please try again." msgstr "" @@ -1700,17 +1673,15 @@ msgstr "An invite link lets people join this group chat without being added dire msgid "An issue not included in these options" msgstr "" -#: src/components/dms/dialogs/NewChatDialog.tsx:92 -msgid "An issue occurred creating the group chat, please try again." -msgstr "An issue occurred creating the group chat, please try again." - #: src/components/dms/dialogs/NewChatDialog.tsx:54 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:69 msgid "An issue occurred starting the chat, please try again." msgstr "An issue occurred starting the chat, please try again." -#: src/components/dms/dialogs/ShareViaChatDialog.tsx:50 -msgid "An issue occurred while trying to open the chat" -msgstr "" +#: src/components/dms/dialogs/NewChatDialog.tsx:92 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:108 +msgid "An issue occurred starting the group chat, please try again." +msgstr "An issue occurred starting the group chat, please try again." #: src/components/hooks/useFollowMethods.ts:35 #: src/components/hooks/useFollowMethods.ts:52 @@ -1764,8 +1735,8 @@ msgid "Any date" msgstr "Any date" #: src/components/dialogs/PostInteractionSettingsDialog.tsx:457 -#: src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx:22 -#: src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx:25 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:22 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:26 msgid "Anyone" msgstr "" @@ -1797,7 +1768,7 @@ msgstr "" msgid "Anyone who has it will no longer be able to join or request to join. You can always create a new one." msgstr "Anyone who has it will no longer be able to join or request to join. You can always create a new one." -#: src/Navigation.tsx:482 +#: src/Navigation.tsx:491 #: src/screens/Settings/AppIconSettings/index.tsx:65 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:19 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:24 @@ -1835,7 +1806,7 @@ msgstr "" msgid "App passwords" msgstr "" -#: src/Navigation.tsx:357 +#: src/Navigation.tsx:358 #: src/screens/Settings/AppPasswords.tsx:51 msgid "App Passwords" msgstr "" @@ -1886,7 +1857,7 @@ msgstr "" msgid "Appeal this label" msgstr "Appeal this label" -#: src/Navigation.tsx:397 +#: src/Navigation.tsx:398 #: src/screens/Settings/AppearanceSettings.tsx:73 #: src/screens/Settings/Settings.tsx:226 #: src/screens/Settings/Settings.tsx:229 @@ -1904,12 +1875,12 @@ msgid "Apply Pull Request" msgstr "" #. placeholder {0}: niceDate(i18n, createdAt, 'medium') -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:610 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:611 msgid "Archived from {0}" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:581 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:619 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:582 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:620 msgid "Archived post" msgstr "" @@ -2014,7 +1985,7 @@ msgid "Aurora" msgstr "" #: src/components/BotAccountAlert.tsx:32 -#: src/components/BotBadge.tsx:63 +#: src/components/BotBadge.tsx:65 msgid "Automated account" msgstr "Automated account" @@ -2028,7 +1999,7 @@ msgstr "Automatic" msgid "Automation label" msgstr "Automation label" -#: src/Navigation.tsx:421 +#: src/Navigation.tsx:422 #: src/screens/Settings/AutomationLabelSettings.tsx:103 msgid "Automation Label" msgstr "Automation Label" @@ -2048,9 +2019,9 @@ msgstr "" #: src/components/dms/AddMembersFlow.tsx:359 #: src/components/dms/AddMembersFlow.tsx:527 #: src/components/dms/AddMembersFlow.tsx:533 -#: src/components/dms/InitiateChatFlow.tsx:540 -#: src/components/dms/InitiateChatFlow.tsx:758 -#: src/components/dms/InitiateChatFlow.tsx:765 +#: src/components/dms/InitiateChatFlow.tsx:645 +#: src/components/dms/InitiateChatFlow.tsx:863 +#: src/components/dms/InitiateChatFlow.tsx:870 #: src/components/moderation/AppealForm.tsx:145 #: src/components/moderation/AppealForm.tsx:146 #: src/screens/Login/ChooseAccountForm.tsx:96 @@ -2155,13 +2126,19 @@ msgstr "" msgid "Beta Feature" msgstr "" -#: src/Navigation.tsx:413 +#: src/Navigation.tsx:414 #: src/screens/Settings/BetaFeaturesSettings.tsx:108 #: src/screens/Settings/Settings.tsx:248 #: src/screens/Settings/Settings.tsx:251 msgid "Beta features" msgstr "Beta features" +#: src/components/BetaBadge.tsx:79 +#: src/components/BetaBadge.tsx:99 +#: src/components/BetaBadge.tsx:100 +msgid "Beta features enabled" +msgstr "Beta features enabled" + #: src/screens/Settings/BetaFeaturesSettings.tsx:144 msgctxt "web" msgid "Beta features may be unstable. Some changes may require reloading the app." @@ -2180,9 +2157,9 @@ msgstr "" msgid "Birthday" msgstr "" -#: src/components/moderation/BlockDialog.tsx:186 -#: src/components/moderation/BlockDialog.tsx:192 -#: src/components/moderation/BlockDialog.tsx:211 +#: src/components/moderation/BlockDialog.tsx:187 +#: src/components/moderation/BlockDialog.tsx:193 +#: src/components/moderation/BlockDialog.tsx:213 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:227 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:190 msgid "Block" @@ -2264,7 +2241,7 @@ msgstr "" msgid "Blocked accounts" msgstr "" -#: src/Navigation.tsx:193 +#: src/Navigation.tsx:194 #: src/view/screens/ModerationBlockedAccounts.tsx:95 msgid "Blocked Accounts" msgstr "" @@ -2300,10 +2277,11 @@ msgstr "bloomscrolling booksky" #: src/components/dialogs/ServerInput.tsx:144 #: src/components/dialogs/ServerInput.tsx:146 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:514 msgid "Bluesky" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:635 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:636 msgid "Bluesky cannot confirm the authenticity of the claimed date." msgstr "" @@ -2420,24 +2398,25 @@ msgstr "" msgid "Browse other feeds" msgstr "" -#: src/components/TrendingTopics.tsx:179 +#: src/components/TrendingTopics.tsx:58 msgid "Browse posts about {displayName}" msgstr "" -#: src/components/TrendingTopics.tsx:187 +#: src/components/TrendingTopics.tsx:66 msgid "Browse posts tagged with {displayName}" msgstr "" -#: src/components/TrendingTopics.tsx:196 +#: src/components/TrendingTopics.tsx:75 msgid "Browse starter pack {displayName}" msgstr "" #. placeholder {0}: trend.displayName -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:83 +#: src/components/interstitials/FeedTrendingTopics.tsx:170 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:104 msgid "Browse topic {0}" msgstr "" -#: src/components/TrendingTopics.tsx:233 +#: src/components/TrendingTopics.tsx:112 msgid "Browse topic {displayName}" msgstr "" @@ -2456,8 +2435,8 @@ msgstr "" #: src/components/LabelingServiceCard/index.tsx:62 #: src/components/moderation/ReportDialog/index.tsx:847 #: src/screens/Messages/JoinRequest.tsx:177 -#: src/screens/Search/components/StarterPackCard.tsx:107 -#: src/screens/Search/Explore.tsx:971 +#: src/screens/Search/components/StarterPackCard.tsx:112 +#: src/screens/Search/Explore.tsx:960 msgid "By {0}" msgstr "" @@ -2468,9 +2447,9 @@ msgstr "by @{0}" #. The group chat creator, in the format 'By {displayName}'. #. placeholder {0}: createSanitizedDisplayName( joinLinkPreview.owner, true, moderateProfile(joinLinkPreview.owner, moderationOpts).ui( 'displayName', ), ) -#. placeholder {0}: sanitizeHandle(info.creatorHandle, '@') +#. placeholder {0}: info.creatorHandle === TRENDING_HANDLE ? l`Bluesky` : sanitizeHandle(info.creatorHandle, '@') #: src/components/intents/GroupChatJoinDialog.tsx:379 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:451 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:502 msgid "By <0>{0}" msgstr "" @@ -2499,7 +2478,7 @@ msgstr "" msgid "By creating an account you agree to the <0>Terms of Service." msgstr "" -#: src/screens/Search/components/StarterPackCard.tsx:106 +#: src/screens/Search/components/StarterPackCard.tsx:111 msgid "By you" msgstr "" @@ -2529,7 +2508,7 @@ msgstr "Camera access needed" #: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:175 #: src/components/dialogs/nuxs/InviteFriendsAnnouncement.tsx:181 #: src/components/Menu/index.tsx:373 -#: src/components/moderation/BlockDialog.tsx:202 +#: src/components/moderation/BlockDialog.tsx:204 #: src/components/PostControls/RepostButton.tsx:210 #: src/components/Prompt.tsx:152 #: src/components/Prompt.tsx:154 @@ -2580,7 +2559,7 @@ msgstr "" msgid "Cancel reply" msgstr "Cancel reply" -#: src/screens/Search/Shell.tsx:595 +#: src/screens/Search/Shell.tsx:592 msgid "Cancel search" msgstr "" @@ -2693,7 +2672,7 @@ msgid "Changes to the starter pack will not be reflected in the list after creat msgstr "" #: src/lib/hooks/useNotificationHandler.ts:133 -#: src/Navigation.tsx:500 +#: src/Navigation.tsx:509 #: src/view/shell/bottom-bar/BottomBar.tsx:242 #: src/view/shell/desktop/LeftNav.tsx:698 #: src/view/shell/Drawer.tsx:525 @@ -2733,8 +2712,8 @@ msgctxt "toast" msgid "Chat muted" msgstr "" -#: src/components/moderation/BlockDialog.tsx:289 -#: src/components/moderation/BlockDialog.tsx:317 +#: src/components/moderation/BlockDialog.tsx:286 +#: src/components/moderation/BlockDialog.tsx:314 msgid "Chat not found." msgstr "Chat not found." @@ -2744,15 +2723,16 @@ msgstr "Chat not found." msgid "Chat options" msgstr "Chat options" -#: src/components/moderation/BlockDialog.tsx:293 +#: src/components/moderation/BlockDialog.tsx:290 msgid "Chat owners cannot leave a group chat." msgstr "Chat owners cannot leave a group chat." #: src/components/dms/dialogs/NewChatDialog.tsx:73 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:88 msgid "Chat recipient is not followed by the sender." msgstr "Chat recipient is not followed by the sender." -#: src/Navigation.tsx:520 +#: src/Navigation.tsx:529 msgid "Chat request inbox" msgstr "" @@ -2762,7 +2742,7 @@ msgid "Chat requests" msgstr "" #: src/components/dms/ConvoMenu.tsx:88 -#: src/Navigation.tsx:515 +#: src/Navigation.tsx:524 #: src/screens/Messages/ChatList.tsx:97 #: src/screens/Messages/ChatList.tsx:101 #: src/screens/Messages/ChatList.tsx:671 @@ -2998,7 +2978,7 @@ msgstr "" #: src/components/dms/AfterReportDialog.tsx:212 #: src/components/dms/AfterReportDialog.tsx:217 #: src/components/dms/EmojiPopup.android.tsx:59 -#: src/components/dms/InitiateChatFlow.tsx:565 +#: src/components/dms/InitiateChatFlow.tsx:670 #: src/components/intents/GroupChatJoinDialog.tsx:246 #: src/components/intents/GroupChatJoinDialog.tsx:281 #: src/components/NewskieDialog.tsx:169 @@ -3042,7 +3022,7 @@ msgstr "Close banner" #: src/components/dialogs/LanguageSelectDialog.tsx:322 #: src/components/dialogs/LanguageSelectDialog.tsx:354 #: src/components/dialogs/NotificationSettingsDialog.tsx:102 -#: src/components/moderation/BlockDialog.tsx:199 +#: src/components/moderation/BlockDialog.tsx:201 #: src/components/verification/VerificationsDialog.tsx:138 #: src/components/verification/VerifierDialog.tsx:140 #: src/features/gifPicker/components/GifPickerErrorBoundary.tsx:36 @@ -3065,7 +3045,7 @@ msgstr "" #: src/components/ContextMenu/Backdrop.ios.tsx:53 #: src/components/ContextMenu/Backdrop.ios.tsx:79 #: src/components/ContextMenu/Backdrop.tsx:45 -#: src/components/Lightbox/chrome/ImageMenu.tsx:84 +#: src/components/Lightbox/chrome/ImageMenu.tsx:85 msgid "Close menu" msgstr "" @@ -3127,7 +3107,7 @@ msgid "Comics" msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:46 -#: src/Navigation.tsx:347 +#: src/Navigation.tsx:348 #: src/view/screens/CommunityGuidelines.tsx:28 msgid "Community Guidelines" msgstr "" @@ -3248,7 +3228,7 @@ msgstr "" msgid "Content and media" msgstr "" -#: src/Navigation.tsx:458 +#: src/Navigation.tsx:467 msgid "Content and Media" msgstr "" @@ -3260,10 +3240,6 @@ msgstr "" msgid "Content filters" msgstr "" -#: src/screens/Search/modules/ExploreRecommendations.tsx:60 -msgid "Content from across the network we think you might like." -msgstr "" - #: src/screens/Settings/LanguageSettings.tsx:177 msgid "Content languages" msgstr "" @@ -3278,7 +3254,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" @@ -3321,7 +3297,7 @@ msgid "Continue thread..." msgstr "" #: src/components/dms/AddMembersFlow.tsx:324 -#: src/components/dms/InitiateChatFlow.tsx:493 +#: src/components/dms/InitiateChatFlow.tsx:598 msgid "Continue to group name" msgstr "Continue to group name" @@ -3363,7 +3339,7 @@ msgstr "Conversation not found." msgid "Copied build version to clipboard" msgstr "" -#: src/screens/Search/Shell.tsx:515 +#: src/screens/Search/Shell.tsx:512 msgid "Copied link to clipboard" msgstr "Copied link to clipboard" @@ -3371,7 +3347,7 @@ msgstr "Copied link to clipboard" #: src/components/dms/MessageContextMenu.tsx:97 #: src/components/PostControls/DiscoverDebug.tsx:36 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:273 -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:75 #: src/lib/sharing.ts:24 #: src/lib/sharing.ts:42 msgid "Copied to clipboard" @@ -3405,8 +3381,8 @@ msgstr "" msgid "Copy at:// URI" msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:154 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:157 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:152 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:155 msgid "Copy author DID" msgstr "" @@ -3444,10 +3420,10 @@ msgstr "" msgid "Copy link to list" msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:142 -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:145 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:88 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:91 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:140 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:143 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:86 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:89 msgid "Copy link to post" msgstr "" @@ -3465,8 +3441,8 @@ msgstr "" msgid "Copy message text" msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:145 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:148 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:143 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:146 msgid "Copy post at:// URI" msgstr "" @@ -3485,7 +3461,7 @@ msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:41 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:108 -#: src/Navigation.tsx:352 +#: src/Navigation.tsx:353 #: src/view/screens/CopyrightPolicy.tsx:25 msgid "Copyright Policy" msgstr "" @@ -3535,11 +3511,11 @@ msgstr "" msgid "Could not leave chat" msgstr "" -#: src/components/moderation/BlockDialog.tsx:285 +#: src/components/moderation/BlockDialog.tsx:282 msgid "Could not leave chat." msgstr "Could not leave chat." -#: src/screens/Profile/ProfileFeed/index.tsx:73 +#: src/screens/Profile/ProfileFeed/index.tsx:72 msgid "Could not load feed" msgstr "" @@ -3557,7 +3533,7 @@ msgstr "Could not load profile" msgid "Could not mute chat" msgstr "" -#: src/components/moderation/BlockDialog.tsx:311 +#: src/components/moderation/BlockDialog.tsx:308 msgid "Could not remove member." msgstr "Could not remove member." @@ -3601,7 +3577,7 @@ msgstr "cows pigs" #. Text on button to create a new starter pack #: src/components/dialogs/StarterPackDialog.tsx:113 #: src/components/dialogs/StarterPackDialog.tsx:210 -#: src/components/dms/InitiateChatFlow.tsx:502 +#: src/components/dms/InitiateChatFlow.tsx:607 #: src/components/StarterPack/ProfileStarterPacks.tsx:332 msgid "Create" msgstr "" @@ -3621,7 +3597,7 @@ msgstr "" #: src/components/StarterPack/ProfileStarterPacks.tsx:210 #: src/components/StarterPack/ProfileStarterPacks.tsx:319 -#: src/Navigation.tsx:551 +#: src/Navigation.tsx:560 msgid "Create a starter pack" msgstr "" @@ -3652,7 +3628,7 @@ msgstr "" #: src/components/dialogs/Signin.tsx:87 #: src/components/dialogs/Signin.tsx:89 #: src/screens/Hashtag.tsx:236 -#: src/screens/Search/SearchResults.tsx:391 +#: src/screens/Search/SearchResults.tsx:418 msgid "Create an account" msgstr "" @@ -3669,7 +3645,7 @@ msgstr "" msgid "Create another" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:501 +#: src/components/dms/InitiateChatFlow.tsx:606 msgid "Create group chat" msgstr "Create group chat" @@ -3738,7 +3714,7 @@ msgstr "" msgid "Current beta features" msgstr "Current beta features" -#: src/components/moderation/BlockDialog.tsx:378 +#: src/components/moderation/BlockDialog.tsx:375 msgid "Current chat" msgstr "Current chat" @@ -4074,6 +4050,10 @@ msgstr "" msgid "Discourage apps from showing my account to logged-out users" msgstr "" +#: src/screens/Search/Explore.tsx:448 +msgid "Discover feeds" +msgstr "Discover feeds" + #: src/view/com/posts/FollowingEmptyState.tsx:64 #: src/view/com/posts/FollowingEmptyState.tsx:69 #: src/view/com/posts/FollowingEndOfFeed.tsx:65 @@ -4081,15 +4061,11 @@ msgstr "" msgid "Discover new custom feeds" msgstr "" -#: src/screens/Search/Explore.tsx:453 -msgid "Discover new feeds" -msgstr "" - #: src/view/screens/Feeds.tsx:723 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 "" @@ -4106,11 +4082,11 @@ msgstr "" msgid "Dismiss getting started guide" msgstr "" -#: src/screens/Search/modules/ExploreInterestsCard.tsx:44 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:42 msgid "Dismiss interests" msgstr "" -#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:82 +#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:74 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:64 msgid "Dismiss live event banner" msgstr "" @@ -4215,11 +4191,11 @@ 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 "" -#: src/screens/VideoFeed/index.tsx:1161 +#: src/screens/VideoFeed/index.tsx:1178 msgid "Double tap to like" msgstr "" @@ -4323,6 +4299,7 @@ msgstr "" #: src/screens/Messages/components/EditTextButton.tsx:52 #: src/screens/Settings/AccountSettings.tsx:148 +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:250 #: src/screens/StarterPack/StarterPackScreen.tsx:604 #: src/screens/StarterPack/Wizard/index.tsx:331 #: src/screens/StarterPack/Wizard/index.tsx:336 @@ -4360,8 +4337,8 @@ msgstr "" msgid "Edit interaction settings" msgstr "" -#: src/screens/Search/modules/ExploreInterestsCard.tsx:99 -#: src/screens/Search/modules/ExploreInterestsCard.tsx:106 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:93 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:100 msgid "Edit interests" msgstr "" @@ -4392,7 +4369,7 @@ msgstr "" msgid "Edit moderation list" msgstr "" -#: src/Navigation.tsx:362 +#: src/Navigation.tsx:363 #: src/view/screens/Feeds.tsx:511 msgid "Edit My Feeds" msgstr "" @@ -4401,6 +4378,11 @@ msgstr "" msgid "Edit name" msgstr "Edit name" +#. placeholder {0}: createSanitizedDisplayName( profile, ) +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:242 +msgid "Edit notifications from {0}" +msgstr "Edit notifications from {0}" + #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:114 msgid "Edit People" msgstr "" @@ -4439,7 +4421,7 @@ msgstr "" msgid "Edit who can reply" msgstr "" -#: src/Navigation.tsx:556 +#: src/Navigation.tsx:565 msgid "Edit your starter pack" msgstr "" @@ -4495,8 +4477,8 @@ msgstr "" #: src/components/dialogs/Embed.tsx:105 #: src/components/dialogs/Embed.tsx:109 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:120 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:125 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:118 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:123 msgid "Embed post" msgstr "" @@ -4504,7 +4486,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:64 +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx:66 msgid "Embedded video player" msgstr "" @@ -4545,12 +4527,13 @@ msgstr "" msgid "Enable media players for" msgstr "" +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:139 #: src/view/screens/Storybook/Admonitions.tsx:76 msgid "Enable notifications for an account by visiting their profile and pressing the <0>bell icon <1/>." msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:136 -#: src/screens/Settings/NotificationSettings/index.tsx:140 +#: src/screens/Settings/NotificationSettings/index.tsx:135 +#: src/screens/Settings/NotificationSettings/index.tsx:139 msgid "Enable push notifications" msgstr "" @@ -4645,7 +4628,7 @@ msgstr "" msgid "Enters full screen" msgstr "" -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:224 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:202 msgid "Entertainment" msgstr "" @@ -4679,7 +4662,7 @@ msgstr "" msgid "Error receiving captcha response." msgstr "" -#: src/screens/Search/SearchResults.tsx:179 +#: src/screens/Search/SearchResults.tsx:206 msgid "Error: {error}" msgstr "" @@ -4691,8 +4674,8 @@ msgstr "" msgid "Everybody can reply to this post." msgstr "" -#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:168 -#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:171 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:170 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:173 msgid "Everyone" msgstr "" @@ -4706,8 +4689,8 @@ msgctxt "allow messages from" msgid "Everyone" msgstr "Everyone" -#: src/screens/Settings/NotificationSettings/index.tsx:299 -#: src/screens/Settings/NotificationSettings/index.tsx:401 +#: src/screens/Settings/NotificationSettings/index.tsx:298 +#: src/screens/Settings/NotificationSettings/index.tsx:387 msgid "Everything else" msgstr "" @@ -4750,11 +4733,11 @@ 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 "" -#: src/screens/VideoFeed/index.tsx:1037 +#: src/screens/VideoFeed/index.tsx:1054 msgid "Expands or collapses post text" msgstr "" @@ -4797,8 +4780,8 @@ msgstr "" msgid "Explicit sexual images." msgstr "" -#: src/Navigation.tsx:760 -#: src/screens/Search/Shell.tsx:552 +#: src/Navigation.tsx:769 +#: src/screens/Search/Shell.tsx:549 #: src/view/shell/desktop/LeftNav.tsx:677 #: src/view/shell/Drawer.tsx:473 msgid "Explore" @@ -4839,7 +4822,7 @@ msgstr "" msgid "External media may allow websites to collect information about you and your device. No information is sent or requested until you press the \"play\" button." msgstr "" -#: src/Navigation.tsx:381 +#: src/Navigation.tsx:382 #: src/screens/Settings/ExternalMediaPreferences.tsx:35 msgid "External Media Preferences" msgstr "" @@ -4881,7 +4864,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:513 msgid "Failed to copy link" msgstr "Failed to copy link" @@ -4974,24 +4957,25 @@ msgstr "Failed to leave group chat" msgid "Failed to load conversations" msgstr "" -#: src/screens/Search/Explore.tsx:530 -#: src/screens/Search/Explore.tsx:575 -#: src/screens/Search/Explore.tsx:621 +#: src/screens/Search/Explore.tsx:526 +#: src/screens/Search/Explore.tsx:571 +#: src/screens/Search/Explore.tsx:617 msgid "Failed to load feeds" msgstr "" -#: src/screens/Search/Explore.tsx:489 -#: src/screens/Search/Explore.tsx:544 -#: src/screens/Search/Explore.tsx:589 -#: src/screens/Search/Explore.tsx:635 +#: src/screens/Search/Explore.tsx:485 +#: src/screens/Search/Explore.tsx:540 +#: src/screens/Search/Explore.tsx:585 +#: src/screens/Search/Explore.tsx:631 msgid "Failed to load feeds preferences" msgstr "" #: src/components/dialogs/NotificationSettingsDialog.tsx:85 #: src/screens/Messages/Settings.tsx:369 -#: src/screens/Settings/NotificationSettings/index.tsx:149 -#: src/screens/Settings/NotificationSettings/index.tsx:268 -#: src/screens/Settings/NotificationSettings/index.tsx:285 +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:112 +#: src/screens/Settings/NotificationSettings/index.tsx:148 +#: src/screens/Settings/NotificationSettings/index.tsx:267 +#: src/screens/Settings/NotificationSettings/index.tsx:284 msgid "Failed to load notification settings." msgstr "" @@ -5007,14 +4991,14 @@ msgstr "" msgid "Failed to load profiles" msgstr "Failed to load profiles" -#: src/screens/Search/Explore.tsx:482 -#: src/screens/Search/Explore.tsx:537 -#: src/screens/Search/Explore.tsx:582 -#: src/screens/Search/Explore.tsx:628 +#: src/screens/Search/Explore.tsx:478 +#: src/screens/Search/Explore.tsx:533 +#: src/screens/Search/Explore.tsx:578 +#: src/screens/Search/Explore.tsx:624 msgid "Failed to load suggested feeds" msgstr "" -#: src/screens/Search/Explore.tsx:391 +#: src/screens/Search/Explore.tsx:386 msgid "Failed to load suggested follows" msgstr "" @@ -5186,7 +5170,7 @@ msgstr "" msgid "False information about elections" msgstr "" -#: src/Navigation.tsx:292 +#: src/Navigation.tsx:293 msgid "Feed" msgstr "" @@ -5207,7 +5191,7 @@ msgstr "" msgid "Feed identifier" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:353 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:401 msgid "Feed menu" msgstr "" @@ -5231,10 +5215,10 @@ msgctxt "toast" msgid "Feedback sent to feed operator" msgstr "" -#: src/Navigation.tsx:536 +#: src/Navigation.tsx:545 #: src/screens/SavedFeeds.tsx:112 #: src/screens/SavedFeeds.tsx:303 -#: src/screens/Search/SearchResults.tsx:104 +#: src/screens/Search/SearchResults.tsx:113 #: src/screens/StarterPack/StarterPackScreen.tsx:196 #: src/view/screens/Feeds.tsx:504 #: src/view/screens/Profile.tsx:239 @@ -5255,10 +5239,6 @@ msgctxt "toast" msgid "Feeds updated!" msgstr "" -#: src/screens/Search/modules/ExploreRecommendations.tsx:66 -msgid "Feeds we think you might like." -msgstr "" - #: src/screens/Settings/components/OTAInfo.tsx:61 msgid "Fetch update" msgstr "" @@ -5268,11 +5248,11 @@ msgstr "" msgid "File saved successfully!" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx:48 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:49 msgid "Filter by author" msgstr "Filter by author" -#: src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx:29 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:30 msgid "Filter by author (currently: {currentLabel})" msgstr "Filter by author (currently: {currentLabel})" @@ -5305,7 +5285,7 @@ msgstr "Filter this search by {0}" msgid "Filter who can opt to receive notifications for your activity" msgstr "" -#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:163 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:165 msgid "Filter who you receive notifications from" msgstr "" @@ -5347,8 +5327,8 @@ msgstr "" msgid "Find and invite friends" msgstr "Find and invite friends" -#: src/Navigation.tsx:445 -#: src/Navigation.tsx:578 +#: src/Navigation.tsx:446 +#: src/Navigation.tsx:587 msgid "Find Contacts" msgstr "" @@ -5424,7 +5404,7 @@ msgstr "Focus the search field" #: src/screens/Messages/ConversationSettings/Member.tsx:170 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:157 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:409 -#: src/screens/VideoFeed/index.tsx:920 +#: src/screens/VideoFeed/index.tsx:937 #: src/view/com/notifications/NotificationFeedItem.tsx:864 #: src/view/com/notifications/NotificationFeedItem.tsx:871 msgid "Follow" @@ -5440,7 +5420,7 @@ msgstr "" msgid "Follow {displayName}" msgstr "Follow {displayName}" -#: src/screens/VideoFeed/index.tsx:899 +#: src/screens/VideoFeed/index.tsx:916 msgid "Follow {handle}" msgstr "" @@ -5523,7 +5503,7 @@ msgid "Followers can join" msgstr "Followers can join" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:246 +#: src/Navigation.tsx:247 msgid "Followers of @{0} that you know" msgstr "" @@ -5539,7 +5519,7 @@ msgstr "" #: src/components/ProfileHoverCard/index.web.tsx:505 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:160 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:405 -#: src/screens/VideoFeed/index.tsx:918 +#: src/screens/VideoFeed/index.tsx:935 #: src/view/com/notifications/NotificationFeedItem.tsx:842 #: src/view/com/notifications/NotificationFeedItem.tsx:859 msgid "Following" @@ -5564,7 +5544,7 @@ msgstr "" msgid "Following {displayName}" msgstr "Following {displayName}" -#: src/screens/VideoFeed/index.tsx:898 +#: src/screens/VideoFeed/index.tsx:915 msgid "Following {handle}" msgstr "" @@ -5573,7 +5553,7 @@ msgstr "" msgid "Following feed preferences" msgstr "" -#: src/Navigation.tsx:368 +#: src/Navigation.tsx:369 #: src/screens/Settings/FollowingFeedPreferences.tsx:57 msgid "Following Feed Preferences" msgstr "" @@ -5646,7 +5626,9 @@ msgstr "Four message bubbles representing a group chat. First message: \"Did you msgid "Free your feed" msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:406 +#. Filter search results by a specific post author +#. Filter who you receive notifications from +#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:408 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:159 msgid "From" msgstr "" @@ -5713,39 +5695,39 @@ msgstr "Get early access to experimental features we’re testing." msgid "Get help" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:403 +#: src/screens/Settings/NotificationSettings/index.tsx:389 msgid "Get notifications for starter pack joins, verification, and other activity." msgstr "Get notifications for starter pack joins, verification, and other activity." -#: src/screens/Settings/NotificationSettings/index.tsx:325 +#: src/screens/Settings/NotificationSettings/index.tsx:324 msgid "Get notifications when people follow you." msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:317 +#: src/screens/Settings/NotificationSettings/index.tsx:316 msgid "Get notifications when people like your posts." msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:380 +#: src/screens/Settings/NotificationSettings/index.tsx:366 msgid "Get notifications when people like your reposts." msgstr "Get notifications when people like your reposts." -#: src/screens/Settings/NotificationSettings/index.tsx:341 +#: src/screens/Settings/NotificationSettings/index.tsx:340 msgid "Get notifications when people mention you." msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:349 +#: src/screens/Settings/NotificationSettings/index.tsx:348 msgid "Get notifications when people quote your posts." msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:333 +#: src/screens/Settings/NotificationSettings/index.tsx:332 msgid "Get notifications when people reply to your posts." msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:358 +#: src/screens/Settings/NotificationSettings/index.tsx:357 msgid "Get notifications when people repost your posts." msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:389 +#: src/screens/Settings/NotificationSettings/index.tsx:375 msgid "Get notifications when people repost your reposts." msgstr "Get notifications when people repost your reposts." @@ -5757,15 +5739,15 @@ msgstr "Get notifications when people send you message requests." msgid "Get notifications when people send you messages." msgstr "Get notifications when people send you messages." -#: src/screens/Settings/NotificationSettings/index.tsx:367 -msgid "Get notifications when there's activity on posts you're subscribed to." -msgstr "Get notifications when there's activity on posts you're subscribed to." - #: src/components/activity-notifications/SubscribeProfileButton.tsx:89 #: src/components/activity-notifications/SubscribeProfileButton.tsx:90 msgid "Get notified about new posts" msgstr "" +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:106 +msgid "Get notified about posts and replies from accounts you choose." +msgstr "Get notified about posts and replies from accounts you choose." + #: src/components/activity-notifications/SubscribeProfileDialog.tsx:226 msgid "Get notified of new posts from {name}" msgstr "" @@ -5808,17 +5790,17 @@ 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 #: src/screens/ProfileList/components/ErrorScreen.tsx:35 #: src/screens/ProfileList/components/ErrorScreen.tsx:41 #: src/screens/VideoFeed/components/Header.tsx:163 -#: src/screens/VideoFeed/index.tsx:1222 -#: src/screens/VideoFeed/index.tsx:1226 +#: src/screens/VideoFeed/index.tsx:1239 +#: src/screens/VideoFeed/index.tsx:1243 #: src/view/com/auth/LoggedOut.tsx:127 #: src/view/com/profile/ProfileFollowers.tsx:211 #: src/view/com/profile/ProfileFollowers.tsx:212 @@ -5871,7 +5853,7 @@ msgid "Go live for" msgstr "" #. placeholder {0}: name.displayName -#: src/screens/PostThread/components/LikesStat.tsx:152 +#: src/screens/PostThread/components/LikesStat.tsx:146 msgid "Go to {0}'s profile" msgstr "Go to {0}'s profile" @@ -5975,7 +5957,7 @@ msgctxt "toast" msgid "Group chat name updated" msgstr "Group chat name updated" -#: src/Navigation.tsx:505 +#: src/Navigation.tsx:514 #: src/screens/Messages/ConversationSettings/index.tsx:113 msgid "Group chat settings" msgstr "Group chat settings" @@ -6005,16 +5987,17 @@ msgid "Group chats can only have a maximum of {0, plural, other {# people}}." msgstr "Group chats can only have a maximum of {0, plural, other {# people}}." #: src/components/dialogs/SearchablePeopleList.tsx:565 +#: src/components/dms/InitiateChatFlow.tsx:1048 msgid "Group is locked" msgstr "Group is locked" -#: src/components/dms/InitiateChatFlow.tsx:264 -#: src/components/dms/InitiateChatFlow.tsx:600 +#: src/components/dms/InitiateChatFlow.tsx:292 +#: src/components/dms/InitiateChatFlow.tsx:705 #: src/screens/Messages/ConversationSettings/prompts.tsx:50 msgid "Group name" msgstr "Group name" -#: src/components/dms/InitiateChatFlow.tsx:625 +#: src/components/dms/InitiateChatFlow.tsx:730 #: src/screens/Messages/ConversationSettings/prompts.tsx:69 msgid "Group name is too long. {MAX_GROUP_NAME_GRAPHEME_LENGTH, plural, other {The maximum number of characters is #.}}" msgstr "Group name is too long. {MAX_GROUP_NAME_GRAPHEME_LENGTH, plural, other {The maximum number of characters is #.}}" @@ -6072,7 +6055,7 @@ msgstr "" msgid "Harming or endangering minors" msgstr "" -#: src/Navigation.tsx:489 +#: src/Navigation.tsx:498 msgid "Hashtag" msgstr "" @@ -6135,7 +6118,7 @@ msgstr "" msgid "Hidden" msgstr "" -#: src/screens/VideoFeed/index.tsx:697 +#: src/screens/VideoFeed/index.tsx:714 msgid "Hidden by your moderation settings." msgstr "" @@ -6143,9 +6126,9 @@ msgstr "" msgid "Hidden list" msgstr "" -#: src/components/interstitials/Trending.tsx:133 +#: src/components/interstitials/Trending.tsx:143 #: 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 @@ -6153,7 +6136,7 @@ msgstr "" #: src/lib/moderation/useLabelBehaviorDescription.ts:23 #: src/lib/moderation/useLabelBehaviorDescription.ts:28 #: src/lib/moderation/useLabelBehaviorDescription.ts:33 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:128 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:137 msgid "Hide" msgstr "" @@ -6193,7 +6176,7 @@ msgstr "" msgid "Hide reply for me" msgstr "" -#: src/screens/Search/modules/ExploreInterestsCard.tsx:111 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:105 msgid "Hide this card" msgstr "" @@ -6213,12 +6196,12 @@ msgstr "" msgid "Hide translation" msgstr "Hide translation" -#: src/components/interstitials/Trending.tsx:115 +#: src/components/interstitials/Trending.tsx:125 msgid "Hide trending topics" msgstr "" -#: src/components/interstitials/Trending.tsx:131 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:126 +#: src/components/interstitials/Trending.tsx:141 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:135 msgid "Hide trending topics?" msgstr "" @@ -6235,7 +6218,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 "" @@ -6288,8 +6271,8 @@ msgstr "" msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" msgstr "" -#: src/Navigation.tsx:755 -#: src/Navigation.tsx:776 +#: src/Navigation.tsx:764 +#: src/Navigation.tsx:785 #: src/view/shell/bottom-bar/BottomBar.tsx:196 #: src/view/shell/desktop/LeftNav.tsx:667 #: src/view/shell/Drawer.tsx:499 @@ -6316,10 +6299,6 @@ msgstr "Hosting provider: {0}" msgid "Hosting provider: Bluesky" msgstr "Hosting provider: Bluesky" -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:179 -msgid "Hot" -msgstr "" - #: src/components/contacts/components/InviteInfo.tsx:54 msgid "How it works:" msgstr "" @@ -6404,6 +6383,7 @@ msgstr "" msgid "If you want to change your password, we will send you a code to verify that this is your account." msgstr "" +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:157 #: src/view/screens/Storybook/Admonitions.tsx:90 msgid "If you want to restrict who can receive notifications for your account's activity, you can change this in <0>Settings β†’ Privacy and Security." msgstr "" @@ -6555,6 +6535,7 @@ msgstr "" #. Advanced search filter #. Advanced search filter #. Advanced search filter +#. Include search results with or without replies #: src/screens/Search/components/AdvancedSearchDialog/FilterBlock.tsx:47 #: src/screens/Search/components/AdvancedSearchDialog/FilterBlock.tsx:58 #: src/screens/Search/components/AdvancedSearchDialog/FilterBlock.tsx:75 @@ -6826,11 +6807,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 "" @@ -6859,7 +6840,7 @@ msgstr "" msgid "Language" msgstr "Language" -#: src/Navigation.tsx:219 +#: src/Navigation.tsx:220 msgid "Language Settings" msgstr "" @@ -6884,7 +6865,7 @@ msgid "Last initiated just now" msgstr "" #: src/screens/Hashtag.tsx:97 -#: src/screens/Search/SearchResults.tsx:86 +#: src/screens/Search/SearchResults.tsx:95 #: src/screens/Topic.tsx:64 msgid "Latest" msgstr "" @@ -6899,11 +6880,11 @@ msgctxt "english-only-resource" msgid "Learn more" msgstr "" -#: src/components/moderation/ScreenHider.tsx:147 +#: src/components/moderation/ScreenHider.tsx:143 msgid "Learn More" msgstr "" -#: src/screens/Search/SearchResults.tsx:246 +#: src/screens/Search/SearchResults.tsx:273 msgctxt "english-only-resource" msgid "Learn more about <0>how to use advanced search." msgstr "Learn more about <0>how to use advanced search." @@ -6932,8 +6913,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 +6927,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 +6951,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 "" @@ -6988,8 +6969,8 @@ msgstr "Leave" #: src/components/dms/MessagesListBlockedFooter.tsx:109 #: src/components/dms/MessagesListBlockedFooter.tsx:116 -#: src/components/moderation/BlockDialog.tsx:384 -#: src/components/moderation/BlockDialog.tsx:391 +#: src/components/moderation/BlockDialog.tsx:381 +#: src/components/moderation/BlockDialog.tsx:388 #: src/screens/Messages/components/ChatEnded.tsx:66 #: src/screens/Messages/components/ChatLocked.tsx:112 msgid "Leave chat" @@ -7029,7 +7010,7 @@ msgstr "" msgid "Leaving this chat will lock it permanently and you won’t be able to rejoin." msgstr "Leaving this chat will lock it permanently and you won’t be able to rejoin." -#: src/components/moderation/BlockDialog.tsx:277 +#: src/components/moderation/BlockDialog.tsx:274 msgid "Left group chat." msgstr "Left group chat." @@ -7061,7 +7042,7 @@ msgctxt "Name of app icon variant" msgid "Light" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:509 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:564 msgid "Like" msgstr "" @@ -7080,7 +7061,7 @@ msgstr "" msgid "Like 10 posts to train the Discover feed" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:497 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:552 msgid "Like this feed" msgstr "" @@ -7088,8 +7069,8 @@ msgstr "" msgid "Like this labeler" msgstr "" -#: src/Navigation.tsx:297 -#: src/Navigation.tsx:302 +#: src/Navigation.tsx:298 +#: src/Navigation.tsx:303 msgid "Liked by" msgstr "" @@ -7106,27 +7087,45 @@ msgstr "" msgid "Liked by {0, plural, one {# user} other {# users}}" msgstr "" +#. Social proof below the post stats; the bolded name is a person the viewer follows who liked the post +#. placeholder {0}: nameLink(names[0]) +#. placeholder {0}: names[0].displayName +#: src/screens/PostThread/components/LikesStat.tsx:132 +#: src/screens/PostThread/components/LikesStat.tsx:173 +msgid "Liked by {0}" +msgstr "Liked by {0}" + +#. Social proof below the post stats; the bolded names are people the viewer follows who liked the post +#. placeholder {0}: nameLink(names[0]) +#. placeholder {0}: names[0].displayName +#. placeholder {1}: nameLink(names[1]) +#. placeholder {1}: names[1].displayName +#: src/screens/PostThread/components/LikesStat.tsx:131 +#: src/screens/PostThread/components/LikesStat.tsx:169 +msgid "Liked by {0} and {1}" +msgstr "Liked by {0} and {1}" + #: src/components/LabelingServiceCard/index.tsx:96 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:486 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:540 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:169 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:183 msgid "Liked by {likeCount, plural, one {# user} other {# users}}" msgstr "" #: src/lib/hooks/useNotificationHandler.ts:158 -#: src/screens/Settings/NotificationSettings/index.tsx:160 -#: src/screens/Settings/NotificationSettings/index.tsx:315 +#: src/screens/Settings/NotificationSettings/index.tsx:159 +#: src/screens/Settings/NotificationSettings/index.tsx:314 #: src/view/screens/Profile.tsx:238 msgid "Likes" msgstr "" #: src/lib/hooks/useNotificationHandler.ts:200 -#: src/screens/Settings/NotificationSettings/index.tsx:239 -#: src/screens/Settings/NotificationSettings/index.tsx:378 +#: src/screens/Settings/NotificationSettings/index.tsx:238 +#: src/screens/Settings/NotificationSettings/index.tsx:364 msgid "Likes of your reposts" msgstr "" -#: src/screens/PostThread/components/LikesStat.tsx:85 +#: src/screens/PostThread/components/LikesStat.tsx:39 msgid "Likes on this post" msgstr "" @@ -7139,7 +7138,7 @@ msgstr "" msgid "Link copied to clipboard" msgstr "Link copied to clipboard" -#: src/Navigation.tsx:252 +#: src/Navigation.tsx:253 msgid "List" msgstr "" @@ -7225,7 +7224,7 @@ msgctxt "toast" msgid "List unmuted" msgstr "" -#: src/Navigation.tsx:173 +#: src/Navigation.tsx:174 #: src/view/screens/Lists.tsx:60 #: src/view/screens/Profile.tsx:233 #: src/view/screens/Profile.tsx:241 @@ -7253,12 +7252,12 @@ msgstr "" msgid "Live event happening now: {0}" msgstr "" -#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:51 +#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:43 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:33 msgid "Live event hidden" msgstr "" -#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:53 +#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:45 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:35 msgid "Live event unhidden" msgstr "" @@ -7274,12 +7273,12 @@ msgstr "" msgid "Live link" msgstr "" -#: src/screens/Search/Explore.tsx:90 +#: src/screens/Search/Explore.tsx:87 msgid "Load more" msgstr "" -#: src/screens/Search/Explore.tsx:519 -#: src/screens/Search/Explore.tsx:610 +#: src/screens/Search/Explore.tsx:515 +#: src/screens/Search/Explore.tsx:606 msgid "Load more suggested feeds" msgstr "" @@ -7287,7 +7286,7 @@ msgstr "" msgid "Load new notifications" msgstr "" -#: src/screens/Profile/ProfileFeed/index.tsx:206 +#: src/screens/Profile/ProfileFeed/index.tsx:208 #: src/screens/Profile/Sections/Feed.tsx:118 #: src/screens/ProfileList/FeedSection.tsx:113 #: src/view/com/feeds/FeedPage.tsx:168 @@ -7331,7 +7330,7 @@ msgstr "Lock this group chat" msgid "Locked" msgstr "Locked" -#: src/Navigation.tsx:327 +#: src/Navigation.tsx:328 msgid "Log" msgstr "" @@ -7451,6 +7450,10 @@ msgstr "Marked all requests as read" msgid "Maybe later" msgstr "" +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:24 +msgid "Me" +msgstr "Me" + #: src/screens/Search/components/AdvancedSearchDialog/index.tsx:375 #: src/view/screens/Profile.tsx:236 msgid "Media" @@ -7470,7 +7473,7 @@ msgstr "" msgid "Media that may be disturbing or inappropriate for some audiences." msgstr "" -#: src/components/moderation/BlockDialog.tsx:303 +#: src/components/moderation/BlockDialog.tsx:300 msgid "Member removed from group chat." msgstr "Member removed from group chat." @@ -7488,8 +7491,8 @@ msgid "mentioned users" msgstr "" #: src/lib/hooks/useNotificationHandler.ts:179 -#: src/screens/Settings/NotificationSettings/index.tsx:193 -#: src/screens/Settings/NotificationSettings/index.tsx:340 +#: src/screens/Settings/NotificationSettings/index.tsx:192 +#: src/screens/Settings/NotificationSettings/index.tsx:339 #: src/view/screens/Notifications.tsx:99 msgid "Mentions" msgstr "" @@ -7558,7 +7561,7 @@ msgstr "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" msgid "Message options" msgstr "" -#: src/Navigation.tsx:770 +#: src/Navigation.tsx:779 msgid "Messages" msgstr "" @@ -7587,7 +7590,7 @@ msgstr "" msgid "Missing media" msgstr "" -#: src/Navigation.tsx:178 +#: src/Navigation.tsx:179 #: src/screens/Moderation/index.tsx:100 msgid "Moderation" msgstr "" @@ -7631,7 +7634,7 @@ msgstr "" msgid "Moderation lists" msgstr "" -#: src/Navigation.tsx:183 +#: src/Navigation.tsx:184 #: src/view/screens/ModerationModlists.tsx:60 msgid "Moderation Lists" msgstr "" @@ -7640,7 +7643,7 @@ msgstr "" msgid "moderation settings" msgstr "" -#: src/Navigation.tsx:312 +#: src/Navigation.tsx:313 msgid "Moderation states" msgstr "" @@ -7781,7 +7784,7 @@ msgstr "Muted" msgid "Muted accounts" msgstr "" -#: src/Navigation.tsx:188 +#: src/Navigation.tsx:189 #: src/view/screens/ModerationMutedAccounts.tsx:107 msgid "Muted Accounts" msgstr "" @@ -7862,7 +7865,6 @@ msgstr "" msgid "Nevermind, create a handle for me" msgstr "" -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:184 #: src/view/com/profile/ProfileMenu.tsx:398 msgid "New" msgstr "" @@ -7926,25 +7928,25 @@ msgid "New Feature" msgstr "" #: src/lib/hooks/useNotificationHandler.ts:193 -#: src/screens/Settings/NotificationSettings/index.tsx:171 -#: src/screens/Settings/NotificationSettings/index.tsx:324 +#: src/screens/Settings/NotificationSettings/index.tsx:170 +#: src/screens/Settings/NotificationSettings/index.tsx:323 msgid "New followers" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:249 -#: src/components/dms/InitiateChatFlow.tsx:263 +#: src/components/dms/InitiateChatFlow.tsx:277 +#: src/components/dms/InitiateChatFlow.tsx:291 msgid "New group chat" msgstr "New group chat" #. Button used to create a new group chat. #. Button used to create a new group chat. -#: src/components/dms/InitiateChatFlow.tsx:800 -#: src/components/dms/InitiateChatFlow.tsx:834 +#: src/components/dms/InitiateChatFlow.tsx:905 +#: src/components/dms/InitiateChatFlow.tsx:939 msgctxt "action" msgid "New group chat" msgstr "New group chat" -#: src/components/dms/InitiateChatFlow.tsx:300 +#: src/components/dms/InitiateChatFlow.tsx:332 msgid "New group chat with:" msgstr "New group chat with:" @@ -7961,13 +7963,13 @@ msgstr "" #: src/screens/Messages/Settings.tsx:289 #: src/screens/Settings/NotificationSettings/components/ChatNotificationDialogs.tsx:31 -#: src/screens/Settings/NotificationSettings/index.tsx:282 +#: src/screens/Settings/NotificationSettings/index.tsx:281 msgid "New message requests" msgstr "New message requests" #: src/screens/Messages/Settings.tsx:268 #: src/screens/Settings/NotificationSettings/components/ChatNotificationDialogs.tsx:21 -#: src/screens/Settings/NotificationSettings/index.tsx:265 +#: src/screens/Settings/NotificationSettings/index.tsx:264 msgid "New messages" msgstr "New messages" @@ -7977,7 +7979,7 @@ msgstr "New messages" msgid "New password" msgstr "" -#: src/screens/Profile/ProfileFeed/index.tsx:217 +#: src/screens/Profile/ProfileFeed/index.tsx:218 #: src/screens/ProfileList/index.tsx:237 #: src/screens/ProfileList/index.tsx:280 #: src/view/screens/Feeds.tsx:545 @@ -8020,14 +8022,14 @@ msgid "Newest replies first" msgstr "" #: src/lib/interests.ts:67 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:226 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:204 msgid "News" msgstr "" #: src/components/contacts/screens/ViewMatches.tsx:395 #: src/components/contacts/screens/ViewMatches.tsx:410 #: src/components/dms/AddMembersFlow.tsx:325 -#: src/components/dms/InitiateChatFlow.tsx:494 +#: src/components/dms/InitiateChatFlow.tsx:599 #: src/screens/Login/ForgotPasswordForm.tsx:143 #: src/screens/Login/ForgotPasswordForm.tsx:150 #: src/screens/Login/SetNewPasswordForm.tsx:171 @@ -8212,13 +8214,13 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:250 #: src/components/dms/AddMembersFlow.tsx:257 -#: src/components/dms/InitiateChatFlow.tsx:376 +#: src/components/dms/InitiateChatFlow.tsx:469 #: src/components/ProgressGuide/FollowDialog.tsx:221 msgid "No results" msgstr "" #. placeholder {0}: interestsDisplayNames[selectedInterest] -#: src/screens/Search/Explore.tsx:828 +#: src/screens/Search/Explore.tsx:817 msgid "No results for \"{0}\"." msgstr "" @@ -8231,19 +8233,19 @@ msgstr "" msgid "No results found for \"{query}\"" msgstr "" -#: src/screens/Search/SearchResults.tsx:206 +#: src/screens/Search/SearchResults.tsx:233 msgid "No results found for β€œ<0>{query}” with advanced search filters applied." msgstr "No results found for β€œ<0>{query}” with advanced search filters applied." -#: src/screens/Search/SearchResults.tsx:218 +#: src/screens/Search/SearchResults.tsx:245 msgid "No results found for β€œ<0>{query}”." msgstr "No results found for β€œ<0>{query}”." -#: src/screens/Search/SearchResults.tsx:212 +#: src/screens/Search/SearchResults.tsx:239 msgid "No results found for your query with advanced search filters applied." msgstr "No results found for your query with advanced search filters applied." -#: src/screens/Search/Explore.tsx:832 +#: src/screens/Search/Explore.tsx:821 msgid "No results." msgstr "" @@ -8293,6 +8295,10 @@ msgstr "" msgid "Non-sexual Nudity" msgstr "" +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:220 +msgid "None" +msgstr "None" + #: src/screens/Search/components/AdvancedSearchDialog/index.tsx:263 #: src/screens/Search/components/AdvancedSearchDialog/index.tsx:266 msgid "None of these words" @@ -8311,7 +8317,7 @@ msgstr "" msgid "Not followed by anyone you’re following" msgstr "Not followed by anyone you’re following" -#: src/Navigation.tsx:168 +#: src/Navigation.tsx:169 #: src/view/screens/Profile.tsx:132 msgid "Not Found" msgstr "" @@ -8328,7 +8334,7 @@ msgstr "" msgid "Note: Bluesky is an open and public network. This setting only limits the visibility of your content on the Bluesky app and website, and other apps may not respect this setting. Your content may still be shown to logged-out users by other apps and websites." msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:135 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:133 msgid "Note: This post is only visible to logged-in users." msgstr "" @@ -8337,8 +8343,8 @@ msgid "Nothing saved yet" msgstr "" #: src/components/dialogs/NotificationSettingsDialog.tsx:72 -#: src/Navigation.tsx:452 -#: src/Navigation.tsx:531 +#: src/Navigation.tsx:453 +#: src/Navigation.tsx:540 #: src/view/screens/Notifications.tsx:134 msgid "Notification settings" msgstr "" @@ -8348,11 +8354,12 @@ msgstr "" msgid "Notification sounds" msgstr "" -#: src/Navigation.tsx:526 -#: src/Navigation.tsx:765 +#: src/Navigation.tsx:535 +#: src/Navigation.tsx:774 #: src/screens/Messages/Settings.tsx:255 #: src/screens/Notifications/ActivityList.tsx:31 -#: src/screens/Settings/NotificationSettings/index.tsx:126 +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:93 +#: src/screens/Settings/NotificationSettings/index.tsx:125 #: src/screens/Settings/Settings.tsx:198 #: src/screens/Settings/Settings.tsx:201 #: src/view/screens/Notifications.tsx:128 @@ -8391,7 +8398,7 @@ msgstr "" #. Confirm button text. #: src/components/contacts/screens/GetContacts.tsx:317 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:227 -#: src/screens/Search/modules/ExploreInterestsCard.tsx:49 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:44 #: src/screens/Settings/AppIconSettings/index.tsx:46 #: src/screens/Settings/AppIconSettings/index.tsx:232 msgid "OK" @@ -8399,10 +8406,10 @@ msgstr "" #: src/components/BotAccountAlert.tsx:52 #: src/components/BotAccountAlert.tsx:57 -#: src/components/dms/InitiateChatFlow.tsx:733 +#: src/components/dms/InitiateChatFlow.tsx:838 #: src/components/dms/MessageItem.tsx:742 #: src/screens/Login/PasswordUpdatedForm.tsx:35 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:641 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:642 msgid "Okay" msgstr "" @@ -8426,10 +8433,12 @@ msgid "Onboarding reset" msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:117 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:133 msgid "One of the selected recipients does not allow group chats." msgstr "One of the selected recipients does not allow group chats." #: src/components/dms/dialogs/NewChatDialog.tsx:100 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:116 msgid "One of the selected recipients has blocked you and cannot be messaged." msgstr "One of the selected recipients has blocked you and cannot be messaged." @@ -8565,7 +8574,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 "" @@ -8574,12 +8583,13 @@ msgstr "" msgid "Open emoji picker" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:192 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:214 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:228 msgid "Open feed info screen" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:293 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:298 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:338 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:343 msgid "Open feed options menu" msgstr "" @@ -8622,7 +8632,7 @@ msgstr "" msgid "Open muted words and tags settings" msgstr "" -#: src/screens/Search/components/StarterPackCard.tsx:120 +#: src/screens/Search/components/StarterPackCard.tsx:128 msgid "Open pack" msgstr "" @@ -8925,7 +8935,7 @@ msgid "Pause video" msgstr "" #: src/screens/ProfileList/index.tsx:167 -#: src/screens/Search/SearchResults.tsx:98 +#: src/screens/Search/SearchResults.tsx:107 #: src/screens/StarterPack/StarterPackScreen.tsx:195 msgid "People" msgstr "" @@ -8939,17 +8949,18 @@ msgid "People {ownerName} follows can request to join" msgstr "People {ownerName} follows can request to join" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:239 +#: src/Navigation.tsx:240 msgid "People followed by @{0}" msgstr "" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:232 +#: src/Navigation.tsx:233 msgid "People following @{0}" msgstr "" -#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:178 -#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:182 +#: src/screens/Search/components/AdvancedSearchDialog/FromDropdown.tsx:23 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:180 +#: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:184 msgid "People I follow" msgstr "" @@ -8972,7 +8983,6 @@ msgid "People I follow can request to join" msgstr "People I follow can request to join" #: src/components/dialogs/PostInteractionSettingsDialog.tsx:510 -#: src/screens/Search/components/AdvancedSearchDialog/FollowingDropdown.tsx:23 msgid "People you follow" msgstr "" @@ -9018,8 +9028,8 @@ msgid "Pictures meant for adults." msgstr "" #: src/components/FeedCard.tsx:364 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:514 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:520 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:569 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:578 #: src/screens/SavedFeeds.tsx:559 msgid "Pin feed" msgstr "" @@ -9029,7 +9039,7 @@ msgstr "" msgid "Pin to home" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:337 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:385 msgid "Pin to Home" msgstr "" @@ -9043,8 +9053,8 @@ msgid "Pinned" msgstr "" #. placeholder {0}: info.displayName -#: src/screens/Profile/components/ProfileFeedHeader.tsx:159 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:173 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:166 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:180 msgid "Pinned {0} to Home" msgstr "" @@ -9071,7 +9081,7 @@ msgstr "" msgid "Play GIF" msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.tsx:169 +#: src/components/Post/Embed/VideoEmbed/index.tsx:178 #: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:333 msgid "Play video" msgstr "" @@ -9256,7 +9266,7 @@ msgid "Please write your message below:" msgstr "" #: src/lib/interests.ts:70 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:220 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:198 msgid "Politics" msgstr "" @@ -9298,10 +9308,10 @@ msgid "Post blocked" msgstr "" #. placeholder {0}: route.params.name -#: src/Navigation.tsx:265 -#: src/Navigation.tsx:272 -#: src/Navigation.tsx:279 -#: src/Navigation.tsx:286 +#: src/Navigation.tsx:266 +#: src/Navigation.tsx:273 +#: src/Navigation.tsx:280 +#: src/Navigation.tsx:287 msgid "Post by @{0}" msgstr "" @@ -9335,7 +9345,7 @@ msgstr "" msgid "Post interaction settings" msgstr "" -#: src/Navigation.tsx:199 +#: src/Navigation.tsx:200 #: src/screens/ModerationInteractionSettings/index.tsx:35 msgid "Post Interaction Settings" msgstr "" @@ -9372,6 +9382,7 @@ msgstr "" #: src/components/activity-notifications/SubscribeProfileDialog.tsx:257 #: src/components/activity-notifications/SubscribeProfileDialog.tsx:269 #: src/screens/ProfileList/index.tsx:167 +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:216 #: src/screens/StarterPack/StarterPackScreen.tsx:197 #: src/view/screens/Profile.tsx:234 msgid "Posts" @@ -9389,6 +9400,10 @@ msgstr "" msgid "Posts hidden" msgstr "" +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:214 +msgid "Posts, Replies" +msgstr "Posts, Replies" + #: src/components/dialogs/LinkWarning.tsx:89 msgid "Potentially misleading link" msgstr "" @@ -9440,20 +9455,21 @@ msgstr "" msgid "Privacy and security" msgstr "" -#: src/Navigation.tsx:429 -#: src/Navigation.tsx:437 +#: src/Navigation.tsx:430 +#: src/Navigation.tsx:438 #: src/screens/Settings/ActivityPrivacySettings.tsx:41 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:45 msgid "Privacy and Security" msgstr "" +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:161 #: src/view/screens/Storybook/Admonitions.tsx:94 msgid "Privacy and Security settings" msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:36 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:103 -#: src/Navigation.tsx:337 +#: src/Navigation.tsx:338 #: src/screens/Settings/AboutSettings.tsx:102 #: src/screens/Settings/AboutSettings.tsx:105 #: src/view/screens/PrivacyPolicy.tsx:25 @@ -9590,12 +9606,12 @@ msgstr "" #: src/lib/hooks/useNotificationHandler.ts:186 #: src/screens/Post/PostQuotes.tsx:31 -#: src/screens/Settings/NotificationSettings/index.tsx:204 -#: src/screens/Settings/NotificationSettings/index.tsx:347 +#: src/screens/Settings/NotificationSettings/index.tsx:203 +#: src/screens/Settings/NotificationSettings/index.tsx:346 msgid "Quotes" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:466 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:465 msgid "Quotes of this post" msgstr "" @@ -9638,7 +9654,7 @@ msgstr "" msgid "Read {0, plural, one {# more reply} other {# more replies}}" msgstr "" -#: src/screens/Search/SearchResults.tsx:249 +#: src/screens/Search/SearchResults.tsx:276 msgctxt "english-only-resource" msgid "Read about how to use advanced search filters" msgstr "Read about how to use advanced search filters" @@ -9648,11 +9664,11 @@ msgstr "Read about how to use advanced search filters" msgid "Read blog post" msgstr "" -#: src/screens/VideoFeed/index.tsx:1038 +#: src/screens/VideoFeed/index.tsx:1055 msgid "Read less" msgstr "" -#: src/screens/VideoFeed/index.tsx:1038 +#: src/screens/VideoFeed/index.tsx:1055 msgid "Read more" msgstr "" @@ -9712,10 +9728,6 @@ msgstr "Recent searches" msgid "Recently used" msgstr "" -#: src/screens/Search/modules/ExploreRecommendations.tsx:55 -msgid "Recommended" -msgstr "" - #: src/screens/Messages/components/MessageListError.tsx:19 msgid "Reconnect" msgstr "" @@ -9836,8 +9848,8 @@ msgstr "Remove filter" msgid "Remove from chat" msgstr "Remove from chat" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:320 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:325 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:368 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:373 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:176 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:179 #: src/screens/SavedFeeds.tsx:549 @@ -9871,8 +9883,8 @@ msgstr "" msgid "Remove live status" msgstr "" -#: src/components/moderation/BlockDialog.tsx:365 -#: src/components/moderation/BlockDialog.tsx:372 +#: src/components/moderation/BlockDialog.tsx:362 +#: src/components/moderation/BlockDialog.tsx:369 msgid "Remove member" msgstr "Remove member" @@ -9943,7 +9955,7 @@ msgstr "" msgid "Removed from starter pack" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:121 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:128 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:77 #: src/view/com/posts/FeedShutdownMsg.tsx:45 msgid "Removed from your feeds" @@ -10009,8 +10021,9 @@ msgstr "Replied-to message, tap to scroll to it" #: src/components/activity-notifications/SubscribeProfileDialog.tsx:274 #: src/components/activity-notifications/SubscribeProfileDialog.tsx:286 #: src/lib/hooks/useNotificationHandler.ts:172 -#: src/screens/Settings/NotificationSettings/index.tsx:182 -#: src/screens/Settings/NotificationSettings/index.tsx:331 +#: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:218 +#: src/screens/Settings/NotificationSettings/index.tsx:181 +#: src/screens/Settings/NotificationSettings/index.tsx:330 #: src/view/screens/Profile.tsx:235 msgid "Replies" msgstr "" @@ -10089,8 +10102,8 @@ msgstr "" msgid "Report dialog" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:536 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:542 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:596 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:602 msgid "Report feed" msgstr "" @@ -10198,18 +10211,18 @@ msgid "Reposted by you" msgstr "" #: src/lib/hooks/useNotificationHandler.ts:165 -#: src/screens/Settings/NotificationSettings/index.tsx:215 -#: src/screens/Settings/NotificationSettings/index.tsx:356 +#: src/screens/Settings/NotificationSettings/index.tsx:214 +#: src/screens/Settings/NotificationSettings/index.tsx:355 msgid "Reposts" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:445 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:444 msgid "Reposts of this post" msgstr "" #: src/lib/hooks/useNotificationHandler.ts:207 -#: src/screens/Settings/NotificationSettings/index.tsx:252 -#: src/screens/Settings/NotificationSettings/index.tsx:387 +#: src/screens/Settings/NotificationSettings/index.tsx:251 +#: src/screens/Settings/NotificationSettings/index.tsx:373 msgid "Reposts of your reposts" msgstr "" @@ -10244,7 +10257,7 @@ msgstr "Requested" msgid "Requests" msgstr "Requests" -#: src/Navigation.tsx:510 +#: src/Navigation.tsx:519 #: src/screens/Messages/JoinRequests.tsx:59 #: src/screens/Messages/JoinRequests.tsx:425 msgid "Requests to join" @@ -10386,7 +10399,7 @@ msgstr "" #: src/screens/ProfileList/components/ErrorScreen.tsx:36 #: src/screens/Settings/components/ChangeHandleDialog.tsx:577 -#: src/screens/VideoFeed/index.tsx:1223 +#: src/screens/VideoFeed/index.tsx:1240 #: src/view/screens/NotFound.tsx:54 msgid "Returns to previous page" msgstr "" @@ -10457,7 +10470,7 @@ msgstr "" msgid "Save draft?" msgstr "" -#: src/components/Lightbox/chrome/ImageMenu.tsx:98 +#: src/components/Lightbox/chrome/ImageMenu.tsx:99 #: src/components/MediaPreview.tsx:231 #: src/components/Post/Embed/ImageContextMenu.tsx:70 #: src/components/StarterPack/ShareDialog.tsx:144 @@ -10478,8 +10491,8 @@ msgstr "" msgid "Save these options for next time" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:320 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:326 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:368 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:374 msgid "Save to my feeds" msgstr "" @@ -10495,12 +10508,12 @@ msgid "Saved Feeds" msgstr "" #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:145 -#: src/Navigation.tsx:570 +#: src/Navigation.tsx:579 #: src/screens/Bookmarks.tsx:59 msgid "Saved Posts" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:131 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:138 #: src/screens/ProfileList/components/Header.tsx:88 msgid "Saved to your feeds" msgstr "" @@ -10526,7 +10539,7 @@ msgstr "Scan" #: src/features/inviteFriends/InviteScannerScreen.tsx:82 #: src/features/inviteFriends/InviteScannerScreen.web.tsx:23 -#: src/Navigation.tsx:317 +#: src/Navigation.tsx:318 msgid "Scan QR code" msgstr "Scan QR code" @@ -10555,8 +10568,8 @@ 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:549 +#: src/screens/Search/Shell.tsx:612 #: src/screens/Search/Shell.tsx:753 #: src/view/shell/bottom-bar/BottomBar.tsx:216 msgid "Search" @@ -10564,7 +10577,7 @@ msgstr "" #. placeholder {0}: profile.handle #. placeholder {0}: route.params.name -#: src/Navigation.tsx:258 +#: src/Navigation.tsx:259 #: src/screens/Profile/ProfileSearch.tsx:37 msgid "Search @{0}'s posts" msgstr "" @@ -10603,11 +10616,11 @@ msgstr "Search for {q}" msgid "Search for feeds that you want to suggest to others." msgstr "" -#: src/screens/Search/Explore.tsx:378 +#: src/screens/Search/Explore.tsx:373 msgid "Search for more accounts" msgstr "" -#: src/screens/Search/Explore.tsx:456 +#: src/screens/Search/Explore.tsx:452 msgid "Search for more feeds" msgstr "" @@ -10621,7 +10634,7 @@ msgid "Search GIFs" msgstr "" #: src/screens/Hashtag.tsx:228 -#: src/screens/Search/SearchResults.tsx:383 +#: src/screens/Search/SearchResults.tsx:410 msgid "Search is currently unavailable when logged out" msgstr "" @@ -10700,6 +10713,7 @@ msgstr "" #: src/components/FeedInterstitials.tsx:491 #: src/components/FeedInterstitials.tsx:549 +#: src/components/interstitials/FeedTrendingTopics.tsx:112 msgid "See more" msgstr "" @@ -10707,6 +10721,10 @@ msgstr "" msgid "See more suggested profiles" msgstr "" +#: src/components/interstitials/FeedTrendingTopics.tsx:103 +msgid "See more trending topics" +msgstr "See more trending topics" + #: src/view/com/profile/ProfileFollows.tsx:190 #: src/view/com/profile/ProfileFollows.tsx:191 msgid "See suggested accounts" @@ -10777,6 +10795,7 @@ msgid "Select caption file (.vtt)" msgstr "Select caption file (.vtt)" #: src/components/dialogs/SearchablePeopleList.tsx:501 +#: src/components/dms/InitiateChatFlow.tsx:984 msgid "Select chat \"{name}\"" msgstr "Select chat \"{name}\"" @@ -10817,7 +10836,7 @@ msgstr "" msgid "Select GIF \"{0}\"" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:725 +#: src/components/dms/InitiateChatFlow.tsx:830 msgid "Select group chat members" msgstr "Select group chat members" @@ -10936,7 +10955,8 @@ msgstr "" msgid "Send post to {name}" msgstr "" -#: src/components/dms/dialogs/ShareViaChatDialog.tsx:72 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:167 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:176 msgid "Send post to..." msgstr "" @@ -10954,12 +10974,12 @@ msgstr "" msgid "Send verification email" msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:116 -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:122 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:105 -#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:111 -msgid "Send via direct message" -msgstr "" +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:114 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:120 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:103 +#: src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx:109 +msgid "Send via chat" +msgstr "Send via chat" #. placeholder {0}: i18n.date(new Date(message.sentAt), { timeStyle: 'short', }) #: src/components/dms/MessageContextMenu.tsx:175 @@ -11012,14 +11032,14 @@ msgstr "Set your hosting provider manually" msgid "Sets email for password reset" msgstr "" -#: src/Navigation.tsx:214 +#: src/Navigation.tsx:215 #: src/screens/Settings/Settings.tsx:99 #: src/view/shell/desktop/LeftNav.tsx:755 #: src/view/shell/Drawer.tsx:668 msgid "Settings" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:221 +#: src/screens/Settings/NotificationSettings/index.tsx:220 msgid "Settings for activity from others" msgstr "" @@ -11027,49 +11047,49 @@ msgstr "" msgid "Settings for allowing others to be notified of your posts" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:155 +#: src/screens/Settings/NotificationSettings/index.tsx:154 msgid "Settings for like notifications" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:188 +#: src/screens/Settings/NotificationSettings/index.tsx:187 msgid "Settings for mention notifications" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:166 +#: src/screens/Settings/NotificationSettings/index.tsx:165 msgid "Settings for new follower notifications" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:294 +#: src/screens/Settings/NotificationSettings/index.tsx:293 msgid "Settings for notifications for everything else" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:234 +#: src/screens/Settings/NotificationSettings/index.tsx:233 msgid "Settings for notifications for likes of your reposts" msgstr "" #: src/screens/Messages/Settings.tsx:280 -#: src/screens/Settings/NotificationSettings/index.tsx:277 +#: src/screens/Settings/NotificationSettings/index.tsx:276 msgid "Settings for notifications for new message requests" msgstr "Settings for notifications for new message requests" #: src/screens/Messages/Settings.tsx:259 -#: src/screens/Settings/NotificationSettings/index.tsx:260 +#: src/screens/Settings/NotificationSettings/index.tsx:259 msgid "Settings for notifications for new messages" msgstr "Settings for notifications for new messages" -#: src/screens/Settings/NotificationSettings/index.tsx:247 +#: src/screens/Settings/NotificationSettings/index.tsx:246 msgid "Settings for notifications for reposts of your reposts" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:199 +#: src/screens/Settings/NotificationSettings/index.tsx:198 msgid "Settings for quote notifications" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:177 +#: src/screens/Settings/NotificationSettings/index.tsx:176 msgid "Settings for reply notifications" msgstr "" -#: src/screens/Settings/NotificationSettings/index.tsx:210 +#: src/screens/Settings/NotificationSettings/index.tsx:209 msgid "Settings for repost notifications" msgstr "" @@ -11106,8 +11126,8 @@ msgstr "Share age range" msgid "Share anyway" msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:176 -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:179 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:174 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:177 msgid "Share author DID" msgstr "" @@ -11118,7 +11138,7 @@ msgstr "" msgid "Share feedback" msgstr "Share feedback" -#: src/components/Lightbox/chrome/ImageMenu.tsx:93 +#: src/components/Lightbox/chrome/ImageMenu.tsx:94 #: src/components/Lightbox/Lightbox.web.tsx:281 #: src/components/Lightbox/Lightbox.web.tsx:307 msgid "Share image" @@ -11145,8 +11165,8 @@ msgstr "" msgid "Share my profile" msgstr "Share my profile" -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:167 -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:170 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:165 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:168 msgid "Share post at:// URI" msgstr "" @@ -11160,12 +11180,12 @@ msgstr "Share Profile" msgid "Share QR code" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:469 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:522 msgid "Share this feed" msgstr "" -#: src/screens/Search/Shell.tsx:568 -#: src/screens/Search/Shell.tsx:638 +#: src/screens/Search/Shell.tsx:565 +#: src/screens/Search/Shell.tsx:634 msgid "Share this search" msgstr "Share this search" @@ -11177,8 +11197,8 @@ msgstr "" msgid "Share this starter pack and help people join your community on Bluesky." msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:132 -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:135 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:130 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:133 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:160 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:166 #: src/screens/StarterPack/StarterPackScreen.tsx:638 @@ -11196,7 +11216,7 @@ msgstr "" msgid "Share your thoughts…" msgstr "Share your thoughts…" -#: src/Navigation.tsx:322 +#: src/Navigation.tsx:323 msgid "Shared Preferences Tester" msgstr "" @@ -11210,7 +11230,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" @@ -11220,13 +11240,13 @@ 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 -#: src/screens/VideoFeed/index.tsx:700 -#: src/screens/VideoFeed/index.tsx:706 +#: src/screens/VideoFeed/index.tsx:717 +#: src/screens/VideoFeed/index.tsx:723 msgid "Show anyway" msgstr "" @@ -11265,9 +11285,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 @@ -11324,7 +11344,7 @@ msgstr "" msgid "Show when you’re live" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:582 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:583 msgid "Shows information about when this post was created" msgstr "" @@ -11332,7 +11352,7 @@ 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 "" @@ -11349,7 +11369,7 @@ msgstr "" #: src/screens/Login/LoginForm.tsx:560 #: src/screens/Messages/JoinRequest.tsx:290 #: src/screens/Messages/JoinRequest.tsx:296 -#: src/screens/Search/SearchResults.tsx:386 +#: src/screens/Search/SearchResults.tsx:413 #: src/view/com/auth/SplashScreen.tsx:116 #: src/view/com/auth/SplashScreen.tsx:123 #: src/view/com/auth/SplashScreen.web.tsx:124 @@ -11427,7 +11447,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 "" @@ -11602,7 +11622,7 @@ msgstr "" msgid "Something went wrong. Please try again." msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:532 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:592 msgid "Something wrong? Let us know." msgstr "" @@ -11641,7 +11661,7 @@ msgid "Spam or other inauthentic behavior or deception" msgstr "" #: src/lib/interests.ts:72 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:218 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:196 msgid "Sports" msgstr "" @@ -11659,7 +11679,7 @@ msgstr "" msgid "Start a group chat" msgstr "Start a group chat" -#: src/components/dms/dialogs/NewChatDialog.tsx:191 +#: src/components/dms/dialogs/NewChatDialog.tsx:192 msgid "Start a new chat" msgstr "" @@ -11673,17 +11693,17 @@ msgstr "" msgid "Start adding people!" msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:726 +#: src/components/dms/InitiateChatFlow.tsx:831 msgid "Start chat" msgstr "Start chat" #: src/components/dialogs/SearchablePeopleList.tsx:428 -#: src/components/dms/InitiateChatFlow.tsx:874 +#: src/components/dms/InitiateChatFlow.tsx:1087 msgid "Start chat with {displayName}" msgstr "" -#: src/Navigation.tsx:541 -#: src/Navigation.tsx:546 +#: src/Navigation.tsx:550 +#: src/Navigation.tsx:555 #: src/screens/StarterPack/Wizard/index.tsx:197 msgid "Starter Pack" msgstr "" @@ -11706,7 +11726,11 @@ msgstr "" msgid "Starter pack is invalid" msgstr "" -#: src/screens/Search/Explore.tsx:665 +#: src/screens/Search/SearchResults.tsx:120 +msgid "Starter packs" +msgstr "Starter packs" + +#: src/screens/Search/Explore.tsx:661 #: src/view/screens/Profile.tsx:240 msgid "Starter Packs" msgstr "" @@ -11745,7 +11769,7 @@ msgstr "" msgid "Stored as part of a secure code for matching with others" msgstr "" -#: src/Navigation.tsx:307 +#: src/Navigation.tsx:308 #: src/screens/Settings/Settings.tsx:465 msgid "Storybook" msgstr "" @@ -11841,11 +11865,11 @@ msgid "Successfully verified" msgstr "" #: src/components/dms/AddMembersFlow.tsx:243 -#: src/components/dms/InitiateChatFlow.tsx:350 +#: src/components/dms/InitiateChatFlow.tsx:432 msgid "Suggested" msgstr "Suggested" -#: src/screens/Search/Explore.tsx:375 +#: src/screens/Search/Explore.tsx:369 msgid "Suggested accounts" msgstr "" @@ -11874,7 +11898,7 @@ msgctxt "Name of app icon variant" msgid "Sunset" msgstr "" -#: src/Navigation.tsx:332 +#: src/Navigation.tsx:333 #: src/view/screens/Support.tsx:25 #: src/view/screens/Support.tsx:28 msgid "Support" @@ -11885,10 +11909,12 @@ msgid "Support for this feature in your country has not been enabled yet! Please msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:98 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:114 msgid "Suspended accounts cannot participate in a group chat." msgstr "Suspended accounts cannot participate in a group chat." #: src/components/dms/dialogs/NewChatDialog.tsx:60 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:75 msgid "Suspended accounts cannot participate in chat." msgstr "Suspended accounts cannot participate in chat." @@ -12051,7 +12077,7 @@ msgstr "" #: src/components/dialogs/BirthDateSettings.tsx:181 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:31 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:98 -#: src/Navigation.tsx:342 +#: src/Navigation.tsx:343 #: src/screens/Settings/AboutSettings.tsx:94 #: src/screens/Settings/AboutSettings.tsx:97 #: src/view/screens/TermsOfService.tsx:25 @@ -12108,7 +12134,7 @@ msgstr "" msgid "That's all, folks!" msgstr "" -#: src/screens/VideoFeed/index.tsx:1195 +#: src/screens/VideoFeed/index.tsx:1212 msgid "That's everything!" msgstr "" @@ -12257,7 +12283,7 @@ msgstr "There was a problem loading GIFs. Check your connection and try again." msgid "There was a problem with your internet connection, please try again" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:177 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:184 #: src/screens/ProfileList/components/Header.tsx:91 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:79 #: src/screens/SavedFeeds.tsx:99 @@ -12265,7 +12291,7 @@ msgstr "" msgid "There was an issue contacting the server" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:416 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:467 #: src/screens/Profile/Header/ProfileHeaderLabeler.tsx:101 msgid "There was an issue contacting the server, please check your internet connection and try again." msgstr "" @@ -12274,8 +12300,8 @@ msgstr "" msgid "There was an issue fetching notifications. Tap here to try again." msgstr "" -#: src/screens/Search/Explore.tsx:1027 -#: src/view/com/posts/PostFeed.tsx:778 +#: src/screens/Search/Explore.tsx:1015 +#: src/view/com/posts/PostFeed.tsx:808 msgid "There was an issue fetching posts. Tap here to try again." msgstr "" @@ -12300,7 +12326,7 @@ msgstr "" msgid "There was an issue removing this feed. Please check your internet connection and try again." msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:136 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:143 #: src/view/com/posts/FeedShutdownMsg.tsx:53 #: src/view/com/posts/FeedShutdownMsg.tsx:74 msgid "There was an issue updating your feeds, please check your internet connection and try again." @@ -12375,7 +12401,7 @@ msgstr "" msgid "These URLs" msgstr "These URLs" -#: src/components/moderation/BlockDialog.tsx:356 +#: src/components/moderation/BlockDialog.tsx:353 msgid "They own this chat" msgstr "They own this chat" @@ -12383,7 +12409,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 "" @@ -12399,7 +12425,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 "" @@ -12528,7 +12554,7 @@ msgid "This feed is empty! You may need to follow more users or tune your langua msgstr "" #: src/components/StarterPack/Main/PostsList.tsx:41 -#: src/screens/Profile/ProfileFeed/index.tsx:171 +#: src/screens/Profile/ProfileFeed/index.tsx:170 #: src/screens/ProfileList/FeedSection.tsx:78 msgid "This feed is empty." msgstr "" @@ -12632,7 +12658,7 @@ msgstr "This person is blocking you" #. placeholder {0}: niceDate(i18n, createdAt) #. placeholder {1}: niceDate(i18n, indexedAt) -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:622 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:623 msgid "This post claims to have been created on <0>{0}, but was first seen by Bluesky on <1>{1}." msgstr "" @@ -12640,7 +12666,7 @@ msgstr "" msgid "This post has an unknown type of threadgate on it. Your app may be out of date." msgstr "" -#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:157 +#: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:155 msgid "This post is only visible to logged-in users." msgstr "" @@ -12694,6 +12720,7 @@ msgid "This user doesn't have any followers." msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:64 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:79 msgid "This user has blocked you and cannot be messaged." msgstr "This user has blocked you and cannot be messaged." @@ -12703,6 +12730,7 @@ msgid "This user has blocked you. You cannot view their content." msgstr "" #: src/components/dms/dialogs/NewChatDialog.tsx:68 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:83 msgid "This user has disabled chat and cannot be messaged." msgstr "This user has disabled chat and cannot be messaged." @@ -12728,7 +12756,7 @@ msgstr "" msgid "This user isn't following anyone." msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.web.tsx:236 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:304 msgid "This video can’t be played on your device. Your browser or system may be missing the required video codecs (H.264/AAC)." msgstr "This video can’t be played on your device. Your browser or system may be missing the required video codecs (H.264/AAC)." @@ -12777,7 +12805,7 @@ msgstr "" msgid "Threaded" msgstr "" -#: src/Navigation.tsx:375 +#: src/Navigation.tsx:376 msgid "Threads Preferences" msgstr "" @@ -12837,7 +12865,7 @@ msgid "Too many contacts - you've exceeded the number of contacts you can import msgstr "" #: src/screens/Hashtag.tsx:86 -#: src/screens/Search/SearchResults.tsx:74 +#: src/screens/Search/SearchResults.tsx:83 #: src/screens/Topic.tsx:58 msgid "Top" msgstr "" @@ -12849,7 +12877,7 @@ msgstr "" msgid "Top replies first" msgstr "" -#: src/Navigation.tsx:494 +#: src/Navigation.tsx:503 msgid "Topic" msgstr "" @@ -12884,7 +12912,9 @@ msgstr "Translation to the same language is unavailable on your device." msgid "Tree view" msgstr "" -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:48 +#: src/components/interstitials/FeedTrendingTopics.tsx:100 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:51 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:54 msgid "Trending" msgstr "" @@ -12893,7 +12923,7 @@ msgstr "" msgid "Trending GIFs" msgstr "Trending GIFs" -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:55 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:61 msgid "Trending options" msgstr "" @@ -12909,11 +12939,11 @@ msgstr "" msgid "Trust emerges from relationships, communities, and shared context, so we’re also enabling <0>trusted verifiers: organizations that can directly issue verification." msgstr "" -#: src/screens/Search/SearchResults.tsx:233 +#: src/screens/Search/SearchResults.tsx:260 msgid "Try a different search term or remove some filters." msgstr "Try a different search term or remove some filters." -#: src/screens/Search/SearchResults.tsx:235 +#: src/screens/Search/SearchResults.tsx:262 msgid "Try a different search term." msgstr "Try a different search term." @@ -12988,10 +13018,12 @@ msgid "Unable to fetch join requests." msgstr "Unable to fetch join requests." #: src/components/dms/dialogs/NewChatDialog.tsx:113 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:129 msgid "Unable to find a selected recipient." msgstr "Unable to find a selected recipient." #: src/components/dms/dialogs/NewChatDialog.tsx:77 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:92 msgid "Unable to find the selected recipient." msgstr "Unable to find the selected recipient." @@ -13018,9 +13050,9 @@ msgstr "" #: src/components/dms/MessageItem.tsx:746 #: src/components/dms/MessagesListBlockedFooter.tsx:97 #: src/components/dms/MessagesListBlockedFooter.tsx:104 -#: src/components/moderation/BlockDialog.tsx:186 -#: src/components/moderation/BlockDialog.tsx:190 -#: src/components/moderation/BlockDialog.tsx:211 +#: src/components/moderation/BlockDialog.tsx:187 +#: src/components/moderation/BlockDialog.tsx:191 +#: src/components/moderation/BlockDialog.tsx:213 #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:227 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:190 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:365 @@ -13060,8 +13092,8 @@ msgstr "" msgid "Unblock list" msgstr "" -#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:58 -#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:64 +#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:50 +#: src/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner.tsx:56 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:40 #: src/features/liveEvents/components/SidebarLiveEventFeedsBanner.tsx:46 #: src/screens/Profile/components/GermButton.tsx:186 @@ -13095,7 +13127,7 @@ msgstr "" msgid "Unfollow account" msgstr "" -#: src/screens/VideoFeed/index.tsx:902 +#: src/screens/VideoFeed/index.tsx:919 msgid "Unfollows the user" msgstr "" @@ -13123,7 +13155,7 @@ msgstr "" msgid "Unlabeled, abusive, or non-consensual adult content" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:509 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:564 msgid "Unlike" msgstr "" @@ -13193,14 +13225,14 @@ msgid "Unpin" msgstr "" #: src/components/FeedCard.tsx:355 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:514 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:520 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:569 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:576 #: src/screens/SavedFeeds.tsx:467 msgid "Unpin feed" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:312 -#: src/screens/Profile/components/ProfileFeedHeader.tsx:314 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:360 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:362 msgid "Unpin from home" msgstr "" @@ -13215,7 +13247,7 @@ msgid "Unpin moderation list" msgstr "" #. placeholder {0}: info.displayName -#: src/screens/Profile/components/ProfileFeedHeader.tsx:162 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:169 msgid "Unpinned {0} from Home" msgstr "" @@ -13249,7 +13281,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" @@ -13501,7 +13533,7 @@ msgstr "" msgid "Verification settings" msgstr "" -#: src/Navigation.tsx:207 +#: src/Navigation.tsx:208 #: src/screens/Moderation/VerificationSettings.tsx:34 msgid "Verification Settings" msgstr "" @@ -13609,7 +13641,7 @@ msgstr "" msgid "Video failed to process" msgstr "" -#: src/Navigation.tsx:562 +#: src/Navigation.tsx:571 msgid "Video Feed" msgstr "" @@ -13619,24 +13651,24 @@ msgid "Video from {0}: {text}" msgstr "" #. placeholder {0}: sanitizeHandle( post.author.handle, '@', ) -#: src/screens/VideoFeed/index.tsx:1157 +#: src/screens/VideoFeed/index.tsx:1174 msgid "Video from {0}. Tap to play or pause the video" msgstr "" #: src/lib/interests.ts:62 -#: src/screens/Search/modules/ExploreTrendingTopics.tsx:222 +#: src/screens/Search/modules/ExploreTrendingTopics.tsx:200 msgid "Video Games" msgstr "" -#: src/screens/VideoFeed/index.tsx:1156 +#: src/screens/VideoFeed/index.tsx:1173 msgid "Video is paused" msgstr "" -#: src/screens/VideoFeed/index.tsx:1156 +#: src/screens/VideoFeed/index.tsx:1173 msgid "Video is playing" msgstr "" -#: src/components/Post/Embed/VideoEmbed/index.web.tsx:232 +#: src/components/Post/Embed/VideoEmbed/index.web.tsx:299 msgid "Video not found." msgstr "" @@ -13682,9 +13714,9 @@ msgstr "" #. placeholder {0}: authors[0].profile.displayName || authors[0].profile.handle #. placeholder {0}: info.creatorHandle #. placeholder {0}: profile.handle -#: src/screens/Profile/components/ProfileFeedHeader.tsx:454 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:505 #: src/screens/Search/components/SearchProfileCard.tsx:37 -#: src/screens/VideoFeed/index.tsx:863 +#: src/screens/VideoFeed/index.tsx:880 #: src/view/com/notifications/NotificationFeedItem.tsx:619 msgid "View {0}'s profile" msgstr "" @@ -13715,13 +13747,13 @@ msgstr "" msgid "View debug entry" msgstr "" -#: src/screens/VideoFeed/index.tsx:728 -#: src/screens/VideoFeed/index.tsx:746 +#: src/screens/VideoFeed/index.tsx:745 +#: src/screens/VideoFeed/index.tsx:763 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 "" @@ -13789,11 +13821,11 @@ msgstr "View the invite link for this group chat" msgid "View the labeling service provided by @{0}" msgstr "" -#: src/components/verification/VerificationCheckButton.tsx:93 +#: src/components/verification/VerificationCheckButton.tsx:103 msgid "View this user's verifications" msgstr "" -#: src/screens/Profile/components/ProfileFeedHeader.tsx:482 +#: src/screens/Profile/components/ProfileFeedHeader.tsx:536 msgid "View users who like this feed" msgstr "" @@ -13822,7 +13854,7 @@ msgstr "" msgid "View your muted accounts" msgstr "" -#: src/components/verification/VerificationCheckButton.tsx:92 +#: src/components/verification/VerificationCheckButton.tsx:102 msgid "View your verifications" msgstr "" @@ -14026,7 +14058,7 @@ msgid "We're having network issues, try again" msgstr "" #: src/components/dms/AddMembersFlow.tsx:197 -#: src/components/dms/InitiateChatFlow.tsx:289 +#: src/components/dms/InitiateChatFlow.tsx:321 msgid "We’re having network issues, try again" msgstr "We’re having network issues, try again" @@ -14055,13 +14087,15 @@ msgstr "" msgid "We're sorry, but we weren't able to load your muted words at this time. Please try again." msgstr "" -#: src/screens/Search/SearchResults.tsx:412 -#: src/screens/Search/SearchResults.tsx:553 +#: src/screens/Search/SearchResults.tsx:439 +#: src/screens/Search/SearchResults.tsx:580 +#: src/screens/Search/SearchResults.tsx:777 msgid "We’re sorry, but your search could not be completed." msgstr "We’re sorry, but your search could not be completed." -#: src/screens/Search/SearchResults.tsx:411 -#: src/screens/Search/SearchResults.tsx:552 +#: src/screens/Search/SearchResults.tsx:438 +#: src/screens/Search/SearchResults.tsx:579 +#: src/screens/Search/SearchResults.tsx:776 msgid "We’re sorry, but your search could not be completed. Please try again in a few minutes." msgstr "We’re sorry, but your search could not be completed. Please try again in a few minutes." @@ -14353,7 +14387,7 @@ msgstr "" msgid "You are verified. You will lose your verification status if you change your handle. <0>Learn more." msgstr "" -#: src/screens/Search/modules/ExploreInterestsCard.tsx:46 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:43 msgid "You can adjust your interests at any time from \"Content and media\" settings." msgstr "" @@ -14430,9 +14464,9 @@ msgstr "You can read chat history but can’t send new messages." msgid "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total." msgstr "You can select up to {MAX_GALLERY_IMAGES, plural, other {# images}} in total." -#: src/components/interstitials/Trending.tsx:132 +#: src/components/interstitials/Trending.tsx:142 #: src/components/interstitials/TrendingVideos.tsx:138 -#: src/view/shell/desktop/SidebarTrendingTopics.tsx:127 +#: src/view/shell/desktop/SidebarTrendingTopics.tsx:136 msgid "You can update this later from your settings." msgstr "" @@ -14442,6 +14476,7 @@ msgid "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reac msgstr "You cannot add more than {EMOJI_REACTION_LIMIT, plural, one {# emoji reaction} other {# emoji reactions}}" #: src/components/dms/dialogs/NewChatDialog.tsx:105 +#: src/components/dms/dialogs/ShareViaChatDialog.tsx:121 msgid "You cannot create a group chat yet." msgstr "You cannot create a group chat yet." @@ -14605,7 +14640,7 @@ msgstr "" msgid "You may only add up to 3 feeds" msgstr "" -#: src/components/moderation/BlockDialog.tsx:321 +#: src/components/moderation/BlockDialog.tsx:318 msgid "You must be a chat owner to remove a member." msgstr "You must be a chat owner to remove a member." @@ -14630,7 +14665,7 @@ msgstr "" msgid "You need to verify your email address before you can enable email 2FA." msgstr "" -#: src/components/moderation/BlockDialog.tsx:352 +#: src/components/moderation/BlockDialog.tsx:349 msgid "You own this chat" msgstr "You own this chat" @@ -14790,7 +14825,7 @@ msgstr "" msgid "You've reached the maximum number of requests allowed. Please try again later." msgstr "" -#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:426 +#: src/screens/Search/components/AdvancedSearchDialog/index.tsx:430 msgid "You’ve reached the maximum of {MAX_FILTERS, plural, one {# filter} other {# filters}}. Add more values to an existing filter instead of creating new ones." msgstr "You’ve reached the maximum of {MAX_FILTERS, plural, one {# filter} other {# filters}}. Add more values to an existing filter instead of creating new ones." @@ -14806,7 +14841,7 @@ msgstr "" msgid "You've reached your daily limit for video uploads (too many videos)" msgstr "" -#: src/screens/VideoFeed/index.tsx:1204 +#: src/screens/VideoFeed/index.tsx:1221 msgid "You've run out of videos to watch. Maybe it's a good time to take a break?" msgstr "" @@ -14826,11 +14861,11 @@ msgstr "" msgid "Your account is not yet old enough to upload videos. Please try again later." msgstr "" -#: src/components/dms/InitiateChatFlow.tsx:731 +#: src/components/dms/InitiateChatFlow.tsx:836 msgid "Your account is too new" msgstr "Your account is too new" -#: src/components/dms/InitiateChatFlow.tsx:732 +#: src/components/dms/InitiateChatFlow.tsx:837 msgid "Your account must be at least 7 days old to create a new group chat." msgstr "Your account must be at least 7 days old to create a new group chat." @@ -14921,8 +14956,8 @@ msgstr "Your hosting provider can’t be detected from an email address, so the msgid "Your hosting provider is detected automatically from the username you enter." msgstr "Your hosting provider is detected automatically from the username you enter." -#: src/Navigation.tsx:466 -#: src/screens/Search/modules/ExploreInterestsCard.tsx:68 +#: src/Navigation.tsx:475 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:62 #: src/screens/Settings/ContentAndMediaSettings.tsx:94 #: src/screens/Settings/ContentAndMediaSettings.tsx:97 #: src/screens/Settings/InterestsSettings.tsx:49 @@ -14934,7 +14969,7 @@ msgctxt "toast" msgid "Your interests have been updated!" msgstr "" -#: src/screens/Search/modules/ExploreInterestsCard.tsx:95 +#: src/screens/Search/modules/ExploreInterestsCard.tsx:89 msgid "Your interests help us find what you like!" msgstr "" 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/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index eaa68d9254..514e892895 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -370,7 +370,7 @@ function SuggestedProfileCard({ category: string | null onSeen: (did: string, position: number) => void recSource?: 'Search' - recId?: number | string + recId?: string }) { const t = useTheme() const ax = useAnalytics() diff --git a/src/screens/PostThread/components/LikesStat.tsx b/src/screens/PostThread/components/LikesStat.tsx index 9a0c7139fb..eab32dc274 100644 --- a/src/screens/PostThread/components/LikesStat.tsx +++ b/src/screens/PostThread/components/LikesStat.tsx @@ -1,15 +1,13 @@ import {View} from 'react-native' import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api' -import {plural} from '@lingui/core/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {enforceLen} from '#/lib/strings/helpers' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useLikedBySampleQuery} from '#/state/queries/post-liked-by' import {useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {AvatarStack} from '#/components/AvatarStack' import {InlineLinkText, Link} from '#/components/Link' import {useFormatPostStatCount} from '#/components/PostControls/util' @@ -18,27 +16,56 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' const AVI_SIZE = 20 -const MAX_NAME_LENGTH = 16 /** - * The likes stat for the expanded anchor post. When the viewer follows some - * of the post's recent likers, renders social proof - a face pile plus - * "Liked by A, B, and N others" - in place of the plain "N likes" text, - * which it falls back to otherwise. - * - * Known likers are sourced client-side from a single `getLikes` request (100 - * likes, the API max per page), so they are a sample of the most recent - * likers, not an exhaustive list. Only the faces and names are affected by - * sampling - the "N others" count is derived from the post's total like - * count. + * The plain "N likes" stat for the expanded anchor post, linking to the likes + * list. Renders nothing when the post has no likes. */ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { const t = useTheme() - const {gtMobile} = useBreakpoints() + const {t: l} = useLingui() + const formatPostStatCount = useFormatPostStatCount() + const ax = useAnalytics() + + const likeCount = post.likeCount ?? 0 + if (likeCount === 0) return null + + const urip = new AtUri(post.uri) + const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + + return ( + ax.metric('post:likedBy:click', {})}> + + + + {formatPostStatCount(likeCount)} + {' '} + + + + + ) +} + +/** + * Social proof for the expanded anchor post. When the viewer follows some of + * the post's recent likers, renders a face pile plus "Liked by A and B" on + * its own row below the interaction stats line. Renders nothing otherwise. + * + * Known likers are sourced client-side from a single `getLikes` request (100 + * likes, the API max per page), so they are a sample of the most recent + * likers, not an exhaustive list. + */ +export function KnownLikers({post}: {post: AppBskyFeedDefs.PostView}) { + const t = useTheme() const {t: l} = useLingui() const {hasSession, currentAccount} = useSession() const moderationOpts = useModerationOpts() - const formatPostStatCount = useFormatPostStatCount() const ax = useAnalytics() const likeCount = post.likeCount ?? 0 @@ -78,67 +105,34 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { knownLikersAndModeration.length > 0 && ax.features.enabled(ax.features.PostThreadKnownLikersEnable) - if (!showKnownLikers) { - return ( - - - - - {formatPostStatCount(likeCount)} - {' '} - - - - - ) - } + if (!showKnownLikers) return null const aviStackProfiles = knownLikersAndModeration .slice(0, 3) .map(({actor}) => actor) - const maxNames = gtMobile ? 2 : 1 const names = knownLikersAndModeration - .slice(0, maxNames) + .slice(0, 2) .map(({actor, moderation}) => { return { did: actor.did, href: makeProfileLink(actor), - displayName: enforceLen( - sanitizeDisplayName( - actor.displayName || actor.handle, - moderation.ui('displayName'), - ), - MAX_NAME_LENGTH, - true, + displayName: sanitizeDisplayName( + actor.displayName || actor.handle, + moderation.ui('displayName'), ), } }) - const others = likeCount - names.length - /* * The row link's a11y label mirrors the visible sentence so screen readers * announce the social proof. */ - const othersLabel = plural(others, { - one: `${formatPostStatCount(others)} other`, - other: `${formatPostStatCount(others)} others`, - }) const rowLabel = names.length >= 2 - ? others > 0 - ? l`${names[0].displayName}, ${names[1].displayName}, and ${othersLabel} like this` - : l`${names[0].displayName} and ${names[1].displayName} like this` - : others > 0 - ? l`${names[0].displayName} and ${othersLabel} like this` - : l`${names[0].displayName} likes this` + ? l`Liked by ${names[0].displayName} and ${names[1].displayName}` + : l`Liked by ${names[0].displayName}` - const textStyle = [a.text_md, t.atoms.text_contrast_medium] - const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text] + const textStyle = [a.text_sm, t.atoms.text_contrast_medium] + const nameStyle = [a.text_sm, a.font_semi_bold, t.atoms.text] /* * Nested inside the row link, but the deepest link claims the press, so @@ -160,10 +154,8 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { return ( /* - * The full-width wrapper keeps the social proof on its own line within - * the wrapping stats row, rather than wrapping mid-row and orphaning - * whichever count stat comes last. The link itself hugs its content so - * the empty space to the right of the text is not pressable. + * The full-width wrapper forces the social proof onto its own line below + * the count stats within the wrapping stats row. */ - + {names.length >= 2 ? ( - others > 0 ? ( - - {nameLink(names[0])}, {nameLink(names[1])}, and{' '} - {' '} - like this - - ) : ( - - {nameLink(names[0])} and {nameLink(names[1])} like this - - ) - ) : others > 0 ? ( - - {nameLink(names[0])} and{' '} - {' '} - like this + + Liked by {nameLink(names[0])} and {nameLink(names[1])} ) : ( - - {nameLink(names[0])} likes this + + Liked by {nameLink(names[0])} )} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index f88ad000c1..7973bc1a8a 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -28,7 +28,7 @@ import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {type PostSource} from '#/state/unstable-post-source' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {LikesStat} from '#/screens/PostThread/components/LikesStat' +import {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' import { LINEAR_AVI_WIDTH, @@ -440,7 +440,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ a.py_md, t.atoms.border_contrast_low, ]}> - {post.repostCount != null && post.repostCount !== 0 ? ( ) : null} + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( ) : null} + ) : null} makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), @@ -70,7 +69,7 @@ export function ProfileFeedScreen(props: Props) { void refetch()} /> @@ -131,7 +130,7 @@ export function ProfileFeedScreenInner({ feedInfo: FeedSourceFeedInfo feedParams: FeedParams | undefined }) { - const {_} = useLingui() + const {t: l} = useLingui() const {hasSession} = useSession() const {openComposer} = useOpenComposer() const isScreenFocused = useIsFocused() @@ -168,10 +167,10 @@ export function ProfileFeedScreenInner({ ) - }, [_]) + }, [l]) const isVideoFeed = useMemo(() => { const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri) @@ -181,13 +180,17 @@ export function ProfileFeedScreenInner({ return IS_NATIVE && _isVideoFeed }, [feedInfo]) + const isTrending = + feedInfo.creatorDid.toLowerCase() === TRENDING_DID || + feedInfo.creatorHandle.toLowerCase() === TRENDING_HANDLE + return ( <> - - + - {(isScrolledDown || hasNew) && ( )} - {hasSession && ( openComposer({logContext: 'Fab'})} icon={} accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} + accessibilityLabel={l`New post`} accessibilityHint="" /> )} diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx index c640c84f33..4cfe838e13 100644 --- a/src/screens/Profile/components/ProfileFeedHeader.tsx +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' import {AtUri} from '@atproto/api' import {Plural, Trans, useLingui} from '@lingui/react/macro' +import {TRENDING_HANDLE} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' @@ -24,20 +25,20 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {Divider} from '#/components/Divider' -import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import { - Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, - Heart2_Stroke2_Corner0_Rounded as Heart, + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilledIcon, + Heart2_Stroke2_Corner0_Rounded as HeartIcon, } from '#/components/icons/Heart2' import { - Pin_Filled_Corner0_Rounded as PinFilled, - Pin_Stroke2_Corner0_Rounded as Pin, + Pin_Filled_Corner0_Rounded as PinFilledIcon, + Pin_Stroke2_Corner0_Rounded as PinIcon, } from '#/components/icons/Pin' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import * as Menu from '#/components/Menu' @@ -74,14 +75,20 @@ export function ProfileFeedHeaderSkeleton() { width: 34, }, ]}> - + ) } -export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { +export function ProfileFeedHeader({ + info, + isTrending, +}: { + info: FeedSourceFeedInfo + isTrending: boolean +}) { const t = useTheme() const {t: l, i18n} = useLingui() const ax = useAnalytics() @@ -188,105 +195,143 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { - + + ) : ( + + + + + )} + + )} - {hasSession && ( + {!isTrending && hasSession ? ( {isPinned ? ( @@ -300,7 +345,10 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { variant="ghost" shape="square" color="secondary"> - + ) }} @@ -310,23 +358,23 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { + onPress={() => void onTogglePinned()}> {l`Unpin from home`} - + + onPress={() => void onToggleSaved()}> {isSaved ? l`Remove from my feeds` : l`Save to my feeds`} @@ -339,12 +387,12 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { variant="ghost" shape="square" color="secondary" - onPress={onTogglePinned}> - + onPress={() => void onTogglePinned()}> + )} - )} + ) : null} @@ -358,7 +406,8 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { setLikeUri={setLikeUri} likeCount={likeCount} isPinned={isPinned} - onTogglePinned={onTogglePinned} + isTrending={isTrending} + onTogglePinned={() => void onTogglePinned()} isFeedStateChangePending={isFeedStateChangePending} /> @@ -373,6 +422,7 @@ function DialogInner({ setLikeUri, likeCount, isPinned, + isTrending, onTogglePinned, isFeedStateChangePending, }: { @@ -381,6 +431,7 @@ function DialogInner({ setLikeUri: (uri: string) => void likeCount: number isPinned: boolean + isTrending: boolean onTogglePinned: () => void isFeedStateChangePending: boolean }) { @@ -459,7 +510,9 @@ function DialogInner({ style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]} numberOfLines={1} onPress={() => control.close()}> - {sanitizeHandle(info.creatorHandle, '@')} + {info.creatorHandle === TRENDING_HANDLE + ? l`Bluesky` + : sanitizeHandle(info.creatorHandle, '@')} @@ -472,12 +525,13 @@ function DialogInner({ color="secondary" shape="round" onPress={onPressShare}> - + - - {typeof likeCount === 'number' && ( + + {typeof likeCount === 'number' && likeCount > 0 ? ( + - )} - - {hasSession && ( + + ) : null} + {hasSession ? ( <> - - - - + + {isLiked ? Unlike : Like} + + + + + ) : null} @@ -541,7 +601,7 @@ function DialogInner({ Report feed - + @@ -556,7 +616,7 @@ function DialogInner({ )} - )} + ) : null} ) } diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index 95f58645ad..364b7cbca2 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -5,9 +5,7 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, } 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 {useQueryClient} from '@tanstack/react-query' import * as bcp47Match from 'bcp-47-match' @@ -48,7 +46,6 @@ import { StarterPackCardSkeleton, } from '#/screens/Search/components/StarterPackCard' import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard' -import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' import {atoms as a, native, platform, useTheme} from '#/alf' @@ -79,7 +76,7 @@ import { function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const handleOnPress = () => { void item.onLoadMore() @@ -87,7 +84,7 @@ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { return ( + + + + + ) +} diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx index 8be51b93d7..73fbdac8d5 100644 --- a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx +++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx @@ -156,7 +156,9 @@ export function Inner({ <> - From + + From + - - + 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/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx index 99dc03d208..2949c6a777 100644 --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -588,7 +588,7 @@ function VideoItemInner({ const {bottom} = useSafeAreaInsets() const [isReady, setIsReady] = useState(!IS_ANDROID) - usePlaybackTelemetry({player, active}) + usePlaybackTelemetry({player, active, playlist: embed.playlist}) useEventListener(player, 'timeUpdate', evt => { if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) { @@ -625,10 +625,13 @@ function VideoItemInner({ function usePlaybackTelemetry({ player, active, + playlist, }: { player: VideoPlayer active: boolean + playlist: string }) { + const ax = useAnalytics() const telemetryRef = useRef(null) useEffect(() => { @@ -652,7 +655,21 @@ function usePlaybackTelemetry({ if (evt.status === 'readyToPlay') { telemetryRef.current?.ready() } else if (evt.status === 'error') { - telemetryRef.current?.error(evt.error?.message ?? 'unknown') + const message = evt.error?.message ?? 'unknown' + telemetryRef.current?.error(message) + /* + * Adjacent players are preloaded and can error before the user ever + * swipes to them - only count failures the user actually sees. + */ + if (active) { + ax.metric('video:playback:failed', { + surface: 'immersiveFeed', + presentation: 'video', + errorClass: 'PlayerError', + errorMessage: message.slice(0, 256), + playlist, + }) + } } }) diff --git a/src/state/queries/__tests__/search-posts-params.test.ts b/src/state/queries/__tests__/search-posts-params.test.ts index 9b0c374642..52ec17d875 100644 --- a/src/state/queries/__tests__/search-posts-params.test.ts +++ b/src/state/queries/__tests__/search-posts-params.test.ts @@ -1,7 +1,9 @@ import {describe, expect, it} from '@jest/globals' import { + appendFromMe, buildSearchPostsV2Filters, + extractFromMe, extractSearchPostsParams, } from '#/state/queries/search-posts-params' @@ -139,6 +141,39 @@ describe(`extractSearchPostsParams`, () => { }) }) +describe(`extractFromMe / appendFromMe`, () => { + it(`strips a bare from:me token and reports it`, () => { + expect(extractFromMe(`cats from:me`)).toEqual({q: `cats`, fromMe: true}) + expect(extractFromMe(`from:me`)).toEqual({q: ``, fromMe: true}) + }) + + it(`reports fromMe false when the token is absent`, () => { + expect(extractFromMe(`cats from:alice`)).toEqual({ + q: `cats from:alice`, + fromMe: false, + }) + }) + + it(`leaves a quoted from:me in the query text`, () => { + expect(extractFromMe(`"from:me"`)).toEqual({q: `"from:me"`, fromMe: false}) + }) + + it(`re-appends the token only when the filter is active`, () => { + expect(appendFromMe(`cats`, true)).toBe(`cats from:me`) + expect(appendFromMe(`cats`, false)).toBe(`cats`) + expect(appendFromMe(``, true)).toBe(`from:me`) + }) + + it(`does not duplicate an existing from:me token`, () => { + expect(appendFromMe(`cats from:me`, true)).toBe(`cats from:me`) + }) + + it(`round-trips through extract and append`, () => { + const {q, fromMe} = extractFromMe(`cats from:me`) + expect(appendFromMe(q, fromMe)).toBe(`cats from:me`) + }) +}) + describe(`buildSearchPostsV2Filters`, () => { it(`maps embedded operators alone into v2 plural params`, () => { expect( diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index c0eb25f54a..c3ac1acd53 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -327,7 +327,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) { count += page.feeds.length } if (count < limit && (data?.pages.length || 0) < 6) { - query.fetchNextPage() + void query.fetchNextPage() lastPageCountRef.current = data?.pages?.length || 0 } }, [query, limit]) diff --git a/src/state/queries/search-posts-params.ts b/src/state/queries/search-posts-params.ts index dcfd9aa4b5..c2769b2295 100644 --- a/src/state/queries/search-posts-params.ts +++ b/src/state/queries/search-posts-params.ts @@ -83,6 +83,30 @@ export function tokenizeQuery(raw: string): string[] { return tokens } +/** + * Splits a bare `from:me` token out of a query. The "Me" author filter always + * travels inside `q` as a `from:me` token (the backend resolves `me` to the + * viewer), but the UI never shows it as text: the search input strips it for + * display and the advanced-search dialog represents it in the From dropdown. + * Tokenization keeps quoted phrases intact, so a `from:me` inside quotes stays + * in the query text. + */ +export function extractFromMe(query: string): {q: string; fromMe: boolean} { + const tokens = tokenizeQuery(query) + const kept = tokens.filter(token => token !== 'from:me') + return {q: kept.join(' '), fromMe: kept.length !== tokens.length} +} + +/** + * Re-appends the `from:me` token when the "Me" author filter is active. + * Idempotent: a query that already carries a bare `from:me` is returned as-is. + */ +export function appendFromMe(query: string, fromMe: boolean): string { + if (!fromMe) return query + if (tokenizeQuery(query).includes('from:me')) return query + return query ? `${query} from:me` : 'from:me' +} + /** * Lifts the operators that `app.bsky.feed.searchPosts` accepts as structured * params out of the free-text query, so the backend filters on them directly. diff --git a/src/state/queries/search-posts-v2.ts b/src/state/queries/search-posts-v2.ts index f4784ba5d8..e33328b5a0 100644 --- a/src/state/queries/search-posts-v2.ts +++ b/src/state/queries/search-posts-v2.ts @@ -16,6 +16,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useAgent} from '#/state/session' import {type SearchFilters} from '#/screens/Search/searchParams' import { + appendFromMe, buildSearchPostsV2Filters, extractSearchPostsParams, } from './search-posts-params' @@ -51,10 +52,11 @@ export function useSearchPostsV2Query({ const moderationOpts = useModerationOpts() const selectArgs = useMemo( () => ({ - isSearchingSpecificUser: /from:(\w+)/.test(query) || !!filters?.author, + isSearchingSpecificUser: + /from:(\w+)/.test(query) || !!filters?.author || filters?.from === 'me', moderationOpts, }), - [query, filters?.author, moderationOpts], + [query, filters?.author, filters?.from, moderationOpts], ) const lastRun = useRef<{ data: InfiniteData @@ -78,9 +80,10 @@ export function useSearchPostsV2Query({ */ const {q, ...embedded} = extractSearchPostsParams(query) const builtFilters = buildSearchPostsV2Filters(embedded, filters) + const finalQuery = appendFromMe(q, filters?.from === 'me') const res = await agent.app.bsky.feed.searchPostsV2({ ...builtFilters, - query: q, + query: finalQuery, limit: 25, cursor: pageParam, /* diff --git a/src/state/queries/starter-pack-search.ts b/src/state/queries/starter-pack-search.ts new file mode 100644 index 0000000000..7987d1e07f --- /dev/null +++ b/src/state/queries/starter-pack-search.ts @@ -0,0 +1,75 @@ +import {type AppBskyGraphSearchStarterPacksV2} from '@atproto/api' +import { + type InfiniteData, + keepPreviousData, + type QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' + +export const RQKEY_ROOT = 'starter-pack-search' +export const RQKEY = (query: string, limit?: number) => [ + RQKEY_ROOT, + query, + limit, +] + +export function useStarterPackSearch({ + query, + enabled, + maintainData, + limit = 25, +}: { + query: string + enabled?: boolean + maintainData?: boolean + limit?: number +}) { + const agent = useAgent() + return useInfiniteQuery< + AppBskyGraphSearchStarterPacksV2.OutputSchema, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + staleTime: STALE.MINUTES.FIVE, + queryKey: RQKEY(query, limit), + queryFn: async ({pageParam}) => { + const res = await agent.app.bsky.graph.searchStarterPacksV2({ + q: query, + limit, + cursor: pageParam, + }) + return res.data + }, + enabled: enabled && !!query, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + placeholderData: maintainData ? keepPreviousData : undefined, + select, + }) +} + +function select( + data: InfiniteData, +) { + // enforce uniqueness + const uris = new Set() + + return { + ...data, + pages: data.pages.map(page => ({ + ...page, + starterPacks: page.starterPacks.filter(starterPack => { + if (uris.has(starterPack.uri)) { + return false + } + uris.add(starterPack.uri) + return true + }), + })), + } +} diff --git a/src/state/queries/trending/useGetTrendsQuery.ts b/src/state/queries/trending/useGetTrendsQuery.ts index c670802aa7..757e868d1f 100644 --- a/src/state/queries/trending/useGetTrendsQuery.ts +++ b/src/state/queries/trending/useGetTrendsQuery.ts @@ -6,6 +6,7 @@ import { aggregateUserInterests, createBskyTopicsHeader, } from '#/lib/api/feed/utils' +import {logger} from '#/logger' import {getContentLanguages} from '#/state/preferences/languages' import {STALE} from '#/state/queries' import {usePreferencesQuery} from '#/state/queries/preferences' @@ -13,24 +14,41 @@ import {useAgent} from '#/state/session' export const DEFAULT_LIMIT = 5 -export const createGetTrendsQueryKey = () => ['trends'] +type QueryProps = { + limit?: number + refetchOnWindowFocus?: boolean +} -export function useGetTrendsQuery() { +function dedupe(trends: T[]): T[] { + const seen = new Set() + return trends.filter(trend => { + if (seen.has(trend.link)) return false + seen.add(trend.link) + return true + }) +} + +export const createGetTrendsQueryKey = (limit?: number) => + limit === undefined ? ['trends'] : ['trends', {limit}] + +export function useGetTrendsQuery(props: QueryProps = {}) { const agent = useAgent() const {data: preferences} = usePreferencesQuery() + const limit = props.limit ?? DEFAULT_LIMIT const mutedWords = useMemo(() => { return preferences?.moderationPrefs?.mutedWords || [] }, [preferences?.moderationPrefs]) return useQuery({ enabled: !!preferences, + refetchOnWindowFocus: props.refetchOnWindowFocus, staleTime: STALE.MINUTES.THREE, - queryKey: createGetTrendsQueryKey(), + queryKey: createGetTrendsQueryKey(limit), queryFn: async () => { const contentLangs = getContentLanguages().join(',') const {data} = await agent.app.bsky.unspecced.getTrends( { - limit: DEFAULT_LIMIT, + limit, }, { headers: { @@ -39,17 +57,23 @@ export function useGetTrendsQuery() { }, }, ) + if (!data.recIdStr) { + logger.debug('useGetTrendsQuery response missing recIdStr') + } return data }, select: useCallback( (data: AppBskyUnspeccedGetTrends.OutputSchema) => { return { - trends: (data.trends ?? []).filter(t => { - return !hasMutedWord({ - mutedWords, - text: t.topic + ' ' + t.displayName + ' ' + t.category, - }) - }), + recId: data.recIdStr, + trends: dedupe( + (data.trends ?? []).filter(t => { + return !hasMutedWord({ + mutedWords, + text: `${t.topic} ${t.displayName} ${t.category}`, + }) + }), + ), } }, [mutedWords], diff --git a/src/state/queries/trending/useTrendingTopics.ts b/src/state/queries/trending/useTrendingTopics.ts deleted file mode 100644 index 81b25e5f94..0000000000 --- a/src/state/queries/trending/useTrendingTopics.ts +++ /dev/null @@ -1,74 +0,0 @@ -import {useCallback, useMemo} from 'react' -import {type AppBskyUnspeccedDefs, hasMutedWord} from '@atproto/api' -import {useQuery} from '@tanstack/react-query' - -import {STALE} from '#/state/queries' -import {usePreferencesQuery} from '#/state/queries/preferences' -import {useAgent} from '#/state/session' - -export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic - -type Response = { - topics: TrendingTopic[] - suggested: TrendingTopic[] -} - -export const DEFAULT_LIMIT = 14 - -function dedup(topics: TrendingTopic[]): TrendingTopic[] { - const seen = new Set() - return topics.filter(t => { - if (seen.has(t.link)) return false - seen.add(t.link) - return true - }) -} - -export const trendingTopicsQueryKey = ['trending-topics'] - -export function useTrendingTopics() { - const agent = useAgent() - const {data: preferences} = usePreferencesQuery() - const mutedWords = useMemo( - () => preferences?.moderationPrefs?.mutedWords ?? [], - [preferences?.moderationPrefs?.mutedWords], - ) - - return useQuery({ - refetchOnWindowFocus: true, - staleTime: STALE.MINUTES.THREE, - queryKey: trendingTopicsQueryKey, - async queryFn() { - const {data} = await agent.app.bsky.unspecced.getTrendingTopics({ - limit: DEFAULT_LIMIT, - }) - return { - topics: data.topics ?? [], - suggested: data.suggested ?? [], - } - }, - select: useCallback( - (data: Response) => { - return { - topics: dedup( - data.topics.filter(t => { - return !hasMutedWord({ - mutedWords, - text: `${t.topic} ${t.displayName ?? ''} ${t.description ?? ''}`, - }) - }), - ), - suggested: dedup( - data.suggested.filter(t => { - return !hasMutedWord({ - mutedWords, - text: `${t.topic} ${t.displayName ?? ''} ${t.description ?? ''}`, - }) - }), - ), - } - }, - [mutedWords], - ), - }) -} 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/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 42274790c7..bc40880bf4 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -17,6 +17,7 @@ import { AppBskyEmbedImages, AppBskyEmbedVideo, type AppBskyFeedDefs, + type RichText as RichTextType, } from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' @@ -50,7 +51,7 @@ import {List, type ListRef} from '#/view/com/util/List' import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' -import {useBreakpoints, useLayoutBreakpoints} from '#/alf' +import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' import { AgeAssuranceDismissibleFeedBanner, useInternalState as useAgeAssuranceBannerState, @@ -60,9 +61,11 @@ import { PostFeedVideoGridRow, PostFeedVideoGridRowPlaceholder, } from '#/components/feeds/PostFeedVideoGridRow' +import {FeedTrendingTopicsInterstitial} from '#/components/interstitials/FeedTrendingTopics' import {TrendingInterstitial} from '#/components/interstitials/Trending' import {TrendingVideos as TrendingVideosInterstitial} from '#/components/interstitials/TrendingVideos' import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils' +import {RichText} from '#/components/RichText' import {useAnalytics} from '#/analytics' import {IS_IOS, IS_NATIVE, IS_WEB} from '#/env' import {DiscoverFeedLiveEventFeedsAndTrendingBanner} from '#/features/liveEvents/components/DiscoverFeedLiveEventFeedsAndTrendingBanner' @@ -104,6 +107,11 @@ type FeedRow = type: 'fallbackMarker' key: string } + | { + type: 'description' + key: string + value: RichTextType + } | { type: 'sliceItem' key: string @@ -140,6 +148,10 @@ type FeedRow = type: 'interstitialTrending' key: string } + | { + type: 'interstitialFeedTrendingTopics' + key: string + } | { type: 'interstitialTrendingVideos' key: string @@ -189,6 +201,7 @@ const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY let PostFeed = ({ feed, + description, feedParams, ignoreFilterFor, style, @@ -211,6 +224,7 @@ let PostFeed = ({ isVideoFeed = false, }: { feed: FeedDescriptor + description?: RichTextType feedParams?: FeedParams ignoreFilterFor?: string style?: StyleProp @@ -227,13 +241,14 @@ let PostFeed = ({ progressViewOffset?: number desktopFixedHeightOffset?: number ListHeaderComponent?: () => React.ReactElement - extraData?: any + extraData?: Record savedFeedConfig?: AppBskyActorDefs.SavedFeed initialNumToRender?: number isVideoFeed?: boolean lastFetchDate?: () => number }): React.ReactNode => { const ax = useAnalytics() + const t = useTheme() const {t: l} = useLingui() const queryClient = useQueryClient() const {currentAccount, hasSession} = useSession() @@ -384,6 +399,7 @@ let PostFeed = ({ * Cached value of whether the current feed was selected at startup. We don't * want this to update when user swipes. */ + // oxlint-disable-next-line react/hook-use-state const [isCurrentFeedAtStartupSelected] = useState(selectedFeed === feed) const blockedOrMutedAuthors = usePostAuthorShadowFilter( @@ -540,6 +556,11 @@ let PostFeed = ({ key: 'composerPrompt-' + sliceIndex, }) } + } else if (sliceIndex === 1) { + arr.push({ + type: 'interstitialFeedTrendingTopics', + key: 'interstitialFeedTrendingTopics-' + sliceIndex, + }) } else if (sliceIndex === 15) { if (areVideoFeedsEnabled && !trendingVideoDisabled) { arr.push({ @@ -670,8 +691,17 @@ let PostFeed = ({ } } + if (description?.text) { + arr.unshift({ + key: 'description', + type: 'description', + value: description, + }) + } + return arr }, [ + description, isFetched, isError, isEmpty, @@ -783,6 +813,18 @@ let PostFeed = ({ return } else if (row.type === 'feedShutdownMsg') { return + } else if (row.type === 'description') { + return ( + + ) } else if (row.type === 'interstitialFollows') { return } else if (row.type === 'interstitialProgressGuide') { @@ -791,6 +833,8 @@ let PostFeed = ({ return } else if (row.type === 'interstitialTrending') { return + } else if (row.type === 'interstitialFeedTrendingTopics') { + return } else if (row.type === 'liveEventFeedsAndTrendingBanner') { return } else if (row.type === 'composerPrompt') { @@ -881,6 +925,7 @@ let PostFeed = ({ feedTab, feedCacheKey, onPressShowLess, + t, ], ) 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/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/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx index f337edb86a..aa7bf4026c 100644 --- a/src/view/shell/desktop/SidebarTrendingTopics.tsx +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -5,7 +5,7 @@ import { useTrendingSettings, useTrendingSettingsApi, } from '#/state/preferences/trending' -import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics' +import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' import {useTrendingConfig} from '#/state/service-config' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' @@ -30,8 +30,14 @@ function Inner() { const ax = useAnalytics() const trendingPrompt = Prompt.usePromptControl() const {setTrendingDisabled} = useTrendingSettingsApi() - const {data: trending, error, isLoading} = useTrendingTopics() - const noTopics = !isLoading && !error && !trending?.topics?.length + const { + data: trending, + error, + isLoading, + } = useGetTrendsQuery({ + refetchOnWindowFocus: true, + }) + const noTopics = !isLoading && !error && !trending?.trends?.length const onConfirmHide = () => { ax.metric('trendingTopics:hide', {context: 'sidebar'}) @@ -82,14 +88,17 @@ function Inner() { /> )) - ) : !trending?.topics ? null : ( + ) : !trending?.trends ? null : ( <> - {trending.topics.slice(0, TRENDING_LIMIT).map((topic, i) => ( + {trending.trends.slice(0, TRENDING_LIMIT).map((topic, i) => ( { - ax.metric('trendingTopic:click', {context: 'sidebar'}) + ax.metric('trendingTopic:click', { + context: 'sidebar', + recId: trending.recId, + }) }}> {({hovered}) => (