Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 419eaa9c83 | |||
| a9179fa296 | |||
| 36785ffce0 | |||
| 50fd127373 | |||
| 1e8eccffd2 |
@@ -1,15 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
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 }}
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set +e
|
||||
|
||||
platform="${1:?usage: cleanup-nightly-e2e.sh <ios|android> <device-id>}"
|
||||
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
|
||||
@@ -1,179 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
platform="${1:?usage: run-nightly-e2e.sh <ios|android> <device-id>}"
|
||||
device_id="${2:?usage: run-nightly-e2e.sh <ios|android> <device-id>}"
|
||||
|
||||
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"
|
||||
@@ -1,356 +0,0 @@
|
||||
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 = /<testcase\b([^>]*?)(?:\/>|>([\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(/<testsuite\b([^>]*)>/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 || '<missing>'}`)
|
||||
}
|
||||
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`)
|
||||
}
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME }}
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME}}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -10,25 +10,12 @@ 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
|
||||
@@ -36,31 +23,6 @@ on:
|
||||
version-code:
|
||||
description: Android version code
|
||||
value: ${{ jobs.build.outputs.version-code }}
|
||||
secrets:
|
||||
EXPO_TOKEN:
|
||||
required: true
|
||||
ENV_TOKEN:
|
||||
required: true
|
||||
SENTRY_DSN:
|
||||
required: true
|
||||
BITDRIFT_API_KEY:
|
||||
required: true
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID:
|
||||
required: true
|
||||
GOOGLE_SERVICES_TOKEN:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: true
|
||||
SLACK_CLIENT_ALERT_WEBHOOK:
|
||||
required: true
|
||||
ANDROID_KEYSTORE_BASE64:
|
||||
required: true
|
||||
ANDROID_KEYSTORE_PASSWORD:
|
||||
required: true
|
||||
ANDROID_KEY_ALIAS:
|
||||
required: true
|
||||
ANDROID_KEY_PASSWORD:
|
||||
required: true
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
@@ -69,92 +31,93 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build Android
|
||||
runs-on: ${{ inputs.runner || 'Linux-x64-32core' }}
|
||||
name: Build and Submit Android
|
||||
runs-on: 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
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
- 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@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- 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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
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
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: ✏️ Write environment variables
|
||||
- name: Env
|
||||
id: env
|
||||
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 }}
|
||||
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
|
||||
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 }}
|
||||
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
|
||||
|
||||
- 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' }}
|
||||
@@ -162,74 +125,33 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Play Store Submission
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
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 ${{ 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"
|
||||
{"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 }}```"}
|
||||
|
||||
- name: 🔧 Setup bundletool
|
||||
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
|
||||
with:
|
||||
version: "1.18.3"
|
||||
bundletool-version: "1.17.2"
|
||||
|
||||
- name: 🔑 Decode keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > keystore.jks
|
||||
run: echo "${{ secrets.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:"$ANDROID_KEYSTORE_PASSWORD" \
|
||||
--ks-key-alias="$ANDROID_KEY_ALIAS" \
|
||||
--key-pass=pass:"$ANDROID_KEY_PASSWORD"
|
||||
--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
|
||||
@@ -251,13 +173,27 @@ jobs:
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack of APK Artifact
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
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 ${{ 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 }}```"}
|
||||
{"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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -265,7 +201,7 @@ jobs:
|
||||
attachToRelease:
|
||||
name: Attach APK to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, universalApk]
|
||||
needs: [build]
|
||||
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -291,9 +227,9 @@ jobs:
|
||||
|
||||
- name: ⬇️ Download APK artifact
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: ${{ needs.universalApk.outputs.apk-artifact-name }}
|
||||
name: ${{ needs.build.outputs.apk-artifact-name }}
|
||||
|
||||
- name: 🏷️ Rename APK for release
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
@@ -302,19 +238,15 @@ jobs:
|
||||
- name: 📎 Attach APK to GitHub Release
|
||||
id: attach
|
||||
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: |
|
||||
gh release upload "$TAG" "$APK" --clobber
|
||||
url=$(gh release view "$TAG" --json url --jq .url)
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
- name: 🔔 Notify Slack of Release Attachment
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -10,29 +10,20 @@ on:
|
||||
options:
|
||||
- testflight
|
||||
- production
|
||||
testFlightGroup:
|
||||
type: choice
|
||||
description: TestFlight group to assign the build to after submitting
|
||||
options:
|
||||
- none
|
||||
- QA Team
|
||||
- Software Mansion
|
||||
default: none
|
||||
assignTestFlightGroup:
|
||||
type: boolean
|
||||
description: Assign the build to the "QA Team" TestFlight group after submitting
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
profile:
|
||||
type: string
|
||||
description: Build profile to use
|
||||
required: true
|
||||
testFlightGroup:
|
||||
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: ''
|
||||
assignTestFlightGroup:
|
||||
type: boolean
|
||||
description: Assign the build to the "QA Team" TestFlight group after submitting
|
||||
default: false
|
||||
outputs:
|
||||
package-version:
|
||||
description: Version from package.json
|
||||
@@ -40,29 +31,6 @@ on:
|
||||
build-number:
|
||||
description: iOS build number
|
||||
value: ${{ jobs.build.outputs.build-number }}
|
||||
secrets:
|
||||
EXPO_TOKEN:
|
||||
required: true
|
||||
ENV_TOKEN:
|
||||
required: true
|
||||
SENTRY_DSN:
|
||||
required: true
|
||||
BITDRIFT_API_KEY:
|
||||
required: true
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID:
|
||||
required: true
|
||||
GOOGLE_SERVICES_TOKEN:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: true
|
||||
ASC_KEY_ID:
|
||||
required: true
|
||||
ASC_ISSUER_ID:
|
||||
required: true
|
||||
ASC_KEY_P8_BASE64:
|
||||
required: true
|
||||
SLACK_CLIENT_ALERT_WEBHOOK:
|
||||
required: true
|
||||
|
||||
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
|
||||
permissions:
|
||||
@@ -71,8 +39,8 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build iOS
|
||||
runs-on: ${{ inputs.runner || 'macos-26-xlarge' }}
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26-xlarge
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
@@ -80,15 +48,38 @@ 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
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- 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: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
@@ -96,7 +87,7 @@ jobs:
|
||||
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.17.0
|
||||
EXPECTED=1.16.2
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
@@ -106,7 +97,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
@@ -115,29 +106,38 @@ jobs:
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
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
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
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 }}
|
||||
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
|
||||
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 }}
|
||||
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
|
||||
|
||||
- name: 📂 Extract build artifact
|
||||
run: |
|
||||
@@ -174,6 +174,16 @@ 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
|
||||
@@ -183,7 +193,6 @@ 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: |
|
||||
@@ -199,98 +208,17 @@ 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.assignTestFlightGroup }}
|
||||
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: ${{ needs.build.outputs.package-version }}
|
||||
BUILD_NUMBER: ${{ needs.build.outputs.build-number }}
|
||||
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
BUILD_NUMBER: ${{ steps.ipa-build-number.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).
|
||||
@@ -313,5 +241,29 @@ jobs:
|
||||
app_identifier:"xyz.blueskyweb.app" \
|
||||
app_version:"$APP_VERSION" \
|
||||
build_number:"$BUILD_NUMBER" \
|
||||
groups:"$TESTFLIGHT_GROUP" \
|
||||
groups:"QA Team" \
|
||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
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
|
||||
|
||||
@@ -31,9 +31,7 @@ jobs:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-deploy
|
||||
cancel-in-progress: true
|
||||
outputs:
|
||||
# A version bump forces a native build even if the fingerprint is unchanged
|
||||
changes-detected: ${{ steps.fingerprint.outputs.includes-changes ||
|
||||
steps.version.outputs.version-changed }}
|
||||
changes-detected: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
@@ -54,7 +52,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -62,44 +60,27 @@ jobs:
|
||||
if: ${{ github.ref != 'refs/heads/main' }}
|
||||
run: git fetch origin main:main --depth 100
|
||||
|
||||
# A change to the version in package.json means a new native release, so
|
||||
# an OTA update must not be deployed and full native builds are required
|
||||
# regardless of what the fingerprint says
|
||||
- name: 🔢 Check for version change
|
||||
id: version
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
env:
|
||||
EVENT_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
CURRENT_VERSION=$(jq -r '.version' package.json)
|
||||
if [ -n "$EVENT_BEFORE" ] && [[ ! "$EVENT_BEFORE" =~ ^0+$ ]] && git cat-file -e "$EVENT_BEFORE:package.json" 2>/dev/null; then
|
||||
PREVIOUS_VERSION=$(git show "$EVENT_BEFORE:package.json" | jq -r '.version')
|
||||
else
|
||||
PREVIOUS_VERSION=$(git show HEAD~1:package.json | jq -r '.version')
|
||||
fi
|
||||
echo "Previous version: $PREVIOUS_VERSION, current version: $CURRENT_VERSION"
|
||||
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
||||
echo "Version changed, full native builds are required"
|
||||
echo "version-changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
|
||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
||||
with:
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
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
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
@@ -112,43 +93,47 @@ jobs:
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
# eas.json not used here, so EXPO_PUBLIC_ENV must be written explicitly
|
||||
- name: ✏️ Write environment variables
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel || 'testflight' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
id: env
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes && !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' }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
|
||||
echo "EXPO_PUBLIC_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: 🏗️ Create Bundle
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: pnpm use-build-number bash scripts/bundleUpdate.sh
|
||||
env:
|
||||
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }}
|
||||
@@ -157,76 +142,315 @@ jobs:
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
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' }}
|
||||
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 }}
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.16.2
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
|
||||
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
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
|
||||
|
||||
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' }}
|
||||
# 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 }}
|
||||
'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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.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:
|
||||
bundletool-version: "1.17.2"
|
||||
|
||||
- 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
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
|
||||
|
||||
@@ -54,18 +54,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
|
||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -40,18 +40,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude review
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
|
||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
@@ -36,9 +36,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Go tooling
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: bskyweb/go.mod
|
||||
cache-dependency-path: bskyweb/go.sum
|
||||
|
||||
@@ -21,11 +21,10 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
|
||||
job: [lint, prettier, typecheck]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Verify Node version pins match package.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -53,7 +52,7 @@ jobs:
|
||||
exit $rc
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -88,10 +87,10 @@ jobs:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
notes: ${{ steps.notes.outputs.notes }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -77,21 +77,8 @@ jobs:
|
||||
uses: ./.github/workflows/build-submit-ios.yml
|
||||
with:
|
||||
profile: testflight
|
||||
testFlightGroup: "QA Team"
|
||||
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
||||
# so the nightly build 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 }}
|
||||
assignTestFlightGroup: true
|
||||
secrets: inherit
|
||||
|
||||
android:
|
||||
name: Nightly Android Build
|
||||
@@ -104,21 +91,7 @@ jobs:
|
||||
uses: ./.github/workflows/build-submit-android.yml
|
||||
with:
|
||||
profile: testflight-android
|
||||
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
||||
# so the nightly build 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 }}
|
||||
secrets: inherit
|
||||
|
||||
notify-ios:
|
||||
name: Notify Slack of iOS nightly
|
||||
@@ -144,7 +117,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -174,7 +147,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
---
|
||||
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 }}
|
||||
@@ -16,12 +16,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Extract language strings
|
||||
run: pnpm intl:extract
|
||||
- name: Create commit
|
||||
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
||||
with:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
core.setOutput('head-ref', pr.data.head.ref);
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
@@ -125,14 +125,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
uses: dcarbone/install-jq-action@b7ef57d46ece78760b4019dbc4080a1ba2a40b45 # v3.2.0
|
||||
|
||||
- name: Env
|
||||
id: env
|
||||
@@ -193,7 +193,7 @@ jobs:
|
||||
RUNTIME_VERSION:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: failure()
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
|
||||
@@ -19,61 +19,22 @@ concurrency:
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
# Populate this from main so every PR can restore the same trusted baseline.
|
||||
webpack-analyzer-base:
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ github.sha }}
|
||||
|
||||
- name: 🔦 Generate stats file for base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
pnpm generate-webpack-stats-file
|
||||
|
||||
- name: ⬆️ Save base stats to cache
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ github.sha }}
|
||||
|
||||
webpack-analyzer:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.event_name == 'pull_request'}}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -110,18 +71,15 @@ jobs:
|
||||
|
||||
- name: ⬇️ Get base stats from cache
|
||||
id: get-base-stats
|
||||
# Restore-only prevents PR-scoped fallback builds from creating caches.
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: stats.json
|
||||
key: stats-base-main-${{ steps.base-commit.outputs.base-commit }}
|
||||
path: stats-base.json
|
||||
key: stats-base-${{ steps.base-commit.outputs.base-commit }}
|
||||
|
||||
- name: Restore to base commit
|
||||
if: ${{ !steps.get-base-stats.outputs.cache-hit }}
|
||||
env:
|
||||
BASE_COMMIT: ${{ steps.base-commit.outputs.base-commit }}
|
||||
run: |
|
||||
git reset "$BASE_COMMIT"
|
||||
git reset HEAD~
|
||||
git restore .
|
||||
|
||||
- name: 🔦 Generate stats file from base commit
|
||||
@@ -130,17 +88,18 @@ jobs:
|
||||
pnpm install
|
||||
pnpm intl:build
|
||||
pnpm generate-webpack-stats-file
|
||||
mv stats.json stats-base.json
|
||||
|
||||
- name: % Get diff
|
||||
id: get-diff
|
||||
uses: NejcZdovc/bundle-size-diff@5321de41d2d62a7b0f4d6e60f59d1280a0034160 # v1.1.0
|
||||
with:
|
||||
base_path: "stats.json"
|
||||
base_path: "stats-base.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: bundle-diff
|
||||
message: |
|
||||
@@ -157,7 +116,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 100
|
||||
|
||||
@@ -168,20 +127,19 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 📷 Check fingerprint and install dependencies
|
||||
id: fingerprint
|
||||
timeout-minutes: 5
|
||||
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.2.0
|
||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
||||
with:
|
||||
profile: pull-request
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
@@ -199,24 +157,8 @@ jobs:
|
||||
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
|
||||
|
||||
- name: 💬 Delete comment
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
with:
|
||||
header: fingerprint-diff
|
||||
delete: true
|
||||
|
||||
- name: 🏷️ Label as fingerprint changed
|
||||
if: ${{ steps.fingerprint.outputs.includes-changes }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh pr edit "$PR_NUMBER" --add-label "bot: fingerprint changed" || true
|
||||
|
||||
- name: 🏷️ Remove fingerprint changed label
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh pr edit "$PR_NUMBER" --remove-label "bot: fingerprint changed" || true
|
||||
|
||||
@@ -14,12 +14,9 @@ jobs:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
steps:
|
||||
- name: Checkout public repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Don't persist the checkout auth header; the push below authenticates
|
||||
# with the app token embedded in the remote URL instead
|
||||
persist-credentials: false
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
@@ -28,16 +25,14 @@ jobs:
|
||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||
repositories: social-app-internal
|
||||
# Scope the token down from the app's full installation permissions;
|
||||
# pushing is the only thing this token is used for. The workflows
|
||||
# permission is required because the sync includes files under
|
||||
# .github/workflows/, which GitHub refuses to push without it.
|
||||
# pushing is the only thing this token is used for
|
||||
permission-contents: write
|
||||
permission-workflows: write
|
||||
- name: Push to internal repo
|
||||
env:
|
||||
TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "test@users.noreply.github.com"
|
||||
git config --unset-all http.https://github.com/.extraheader
|
||||
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
|
||||
git push internal main --force
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out PR HEAD
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
with:
|
||||
# Annotate the PR directly instead of uploading SARIF to the
|
||||
# security tab, and fail the check on any finding
|
||||
|
||||
@@ -108,8 +108,8 @@ google-services.json
|
||||
# Performance results (Flashlight)
|
||||
.perf/
|
||||
|
||||
# Oxlint
|
||||
.oxlintcache
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
# i18n
|
||||
src/locale/locales/_build/
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [
|
||||
"typescript",
|
||||
"react",
|
||||
"import"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"env": {
|
||||
"builtin": true
|
||||
},
|
||||
"settings": {
|
||||
"import-x/extensions": [
|
||||
".ts",
|
||||
".tsx",
|
||||
".cts",
|
||||
".mts",
|
||||
".js",
|
||||
".jsx",
|
||||
".cjs",
|
||||
".mjs"
|
||||
],
|
||||
"import-x/external-module-folders": [
|
||||
"node_modules",
|
||||
"node_modules/@types"
|
||||
],
|
||||
"import-x/parsers": {
|
||||
"@typescript-eslint/parser": [
|
||||
".ts",
|
||||
".tsx",
|
||||
".cts",
|
||||
".mts"
|
||||
]
|
||||
},
|
||||
"import-x/resolver": {
|
||||
"node": {
|
||||
"extensions": [
|
||||
".js",
|
||||
".web.js",
|
||||
".ios.js",
|
||||
".android.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/__mocks__/*.ts",
|
||||
"ios/**",
|
||||
"android/**",
|
||||
"coverage/**",
|
||||
"*.lock",
|
||||
".husky/**",
|
||||
"patches/**",
|
||||
"*.html",
|
||||
"bskyweb/**",
|
||||
"bskyembed/**",
|
||||
"bskyogcard/**",
|
||||
"src/locale/locales/_build/**",
|
||||
"src/locale/locales/**/*.js",
|
||||
"*.e2e.ts",
|
||||
"*.e2e.tsx",
|
||||
"eslint.config.mjs",
|
||||
".jscodeshift/**"
|
||||
],
|
||||
"rules": {
|
||||
"constructor-super": "error",
|
||||
"for-direction": "error",
|
||||
"getter-return": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-misleading-character-class": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unreachable": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-with": "error",
|
||||
"require-yield": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"import/namespace": "off",
|
||||
"import/default": "error",
|
||||
"import/no-named-as-default": "warn",
|
||||
"import/no-named-as-default-member": "warn",
|
||||
"import/no-duplicates": "warn",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/exhaustive-deps": "warn",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-array-delete": "error",
|
||||
"typescript/no-base-to-string": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-duplicate-type-constituents": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-for-in-array": "error",
|
||||
"typescript/no-implied-eval": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-redundant-type-constituents": "error",
|
||||
"typescript/no-require-imports": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unsafe-argument": "error",
|
||||
"typescript/no-unsafe-assignment": "error",
|
||||
"typescript/no-unsafe-call": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-enum-comparison": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-unsafe-member-access": "error",
|
||||
"typescript/no-unsafe-return": "error",
|
||||
"typescript/no-unsafe-unary-minus": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/only-throw-error": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/prefer-promise-reject-errors": "error",
|
||||
"typescript/require-await": "error",
|
||||
"typescript/restrict-plus-operands": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/triple-slash-reference": "error",
|
||||
"typescript/unbound-method": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.mts",
|
||||
"**/*.cts"
|
||||
],
|
||||
"rules": {
|
||||
"constructor-super": "off",
|
||||
"getter-return": "off",
|
||||
"no-class-assign": "off",
|
||||
"no-const-assign": "off",
|
||||
"no-dupe-class-members": "off",
|
||||
"no-dupe-keys": "off",
|
||||
"no-func-assign": "off",
|
||||
"no-import-assign": "off",
|
||||
"no-new-native-nonconstructor": "off",
|
||||
"no-obj-calls": "off",
|
||||
"no-redeclare": "off",
|
||||
"no-setter-return": "off",
|
||||
"no-this-before-super": "off",
|
||||
"no-unreachable": "off",
|
||||
"no-unsafe-negation": "off",
|
||||
"no-var": "error",
|
||||
"no-with": "off",
|
||||
"prefer-const": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"bsky-internal/avoid-unwrapped-text": [
|
||||
"error",
|
||||
{
|
||||
"impliedTextComponents": [
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
"H5",
|
||||
"H6",
|
||||
"P",
|
||||
"Admonition",
|
||||
"Admonition.Admonition",
|
||||
"Toast.Action",
|
||||
"AgeAssuranceAdmonition",
|
||||
"Span",
|
||||
"StackedButton"
|
||||
],
|
||||
"impliedTextProps": [],
|
||||
"suggestedTextWrappers": {
|
||||
"Button": "ButtonText",
|
||||
"ToggleButton.Button": "ToggleButton.ButtonText",
|
||||
"SegmentedControl.Item": "SegmentedControl.ItemText"
|
||||
}
|
||||
}
|
||||
],
|
||||
"bsky-internal/use-exact-imports": "error",
|
||||
"bsky-internal/use-prefixed-imports": "error",
|
||||
"bsky-internal/lingui-msg-rule": "error",
|
||||
"react/display-name": "error",
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-no-comment-textnodes": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/no-children-prop": "error",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-find-dom-node": "error",
|
||||
"react/no-is-mounted": "error",
|
||||
"react/no-render-return-value": "error",
|
||||
"react/no-string-refs": "error",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/no-unknown-property": "error",
|
||||
"react/no-unsafe": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/hook-use-state": "warn",
|
||||
"react-native/no-inline-styles": "off",
|
||||
"react-native-a11y/has-accessibility-hint": "error",
|
||||
"react-native-a11y/has-accessibility-props": "error",
|
||||
"react-native-a11y/has-valid-accessibility-actions": "error",
|
||||
"react-native-a11y/has-valid-accessibility-component-type": "error",
|
||||
"react-native-a11y/has-valid-accessibility-descriptors": "error",
|
||||
"react-native-a11y/has-valid-accessibility-role": "error",
|
||||
"react-native-a11y/has-valid-accessibility-state": "error",
|
||||
"react-native-a11y/has-valid-accessibility-states": "error",
|
||||
"react-native-a11y/has-valid-accessibility-traits": "error",
|
||||
"react-native-a11y/has-valid-accessibility-value": "error",
|
||||
"react-native-a11y/no-nested-touchables": "error",
|
||||
"react-native-a11y/has-valid-accessibility-ignores-invert-colors": "error",
|
||||
"react-native-a11y/has-valid-accessibility-live-region": "error",
|
||||
"react-native-a11y/has-valid-important-for-accessibility": "error",
|
||||
"react-compiler/react-compiler": "warn",
|
||||
"simple-import-sort/imports": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
[
|
||||
"^\\u0000"
|
||||
],
|
||||
[
|
||||
"^node:"
|
||||
],
|
||||
[
|
||||
"^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)",
|
||||
"^(expo(.*)$)|^(expo$)",
|
||||
"^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w"
|
||||
],
|
||||
[
|
||||
"^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)",
|
||||
"^(?:#\\/)?view(?:$|\\/)",
|
||||
"^(?:#\\/)?screens(?:$|\\/)",
|
||||
"^(?:#\\/)?alf(?:$|\\/)",
|
||||
"^(?:#\\/)?components(?:$|\\/)",
|
||||
"^#\\/",
|
||||
"^\\."
|
||||
],
|
||||
[
|
||||
"^"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"simple-import-sort/exports": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_.+",
|
||||
"caughtErrors": "none",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react",
|
||||
"importNames": [
|
||||
"React",
|
||||
"default"
|
||||
],
|
||||
"message": "React is already in the global type namespace. Use named imports for runtime modules."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-empty-pattern": "off",
|
||||
"no-async-promise-executor": "off",
|
||||
"no-constant-binary-expression": "warn",
|
||||
"prefer-const": "off",
|
||||
"no-empty": "off",
|
||||
"no-unsafe-optional-chaining": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-var": "off",
|
||||
"prefer-rest-params": "off",
|
||||
"no-case-declarations": "off",
|
||||
"no-irregular-whitespace": "off",
|
||||
"no-useless-escape": "off",
|
||||
"no-sparse-arrays": "off",
|
||||
"no-fallthrough": "off",
|
||||
"no-control-regex": "off",
|
||||
"no-unused-expressions": [
|
||||
"error",
|
||||
{
|
||||
"allowTernary": true
|
||||
}
|
||||
],
|
||||
"import/consistent-type-specifier-style": [
|
||||
"warn",
|
||||
"prefer-inline"
|
||||
],
|
||||
"import/no-nodejs-modules": "error",
|
||||
"typescript/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/no-require-imports": "off",
|
||||
"typescript/ban-ts-comment": "off",
|
||||
"typescript/no-empty-object-type": "off",
|
||||
"typescript/no-unsafe-function-type": "off",
|
||||
"typescript/no-unsafe-assignment": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-unsafe-argument": "off",
|
||||
"typescript/no-unsafe-return": "off"
|
||||
},
|
||||
"jsPlugins": [
|
||||
"eslint-plugin-bsky-internal",
|
||||
"eslint-plugin-react-native",
|
||||
"eslint-plugin-react-native-a11y",
|
||||
"eslint-plugin-react-compiler",
|
||||
"eslint-plugin-simple-import-sort"
|
||||
],
|
||||
"env": {
|
||||
"es2026": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"bskyogcard/**/*.{js,jsx,ts,tsx}",
|
||||
"dev-env/**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"import/no-nodejs-modules": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/__tests__/**/*.{js,jsx,ts,tsx}",
|
||||
"**/*.test.{js,jsx,ts,tsx}"
|
||||
],
|
||||
"env": {
|
||||
"jest": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,7 @@ Bluesky Social is a cross-platform social media application built with React Nat
|
||||
|
||||
- React 19.1
|
||||
- React Native 0.81 with Expo 54
|
||||
- TypeScript 7
|
||||
- TypeScript 6
|
||||
- React Navigation 7 for routing
|
||||
- TanStack Query (React Query) for data fetching
|
||||
- Lingui 5 for internationalization
|
||||
@@ -30,9 +30,8 @@ pnpm ios # Run on iOS
|
||||
# Testing & Quality
|
||||
# IMPORTANT: Always use these pnpm scripts, never call the underlying tools directly
|
||||
pnpm test # Run Jest tests
|
||||
pnpm lint # Run Oxlint
|
||||
pnpm lint # Run ESLint
|
||||
pnpm typecheck # Run TypeScript type checking
|
||||
pnpm prettier # Run Prettier for code formatting
|
||||
|
||||
# Internationalization
|
||||
# DO NOT run these commands - extraction and compilation are handled by CI
|
||||
@@ -82,7 +81,7 @@ should go in `/screens` (not `/view/screens`) to encourage better organization
|
||||
and separation from legacy code.
|
||||
|
||||
For complex screens that have specific components or data needs that _are not
|
||||
shared by other screens_, we encourage subdirectories within `/screens/<name>`
|
||||
shared by other screens_, we encourage subdirectoreis within `/screens/<name>`
|
||||
e.g. `/screens/ProfileScreen/ProfileScreen.tsx` and
|
||||
`/screens/ProfileScreen/components/`.
|
||||
|
||||
@@ -126,88 +125,133 @@ eventually.
|
||||
Typically JS style for variables, functions, etc. We use ProudCamelCase for
|
||||
components, and camelCase directories and files.
|
||||
|
||||
For "macro" cases in `/features`, `/screens`, or `/components`, co-locate related
|
||||
code in a directory with an `index.tsx` main component plus sibling
|
||||
components/hooks/utils (e.g. `screens/ProfileScreen/index.tsx` +
|
||||
`screens/ProfileScreen/components/`). Keep related code together so it lives where
|
||||
someone would look for it. Don't overdo it: a component that fits in one file
|
||||
should just be `Component.tsx`, not `Component/index.tsx`.
|
||||
When organizing new code, consider if it fits into a single file, or if it
|
||||
should be broken down into multiple files. For "macro" component cases, or
|
||||
things that live in `/features` or `/screens`, we often follow a pattern of
|
||||
having an `index.tsx` for the main component, and then co-locating related
|
||||
components, hooks, and utilities in the same directory. For example:
|
||||
|
||||
Platform-specific files are covered under "Platform-Specific Code" below.
|
||||
```
|
||||
src
|
||||
├── screens/
|
||||
│ ├── ProfileScreen/
|
||||
│ │ ├── index.tsx # Main screen component
|
||||
│ │ ├── components/ # Sub-components used only by this screen
|
||||
```
|
||||
|
||||
### Comments
|
||||
Similar patterns can be found in `/features` and `/components`. The idea here is
|
||||
to keep related code together and make it easier to navigate.
|
||||
|
||||
You should ask yourself: if someone new was looking for the code related to this
|
||||
feature or screen, where would they expect to find it? Organizing code in a way
|
||||
that matches developer expectations can make the codebase much more
|
||||
approachable. Being able to say "Live Now stuff lives in `/features/liveNow`" is
|
||||
easier to understand than having it scattered across multiple directories.
|
||||
|
||||
No need to go overboard with this. If a component or feature fits into a single
|
||||
file, there's no reason to have a `/Component/index.tsx` file when it could just
|
||||
be `/Component.tsx`. Use your judgment based on the complexity and amount of
|
||||
related code.
|
||||
|
||||
#### Platform Specific Files
|
||||
|
||||
We have conflicting patterns in the app for this. The preferred approach is to
|
||||
group platform-specific files into a directory as much as possible. For example,
|
||||
rather than having `Component.tsx`, `Component.web.tsx`, and
|
||||
`Component.native.tsx` in the same directory, we prefer to have a `Component/`
|
||||
directory with `index.tsx`, `index.web.tsx`, and `index.native.tsx`. This keeps
|
||||
related code together and gives us a better visual cue that there are probably
|
||||
other files contained within this "macro" feature, whereas `Component.tsx` on
|
||||
its own looks more like a single component file.
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
Comment code when necessary to explain the “why” behind something; avoid
|
||||
comments that simply describe the code. Avoid Unicode characters in comments,
|
||||
e.g., use `-` not `—`.
|
||||
|
||||
Always use docblock (`/** */`) syntax for comments that document a type, type
|
||||
member, method, function, or variable. These are the comments a reader expects
|
||||
to find attached to a named declaration, and the docblock form makes that intent
|
||||
clear and surfaces nicely in editor tooltips.
|
||||
For larger features or components, it's helpful to include a README.md file
|
||||
within the directory that explains the purpose of the feature, how it works, and
|
||||
any important implementation details. The `/Component/index.tsx` pattern lends
|
||||
itself well to this, since the `index.tsx` can be the main component file, and
|
||||
the `README.md` can provide documentation for the whole feature. This is
|
||||
optional, but can be a nice way to keep documentation close to the code it
|
||||
describes.
|
||||
|
||||
```tsx
|
||||
type DateFieldProps = {
|
||||
/**
|
||||
* An empty string renders the placeholder and opens the picker at today (or
|
||||
* maximumDate, if earlier).
|
||||
*/
|
||||
value: string | Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
|
||||
*/
|
||||
export function DateField() {}
|
||||
```
|
||||
|
||||
More generally, any multiline comment should use the `/* */` block syntax rather
|
||||
than stacked `//` lines. Reserve `//` for short, single-line comments.
|
||||
|
||||
```tsx
|
||||
/*
|
||||
* The picker requires a valid date, so when value is empty we fall back to
|
||||
* maximumDate (if set) or today.
|
||||
*/
|
||||
const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
|
||||
```
|
||||
|
||||
### Documentation and Tests Within Features
|
||||
|
||||
For larger features or components, co-locate documentation and tests with the
|
||||
code. A `README.md` in the directory (the `/Component/index.tsx` pattern lends
|
||||
itself well to this) can document the whole feature, and feature-specific tests
|
||||
belong alongside it as `Component.test.tsx` or in a `__tests__/` subdirectory.
|
||||
Both are optional.
|
||||
Similarly, if there are tests that are specific to a component or feature, it
|
||||
can be helpful to include them in the same directory, either as
|
||||
`Component.test.tsx` or in a `__tests__/` subdirectory. This keeps everything
|
||||
related to the component or feature in one place and makes it easier to find and
|
||||
maintain tests.
|
||||
|
||||
## Styling System (ALF)
|
||||
|
||||
ALF is the custom design system. Tailwind-inspired naming with underscores
|
||||
instead of hyphens. Static atoms (`atoms as a`) are theme-independent; theme
|
||||
atoms/palette come from `useTheme()` (`t.atoms.bg`, `t.palette.primary_500`).
|
||||
Style props take an array of atoms + theme atoms + raw styles.
|
||||
ALF is the custom design system. It uses Tailwind-inspired naming with underscores instead of hyphens.
|
||||
|
||||
Order atoms by: flexbox (`a.flex_row`), spacing (`a.px_md`), text (`a.font_bold`),
|
||||
themes (`t.atoms.text`), then raw styles (`{backgroundColor: t.palette.primary_500}`).
|
||||
### Basic Usage
|
||||
|
||||
Generally, order atoms by:
|
||||
|
||||
- Flexbox configuration, e.g., `a.flex_row`
|
||||
- Spacing, e.g., `a.px_md`
|
||||
- Text styles, e.g., `a.font_bold`
|
||||
- Themes, e.g., `t.atoms.text`,
|
||||
- Raw styles, e.g., `{backgroundColor: t.palette.primary_500}`
|
||||
|
||||
```tsx
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
const t = useTheme()
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]} />
|
||||
function MyComponent() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
|
||||
Static atoms live in `a.*` (e.g. `a.flex_row`, `a.p_md`, `a.rounded_md`,
|
||||
`a.text_lg`). Theme atoms/palette come from `useTheme()` (`t.atoms.bg`,
|
||||
`t.atoms.text`, `t.atoms.border_contrast_low`, `t.palette.primary_500`).
|
||||
**Static Atoms** – Theme-independent styles imported from `atoms`:
|
||||
|
||||
**Platform utilities** (`import {web, native, ios, android, platform} from '#/alf'`)
|
||||
return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
|
||||
`native({paddingBottom: 20})`, `platform({ios: {...}, android: {...}, web: {...}})`.
|
||||
```tsx
|
||||
import {atoms as a} from '#/alf'
|
||||
// a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc.
|
||||
```
|
||||
|
||||
**Breakpoints:** `const {gtPhone, gtMobile, gtTablet} = useBreakpoints()` from `#/alf`.
|
||||
**Theme Atoms** – Theme-dependent colors from `useTheme()`:
|
||||
|
||||
```tsx
|
||||
const t = useTheme()
|
||||
// t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc.
|
||||
// t.palette.primary_500, t.palette.negative_400, etc.
|
||||
```
|
||||
|
||||
**Platform Utilities** – For platform-specific styles:
|
||||
|
||||
```tsx
|
||||
import {web, native, ios, android, platform} from '#/alf'
|
||||
|
||||
const styles = [
|
||||
a.p_md,
|
||||
web({cursor: 'pointer'}),
|
||||
native({paddingBottom: 20}),
|
||||
platform({ios: {...}, android: {...}, web: {...}}),
|
||||
]
|
||||
```
|
||||
|
||||
**Breakpoints** – Responsive design:
|
||||
|
||||
```tsx
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
const {gtPhone, gtMobile, gtTablet} = useBreakpoints()
|
||||
if (gtMobile) {
|
||||
// Tablet or desktop layout
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
@@ -228,67 +272,176 @@ return conditional styles inline in a style array: `web({cursor: 'pointer'})`,
|
||||
```tsx
|
||||
import {Fragment} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function MyComponent({items = []}: {items?: string[]}) {
|
||||
function MyComponent({foo = []}: {foo?: string[]}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<>
|
||||
<View><Text><Trans>Example</Trans><Text></View>
|
||||
<View>
|
||||
<Text>
|
||||
<Trans>Example</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item}>
|
||||
{foo.map((foo, index) => (
|
||||
<Fragment key={foo}>
|
||||
<Text>{index}</Text>
|
||||
<Text>{item}</Text>
|
||||
<Text>{foo}</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog Component
|
||||
|
||||
Lives in `#/components/Dialog`. Bottom sheet on native, modal on web. Manage
|
||||
state with `useDialogControl()`. `Dialog.Handle` renders native-only, `Dialog.Close`
|
||||
web-only. CRITICAL: run any post-close action inside the `control.close(() => ...)`
|
||||
callback (see Footguns). Compound-component usage; canonical example in any dialog
|
||||
under `#/components`.
|
||||
Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state.
|
||||
|
||||
```tsx
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function MyFeature() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button label="Open" onPress={control.open}>
|
||||
<ButtonText>Open Dialog</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
{/* Typically the inner part is in its own component */}
|
||||
<DialogInner />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner() {
|
||||
return (
|
||||
<>
|
||||
<Dialog.Handle /> {/* Native-only drag handle */}
|
||||
<Dialog.ScrollableInner label={l`My Dialog`}>
|
||||
<Dialog.Header>
|
||||
<Dialog.HeaderText>Title</Dialog.HeaderText>
|
||||
</Dialog.Header>
|
||||
<Text>Dialog content here</Text>
|
||||
<Button label="Done" onPress={() => control.close()}>
|
||||
<ButtonText>Done</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close /> {/* Web-only X button in top left */}
|
||||
</Dialog.ScrollableInner>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Menu Component
|
||||
|
||||
Lives in `#/components/Menu`. Dropdown on web, bottom sheet dialog on native.
|
||||
`Menu.Divider` is web-only, `Menu.ContainerItem` native-only. Compound API
|
||||
(`Menu.Root` / `Menu.Trigger` / `Menu.Outer` / `Menu.Group` / `Menu.Item`); grep
|
||||
existing usages across the app for a canonical example.
|
||||
Menus render as a dropdown on web and a bottom sheet dialog on native.
|
||||
|
||||
```tsx
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
function MyMenu() {
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label="Open menu">
|
||||
{({props}) => (
|
||||
<Button {...props} label="Menu">
|
||||
<ButtonIcon icon={DotsHorizontal} />
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item label="Edit" onPress={handleEdit}>
|
||||
<Menu.ItemIcon icon={Pencil} />
|
||||
<Menu.ItemText>Edit</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
<Menu.Item label="Delete" onPress={handleDelete}>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemText>Delete</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Button Component
|
||||
|
||||
`import {Button, ButtonText, ButtonIcon} from '#/components/Button'`. Props:
|
||||
```tsx
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
|
||||
// Solid primary button (most common)
|
||||
<Button label="Save" onPress={handleSave} color="primary" size="large">
|
||||
<ButtonText>Save</ButtonText>
|
||||
</Button>
|
||||
|
||||
// With icon
|
||||
<Button label="Share" onPress={handleShare} color="secondary" size="small">
|
||||
<ButtonIcon icon={Share} />
|
||||
<ButtonText>Share</ButtonText>
|
||||
</Button>
|
||||
|
||||
// Icon-only button
|
||||
<Button label="Close" onPress={handleClose} color="secondary" size="small" shape="round">
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
|
||||
// Ghost variant (deprecated - use color prop)
|
||||
<Button label="Cancel" variant="ghost" color="secondary" size="small">
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
```
|
||||
|
||||
**Button Props:**
|
||||
|
||||
- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
|
||||
- `size`: `'tiny'` | `'small'` | `'large'`
|
||||
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, prefer `color`)
|
||||
|
||||
### TextField
|
||||
|
||||
Compound component at `#/components/forms/TextField` (`TextField.LabelText`,
|
||||
`TextField.Root`, `TextField.Icon`, `TextField.Input`). Prefer `defaultValue` over
|
||||
`value` (see Footguns).
|
||||
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
|
||||
|
||||
### Typography
|
||||
|
||||
`import {Text, H1, H2, P} from '#/components/Typography'`. The `Text` default style
|
||||
is `[a.text_sm, a.leading_snug, t.atoms.text]`. Pass the `emoji` prop to any `Text`
|
||||
that may contain emoji - user-generated text (display names etc.) almost always
|
||||
does, so only omit it for static, emoji-free strings: `<Text emoji>Hello!</Text>`.
|
||||
```tsx
|
||||
import {Text, H1, H2, P} from '#/components/Typography'
|
||||
|
||||
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
|
||||
<P>Paragraph text with default styling.</P>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
|
||||
|
||||
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
|
||||
// will almost certainly contain emoji, so only omit it when the text is static and
|
||||
// does not contain an emoji
|
||||
<Text emoji>Hello! 👋</Text>
|
||||
```
|
||||
|
||||
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
|
||||
|
||||
### TextField
|
||||
|
||||
```tsx
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
|
||||
<TextField.LabelText>Email</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={AtSign} />
|
||||
<TextField.Input
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</TextField.Root>
|
||||
```
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
@@ -362,49 +515,201 @@ pnpm intl:compile # Compile translations for runtime
|
||||
|
||||
### TanStack Query (Data Fetching)
|
||||
|
||||
Follow the established pattern in `src/state/queries/`; `src/state/queries/feed.ts`
|
||||
is a good canonical reference (it uses `createQueryKey`, matching key roots,
|
||||
`useInfiniteQuery`, and `persistedVersion`).
|
||||
```tsx
|
||||
// src/state/queries/profile.ts
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
- Build query keys with `createQueryKey(root, args)` (from `#/state/queries/util`)
|
||||
using an object for `args`. The key root variable should match the hook name.
|
||||
- Naming conventions: `use[Name]Query` for queries, `use[Name]Mutation` for
|
||||
mutations, `use[Name]CacheMutation` for helpers that mutate cached data directly.
|
||||
- Stale times come from `STALE` in `src/state/queries/index.ts`: `STALE.SECONDS.FIFTEEN`,
|
||||
`STALE.MINUTES.ONE`, `STALE.MINUTES.FIVE`, `STALE.HOURS.ONE`, `STALE.INFINITY`.
|
||||
- Paginated atproto APIs (those returning a `cursor`) use `useInfiniteQuery` with
|
||||
`getNextPageParam: page => page.cursor`; flatten results with
|
||||
`data?.pages.flatMap(page => page.items) ?? []`.
|
||||
- Persist a query across restarts by passing options:
|
||||
`createQueryKey(root, args, {persistedVersion: n})`. Bumping `n` clears the old
|
||||
persisted data and refetches - do this whenever the data shape changes.
|
||||
- Error handling in mutations: don't log network errors (just inform the user),
|
||||
handle typed XRPC errors specifically (e.g. `err instanceof SomeNsid.SomeError`),
|
||||
and send unexpected errors to `logger.error('...', {safeMessage: error})`.
|
||||
import {createQueryKey} from '#/state/queries/util'
|
||||
|
||||
/*
|
||||
* Query key name should match the query hook name for consistency
|
||||
*/
|
||||
const profileQueryKeyRoot = 'profile'
|
||||
|
||||
/*
|
||||
* Use object params and createQueryKey helper for better readability and to
|
||||
* avoid bugs with parameter order or types.
|
||||
*/
|
||||
export const createProfileQueryKey = (args: {did: string}) =>
|
||||
createQueryKey(profileQueryKeyRoot, args)
|
||||
|
||||
/*
|
||||
* Query hook should be named use[Name]Query, where [Name] describes the data
|
||||
* being fetched. This is not a strict requirement, but it's a helpful
|
||||
* convention for discoverability
|
||||
*/
|
||||
export function useProfileQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: createProfileQueryKey({did}),
|
||||
queryFn: async () => {
|
||||
const res = await agent.getProfile({actor: did})
|
||||
return res.data
|
||||
},
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
enabled: !!did,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Mutation hook should match the name of the query hook, but with "Mutation"
|
||||
* suffix. This is not a strict requirement, but it's a helpful convention for
|
||||
* discoverability and consistency.
|
||||
*/
|
||||
export function useProfileMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async data => {
|
||||
// Update logic
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: createProfileQueryKey({did: variables.did}),
|
||||
})
|
||||
},
|
||||
onError: error => {
|
||||
if (isNetworkError(error)) {
|
||||
// don't log, but inform user
|
||||
} else if (error instanceof AppBskyExampleProcedure.ExampleError) {
|
||||
// XRPC APIs often have typed errors, allows nicer handling
|
||||
} else {
|
||||
// Log unexpected errors to Sentry
|
||||
logger.error('Error updating profile', {safeMessage: error})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* If cache mutation is needed, include specific interfaces for the specific
|
||||
* mutations you require adjacent to the source queries. Naming should be
|
||||
* descriptive of the mutation's purpose, e.g. use[Name]CacheMutation. This is
|
||||
* not a strict requirement, but it's a helpful convention for discoverability
|
||||
* and consistency.
|
||||
*/
|
||||
export function useProfileCacheMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return (data: Partial<Profile>) => {
|
||||
queryClient.setQueryData(
|
||||
createProfileQueryKey({did: data.did}),
|
||||
oldData => {
|
||||
if (!oldData) return oldData
|
||||
return {...oldData, ...data}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Stale Time Constants** (from `src/state/queries/index.ts`):
|
||||
|
||||
```tsx
|
||||
STALE.SECONDS.FIFTEEN // 15 seconds
|
||||
STALE.MINUTES.ONE // 1 minute
|
||||
STALE.MINUTES.FIVE // 5 minutes
|
||||
STALE.HOURS.ONE // 1 hour
|
||||
STALE.INFINITY // Never stale
|
||||
```
|
||||
|
||||
**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
|
||||
|
||||
```tsx
|
||||
export function useDraftsQuery() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: createQueryKey('drafts'),
|
||||
queryFn: async ({pageParam}) => {
|
||||
const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
|
||||
return res.data
|
||||
},
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: page => page.cursor,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
|
||||
|
||||
**Persisted Queries**
|
||||
|
||||
To persist query data across app restarts, `createQueryKey` supports a third
|
||||
parameter called `options`, which has a `persistedVersion` property. When this
|
||||
property is set to a number, the query will be persisted.
|
||||
|
||||
When this property is updated (e.g. incremented), the persisted data will be cleared and replaced with the new data from the query function. This is useful for cases where the shape of the data has changed and old persisted data would no longer be valid.
|
||||
|
||||
```tsx
|
||||
export const createProfileQueryKey = (args: {did: string}) =>
|
||||
createQueryKey(profileQueryKeyRoot, args, {persistedVersion: 1})
|
||||
```
|
||||
|
||||
### Preferences (React Context)
|
||||
|
||||
Boolean/simple UI preferences are exposed as paired hooks from `#/state/preferences`,
|
||||
e.g. `useAutoplayDisabled()` / `useSetAutoplayDisabled()`.
|
||||
```tsx
|
||||
// Simple boolean preference pattern
|
||||
import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences'
|
||||
|
||||
function SettingsScreen() {
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const setAutoplayDisabled = useSetAutoplayDisabled()
|
||||
|
||||
return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} />
|
||||
}
|
||||
```
|
||||
|
||||
### Session State
|
||||
|
||||
`import {useSession, useAgent} from '#/state/session'`. `useSession()` gives
|
||||
`hasSession` and `currentAccount`; `useAgent()` gives the atproto agent for API calls.
|
||||
```tsx
|
||||
import {useSession, useAgent} from '#/state/session'
|
||||
|
||||
function MyComponent() {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
if (!hasSession) {
|
||||
return <LoginPrompt />
|
||||
}
|
||||
|
||||
// Use agent for API calls
|
||||
const response = await agent.getProfile({actor: currentAccount.did})
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
React Navigation with type-safe route params. Type a screen with
|
||||
`NativeStackScreenProps<CommonNavigatorParams, 'X'>` (`route`/`navigation` come
|
||||
from props; params via `route.params`). Navigate programmatically with
|
||||
`useNavigation()`, or the `navigate` helper from `#/Navigation`. Config lives in
|
||||
`src/Navigation.tsx`, routes in `src/routes.ts`, types in `src/lib/routes/types.ts`.
|
||||
Navigation uses React Navigation with type-safe route parameters.
|
||||
|
||||
```tsx
|
||||
// Screen component
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
|
||||
|
||||
export function ProfileScreen({route, navigation}: Props) {
|
||||
const {name} = route.params // Type-safe params
|
||||
|
||||
return <Layout.Screen>{/* Screen content */}</Layout.Screen>
|
||||
}
|
||||
|
||||
// Programmatic navigation
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
const navigation = useNavigation()
|
||||
navigation.navigate('Profile', {name: 'alice.bsky.social'})
|
||||
|
||||
// Or use the navigate helper
|
||||
import {navigate} from '#/Navigation'
|
||||
navigate('Profile', {name: 'alice.bsky.social'})
|
||||
```
|
||||
|
||||
## Platform-Specific Code
|
||||
|
||||
Use file extensions for platform-specific implementations. The bundler resolves
|
||||
them automatically - just import the base path normally, never a conditional
|
||||
`require()`.
|
||||
Use file extensions for platform-specific implementations:
|
||||
|
||||
```
|
||||
Component.tsx # Shared/default
|
||||
@@ -414,11 +719,12 @@ Component.ios.tsx # iOS-only
|
||||
Component.android.tsx # Android-only
|
||||
```
|
||||
|
||||
Prefer grouping variants into a `Component/` directory (`index.tsx`,
|
||||
`index.web.tsx`, `index.native.tsx`) rather than sibling `Component.web.tsx` files,
|
||||
so the shared surface reads as one "macro" module (e.g. `src/components/Dialog/index.tsx`
|
||||
native vs `index.web.tsx` web). The app has both patterns; the directory form is
|
||||
preferred for new code.
|
||||
Example from Dialog:
|
||||
|
||||
- `src/components/Dialog/index.tsx` – Native (uses BottomSheet)
|
||||
- `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives)
|
||||
|
||||
**Important:** The bundler automatically resolves platform-specific files. Just import normally:
|
||||
|
||||
```tsx
|
||||
// CORRECT - bundler picks storage.ts or storage.web.ts automatically
|
||||
@@ -430,7 +736,15 @@ const storage = IS_NATIVE
|
||||
: require('#/state/drafts/storage.web')
|
||||
```
|
||||
|
||||
Runtime platform detection (not for imports): `import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'`.
|
||||
Platform detection (for runtime logic, not imports):
|
||||
|
||||
```tsx
|
||||
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
|
||||
|
||||
if (IS_NATIVE) {
|
||||
// Native-specific logic
|
||||
}
|
||||
```
|
||||
|
||||
## Import Aliases
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.18.0-alpine3.23 AS build
|
||||
FROM node:24.15.0-alpine3.22 AS build
|
||||
|
||||
# Move files into the image and install
|
||||
WORKDIR /app
|
||||
@@ -14,7 +14,7 @@ RUN yarn build
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:24.18.0-alpine3.23
|
||||
FROM node:24.15.0-alpine3.22
|
||||
|
||||
RUN apk add --update dumb-init
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.18.0-alpine3.23 AS build
|
||||
FROM node:24.15.0-alpine3.22 AS build
|
||||
|
||||
# Tells pnpm to run non-interactively (needed for install/script steps)
|
||||
ENV CI=true
|
||||
@@ -9,7 +9,7 @@ WORKDIR /app
|
||||
COPY ./bskyogcard/package.json ./
|
||||
COPY ./bskyogcard/pnpm-lock.yaml ./
|
||||
COPY ./bskyogcard/pnpm-workspace.yaml ./
|
||||
RUN npm install --global pnpm@11.13.1
|
||||
RUN npm install --global pnpm@11.5.2
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ./bskyogcard ./
|
||||
@@ -19,7 +19,7 @@ RUN pnpm install-fonts && pnpm build
|
||||
RUN pnpm install --prod --ignore-scripts --prefer-offline
|
||||
|
||||
# Uses assets from build stage to reduce build size
|
||||
FROM node:24.18.0-alpine3.23
|
||||
FROM node:24.15.0-alpine3.22
|
||||
|
||||
RUN apk add --update dumb-init
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Node
|
||||
ENV NODE_VERSION=24.18.0
|
||||
ENV NODE_VERSION=24.15.0
|
||||
ENV NVM_DIR=/usr/share/nvm
|
||||
|
||||
# Go
|
||||
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
|
||||
RUN \. "$NVM_DIR/nvm.sh" && \
|
||||
nvm install $NODE_VERSION && \
|
||||
nvm use $NODE_VERSION && \
|
||||
npm install --global pnpm@11.13.1 && \
|
||||
npm install --global pnpm@11.7.0 && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
|
||||
pnpm intl:build && \
|
||||
|
||||
@@ -44,12 +44,6 @@ 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"
|
||||
@@ -57,11 +51,6 @@ 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"
|
||||
@@ -74,11 +63,6 @@ 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"
|
||||
|
||||
@@ -135,8 +135,7 @@ appId: xyz.blueskyweb.app
|
||||
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "searchScreenInput"
|
||||
- tapOn: "Search for posts, users[,]? or feeds"
|
||||
- inputText: "bob"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -29,88 +29,51 @@ appId: xyz.blueskyweb.app
|
||||
id: "homeScreenFeedTabs-selector-1"
|
||||
text: "alice-favs"
|
||||
|
||||
# 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 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"
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
# Remove following
|
||||
- tapOn: "Open drawer menu"
|
||||
|
||||
@@ -11,8 +11,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Sign in"
|
||||
- tapOn:
|
||||
id: "selectServiceButton"
|
||||
- tapOn:
|
||||
id: "manualSelectBtn"
|
||||
- tapOn: "Custom"
|
||||
- tapOn:
|
||||
id: "customServerTextInput"
|
||||
- inputText: "http://localhost:3000"
|
||||
@@ -21,8 +20,7 @@ appId: xyz.blueskyweb.app
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "doneBtn"
|
||||
- tapOn: "Done"
|
||||
- tapOn:
|
||||
id: "loginUsernameInput"
|
||||
- inputText: "Alice"
|
||||
|
||||
@@ -15,24 +15,6 @@ 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"
|
||||
@@ -40,20 +22,6 @@ 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
|
||||
|
||||
@@ -16,28 +16,13 @@ appId: xyz.blueskyweb.app
|
||||
id: "e2eStartOnboarding"
|
||||
- tapOn: "Select an avatar"
|
||||
- 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
|
||||
- assertVisible: "Photos"
|
||||
- assertVisible: "Collections"
|
||||
- tapOn:
|
||||
point: "50%,22%"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Done"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "What are your interests?"
|
||||
|
||||
@@ -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,10 +64,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "editProfileSaveBtn"
|
||||
- assertNotVisible:
|
||||
id: "editProfileModal"
|
||||
# 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.*"
|
||||
- assertVisible: "alice.test"
|
||||
- assertNotVisible: "One cool hacker"
|
||||
|
||||
# Set avi and banner via the edit profile modal
|
||||
|
||||
@@ -15,8 +15,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "searchScreenInput"
|
||||
- tapOn: "Search for posts, users[,]? or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -22,7 +22,5 @@ appId: xyz.blueskyweb.app
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
|
||||
@@ -22,7 +22,5 @@ appId: xyz.blueskyweb.app
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
|
||||
@@ -39,7 +39,5 @@ appId: xyz.blueskyweb.app
|
||||
text: Your report will be sent to Dev-env Moderation.*
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
|
||||
@@ -29,7 +29,5 @@ appId: xyz.blueskyweb.app
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
|
||||
@@ -15,10 +15,8 @@ appId: xyz.blueskyweb.app
|
||||
id: "bottomBarSearchBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarSearchBtn"
|
||||
- assertVisible:
|
||||
id: "searchScreenInput"
|
||||
- tapOn:
|
||||
id: "searchScreenInput"
|
||||
- assertVisible: "Search for posts, users[,]? or feeds"
|
||||
- tapOn: "Search for posts, users[,]? or feeds"
|
||||
- inputText: "b"
|
||||
- tapOn:
|
||||
id: "searchAutoCompleteResult-bob.test"
|
||||
|
||||
@@ -20,12 +20,6 @@ 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:
|
||||
@@ -37,19 +31,9 @@ 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:
|
||||
@@ -61,8 +45,10 @@ appId: xyz.blueskyweb.app
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarNotificationsBtn"
|
||||
- assertVisible: ".*Reply 1.*"
|
||||
- tapOn: ".*Reply 1.*"
|
||||
- assertVisible:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
@@ -81,78 +67,16 @@ appId: xyz.blueskyweb.app
|
||||
id: "bottomBarProfileBtn"
|
||||
- tapOn:
|
||||
id: "profilePager-selector-1"
|
||||
# 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
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
- inputText: "Reply 2"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
# 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
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
- 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
|
||||
@@ -168,7 +92,10 @@ appId: xyz.blueskyweb.app
|
||||
- assertVisible: ".*Reply 1.*"
|
||||
- assertNotVisible: ".*Reply 2.*"
|
||||
- assertNotVisible: ".*Reply 3.*"
|
||||
- tapOn: ".*Reply 1.*"
|
||||
- assertVisible:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
|
||||
@@ -9,27 +9,23 @@ appId: xyz.blueskyweb.app
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "http://localhost:8081"
|
||||
timeout: 60000
|
||||
- tapOn: "http://localhost:8081"
|
||||
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: 'Open in "Bluesky"'
|
||||
commands:
|
||||
- tapOn: Open
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- 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: 'http://localhost:8081'
|
||||
- runFlow:
|
||||
label: "Dismiss Expo dev menu"
|
||||
when:
|
||||
visible: "Continue"
|
||||
commands:
|
||||
- back
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
buildStateObject,
|
||||
getCurrentRoute,
|
||||
isStateAtTabRoot,
|
||||
} from '#/lib/routes/helpers'
|
||||
import {type State} from '#/lib/routes/types'
|
||||
|
||||
describe('getCurrentRoute', () => {
|
||||
it('returns Home when there is no state', () => {
|
||||
expect(getCurrentRoute(undefined).name).toBe('Home')
|
||||
})
|
||||
|
||||
it('descends into nested state using the index', () => {
|
||||
const state = {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'HomeTab',
|
||||
state: {
|
||||
index: 1,
|
||||
routes: [{name: 'Home'}, {name: 'PostThread'}],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as State
|
||||
|
||||
expect(getCurrentRoute(state).name).toBe('PostThread')
|
||||
})
|
||||
|
||||
/*
|
||||
* Right after a cold start from a deep link, nested navigator states are
|
||||
* still partial and have no `index`. React Navigation focuses the last
|
||||
* route when rehydrating such a state, so getCurrentRoute must do the
|
||||
* same (previously it stopped at the tab route, which re-enabled the
|
||||
* drawer swipe gesture on top of the deep-linked screen).
|
||||
*/
|
||||
it('descends into partial nested state without an index', () => {
|
||||
const state = {
|
||||
routes: [
|
||||
{
|
||||
name: 'HomeTab',
|
||||
state: {
|
||||
routes: [{name: 'Home'}, {name: 'PostThread'}],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as State
|
||||
|
||||
expect(getCurrentRoute(state).name).toBe('PostThread')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isStateAtTabRoot', () => {
|
||||
it('returns true for the initial deep link state of a tab root', () => {
|
||||
const state = buildStateObject('HomeTab', 'Home', {}) as unknown as State
|
||||
expect(isStateAtTabRoot(state)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for the initial deep link state of a nested screen', () => {
|
||||
const state = buildStateObject(
|
||||
'HomeTab',
|
||||
'PostThread',
|
||||
{name: 'alice.test', rkey: '123'},
|
||||
[{name: 'Home', params: {}}],
|
||||
) as unknown as State
|
||||
expect(isStateAtTabRoot(state)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildStateObject', () => {
|
||||
it('focuses the deep-linked route in the nested state', () => {
|
||||
const state = buildStateObject(
|
||||
'HomeTab',
|
||||
'PostThread',
|
||||
{name: 'alice.test', rkey: '123'},
|
||||
[{name: 'Home', params: {}}],
|
||||
)
|
||||
|
||||
expect(state).toEqual({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'HomeTab',
|
||||
state: {
|
||||
index: 1,
|
||||
routes: [
|
||||
{name: 'Home', params: {}},
|
||||
{name: 'PostThread', params: {name: 'alice.test', rkey: '123'}},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a single-route state for the Flat navigator', () => {
|
||||
const state = buildStateObject('Flat', 'PostThread', {
|
||||
name: 'alice.test',
|
||||
rkey: '123',
|
||||
})
|
||||
|
||||
expect(state).toEqual({
|
||||
index: 0,
|
||||
routes: [{name: 'PostThread', params: {name: 'alice.test', rkey: '123'}}],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -59,7 +59,6 @@ module.exports = function (_config) {
|
||||
ios: {
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
appleTeamId: process.env.EXPO_APPLE_TEAM_ID,
|
||||
config: {
|
||||
usesNonExemptEncryption: false,
|
||||
},
|
||||
@@ -82,7 +81,6 @@ module.exports = function (_config) {
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cs',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
@@ -128,7 +126,6 @@ module.exports = function (_config) {
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
'com.apple.developer.usernotifications.communication': true,
|
||||
// 'com.apple.developer.device-information.user-assigned-device-name': true,
|
||||
'com.apple.developer.declared-age-range': true,
|
||||
},
|
||||
privacyManifests: {
|
||||
NSPrivacyCollectedDataTypes: [
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006aff" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 250 B After Width: | Height: | Size: 250 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" viewBox="0 0 1000 500"><defs><clipPath id="cp1" clipPathUnits="userSpaceOnUse"><path d="M626.36 100.49c-5.11 3.07-10.03 6.4-14.6 9.83-16.71 12.55-33.83 29.35-50.01 47.72-20.77 23.59-39.97 49.76-54.68 72.84-8.59 13.48-15.65 25.91-20.6 36.14-17.68-36.56-62.26-101-107.36-141.8-5.99-5.42-11.99-10.44-17.94-14.9-26.96-20.24-66.21-37.06-85.02-17.49-6.73 6.99-10.87 18.59-10.87 36.4 0 13.51 7.75 113.48 12.29 129.72 12.82 45.8 53.13 63.9 95.24 64.44l1.72 4.1c-66.07 20.14-79.15 64.89-35.52 109.66 40.27 41.32 70.75 42.51 93.25 28.05 29.66-19.06 45.43-65.32 51.38-82.78q.71-2.11 1.24-3.62c1.07-3.02 1.58-4.08 1.59-2.4.01-2.24.91.39 2.83 6.02 9.24 27.14 42.23 123.92 114.26 79.79 9.44-5.78 19.55-13.95 30.36-25.06 15.25-15.63 23.56-31.27 25.13-45.86 1.64-15.29-4.17-29.4-17.18-41.17l-12.59-30.19c29.25-6.89 54.25-24.56 64.85-56.99.43-1.32.85-2.63 1.24-3.99 4.54-16.24 12.28-116.21 12.28-129.72 0-59.2-45.42-50.3-81.29-28.74"/></clipPath></defs><style>.s0{fill:#7bb244}.s1{fill:#008442}.s3{fill:#008cd2}.s4{fill:#e7451e}.s5{fill:#f6a700}.s6{fill:#fff}.s7{mix-blend-mode:multiply;fill:#e7451e}.s8{fill:#4b9754}</style><g id="デザイン"><g id="<Group>"><path id="<Path>" d="M362.1 110.32c-26.96-20.24-66.21-37.06-85.03-17.49l96.66 230.56c9.77.13 19.64-.69 29.28-2.33-10.04 1.71-19.2 3.88-27.56 6.43l57.74 137.71c29.66-19.06 45.43-65.32 51.38-82.78q.71-2.11 1.24-3.62L380.04 125.22c-5.99-5.42-11.99-10.44-17.94-14.9" class="s0"/><path d="M266.21 129.23c0 13.51 7.75 113.48 12.29 129.72 12.81 45.8 53.13 63.9 95.23 64.44L277.07 92.83c-6.72 6.99-10.86 18.59-10.86 36.4m73.73 307.93c40.26 41.31 70.75 42.5 93.25 28.04l-57.74-137.71c-66.06 20.14-79.14 64.89-35.51 109.67" class="s1"/><path d="M696.29 258.95c4.55-16.24 12.29-116.21 12.29-129.72 0-59.2-45.42-50.31-81.29-28.74l67.76 162.45q.66-1.96 1.24-3.99" class="s0"/><path d="m642.8 350.12 17.17 41.17c1.64-15.28-4.16-29.4-17.17-41.17m52.25-87.18-67.76-162.45c-5.11 3.07-10.03 6.4-14.6 9.83-16.71 12.55-33.84 29.35-50.01 47.72l67.53 161.89c29.25-6.89 54.25-24.56 64.84-56.99m-207.65 4.08c-17.69-36.57-62.27-101-107.36-141.8L485.81 378.8c1.07-3.02 1.58-4.09 1.59-2.4.01-2.24.91.39 2.82 6.02 9.25 27.14 42.24 123.92 114.27 79.79L508 230.88c-8.59 13.48-15.65 25.91-20.6 36.14" class="s1"/><path d="M571.78 321.06c19.44 3.31 39.79 3.27 58.43-1.13l-67.53-161.89c-20.78 23.59-39.97 49.76-54.68 72.84l96.49 231.33c9.44-5.78 19.54-13.95 30.36-25.05 15.24-15.64 23.56-31.28 25.12-45.87l-17.17-41.17c-14.63-13.24-38.36-23.5-71.02-29.06" class="s0"/><path d="m537.4 4-17.72 143.35 88.33-113.64z" style="fill:#f4ae1a"/><path d="m895.69 371.73-230.18-31.9 148.57 101.66z" class="s3"/><path d="m90.72 95.32 159.34 126.77L0 162.43z" class="s4"/><path d="m148.73 361.28 250.64-7.29-290.74 141.27z" class="s5"/><g id="<Clip Group>" clip-path="url(#cp1)"><path id="<Compound Path>" fill-rule="evenodd" d="m569.43 371.9 51.37 123.15-4.45 1.85-51.37-123.16c-10.11 3.92-21.06 6.16-32.54 6.16-49.82 0-90.36-40.54-90.36-90.36 0-36.66 21.97-68.22 53.41-82.38l-33.88-81.21 4.45-1.86 33.88 81.23c10.11-3.92 21.04-6.15 32.5-6.15 49.83 0 90.37 40.54 90.37 90.37 0 36.64-21.96 68.19-53.38 82.36M501.8 209.77l65.77 157.68c29.68-13.44 50.42-43.27 50.42-77.91 0-47.17-38.38-85.55-85.55-85.55-10.81 0-21.11 2.1-30.64 5.78m61.32 159.52-65.77-157.68c-29.7 13.43-50.45 43.27-50.45 77.93 0 47.16 38.37 85.54 85.54 85.54 10.82 0 21.14-2.1 30.68-5.79" class="s6"/></g><path d="m763.79 80.37-201.27 208 304.16 14z" class="s4"/><path d="M970.03 253.88c-48.46 57.07-138.53 68.27-201.19 25.02-62.65-43.24-74.16-124.56-25.7-181.63 48.46-57.06 138.53-68.26 201.18-25.02 62.66 43.25 74.17 124.57 25.71 181.63" class="s6"/><path d="M914.45 112.85s-47.72-9.42-60.98-3.44c0 0-20.85 41.71-19.79 49.19 0 0 42.36 33.2 51.62 34.93 0 0 41.25-18.78 47.5-27.04 0 0-6.8-44.96-18.35-53.64" class="s5"/><path d="M745.71 149.33s8.94 18.59 30.66 28.61c0 0-8.04 41.65-6.08 52.61 0 0-29.32 1.2-36.92-.32 0 0-13.68-21.28-15.2-48.63 0 0 11.16-22.62 27.54-32.27" class="s7"/><path d="M882.41 248.11s19.49 18.58 36.64 21.56c0 0-10.87 18.86-27.62 28.95 0 0-46.24 8.07-56.83.06 0 0-12.6-21.27-11.02-30.65 0 0 47.78-9.62 58.83-19.92" class="s8"/><path d="M996.29 160.32s-11.03 24.46-22.17 27.94c0 0 1.59 34.85-6.18 48.82l10.12.75s24.82-41.68 18.23-77.51" class="s7"/><path d="m908.15 55.46-6.72 1.39s22.77 2.58 34.07 29c0 0 29.75 6.27 39.52 19.76 0 0-11.63-20.9-27.55-31.65-15.91-10.75-39.32-18.5-39.32-18.5" class="s8"/><path d="m829.78 51.19 9.62 1.95s-22.02 16.72-20.32 25.78c0 0-32.01 5-52.33 20.56l-7.5-13.26s16.01-15.28 34.08-24.48c18.08-9.21 36.45-10.55 36.45-10.55" class="s3"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.5 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 426 B After Width: | Height: | Size: 426 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080b12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 46 46"><path stroke="#080B12" stroke-linecap="round" stroke-width="1.5" d="m1.417 28.645 7.586-5.676a5.33 5.33 0 0 1 6.867.809c3.98 4.286 8.594 8.182 14.88 8.182 5.794 0 9.633-2.147 13.333-5.847m-38 18.637h33.334a5.333 5.333 0 0 0 5.333-5.333V6.083A5.333 5.333 0 0 0 39.417.75H6.083A5.333 5.333 0 0 0 .75 6.083v33.334a5.333 5.333 0 0 0 5.333 5.333ZM36.75 14.083a5.333 5.333 0 1 1-10.667 0 5.333 5.333 0 0 1 10.667 0Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 491 B After Width: | Height: | Size: 491 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185fe" d="M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185FE" d="M6.335 4.212c2.293 1.76 4.76 5.327 5.665 7.241.906-1.914 3.372-5.482 5.665-7.241C19.319 2.942 22 1.96 22 5.086c0 .624-.35 5.244-.556 5.994-.713 2.608-3.315 3.273-5.629 2.87 4.045.704 5.074 3.035 2.852 5.366-4.22 4.426-6.066-1.111-6.54-2.53-.086-.26-.126-.382-.127-.278 0-.104-.041.018-.128.278-.473 1.419-2.318 6.956-6.539 2.53-2.222-2.331-1.193-4.662 2.852-5.366-2.314.403-4.916-.262-5.63-2.87C2.35 10.33 2 5.71 2 5.086c0-3.126 2.68-2.144 4.335-.874Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 557 B After Width: | Height: | Size: 557 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#ffc404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021s5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.87.87 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021s5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.87.87 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 838 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4Zm4-10a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5Zm-5 8a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4Zm4-10a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5Zm-5 8a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 579 B After Width: | Height: | Size: 579 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 355 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080b12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 233 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a50 50 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232q.369-.007.741-.007c21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.6 38.6 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0a7aff"/><stop offset="1" stop-color="#59b9ff"/></linearGradient></defs></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a50 50 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232q.369-.007.741-.007c21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.6 38.6 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11.5" fill="#1183fe"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.175a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11.5" fill="#1183FE"/><path fill="#fff" fill-rule="evenodd" d="M17.659 8.175a1.36 1.36 0 0 1 0 1.925l-6.224 6.223a1.36 1.36 0 0 1-1.925 0L6.4 13.212a1.361 1.361 0 0 1 1.925-1.925l2.149 2.148 5.26-5.26a1.36 1.36 0 0 1 1.925 0Z" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 354 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185fe" d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.15 4.15 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.15 4.15 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.15 4.15 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.15 4.15 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.15 4.15 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.15 4.15 0 0 0 .776-3.404 4.154 4.154 0 0 1 4-5.017 4.15 4.15 0 0 0 3.146-1.515Z"/><path fill="#fff" fill-rule="evenodd" d="M17.861 8.26a1.44 1.44 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1185FE" d="M8.792 1.615a4.154 4.154 0 0 1 6.416 0 4.15 4.15 0 0 0 3.146 1.515 4.154 4.154 0 0 1 4 5.017 4.15 4.15 0 0 0 .777 3.404 4.154 4.154 0 0 1-1.427 6.255 4.15 4.15 0 0 0-2.177 2.73 4.154 4.154 0 0 1-5.781 2.784 4.15 4.15 0 0 0-3.492 0 4.154 4.154 0 0 1-5.78-2.784 4.15 4.15 0 0 0-2.178-2.73A4.154 4.154 0 0 1 .87 11.551a4.15 4.15 0 0 0 .776-3.404 4.154 4.154 0 0 1 4-5.017 4.15 4.15 0 0 0 3.146-1.515Z"/><path fill="#fff" fill-rule="evenodd" d="M17.861 8.26a1.44 1.44 0 0 1 0 2.033l-6.571 6.571a1.437 1.437 0 0 1-2.033 0L5.97 13.58a1.438 1.438 0 0 1 2.033-2.033l2.27 2.269 5.554-5.555a1.437 1.437 0 0 1 2.033 0Z" clip-rule="evenodd"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 733 B After Width: | Height: | Size: 733 B |
@@ -18,7 +18,19 @@ module.exports = function (api) {
|
||||
plugins: [
|
||||
'@lingui/babel-plugin-lingui-macro',
|
||||
['babel-plugin-react-compiler', {target: '19'}],
|
||||
'module:react-native-dotenv', // used by web build! can remove when we drop webpack
|
||||
[
|
||||
'module:react-native-dotenv',
|
||||
{
|
||||
envName: 'APP_ENV',
|
||||
moduleName: '@env',
|
||||
path: '.env',
|
||||
blocklist: null,
|
||||
allowlist: null,
|
||||
safe: false,
|
||||
allowUndefined: true,
|
||||
verbose: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.13.1",
|
||||
"version": "11.5.2",
|
||||
"onFail": "warn"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,52 +7,52 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: 11.13.1
|
||||
version: 11.13.1
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
pnpm:
|
||||
specifier: 11.13.1
|
||||
version: 11.13.1
|
||||
specifier: 11.5.2
|
||||
version: 11.5.2
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.13.1':
|
||||
resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
|
||||
'@pnpm/exe@11.5.2':
|
||||
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -116,45 +116,45 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.13.1:
|
||||
resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
|
||||
pnpm@11.5.2:
|
||||
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.13.1':
|
||||
'@pnpm/exe@11.5.2':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.13.1
|
||||
'@pnpm/linux-x64': 11.13.1
|
||||
'@pnpm/linuxstatic-arm64': 11.13.1
|
||||
'@pnpm/linuxstatic-x64': 11.13.1
|
||||
'@pnpm/macos-arm64': 11.13.1
|
||||
'@pnpm/win-arm64': 11.13.1
|
||||
'@pnpm/win-x64': 11.13.1
|
||||
'@pnpm/linux-arm64': 11.5.2
|
||||
'@pnpm/linux-x64': 11.5.2
|
||||
'@pnpm/linuxstatic-arm64': 11.5.2
|
||||
'@pnpm/linuxstatic-x64': 11.5.2
|
||||
'@pnpm/macos-arm64': 11.5.2
|
||||
'@pnpm/win-arm64': 11.5.2
|
||||
'@pnpm/win-x64': 11.5.2
|
||||
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
'@pnpm/linux-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
'@pnpm/linux-x64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
'@pnpm/linuxstatic-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
'@pnpm/linuxstatic-x64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
'@pnpm/macos-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
'@pnpm/win-arm64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
'@pnpm/win-x64@11.5.2':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
@@ -194,7 +194,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.13.1: {}
|
||||
pnpm@11.5.2: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
@@ -65,7 +65,6 @@ type discussionForumPosting struct {
|
||||
CommentCount *int64 `json:"commentCount,omitempty"`
|
||||
Comment []comment `json:"comment,omitempty"`
|
||||
IsBasedOn string `json:"isBasedOn,omitempty"`
|
||||
IsPartOf string `json:"isPartOf,omitempty"`
|
||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
||||
}
|
||||
|
||||
@@ -345,43 +344,6 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// threadRootURI returns the AT-URI of the root post of the thread a reply
|
||||
// belongs to, or "" if the post is not a reply or the record is malformed.
|
||||
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
|
||||
if pv == nil || pv.Record == nil {
|
||||
return ""
|
||||
}
|
||||
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
|
||||
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
|
||||
return ""
|
||||
}
|
||||
return rec.Reply.Root.Uri
|
||||
}
|
||||
|
||||
// findRootPostInParents walks tv's parent chain upward and returns the
|
||||
// PostView whose URI matches rootURI, or nil if the root is not present.
|
||||
// The root is absent when the chain is truncated by parentHeight (reply
|
||||
// deeper than the fetched height) or broken by a blocked/not-found parent.
|
||||
// Avoids a separate FeedGetPosts call when the thread response already
|
||||
// contains the root.
|
||||
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
|
||||
if rootURI == "" {
|
||||
return nil
|
||||
}
|
||||
for node := tv; node != nil; {
|
||||
if node.Post != nil && node.Post.Uri == rootURI {
|
||||
return node.Post
|
||||
}
|
||||
if node.Parent == nil {
|
||||
return nil
|
||||
}
|
||||
// Only threadViewPost parents continue the chain; a blocked or
|
||||
// not-found parent breaks it before reaching the root.
|
||||
node = node.Parent.FeedDefs_ThreadViewPost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAuthor constructs a Person. Organization classification for
|
||||
// custom-domain accounts is a future enhancement.
|
||||
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
|
||||
@@ -622,21 +584,13 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
||||
|
||||
// buildPostJSONLD marshals the WebPage envelope wrapping a
|
||||
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
|
||||
// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
|
||||
// non-empty, is the handle-form canonical URL of the thread root the handler
|
||||
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
|
||||
// not be resolved). We never emit a DID-form isPartOf because it would not
|
||||
// match the root page's handle-form canonical.
|
||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
||||
// (as a fallback) mainEntity.url so they always agree.
|
||||
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return "", fmt.Errorf("nil post view or author")
|
||||
}
|
||||
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
|
||||
|
||||
if isPartOfURL != "" {
|
||||
node.IsPartOf = isPartOfURL
|
||||
}
|
||||
|
||||
// mainEntity.url is empty when the author handle is unusable; fall back
|
||||
// to canonicalURL so it agrees with envelope.url.
|
||||
if node.URL == "" {
|
||||
|
||||
@@ -183,19 +183,6 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
|
||||
}
|
||||
}
|
||||
|
||||
// withReplyRoot marks the post as a reply by setting its record's Reply.Root
|
||||
// strong-ref to the given thread-root post.
|
||||
func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
|
||||
uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
|
||||
rec.Reply = &appbsky.FeedPost_ReplyRef{
|
||||
Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
|
||||
Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withSelfLabel adds a self-label that should hide embeds.
|
||||
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
|
||||
return func(pv *appbsky.FeedDefs_PostView) {
|
||||
@@ -276,7 +263,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
|
||||
func TestBuildPostJSONLD_Bare(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -346,7 +333,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -371,7 +358,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -398,7 +385,7 @@ func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
|
||||
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -451,7 +438,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
|
||||
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["thumbnailUrl"] != thumb {
|
||||
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
|
||||
@@ -464,7 +451,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
|
||||
|
||||
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
|
||||
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
|
||||
@@ -473,122 +460,16 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
|
||||
|
||||
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["isBasedOn"]; present {
|
||||
t.Errorf("blocked quote should not produce isBasedOn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
|
||||
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
|
||||
// it is emitted on the main post; when empty, no isPartOf is present.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
|
||||
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if main["isPartOf"] != isPartOf {
|
||||
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
|
||||
}
|
||||
|
||||
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["isPartOf"]; present {
|
||||
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadRootURI(t *testing.T) {
|
||||
// A reply returns its root AT-URI; a non-reply returns "".
|
||||
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
|
||||
withReplyRoot("did:plc:root", "rootrkey"))
|
||||
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
|
||||
if got := threadRootURI(reply); got != want {
|
||||
t.Errorf("threadRootURI = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
|
||||
if got := threadRootURI(post); got != "" {
|
||||
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRootPostInParents(t *testing.T) {
|
||||
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
|
||||
rootURI := rootPost.Uri
|
||||
|
||||
// tvp wraps a PostView, optionally chaining to a parent thread node.
|
||||
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
|
||||
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
|
||||
if parent != nil {
|
||||
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
t.Run("direct reply, parent is root", func(t *testing.T) {
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||
t.Errorf("expected root post, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-level chain", func(t *testing.T) {
|
||||
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
|
||||
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
|
||||
t.Errorf("expected root post in chain, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("root absent, chain truncated", func(t *testing.T) {
|
||||
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
|
||||
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
|
||||
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||
t.Errorf("expected nil when root absent, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chain broken by blocked parent", func(t *testing.T) {
|
||||
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
|
||||
// breaking the walk before the root.
|
||||
leaf := &appbsky.FeedDefs_ThreadViewPost{
|
||||
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
|
||||
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
|
||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
|
||||
},
|
||||
}
|
||||
if got := findRootPostInParents(leaf, rootURI); got != nil {
|
||||
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty rootURI", func(t *testing.T) {
|
||||
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
|
||||
if got := findRootPostInParents(leaf, ""); got != nil {
|
||||
t.Errorf("expected nil for empty rootURI, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
|
||||
// Replies surfaced under the main post as comment[] are Comment nodes and
|
||||
// never carry isPartOf, even when the main post has one.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
|
||||
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
if _, present := c["isPartOf"]; present {
|
||||
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
sc, ok := main["sharedContent"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -606,7 +487,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/x@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withImages(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed post should not emit image")
|
||||
@@ -625,7 +506,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/g@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
|
||||
withGallery(thumb), withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["image"]; present {
|
||||
t.Errorf("hidden-embed gallery post should not emit image")
|
||||
@@ -639,7 +520,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
|
||||
// Includes ", \, newline, </script>, and a unicode char.
|
||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -675,7 +556,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
||||
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
|
||||
})
|
||||
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
|
||||
if cc := main["commentCount"].(float64); int64(cc) != 14 {
|
||||
@@ -717,7 +598,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
|
||||
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
|
||||
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
|
||||
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
|
||||
envelope := unmarshalLD(t, out)
|
||||
main := envelope["mainEntity"].(map[string]any)
|
||||
// mainEntity.url falls back to the caller's canonical URL so envelope
|
||||
@@ -766,7 +647,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
|
||||
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
|
||||
env := unmarshalLD(t, out)
|
||||
main := env["mainEntity"].(map[string]any)
|
||||
if env["url"] != tc.canonical {
|
||||
@@ -783,7 +664,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
|
||||
// Defensive: don't panic if Author is nil.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
pv.Author = nil
|
||||
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
t.Errorf("expected error for nil-author post, got nil")
|
||||
}
|
||||
}
|
||||
@@ -801,7 +682,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
|
||||
}
|
||||
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -822,7 +703,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
|
||||
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
|
||||
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
|
||||
}
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
imgs, ok := c["image"].([]any)
|
||||
@@ -1029,7 +910,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
|
||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||
withPostLabel("!hide", false))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
|
||||
@@ -1042,7 +923,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
|
||||
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
|
||||
withSelfLabel("spam"))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected self-labeled reply dropped, got %v", ids)
|
||||
@@ -1058,7 +939,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
|
||||
// union behavior.
|
||||
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
|
||||
withPostLabel("self-harm", false))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != good.Uri {
|
||||
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
|
||||
@@ -1070,7 +951,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
|
||||
withPostLabel("!hide", true))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
ids := commentIdentifiers(t, out)
|
||||
if len(ids) != 1 || ids[0] != reply.Uri {
|
||||
t.Errorf("expected negated-label reply to be kept, got %v", ids)
|
||||
@@ -1081,7 +962,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
|
||||
// Reply author should also carry a DID identifier.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
auth, ok := c["author"].(map[string]any)
|
||||
@@ -1222,7 +1103,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
|
||||
})
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
|
||||
withVerifications(state))
|
||||
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1262,7 +1143,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
|
||||
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
|
||||
withVerifications(state))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
auth := c["author"].(map[string]any)
|
||||
@@ -1386,7 +1267,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
|
||||
hasAspect: true, width: 16, height: 9,
|
||||
}))
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1434,7 +1315,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -1451,7 +1332,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["name"] != "Video on Bluesky" {
|
||||
@@ -1466,7 +1347,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if video["description"] != "scenic clip" {
|
||||
@@ -1479,7 +1360,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video := main["video"].(map[string]any)
|
||||
if _, present := video["width"]; present {
|
||||
@@ -1496,7 +1377,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
|
||||
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
|
||||
withVideo(thumb))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("video without playlist should not produce VideoObject")
|
||||
@@ -1515,7 +1396,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
|
||||
alt: "should be dropped",
|
||||
}),
|
||||
withSelfLabel("porn"))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("hidden-embed post should not emit video")
|
||||
@@ -1530,7 +1411,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
|
||||
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
|
||||
hasAspect: true, width: 4, height: 3,
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -1557,7 +1438,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
video, ok := c["video"].(map[string]any)
|
||||
@@ -1580,7 +1461,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
|
||||
|
||||
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
if _, present := main["video"]; present {
|
||||
t.Errorf("post without video should not include video field")
|
||||
@@ -1624,7 +1505,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
|
||||
playlist: playlist, alt: "scenic clip",
|
||||
}))
|
||||
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
|
||||
out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
video, ok := main["video"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -1647,7 +1528,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
|
||||
withVideoFull(videoEmbedOpts{
|
||||
playlist: playlist, alt: "bob's clip",
|
||||
}))
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
|
||||
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
|
||||
c := main["comment"].([]any)[0].(map[string]any)
|
||||
video, ok := c["video"].(map[string]any)
|
||||
|
||||
@@ -20,20 +20,3 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postAuthorRequiresAuth reports whether the post author self-applied the
|
||||
// !no-unauthenticated label, read from the author view embedded in a
|
||||
// getPostThread response. The appview surfaces the account's profile-record
|
||||
// self-labels on the post author (src == author DID), so this mirrors
|
||||
// profileRequiresAuth without a separate ActorGetProfile call.
|
||||
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
|
||||
if pv == nil || pv.Author == nil {
|
||||
return false
|
||||
}
|
||||
for _, label := range pv.Author.Labels {
|
||||
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -77,76 +77,3 @@ func TestProfileRequiresAuth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAuthorRequiresAuth(t *testing.T) {
|
||||
negTrue := true
|
||||
|
||||
authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView {
|
||||
return &appbsky.FeedDefs_PostView{
|
||||
Author: &appbsky.ActorDefs_ProfileViewBasic{
|
||||
Did: "did:plc:alice",
|
||||
Handle: "alice.bsky.social",
|
||||
Labels: labels,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pv *appbsky.FeedDefs_PostView
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil post view",
|
||||
pv: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil author",
|
||||
pv: &appbsky.FeedDefs_PostView{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no labels",
|
||||
pv: authorPV(nil),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "self-applied !no-unauthenticated",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
|
||||
}),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "label from a different src does not gate",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
|
||||
}),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different label value does not gate",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "spam"},
|
||||
}),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Negation isn't honored - matches profileRequiresAuth behavior.
|
||||
name: "negated label still triggers (matches profile behavior)",
|
||||
pv: authorPV([]*comatprototypes.LabelDefs_Label{
|
||||
{Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
|
||||
}),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := postAuthorRequiresAuth(tt.pv); got != tt.want {
|
||||
t.Errorf("got %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) {
|
||||
|
||||
func TestRenderPost_EmitsJSONLD(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
|
||||
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
@@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
||||
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
|
||||
thumbs := extractPostMedia(pv, false)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
|
||||
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
|
||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
|
||||
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
|
||||
// og:url and <link rel="canonical"> must emit the same URL.
|
||||
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
|
||||
// video without a thumbnail dropped og:video entirely.
|
||||
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
|
||||
videoURL := "https://video.bsky.app/v.m3u8"
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
|
||||
// flip of the noindex flag for indexable pages.
|
||||
func TestRenderPost_PublicNoNoindex(t *testing.T) {
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
|
||||
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
|
||||
html := renderTemplate(t, "post.html", pongo2.Context{
|
||||
"postView": pv,
|
||||
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
|
||||
|
||||
@@ -328,7 +328,6 @@ 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)
|
||||
@@ -621,39 +620,23 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
||||
identifier := handleOrDID.Normalize().String()
|
||||
|
||||
// requires two fetches: first fetch profile (!)
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
unauthedViewingOkay := !profileRequiresAuth(pv)
|
||||
|
||||
req := c.Request()
|
||||
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
|
||||
// Fetch the post thread directly. The AT-URI authority accepts either a
|
||||
// handle or a DID (the appview resolves it), so we skip the separate
|
||||
// ActorGetProfile call and source identity, the canonical URL, and the
|
||||
// auth gate from the thread response's author view instead.
|
||||
// parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
|
||||
// up to the root in nearly all threads, letting isPartOf resolve from this
|
||||
// response without a separate FeedGetPosts call.
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
|
||||
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch post: %s\t%v", uri, err)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
threadView := tpv.Thread.FeedDefs_ThreadViewPost
|
||||
if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
postView := threadView.Post
|
||||
|
||||
// Always prefer the handle-form URL so JSON-LD `url` and
|
||||
// <link rel="canonical"> match. Falls back to requestURI when the
|
||||
// handle is unusable (template strips query/fragment).
|
||||
canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
|
||||
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
|
||||
|
||||
// Gate before populating any post content into the template so that
|
||||
// !no-unauthenticated posts never leak text/media. The appview returns the
|
||||
// post (with the author self-label) to unauthed callers, so we detect the
|
||||
// label here rather than via a profile fetch.
|
||||
if postAuthorRequiresAuth(postView) {
|
||||
if !unauthedViewingOkay {
|
||||
// Provide minimal OpenGraph data for auth-required posts
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
@@ -662,13 +645,26 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
data["requiresAuth"] = true
|
||||
data["noindex"] = true
|
||||
data["nofollow"] = true
|
||||
data["profileHandle"] = postView.Author.Handle
|
||||
if postView.Author.DisplayName != nil {
|
||||
data["profileDisplayName"] = *postView.Author.DisplayName
|
||||
data["profileHandle"] = pv.Handle
|
||||
if pv.DisplayName != nil {
|
||||
data["profileDisplayName"] = *pv.DisplayName
|
||||
}
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
// then fetch the post thread (with extra context)
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
|
||||
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch post: %s\t%v", uri, err)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
threadView := tpv.Thread.FeedDefs_ThreadViewPost
|
||||
if threadView == nil || threadView.Post == nil {
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
postView := threadView.Post
|
||||
data["postView"] = postView
|
||||
data["requestURI"] = requestURI
|
||||
if canonicalURL != "" {
|
||||
@@ -698,32 +694,7 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
if jsonldURL == "" {
|
||||
jsonldURL = requestURI
|
||||
}
|
||||
|
||||
// Best-effort: resolve a reply's thread root to its handle-form canonical
|
||||
// URL for isPartOf. Prefer the root already present in the thread response
|
||||
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
|
||||
// chain is truncated (very deep thread) or broken by a blocked/not-found
|
||||
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
|
||||
// rather than point at a non-indexable page.
|
||||
isPartOfURL := ""
|
||||
if rootURI := threadRootURI(postView); rootURI != "" {
|
||||
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
|
||||
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
|
||||
} else {
|
||||
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
|
||||
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
|
||||
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
|
||||
// Handle-form only (no DID fallback): isPartOf must match the
|
||||
// root page's handle-form canonical, so an unusable handle omits
|
||||
// isPartOf rather than point at a non-canonical DID-form URL.
|
||||
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
|
||||
data["postJSONLD"] = jsonld
|
||||
} else {
|
||||
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
|
||||
|
||||
@@ -44,9 +44,9 @@ require (
|
||||
github.com/ipfs/go-log/v2 v2.5.1 // indirect
|
||||
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -90,11 +90,11 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
|
||||
@@ -77,12 +77,12 @@ github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fG
|
||||
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
|
||||
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
|
||||
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
|
||||
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
|
||||
@@ -194,8 +194,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
@@ -247,8 +247,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -266,16 +266,16 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -291,8 +291,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -304,8 +304,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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"
|
||||
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.20.22",
|
||||
"@atproto/dev-env": "^0.5.3",
|
||||
"@atproto/api": "^0.20.0",
|
||||
"@atproto/dev-env": "^0.5.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -208,10 +208,10 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: ^0.20.22
|
||||
version: 0.20.22
|
||||
specifier: ^0.20.0
|
||||
version: 0.20.5
|
||||
'@atproto/dev-env':
|
||||
specifier: ^0.5.3
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.3
|
||||
typescript:
|
||||
specifier: ^6.0.3
|
||||
@@ -257,8 +257,8 @@ packages:
|
||||
resolution: {integrity: sha512-nmjM83KucbRnz/CDSXQX6FLH/GP93XjcI1u5WFlomPscmeOAP1+9AOVsMQlKmh27I3hgaSOdCr03GJjAIpuPZQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/api@0.20.22':
|
||||
resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
|
||||
'@atproto/api@0.20.5':
|
||||
resolution: {integrity: sha512-VqkRYKR9vRRk36NhtytJz5TPhNtR1hczCcfM+bWoOK6MBvWUT8HwelufrycFAIbFIH7n4ws+5UbnLYpTIBQZ6Q==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/aws@0.3.0':
|
||||
@@ -277,10 +277,6 @@ packages:
|
||||
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.2':
|
||||
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common@0.1.0':
|
||||
resolution: {integrity: sha512-OB5tWE2R19jwiMIs2IjQieH5KTUuMb98XGCn9h3xuu6NanwjlmbCYMv08fMYwIp3UQ6jcq//84cDT3Bu6fJD+A==}
|
||||
|
||||
@@ -335,10 +331,6 @@ packages:
|
||||
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-data@0.1.3':
|
||||
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-document@0.1.0':
|
||||
resolution: {integrity: sha512-I2q2iwK8RnSHkBeAj5xdJNYdHiUQqlkbbleBSJKJXlYOYy5y86dWRo9IfE0l9E3qZ3/zvy1ydlEZmZupEt0FGg==}
|
||||
engines: {node: '>=22'}
|
||||
@@ -351,10 +343,6 @@ packages:
|
||||
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-json@0.1.2':
|
||||
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-resolver@0.1.0':
|
||||
resolution: {integrity: sha512-zliMiRW4ttSNFreKxyvSpaYQjeWrKpV/lutnXI9BNE8Ysgnw8Rltm0bR7kG/y0OlyClxhhU/vAG+OR8NpjhU1Q==}
|
||||
engines: {node: '>=22'}
|
||||
@@ -372,10 +360,6 @@ packages:
|
||||
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lexicon@0.7.3':
|
||||
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/oauth-provider-api@0.6.0':
|
||||
resolution: {integrity: sha512-wFioPBgI71v4PuEwYmIYPp4GNOyaRw1UX8FJq0AiNuUGMbqCjdd7ImSsG4b43hsHr5wOsMirsWyccjUEdhb+yQ==}
|
||||
engines: {node: '>=22'}
|
||||
@@ -416,10 +400,6 @@ packages:
|
||||
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/syntax@0.6.3':
|
||||
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/ws-client@0.1.0':
|
||||
resolution: {integrity: sha512-8qG+A+htxEHpDJRgtYNUExUOejDwU4nE2SEJCFLiTc1G+s0GbFaeg7Fk5drQA9tLMHQWoU9uqIr1YQpJyvJs8w==}
|
||||
engines: {node: '>=22'}
|
||||
@@ -432,10 +412,6 @@ packages:
|
||||
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/xrpc@0.8.2':
|
||||
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -2134,12 +2110,12 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@atproto/api@0.20.22':
|
||||
'@atproto/api@0.20.5':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.2
|
||||
'@atproto/lexicon': 0.7.3
|
||||
'@atproto/syntax': 0.6.3
|
||||
'@atproto/xrpc': 0.8.2
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/xrpc': 0.8.0
|
||||
await-lock: 3.0.0
|
||||
multiformats: 13.4.2
|
||||
tlds: 1.261.0
|
||||
@@ -2164,7 +2140,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@atproto-labs/fetch-node': 0.3.0
|
||||
'@atproto-labs/xrpc-utils': 0.1.0
|
||||
'@atproto/api': 0.20.22
|
||||
'@atproto/api': 0.20.5
|
||||
'@atproto/common': 0.6.1
|
||||
'@atproto/crypto': 0.5.0
|
||||
'@atproto/did': 0.4.0
|
||||
@@ -2234,13 +2210,6 @@ snapshots:
|
||||
'@atproto/syntax': 0.6.1
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/common-web@0.5.2':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.3
|
||||
'@atproto/lex-json': 0.1.2
|
||||
'@atproto/syntax': 0.6.3
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/common@0.1.0':
|
||||
dependencies:
|
||||
'@ipld/dag-cbor': 7.0.3
|
||||
@@ -2279,7 +2248,7 @@ snapshots:
|
||||
|
||||
'@atproto/dev-env@0.5.3':
|
||||
dependencies:
|
||||
'@atproto/api': 0.20.22
|
||||
'@atproto/api': 0.20.5
|
||||
'@atproto/bsky': 0.0.234
|
||||
'@atproto/bsync': 0.0.27
|
||||
'@atproto/common-web': 0.5.0
|
||||
@@ -2354,13 +2323,6 @@ snapshots:
|
||||
uint8arrays: 5.1.1
|
||||
unicode-segmenter: 0.14.5
|
||||
|
||||
'@atproto/lex-data@0.1.3':
|
||||
dependencies:
|
||||
multiformats: 13.4.2
|
||||
tslib: 2.8.1
|
||||
uint8arrays: 5.1.1
|
||||
unicode-segmenter: 0.14.5
|
||||
|
||||
'@atproto/lex-document@0.1.0':
|
||||
dependencies:
|
||||
'@atproto/lex-schema': 0.1.1
|
||||
@@ -2383,11 +2345,6 @@ snapshots:
|
||||
'@atproto/lex-data': 0.1.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/lex-json@0.1.2':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.3
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/lex-resolver@0.1.0':
|
||||
dependencies:
|
||||
'@atproto-labs/did-resolver': 0.3.0
|
||||
@@ -2425,13 +2382,6 @@ snapshots:
|
||||
multiformats: 13.4.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/lexicon@0.7.3':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.2
|
||||
'@atproto/syntax': 0.6.3
|
||||
multiformats: 13.4.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/oauth-provider-api@0.6.0':
|
||||
dependencies:
|
||||
'@atproto/jwk': 0.7.0
|
||||
@@ -2486,7 +2436,7 @@ snapshots:
|
||||
|
||||
'@atproto/ozone@0.1.176':
|
||||
dependencies:
|
||||
'@atproto/api': 0.20.22
|
||||
'@atproto/api': 0.20.5
|
||||
'@atproto/common': 0.6.1
|
||||
'@atproto/crypto': 0.5.0
|
||||
'@atproto/identity': 0.5.0
|
||||
@@ -2601,11 +2551,6 @@ snapshots:
|
||||
iso-datestring-validator: 2.2.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/syntax@0.6.3':
|
||||
dependencies:
|
||||
iso-datestring-validator: 2.2.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/ws-client@0.1.0':
|
||||
dependencies:
|
||||
'@atproto/common': 0.6.1
|
||||
@@ -2641,11 +2586,6 @@ snapshots:
|
||||
'@atproto/lexicon': 0.7.1
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/xrpc@0.8.2':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.7.3
|
||||
zod: 3.25.76
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
|
||||
@@ -29,49 +29,6 @@ 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-<platform>-<run-id>` 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/)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "24.18.0"
|
||||
"node": "24.15.0"
|
||||
},
|
||||
"development": {
|
||||
"extends": "base",
|
||||
@@ -21,17 +21,6 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
// @ts-check
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import {defineConfig} from 'eslint/config'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
// @ts-expect-error no types
|
||||
import reactNative from 'eslint-plugin-react-native'
|
||||
// @ts-expect-error no types
|
||||
import reactNativeA11y from 'eslint-plugin-react-native-a11y'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import importX from 'eslint-plugin-import-x'
|
||||
import lingui from 'eslint-plugin-lingui'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import bskyInternal from 'eslint-plugin-bsky-internal'
|
||||
import globals from 'globals'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
|
||||
export default defineConfig(
|
||||
/**
|
||||
* Global ignores
|
||||
*/
|
||||
{
|
||||
ignores: [
|
||||
'**/__mocks__/*.ts',
|
||||
'ios/**',
|
||||
'android/**',
|
||||
'coverage/**',
|
||||
'*.lock',
|
||||
'.husky/**',
|
||||
'patches/**',
|
||||
'*.html',
|
||||
'bskyweb/**',
|
||||
'bskyembed/**',
|
||||
'bskyogcard/**',
|
||||
'src/locale/locales/_build/**',
|
||||
'src/locale/locales/**/*.js',
|
||||
'*.e2e.ts',
|
||||
'*.e2e.tsx',
|
||||
'eslint.config.mjs',
|
||||
'.jscodeshift/**',
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* Base configurations
|
||||
*/
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
reactHooks.configs.flat.recommended,
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
importX.flatConfigs['react-native'],
|
||||
|
||||
/**
|
||||
* Main configuration for all JS/TS/JSX/TSX files
|
||||
*/
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
react,
|
||||
'react-native': reactNative,
|
||||
'react-native-a11y': reactNativeA11y,
|
||||
'simple-import-sort': simpleImportSort,
|
||||
// @ts-expect-error - not sure why
|
||||
lingui,
|
||||
'react-compiler': reactCompiler,
|
||||
'bsky-internal': bskyInternal,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
componentWrapperFunctions: ['observer'],
|
||||
},
|
||||
rules: {
|
||||
/**
|
||||
* Custom rules
|
||||
*/
|
||||
'bsky-internal/avoid-unwrapped-text': [
|
||||
'error',
|
||||
{
|
||||
impliedTextComponents: [
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
'Toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||
},
|
||||
},
|
||||
],
|
||||
'bsky-internal/use-exact-imports': 'error',
|
||||
'bsky-internal/use-prefixed-imports': 'error',
|
||||
'bsky-internal/lingui-msg-rule': 'error',
|
||||
|
||||
/**
|
||||
* React & React Native
|
||||
*/
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
'react/hook-use-state': 'warn',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react-native/no-inline-styles': 'off',
|
||||
...reactNativeA11y.configs.all.rules,
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'error',
|
||||
'react-hooks/purity': 'error',
|
||||
'react-hooks/refs': 'error',
|
||||
'react-hooks/immutability': 'error',
|
||||
|
||||
/**
|
||||
* Import sorting
|
||||
*/
|
||||
'simple-import-sort/imports': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Node.js builtins prefixed with `node:`.
|
||||
['^node:'],
|
||||
// Packages.
|
||||
// Things that start with a letter (or digit or underscore), or `@` followed by a letter.
|
||||
// React/React Native prioritized, followed by expo
|
||||
// Followed by all packages excluding unprefixed relative ones
|
||||
[
|
||||
'^(react\\/(.*)$)|^(react$)|^(react-native(.*)$)',
|
||||
'^(expo(.*)$)|^(expo$)',
|
||||
'^(?!(?:alf|components|lib|locale|logger|platform|screens|state|view)(?:$|\\/))@?\\w',
|
||||
],
|
||||
// Relative imports.
|
||||
// Ideally, anything that starts with a dot or #
|
||||
// due to unprefixed relative imports being used, we whitelist the relative paths we use
|
||||
// (?:$|\\/) matches end of string or /
|
||||
[
|
||||
'^(?:#\\/)?(?:lib|state|logger|platform|locale)(?:$|\\/)',
|
||||
'^(?:#\\/)?view(?:$|\\/)',
|
||||
'^(?:#\\/)?screens(?:$|\\/)',
|
||||
'^(?:#\\/)?alf(?:$|\\/)',
|
||||
'^(?:#\\/)?components(?:$|\\/)',
|
||||
'^#\\/',
|
||||
'^\\.',
|
||||
],
|
||||
// anything else - hopefully we don't have any of these
|
||||
['^'],
|
||||
],
|
||||
},
|
||||
],
|
||||
'simple-import-sort/exports': 'error',
|
||||
|
||||
/**
|
||||
* Import linting
|
||||
*/
|
||||
'import-x/consistent-type-specifier-style': ['warn', 'prefer-inline'],
|
||||
'import-x/no-unresolved': [
|
||||
'error',
|
||||
{
|
||||
/*
|
||||
* The `postinstall` hook runs `compile-if-needed` locally, but not in
|
||||
* CI. For CI-sake, ignore this.
|
||||
*/
|
||||
ignore: ['^#\/locale\/locales\/.+\/messages'],
|
||||
},
|
||||
],
|
||||
'import-x/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
whitelist: [
|
||||
// test files only
|
||||
'@jest/globals',
|
||||
// we only use a really simple util from this, and we know it will be present
|
||||
'expo-modules-core',
|
||||
// this is a dep for @atproto/api, but we absolutely need them in sync, so just
|
||||
// rely on the transient version
|
||||
'@atproto/common-web',
|
||||
],
|
||||
},
|
||||
],
|
||||
'import-x/no-nodejs-modules': 'error',
|
||||
|
||||
/**
|
||||
* TypeScript-specific rules
|
||||
*/
|
||||
'no-unused-vars': 'off', // off, we use TS-specific rule below
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_.+',
|
||||
caughtErrors: 'none',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{prefer: 'type-imports', fixStyle: 'inline-type-imports'},
|
||||
],
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowTernary: true,
|
||||
},
|
||||
],
|
||||
/**
|
||||
* Maintain previous behavior via eslint-suppressions.json - these are
|
||||
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
|
||||
*/
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'error',
|
||||
'@typescript-eslint/no-base-to-string': 'error',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react',
|
||||
importNames: ['React', 'default'],
|
||||
message:
|
||||
'React is already in the global type namespace. Use named imports for runtime modules.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Turn off rules that we haven't enforced thus far
|
||||
*/
|
||||
'no-empty-pattern': 'off',
|
||||
'no-async-promise-executor': 'off',
|
||||
'no-constant-binary-expression': 'warn',
|
||||
'prefer-const': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-unsafe-optional-chaining': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'no-var': 'off',
|
||||
'prefer-rest-params': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'no-irregular-whitespace': 'off',
|
||||
'no-useless-escape': 'off',
|
||||
'no-sparse-arrays': 'off',
|
||||
'no-fallthrough': 'off',
|
||||
'no-control-regex': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* bskyogcard, dev-env - server-side, Node.js imports are fine
|
||||
*/
|
||||
{
|
||||
files: ['bskyogcard/**/*.{js,jsx,ts,tsx}', 'dev-env/**/*.{js,jsx,ts,tsx}'],
|
||||
rules: {
|
||||
'import-x/no-nodejs-modules': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Test files configuration
|
||||
*/
|
||||
{
|
||||
files: ['**/__tests__/**/*.{js,jsx,ts,tsx}', '**/*.test.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
## Eslint plugin tests
|
||||
|
||||
These are disabled as Oxlint’s RuleTester doesn’t work well with Hermes.
|
||||
@@ -1,11 +1,16 @@
|
||||
const {RuleTester} = require('oxlint/plugins-dev')
|
||||
const {RuleTester} = require('eslint')
|
||||
const tseslint = require('typescript-eslint')
|
||||
const avoidUnwrappedText = require('../avoid-unwrapped-text')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
eslintCompat: true,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
lang: 'tsx',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,11 +1,16 @@
|
||||
const {RuleTester} = require('oxlint/plugins-dev')
|
||||
const {RuleTester} = require('eslint')
|
||||
const tseslint = require('typescript-eslint')
|
||||
const linguiMsgRule = require('../lingui-msg-rule')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
eslintCompat: true,
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
lang: 'tsx',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -7,7 +7,6 @@ export default defineConfig({
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cs',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const {getSentryExpoConfig} = require('@sentry/react-native/metro')
|
||||
const cfg = getSentryExpoConfig(__dirname)
|
||||
|
||||
// inject `.e2e.ts` and `.e2e.tsx` into the sourceExts when running tests
|
||||
cfg.resolver.sourceExts = process.env.RN_SRC_EXT
|
||||
? process.env.RN_SRC_EXT.split(',').concat(cfg.resolver.sourceExts)
|
||||
: cfg.resolver.sourceExts
|
||||
|
||||
if (cfg.resolver.resolveRequest) {
|
||||
throw Error('Update this override because it is conflicting now.')
|
||||
}
|
||||
|
||||
if (process.env.BSKY_PROFILE) {
|
||||
cfg.cacheVersion += ':PROFILE'
|
||||
}
|
||||
|
||||
cfg.resolver.assetExts = [...cfg.resolver.assetExts, 'woff2']
|
||||
|
||||
cfg.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
if (process.env.BSKY_PROFILE) {
|
||||
if (moduleName.endsWith('ReactNativeRenderer-prod')) {
|
||||
return context.resolveRequest(
|
||||
context,
|
||||
moduleName.replace('-prod', '-profiling'),
|
||||
platform,
|
||||
)
|
||||
}
|
||||
}
|
||||
return context.resolveRequest(context, moduleName, platform)
|
||||
}
|
||||
|
||||
cfg.transformer.getTransformOptions = async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: true,
|
||||
inlineRequires: true,
|
||||
},
|
||||
})
|
||||
|
||||
module.exports = cfg
|
||||
@@ -1,57 +0,0 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
import {type CustomResolver} from '@expo/metro/metro-resolver'
|
||||
import {getDefaultConfig} from '@expo/metro-config'
|
||||
import {getSentryExpoConfig} from '@sentry/react-native/metro.js'
|
||||
|
||||
const config = getSentryExpoConfig(import.meta.dirname, {
|
||||
// TODO: confirm this doesn't break anything when we switch to metro web
|
||||
includeWebReplay: false,
|
||||
annotateReactComponents: {
|
||||
textComponentNames: ['Text', 'ButtonText'],
|
||||
},
|
||||
getDefaultConfig: (projectRoot, options) => {
|
||||
const config = getDefaultConfig(projectRoot, options)
|
||||
|
||||
if (typeof process.env.RN_SRC_EXT === 'string') {
|
||||
// inject `.e2e.ts` and `.e2e.tsx` into the sourceExts when running tests)
|
||||
config.resolver.sourceExts.unshift(...process.env.RN_SRC_EXT.split(','))
|
||||
}
|
||||
|
||||
config.resolver.assetExts = [...config.resolver.assetExts, 'woff2']
|
||||
|
||||
if (config.resolver.resolveRequest) {
|
||||
throw Error('Update this override because it is conflicting now.')
|
||||
}
|
||||
|
||||
if (process.env.BSKY_PROFILE) {
|
||||
// @ts-expect-error readonly property
|
||||
config.cacheVersion += ':PROFILE'
|
||||
|
||||
const resolver: CustomResolver = (context, moduleName, platform) => {
|
||||
if (moduleName.endsWith('ReactNativeRenderer-prod')) {
|
||||
return context.resolveRequest(
|
||||
context,
|
||||
moduleName.replace('-prod', '-profiling'),
|
||||
platform,
|
||||
)
|
||||
}
|
||||
return context.resolveRequest(context, moduleName, platform)
|
||||
}
|
||||
|
||||
// @ts-expect-error readonly property
|
||||
config.resolver.resolveRequest = resolver
|
||||
}
|
||||
|
||||
config.transformer.getTransformOptions = () =>
|
||||
Promise.resolve({
|
||||
transform: {
|
||||
experimentalImportSupport: true,
|
||||
inlineRequires: true as false, // ??? typescript why?
|
||||
},
|
||||
})
|
||||
|
||||
return config as unknown as Record<string, unknown>
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
@@ -3,7 +3,6 @@ import AVKit
|
||||
|
||||
let IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"]
|
||||
let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"]
|
||||
let MAX_IMAGES = 10
|
||||
|
||||
enum URLType: String, CaseIterable {
|
||||
case image
|
||||
@@ -72,12 +71,17 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func handleImages(items: [NSItemProvider]) async {
|
||||
let itemsToProcess = Array(items.prefix(MAX_IMAGES))
|
||||
let firstFourItems: [NSItemProvider]
|
||||
if items.count < 4 {
|
||||
firstFourItems = items
|
||||
} else {
|
||||
firstFourItems = Array(items[0...3])
|
||||
}
|
||||
|
||||
var valid = true
|
||||
var imageUris = ""
|
||||
|
||||
for (index, item) in itemsToProcess.enumerated() {
|
||||
for (index, item) in firstFourItems.enumerated() {
|
||||
var imageUriInfo: String?
|
||||
|
||||
do {
|
||||
@@ -96,7 +100,7 @@ class ShareViewController: UIViewController {
|
||||
|
||||
if let imageUriInfo = imageUriInfo {
|
||||
imageUris.append(imageUriInfo)
|
||||
if index < itemsToProcess.count - 1 {
|
||||
if index < items.count - 1 {
|
||||
imageUris.append(",")
|
||||
}
|
||||
} else {
|
||||
@@ -117,6 +121,7 @@ class ShareViewController: UIViewController {
|
||||
let firstItem = items.first
|
||||
|
||||
if let dataUrl = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
|
||||
let ext = String(dataUrl.lastPathComponent.split(separator: ".").last ?? "mp4")
|
||||
if let videoUriInfo = saveVideoWithInfo(dataUrl),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") {
|
||||
_ = self.openURL(url)
|
||||
|
||||
@@ -32,12 +32,9 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
var cornerRadius: CGFloat?
|
||||
var sourceViewTag: Int?
|
||||
var minHeight = 0.0
|
||||
// getScreenHeight() is nil when no window scene is connected yet (e.g. during
|
||||
// prewarming or a background launch). A nil here previously trapped in
|
||||
// clampHeight, so fall back to the full screen bounds instead.
|
||||
var maxHeight: CGFloat = UIScreen.main.bounds.height {
|
||||
var maxHeight: CGFloat! {
|
||||
didSet {
|
||||
let screenHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||
let screenHeight = Util.getScreenHeight() ?? 0
|
||||
if maxHeight > screenHeight {
|
||||
maxHeight = screenHeight
|
||||
}
|
||||
@@ -80,7 +77,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
|
||||
required init (appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
self.maxHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||
self.maxHeight = Util.getScreenHeight()
|
||||
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
||||
SheetManager.shared.add(self)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,6 @@ 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
|
||||
@@ -183,7 +182,7 @@ function BottomSheetNativeComponentInner({
|
||||
]}>
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={isHeightConstrained ? {flex: 1} : undefined}>
|
||||
style={maxHeight == null ? undefined : {flex: 1}}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import {Component} from 'react'
|
||||
|
||||
import {type BottomSheetViewProps} from './BottomSheet.types'
|
||||
|
||||
export class BottomSheetNativeComponent extends Component<BottomSheetViewProps> {
|
||||
/*
|
||||
* Native sheets do not exist on web; there is nothing to dismiss.
|
||||
*/
|
||||
static dismissAll = async () => {}
|
||||
|
||||
render(): never {
|
||||
throw new Error('BottomSheetNativeComponent is not available on web')
|
||||
}
|
||||
export function BottomSheetNativeComponent(_: BottomSheetViewProps) {
|
||||
throw new Error('BottomSheetNativeComponent is not available on web')
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import {createPortalGroup_INTERNAL} from './lib/Portal'
|
||||
|
||||
type PortalContext = React.ElementType<{children: React.ReactNode}>
|
||||
|
||||
export const Context = createContext({} as PortalContext)
|
||||
export const Context = React.createContext({} as PortalContext)
|
||||
Context.displayName = 'BottomSheetPortalContext'
|
||||
|
||||
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
|
||||
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
|
||||
|
||||
export function BottomSheetPortalProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const portal = useMemo(() => {
|
||||
const portal = React.useMemo(() => {
|
||||
return createPortalGroup_INTERNAL()
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import React from 'react'
|
||||
|
||||
type Component = React.ReactElement
|
||||
|
||||
@@ -23,7 +13,7 @@ type ComponentMap = {
|
||||
}
|
||||
|
||||
export function createPortalGroup_INTERNAL() {
|
||||
const Context = createContext<ContextType>({
|
||||
const Context = React.createContext<ContextType>({
|
||||
outlet: null,
|
||||
append: () => {},
|
||||
remove: () => {},
|
||||
@@ -31,21 +21,21 @@ export function createPortalGroup_INTERNAL() {
|
||||
Context.displayName = 'BottomSheetPortalContext'
|
||||
|
||||
function Provider(props: React.PropsWithChildren<{}>) {
|
||||
const map = useRef<ComponentMap>({})
|
||||
const [outlet, setOutlet] = useState<ContextType['outlet']>(null)
|
||||
const map = React.useRef<ComponentMap>({})
|
||||
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
|
||||
|
||||
const append = useCallback<ContextType['append']>((id, component) => {
|
||||
const append = React.useCallback<ContextType['append']>((id, component) => {
|
||||
if (map.current[id]) return
|
||||
map.current[id] = <Fragment key={id}>{component}</Fragment>
|
||||
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
const remove = useCallback<ContextType['remove']>(id => {
|
||||
const remove = React.useCallback<ContextType['remove']>(id => {
|
||||
delete map.current[id]
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
const contextValue = useMemo(
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
outlet,
|
||||
append,
|
||||
@@ -60,14 +50,14 @@ export function createPortalGroup_INTERNAL() {
|
||||
}
|
||||
|
||||
function Outlet() {
|
||||
const ctx = useContext(Context)
|
||||
const ctx = React.useContext(Context)
|
||||
return ctx.outlet
|
||||
}
|
||||
|
||||
function Portal({children}: React.PropsWithChildren<{}>) {
|
||||
const {append, remove} = useContext(Context)
|
||||
const id = useId()
|
||||
useEffect(() => {
|
||||
const {append, remove} = React.useContext(Context)
|
||||
const id = React.useId()
|
||||
React.useEffect(() => {
|
||||
append(id, children as Component)
|
||||
return () => remove(id)
|
||||
}, [id, children, append, remove])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
|
||||
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
|
||||
@@ -11,10 +11,11 @@ interface BackgroundNotificationPreferencesContext {
|
||||
) => void
|
||||
}
|
||||
|
||||
const Context = createContext<BackgroundNotificationPreferencesContext>(
|
||||
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
|
||||
{} as BackgroundNotificationPreferencesContext,
|
||||
)
|
||||
export const useBackgroundNotificationPreferences = () => useContext(Context)
|
||||
export const useBackgroundNotificationPreferences = () =>
|
||||
React.useContext(Context)
|
||||
|
||||
export function BackgroundNotificationPreferencesProvider({
|
||||
children,
|
||||
@@ -22,18 +23,18 @@ export function BackgroundNotificationPreferencesProvider({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [preferences, setPreferences] =
|
||||
useState<BackgroundNotificationHandlerPreferences>({
|
||||
React.useState<BackgroundNotificationHandlerPreferences>({
|
||||
playSoundChat: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
;(async () => {
|
||||
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
|
||||
setPreferences(prefs)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
preferences,
|
||||
setPref: async <
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import {createRef, PureComponent} from 'react'
|
||||
import React from 'react'
|
||||
import {requireNativeModule} from 'expo'
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
interface GifViewNativeRef {
|
||||
playAsync: () => Promise<void>
|
||||
pauseAsync: () => Promise<void>
|
||||
toggleAsync: () => Promise<void>
|
||||
}
|
||||
|
||||
const NativeModule: {
|
||||
prefetchAsync: (sources: string[]) => Promise<void>
|
||||
} = requireNativeModule('ExpoBlueskyGifView')
|
||||
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
|
||||
const NativeView: React.ComponentType<
|
||||
GifViewProps & {ref: React.RefObject<GifViewNativeRef | null>}
|
||||
GifViewProps & {ref: React.RefObject<any>}
|
||||
> = requireNativeViewManager('ExpoBlueskyGifView')
|
||||
|
||||
export class GifView extends PureComponent<GifViewProps> {
|
||||
private nativeRef: React.RefObject<GifViewNativeRef | null> = createRef()
|
||||
export class GifView extends React.PureComponent<GifViewProps> {
|
||||
// TODO native types, should all be the same as those in this class
|
||||
private nativeRef: React.RefObject<any> = React.createRef()
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
super(props)
|
||||
@@ -29,15 +22,15 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
}
|
||||
|
||||
async playAsync(): Promise<void> {
|
||||
await this.nativeRef.current?.playAsync()
|
||||
await this.nativeRef.current.playAsync()
|
||||
}
|
||||
|
||||
async pauseAsync(): Promise<void> {
|
||||
await this.nativeRef.current?.pauseAsync()
|
||||
await this.nativeRef.current.pauseAsync()
|
||||
}
|
||||
|
||||
async toggleAsync(): Promise<void> {
|
||||
await this.nativeRef.current?.toggleAsync()
|
||||
await this.nativeRef.current.toggleAsync()
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {createRef, PureComponent} from 'react'
|
||||
import {createRef, PureComponent, type RefObject} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
|
||||
import {type GifViewProps} from './GifView.types'
|
||||
|
||||
export class GifView extends PureComponent<GifViewProps> {
|
||||
private readonly videoPlayerRef: React.RefObject<HTMLVideoElement | null> =
|
||||
createRef()
|
||||
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
|
||||
private isLoaded = false
|
||||
|
||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||
@@ -19,9 +18,9 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
|
||||
if (prevProps.autoplay !== this.props.autoplay) {
|
||||
if (this.props.autoplay) {
|
||||
void this.playAsync()
|
||||
this.playAsync()
|
||||
} else {
|
||||
void this.pauseAsync()
|
||||
this.pauseAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +29,6 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
static async prefetchAsync(_: string[]): Promise<void> {
|
||||
console.warn('prefetchAsync is not supported on web')
|
||||
}
|
||||
@@ -83,7 +81,6 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async pauseAsync(): Promise<void> {
|
||||
this.videoPlayerRef.current?.pause()
|
||||
}
|
||||
@@ -105,12 +102,12 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
// When `<source>` children are present, omit `src` so the browser
|
||||
// walks the source list and picks via canPlayType.
|
||||
src={useSources ? undefined : source}
|
||||
autoPlay={autoplay ? true : undefined}
|
||||
autoPlay={autoplay ? 'autoplay' : undefined}
|
||||
preload={autoplay ? 'auto' : undefined}
|
||||
playsInline={true}
|
||||
loop={true}
|
||||
muted={true}
|
||||
style={StyleSheet.flatten(style) as React.CSSProperties}
|
||||
loop="loop"
|
||||
muted="muted"
|
||||
style={StyleSheet.flatten(style)}
|
||||
onCanPlay={this.onLoad}
|
||||
onPlay={this.firePlayerStateChangeEvent}
|
||||
onPause={this.firePlayerStateChangeEvent}
|
||||
|
||||
@@ -10,7 +10,6 @@ This module consolidates several native features into a single Expo module:
|
||||
- **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer)
|
||||
- **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android)
|
||||
- **VisibilityView**: A native view component that tracks which view is currently visible on screen
|
||||
- **NotificationSettings**: iOS handler that routes the system "notification settings" intent into the app
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -124,24 +123,9 @@ This is useful for features like video autoplay, where you want to know which vi
|
||||
- Android: Full support using View position tracking
|
||||
- Web: Passthrough component (renders children without tracking)
|
||||
|
||||
### NotificationSettings
|
||||
## Architecture
|
||||
|
||||
iOS only. Has no JavaScript surface - it is a pure native side effect registered
|
||||
at app launch.
|
||||
|
||||
When push permissions are requested with `provideAppNotificationSettings: true`,
|
||||
iOS adds an in-app notification settings button to the system Settings screen for
|
||||
Bluesky. Tapping it launches the app and triggers
|
||||
`userNotificationCenter(_:openSettingsFor:)`. expo-notifications owns the
|
||||
`UNUserNotificationCenter` delegate and fans this callback out to registered
|
||||
`NotificationDelegate`s. This module registers one and converts the callback into
|
||||
a `bluesky://settings/notifications` deep link, which the app's existing linking
|
||||
config routes to the notification settings screen.
|
||||
|
||||
**Platform Support:**
|
||||
- iOS: Full support
|
||||
- Android: Not applicable (Android opens the system notification settings directly)
|
||||
- Web: Not applicable
|
||||
### TypeScript Layer
|
||||
|
||||
The module uses platform-specific file extensions to provide appropriate implementations:
|
||||
|
||||
@@ -186,9 +170,7 @@ The module uses platform-specific file extensions to provide appropriate impleme
|
||||
|
||||
### Expo Module Config
|
||||
|
||||
The module is registered in `expo-module.config.json`. The PlatformInfo,
|
||||
Referrer, SharedPrefs, and VisibilityView sub-modules are registered for both iOS
|
||||
and Android; NotificationSettings is iOS only.
|
||||
The module is registered in `expo-module.config.json` with all four sub-modules for both iOS and Android.
|
||||
|
||||
### iOS
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"ExpoBlueskySharedPrefsModule",
|
||||
"ExpoBlueskyReferrerModule",
|
||||
"ExpoBlueskyVisibilityViewModule",
|
||||
"ExpoPlatformInfoModule",
|
||||
"ExpoBlueskyNotificationSettingsModule"
|
||||
"ExpoPlatformInfoModule"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
|
||||
@@ -10,7 +10,6 @@ Pod::Spec.new do |s|
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'EXNotifications'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
|
||||