Merge remote-tracking branch 'origin/main' into app-2670
# Conflicts: # src/analytics/features/types.ts
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Compile translations
|
||||
description: Compile i18n translations and fail on compilation errors.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: 🔤 Compile translations
|
||||
shell: bash
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
shell: bash
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
|
||||
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: Local EAS Build
|
||||
description: Build an Expo app locally with a selected EAS profile.
|
||||
|
||||
inputs:
|
||||
platform:
|
||||
description: EAS platform to build (ios or android)
|
||||
required: true
|
||||
profile:
|
||||
description: EAS build profile
|
||||
required: true
|
||||
output:
|
||||
description: Output path for the local build artifact
|
||||
required: true
|
||||
log-path:
|
||||
description: Optional path to tee build output into
|
||||
required: false
|
||||
default: ""
|
||||
bump-build-number:
|
||||
description: Run the build through use-build-number-with-bump
|
||||
required: false
|
||||
default: "false"
|
||||
sentry-auth-token:
|
||||
description: Optional Sentry authentication token
|
||||
required: false
|
||||
default: ""
|
||||
sentry-release:
|
||||
description: Optional Sentry release
|
||||
required: false
|
||||
default: ""
|
||||
sentry-dist:
|
||||
description: Optional Sentry distribution
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Build locally with EAS
|
||||
shell: bash
|
||||
env:
|
||||
PLATFORM: ${{ inputs.platform }}
|
||||
PROFILE: ${{ inputs.profile }}
|
||||
OUTPUT: ${{ inputs.output }}
|
||||
LOG_PATH: ${{ inputs.log-path }}
|
||||
BUMP_BUILD_NUMBER: ${{ inputs.bump-build-number }}
|
||||
SENTRY_AUTH_TOKEN: ${{ inputs.sentry-auth-token }}
|
||||
SENTRY_RELEASE: ${{ inputs.sentry-release }}
|
||||
SENTRY_DIST: ${{ inputs.sentry-dist }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
build_command=(
|
||||
pnpm eas build
|
||||
--platform "$PLATFORM"
|
||||
--profile "$PROFILE"
|
||||
--local
|
||||
--output "$OUTPUT"
|
||||
--non-interactive
|
||||
)
|
||||
|
||||
if [ -n "$LOG_PATH" ]; then
|
||||
mkdir -p "$(dirname "$LOG_PATH")"
|
||||
if [ "$BUMP_BUILD_NUMBER" = "true" ]; then
|
||||
pnpm use-build-number-with-bump "${build_command[@]}" 2>&1 | tee "$LOG_PATH"
|
||||
else
|
||||
"${build_command[@]}" 2>&1 | tee "$LOG_PATH"
|
||||
fi
|
||||
elif [ "$BUMP_BUILD_NUMBER" = "true" ]; then
|
||||
pnpm use-build-number-with-bump "${build_command[@]}"
|
||||
else
|
||||
"${build_command[@]}"
|
||||
fi
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: Setup Expo Project
|
||||
description: Install dependencies and set up the Expo/EAS CLI for a build. Does not check out the repo.
|
||||
|
||||
inputs:
|
||||
expo-token:
|
||||
description: Expo token (EXPO_TOKEN secret)
|
||||
required: true
|
||||
eas-version:
|
||||
description: EAS CLI version to install
|
||||
required: false
|
||||
default: '19.0.5'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
shell: bash
|
||||
env:
|
||||
EXPO_TOKEN: ${{ inputs.expo-token }}
|
||||
run: >
|
||||
if [ -z "$EXPO_TOKEN" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: ${{ inputs.eas-version }}
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ inputs.expo-token }}
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: Write Environment Variables
|
||||
description: Write the .env file and google-services.json used by the build.
|
||||
|
||||
inputs:
|
||||
env-token:
|
||||
description: Base .env contents (ENV_TOKEN secret)
|
||||
required: true
|
||||
sentry-dsn:
|
||||
description: Sentry DSN (SENTRY_DSN secret)
|
||||
required: true
|
||||
bitdrift-api-key:
|
||||
description: Bitdrift API key (BITDRIFT_API_KEY secret)
|
||||
required: true
|
||||
gcp-project-id:
|
||||
description: GCP project ID (EXPO_PUBLIC_GCP_PROJECT_ID secret)
|
||||
required: true
|
||||
google-services-token:
|
||||
description: google-services.json contents (GOOGLE_SERVICES_TOKEN secret)
|
||||
required: true
|
||||
expo-public-env:
|
||||
description: >
|
||||
EXPO_PUBLIC_ENV value. Only set for OTA deploys where eas.json isn't used;
|
||||
for regular builds this is normally handled in eas.json.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
outputs:
|
||||
release-version:
|
||||
description: Version from package.json
|
||||
value: ${{ steps.env.outputs.release-version }}
|
||||
bundle-identifier:
|
||||
description: git SHA of HEAD
|
||||
value: ${{ steps.env.outputs.bundle-identifier }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
shell: bash
|
||||
env:
|
||||
ENV_TOKEN: ${{ inputs.env-token }}
|
||||
SENTRY_DSN: ${{ inputs.sentry-dsn }}
|
||||
BITDRIFT_API_KEY: ${{ inputs.bitdrift-api-key }}
|
||||
GCP_PROJECT_ID: ${{ inputs.gcp-project-id }}
|
||||
GOOGLE_SERVICES_TOKEN: ${{ inputs.google-services-token }}
|
||||
EXPO_PUBLIC_ENV: ${{ inputs.expo-public-env }}
|
||||
run: |
|
||||
echo "$ENV_TOKEN" > .env
|
||||
# EXPO_PUBLIC_ENV is normally handled in eas.json; only written here for OTA deploys.
|
||||
if [ -n "$EXPO_PUBLIC_ENV" ]; then
|
||||
echo "EXPO_PUBLIC_ENV=$EXPO_PUBLIC_ENV" >> .env
|
||||
fi
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "release-version=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
echo "bundle-identifier=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_SENTRY_DSN=$SENTRY_DSN" >> .env
|
||||
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=$BITDRIFT_API_KEY" >> .env
|
||||
echo "EXPO_PUBLIC_GCP_PROJECT_ID=$GCP_PROJECT_ID" >> .env
|
||||
echo "$GOOGLE_SERVICES_TOKEN" > google-services.json
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/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
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,356 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const ENTITY_REPLACEMENTS = {
|
||||
'&': '&',
|
||||
''': "'",
|
||||
'>': '>',
|
||||
'<': '<',
|
||||
'"': '"',
|
||||
}
|
||||
|
||||
function decodeXml(value = '') {
|
||||
return value
|
||||
.replace(/&(amp|apos|gt|lt|quot);/g, entity => ENTITY_REPLACEMENTS[entity])
|
||||
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([\da-f]+);/gi, (_, code) =>
|
||||
String.fromCodePoint(Number.parseInt(code, 16)),
|
||||
)
|
||||
}
|
||||
|
||||
function attributes(source = '') {
|
||||
const result = {}
|
||||
for (const match of source.matchAll(/([\w:.-]+)\s*=\s*(["'])(.*?)\2/gs)) {
|
||||
result[match[1]] = decodeXml(match[3])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function concise(value, limit = 300) {
|
||||
const normalized = decodeXml(value)
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return normalized.length > limit
|
||||
? `${normalized.slice(0, limit - 1)}…`
|
||||
: normalized
|
||||
}
|
||||
|
||||
export function parseJUnit(xml) {
|
||||
const failures = []
|
||||
const testcasePattern = /<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`)
|
||||
}
|
||||
@@ -10,12 +10,25 @@ on:
|
||||
options:
|
||||
- testflight-android
|
||||
- production
|
||||
submit:
|
||||
type: boolean
|
||||
description: Submit the build to Google Play (disable to only produce the APK artifact)
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
profile:
|
||||
type: string
|
||||
description: Build profile to use
|
||||
required: true
|
||||
submit:
|
||||
type: boolean
|
||||
description: Submit the build to Google Play (disable to only produce the APK artifact)
|
||||
default: true
|
||||
runner:
|
||||
type: string
|
||||
description: Runner for the build job (defaults to Linux-x64-32core)
|
||||
required: false
|
||||
default: ''
|
||||
outputs:
|
||||
package-version:
|
||||
description: Version from package.json
|
||||
@@ -56,93 +69,92 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit Android
|
||||
runs-on: Linux-x64-32core
|
||||
name: Build Android
|
||||
runs-on: ${{ inputs.runner || 'Linux-x64-32core' }}
|
||||
concurrency:
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
outputs:
|
||||
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
|
||||
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: 🔧 Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
|
||||
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: Env
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
|
||||
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
|
||||
echo "$json" > google-services.json
|
||||
uses: ./.github/actions/write-env
|
||||
with:
|
||||
env-token: ${{ secrets.ENV_TOKEN }}
|
||||
sentry-dsn: ${{ secrets.SENTRY_DSN }}
|
||||
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight-android' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile $PROFILE
|
||||
--local --output build.aab --non-interactive
|
||||
uses: ./.github/actions/eas-local-build
|
||||
with:
|
||||
platform: android
|
||||
profile: ${{ inputs.profile || 'testflight-android' }}
|
||||
output: build.aab
|
||||
bump-build-number: "true"
|
||||
sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
sentry-release: ${{ steps.env.outputs.release-version }}
|
||||
sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
# Hands the built bundle off to the submit / universalApk jobs. Retention is
|
||||
# deliberately short (1 day) since it's only an intra-run handoff artifact.
|
||||
- name: 🚀 Upload AAB artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: android-aab-${{ github.run_id }}
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
path: build.aab
|
||||
|
||||
submit:
|
||||
name: Submit to Google Play
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
# Submit unless explicitly disabled; on events where inputs is empty this still submits.
|
||||
if: ${{ inputs.submit != false }}
|
||||
steps:
|
||||
# eas submit reads app config from the repo, so we need a checkout.
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⬇️ Download AAB artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: android-aab-${{ github.run_id }}
|
||||
|
||||
- name: 🚀 Submit to Google Play
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight-android' }}
|
||||
@@ -150,13 +162,49 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Play Store Submission
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"}
|
||||
|
||||
# Record the commit only after a successful submit, so a failed submit doesn't
|
||||
# advance the "most recent testflight" marker.
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# Runs in parallel with submit: the QA APK shouldn't be blocked by a Play submission failure.
|
||||
universalApk:
|
||||
name: Build universal APK
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
outputs:
|
||||
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
steps:
|
||||
- name: ⬇️ Download AAB artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: android-aab-${{ github.run_id }}
|
||||
|
||||
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
|
||||
# like the build job so the toolchain is deterministic.
|
||||
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: 🔧 Setup bundletool
|
||||
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
|
||||
@@ -164,19 +212,24 @@ jobs:
|
||||
version: "1.18.3"
|
||||
|
||||
- name: 🔑 Decode keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
|
||||
keystore.jks
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > keystore.jks
|
||||
|
||||
- name: 📦 Build signed universal APK
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
bundletool build-apks \
|
||||
--bundle=build.aab \
|
||||
--output=universal.apks \
|
||||
--mode=universal \
|
||||
--ks=keystore.jks \
|
||||
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
|
||||
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
--ks-pass=pass:"$ANDROID_KEYSTORE_PASSWORD" \
|
||||
--ks-key-alias="$ANDROID_KEY_ALIAS" \
|
||||
--key-pass=pass:"$ANDROID_KEY_PASSWORD"
|
||||
|
||||
- name: 📋 Rename to .zip for extraction
|
||||
run: mv universal.apks universal.zip
|
||||
@@ -198,27 +251,13 @@ jobs:
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack of APK Artifact
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"}
|
||||
|
||||
# Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production
|
||||
# build is dispatched against such a tag we attach the APK to the matching release. This runs
|
||||
@@ -226,7 +265,7 @@ jobs:
|
||||
attachToRelease:
|
||||
name: Attach APK to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
needs: [build, universalApk]
|
||||
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -254,7 +293,7 @@ jobs:
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ needs.build.outputs.apk-artifact-name }}
|
||||
name: ${{ needs.universalApk.outputs.apk-artifact-name }}
|
||||
|
||||
- name: 🏷️ Rename APK for release
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
@@ -265,6 +304,7 @@ jobs:
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
APK: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
||||
run: |
|
||||
@@ -274,7 +314,7 @@ jobs:
|
||||
|
||||
- name: 🔔 Notify Slack of Release Attachment
|
||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -28,6 +28,11 @@ on:
|
||||
type: string
|
||||
description: TestFlight group to assign the build to after submitting ("none" to skip)
|
||||
default: none
|
||||
runner:
|
||||
type: string
|
||||
description: Runner for the build job (defaults to macos-26-xlarge)
|
||||
required: false
|
||||
default: ''
|
||||
outputs:
|
||||
package-version:
|
||||
description: Version from package.json
|
||||
@@ -66,8 +71,8 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26-xlarge
|
||||
name: Build iOS
|
||||
runs-on: ${{ inputs.runner || 'macos-26-xlarge' }}
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
@@ -75,38 +80,15 @@ jobs:
|
||||
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
build-number: ${{ steps.ipa-build-number.outputs.build-number }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: 🔧 Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🪛 Setup jq
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔨 Setup Expo CLI
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
@@ -133,38 +115,29 @@ jobs:
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
|
||||
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
|
||||
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
uses: ./.github/actions/write-env
|
||||
with:
|
||||
env-token: ${{ secrets.ENV_TOKEN }}
|
||||
sentry-dsn: ${{ secrets.SENTRY_DSN }}
|
||||
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p ios
|
||||
--profile $PROFILE
|
||||
--local --output build.tar.gz --non-interactive
|
||||
uses: ./.github/actions/eas-local-build
|
||||
with:
|
||||
platform: ios
|
||||
profile: ${{ inputs.profile || 'testflight' }}
|
||||
output: build.tar.gz
|
||||
bump-build-number: "true"
|
||||
sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
sentry-release: ${{ steps.env.outputs.release-version }}
|
||||
sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
|
||||
|
||||
- name: 📂 Extract build artifact
|
||||
run: |
|
||||
@@ -201,16 +174,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
|
||||
|
||||
- name: 🪲 Upload dSYM to Sentry
|
||||
run: >
|
||||
SENTRY_ORG=blueskyweb
|
||||
SENTRY_PROJECT=app
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
@@ -220,6 +183,7 @@ jobs:
|
||||
# number that actually lands in App Store Connect. `eas build:version:get` reads the
|
||||
# remote counter, which a --local build does not advance, so it can be off by one —
|
||||
# using it here would make distribute_only poll for a nonexistent build.
|
||||
# PlistBuddy is macOS-only, which is why this stays in the build job.
|
||||
- name: 🔢 Read build number from IPA
|
||||
id: ipa-build-number
|
||||
run: |
|
||||
@@ -235,18 +199,98 @@ jobs:
|
||||
echo "IPA build number: $build_number"
|
||||
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Hand the IPA and dSYM off to the submit job. Retention is deliberately short since
|
||||
# this artifact only exists to bridge the two jobs within a single run.
|
||||
- name: 🚀 Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ios-build-${{ github.run_id }}
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
${{ env.BUILD_DIR }}/Bluesky.ipa
|
||||
${{ env.BUILD_DIR }}/Bluesky.app.dSYM.zip
|
||||
|
||||
submit:
|
||||
name: Submit iOS
|
||||
# Submission and dSYM upload are I/O bound and don't need the xlarge builder.
|
||||
runs-on: macos-26
|
||||
needs: [build]
|
||||
steps:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# eas submit reads the app config from the repo
|
||||
fetch-depth: 5
|
||||
|
||||
- name: 🔧 Setup Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⬇️ Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ios-build-${{ github.run_id }}
|
||||
path: ios-build
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: pnpm eas submit -p ios --non-interactive --path ios-build/Bluesky.ipa
|
||||
|
||||
- name: 🪲 Upload dSYM to Sentry
|
||||
env:
|
||||
SENTRY_ORG: blueskyweb
|
||||
SENTRY_PROJECT: app
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
run: pnpm sentry-cli debug-files upload ios-build/Bluesky.app.dSYM.zip --include-sources
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.build-number }}```"}
|
||||
|
||||
# Record the commit only after a successful submit, so a failed submit doesn't advance
|
||||
# the baseline used for the next testflight build's changelog.
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
distribute:
|
||||
name: Assign build to TestFlight group
|
||||
# fastlane and jq ship preinstalled on the macOS runner image, and this step mostly idles
|
||||
# polling Apple processing, so it runs on a normal-size runner.
|
||||
runs-on: macos-26
|
||||
needs: [build, submit]
|
||||
# testFlightGroup defaults to 'none' on both workflow_call and dispatch; guard against the
|
||||
# empty string too, since `!= 'none'` alone would be true for ''.
|
||||
if: ${{ inputs.testFlightGroup && inputs.testFlightGroup != 'none' }}
|
||||
steps:
|
||||
# eas submit only uploads to App Store Connect; it can't assign a build to a
|
||||
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
|
||||
# already-submitted build to the group, polling until Apple finishes processing it.
|
||||
- name: 🧪 Assign build to TestFlight group
|
||||
if: ${{ inputs.testFlightGroup != 'none' }}
|
||||
env:
|
||||
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
||||
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
|
||||
BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }}
|
||||
APP_VERSION: ${{ needs.build.outputs.package-version }}
|
||||
BUILD_NUMBER: ${{ needs.build.outputs.build-number }}
|
||||
run: |
|
||||
# Ensure the API key material is removed even if fastlane exits non-zero
|
||||
# (the step runs under `bash -e`, which would otherwise abort before cleanup).
|
||||
@@ -271,27 +315,3 @@ jobs:
|
||||
build_number:"$BUILD_NUMBER" \
|
||||
groups:"$TESTFLIGHT_GROUP" \
|
||||
notify_external_testers:true
|
||||
|
||||
- name: 🔔 Notify Slack of Production Build
|
||||
if: ${{ inputs.profile == 'production' }}
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.ipa-build-number.outputs.build-number }}```"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -99,11 +99,7 @@ jobs:
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
|
||||
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
@@ -128,35 +124,26 @@ jobs:
|
||||
!steps.version.outputs.version-changed }}
|
||||
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
|
||||
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel || 'testflight' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
# eas.json not used here, so EXPO_PUBLIC_ENV must be written explicitly
|
||||
- name: ✏️ Write environment variables
|
||||
id: env
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
|
||||
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
|
||||
echo "$json" > google-services.json
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }}
|
||||
uses: ./.github/actions/write-env
|
||||
with:
|
||||
env-token: ${{ secrets.ENV_TOKEN }}
|
||||
sentry-dsn: ${{ secrets.SENTRY_DSN }}
|
||||
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
expo-public-env: ${{ inputs.channel || 'testflight' }}
|
||||
|
||||
- name: 🏗️ Create Bundle
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes &&
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
|
||||
pnpm export
|
||||
|
||||
- name: 📦 Package Bundle and 🚀 Deploy
|
||||
@@ -182,305 +169,64 @@ jobs:
|
||||
!steps.version.outputs.version-changed }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-26
|
||||
concurrency:
|
||||
group: ios-build
|
||||
cancel-in-progress: false
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' &&
|
||||
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
||||
'bluesky-social/social-app' }}
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: ☕️ Assert Cocoapods version
|
||||
run: |
|
||||
EXPECTED=1.17.0
|
||||
ACTUAL=$(pod --version)
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
|
||||
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
|
||||
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 💾 Cache Pods
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
id: pods-cache
|
||||
with:
|
||||
path: ./ios/Pods
|
||||
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
|
||||
# change unless the pnpm version changes as well.
|
||||
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: Env
|
||||
id: env
|
||||
run: |
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
|
||||
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p ios
|
||||
--profile testflight
|
||||
--local --output build.tar.gz --non-interactive
|
||||
|
||||
- name: 📂 Extract build artifact
|
||||
run: |
|
||||
if [ -f "build.tar.gz" ]; then
|
||||
echo "Extracting build.tar.gz..."
|
||||
rm -rf ios-build
|
||||
mkdir -p ios-build
|
||||
tar -xzf build.tar.gz -C ios-build
|
||||
echo "Extraction completed successfully"
|
||||
|
||||
echo ""
|
||||
echo "Top-level extracted files:"
|
||||
find ios-build -maxdepth 3 -print
|
||||
|
||||
echo ""
|
||||
echo "Searching for IPA..."
|
||||
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
|
||||
if [ -z "$IPA_PATH" ]; then
|
||||
echo "ERROR: No .ipa found anywhere under ios-build."
|
||||
echo "Archive contents:"
|
||||
tar -tzf build.tar.gz | sed -n '1,200p'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_DIR="$(dirname "$IPA_PATH")"
|
||||
echo "Found IPA at: $IPA_PATH"
|
||||
echo "Build dir: $BUILD_DIR"
|
||||
echo ""
|
||||
echo "Build dir contents:"
|
||||
ls -la "$BUILD_DIR"
|
||||
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
|
||||
else
|
||||
echo "Archive file not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
|
||||
|
||||
- name: 🪲 Upload dSYM to Sentry
|
||||
run: >
|
||||
SENTRY_ORG=blueskyweb
|
||||
SENTRY_PROJECT=app
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
uses: ./.github/workflows/build-submit-ios.yml
|
||||
with:
|
||||
profile: testflight
|
||||
testFlightGroup: none
|
||||
# OTA rebuilds don't need the xlarge builder used for releases
|
||||
runner: macos-26
|
||||
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
||||
# so this workflow never hands the reusable workflow the entire repo secret store.
|
||||
secrets:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
|
||||
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
|
||||
buildIfNecessaryAndroid:
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: android-build
|
||||
cancel-in-progress: false
|
||||
needs: [bundleDeploy]
|
||||
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
|
||||
# available here
|
||||
if: ${{ inputs.channel != 'production' &&
|
||||
needs.bundleDeploy.outputs.changes-detected && github.repository ==
|
||||
'bluesky-social/social-app'}}
|
||||
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
|
||||
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 5
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: 🔨 Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
|
||||
with:
|
||||
eas-version: '19.0.5'
|
||||
packager: 'pnpm --allow-build=dtrace-provider'
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: ⚙️ Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build
|
||||
|
||||
# EXPO_PUBLIC_ENV is handled in eas.json
|
||||
- name: Env
|
||||
id: env
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
|
||||
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
|
||||
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
|
||||
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
pnpm use-build-number-with-bump
|
||||
pnpm eas build -p android
|
||||
--profile testflight-android
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: 📚 Get version from package.json
|
||||
id: get-build-info
|
||||
run: bash scripts/setGitHubOutput.sh
|
||||
|
||||
- name: 🚀 Submit to Google Play
|
||||
run: pnpm eas submit -p android --profile testflight-android --non-interactive --path
|
||||
build.aab
|
||||
|
||||
- name: 🔧 Setup bundletool
|
||||
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
|
||||
with:
|
||||
version: "1.18.3"
|
||||
|
||||
- name: 🔑 Decode keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
|
||||
keystore.jks
|
||||
|
||||
- name: 📦 Build signed universal APK
|
||||
run: |
|
||||
bundletool build-apks \
|
||||
--bundle=build.aab \
|
||||
--output=universal.apks \
|
||||
--mode=universal \
|
||||
--ks=keystore.jks \
|
||||
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
|
||||
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: 📋 Rename to .zip for extraction
|
||||
run: mv universal.apks universal.zip
|
||||
|
||||
- name: 📦 Extract universal APK
|
||||
run: unzip -p universal.zip universal.apk > build.apk
|
||||
|
||||
- name: ⏰ Get a timestamp
|
||||
id: timestamp
|
||||
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🚀 Upload Artifact
|
||||
id: upload-artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
name: build-${{ steps.timestamp.outputs.time }}.apk
|
||||
path: build.apk
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-templated: true
|
||||
payload: |
|
||||
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"}
|
||||
|
||||
- name: ⬇️ Restore Cache
|
||||
id: get-base-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
with:
|
||||
path: most-recent-testflight-commit.txt
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
'bluesky-social/social-app' }}
|
||||
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
|
||||
# That job is skipped here (it needs a production tag build), but GitHub statically
|
||||
# validates the reusable-workflow permission ceiling, so the caller must grant it.
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/build-submit-android.yml
|
||||
with:
|
||||
profile: testflight-android
|
||||
runner: ubuntu-latest
|
||||
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
|
||||
# so this workflow never hands the reusable workflow the entire repo secret store.
|
||||
secrets:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
|
||||
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
|
||||
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
@@ -59,13 +59,13 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
|
||||
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
|
||||
aws-region: us-east-2
|
||||
|
||||
- name: Claude review
|
||||
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
|
||||
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
|
||||
with:
|
||||
use_bedrock: 'true'
|
||||
additional_permissions: |
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
exit $rc
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🔔 Notify Slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
---
|
||||
name: Nightly Maestro E2E
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly-maestro-e2e-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CI: "1"
|
||||
MAESTRO_VERSION: "2.6.1"
|
||||
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
|
||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||
MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true"
|
||||
MAESTRO_DISABLE_UPDATE_CHECK: "1"
|
||||
|
||||
jobs:
|
||||
ios:
|
||||
name: iOS Maestro E2E
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
runs-on: macos-26-xlarge
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Select Xcode 26.4
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
|
||||
with:
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: Prepare E2E configuration
|
||||
run: |
|
||||
mkdir -p artifacts/ios
|
||||
echo "Installing dependencies" > artifacts/ios/phase.txt
|
||||
cp .env.example .env.test
|
||||
cp google-services.json.example google-services.json
|
||||
|
||||
- name: Set up Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install dev-env dependencies
|
||||
run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/ios/dependencies.log
|
||||
|
||||
- name: Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: Install Maestro 2.6.1
|
||||
run: |
|
||||
echo "Installing Maestro" > artifacts/ios/phase.txt
|
||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
|
||||
| shasum -a 256 --check
|
||||
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
||||
"$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/ios/maestro-version.log
|
||||
test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION"
|
||||
|
||||
- name: Boot one iOS simulator
|
||||
run: |
|
||||
echo "Booting iOS simulator" > artifacts/ios/phase.txt
|
||||
device_name="iPhone 17"
|
||||
runtime_name="iOS 26.5"
|
||||
|
||||
runtime_id=$(xcrun simctl list runtimes available --json | jq -r \
|
||||
--arg name "$runtime_name" \
|
||||
'[.runtimes[] | select(.name == $name and .isAvailable != false)] | first | .identifier // empty')
|
||||
if [ -z "$runtime_id" ]; then
|
||||
echo "The $runtime_name simulator runtime is not installed. Available iOS runtimes:" >&2
|
||||
xcrun simctl list runtimes available --json | jq -r \
|
||||
'.runtimes[] | select(.name | startswith("iOS")) | "- \(.name)"' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
device_type_id=$(xcrun simctl list devicetypes --json | jq -r \
|
||||
--arg name "$device_name" \
|
||||
'[.devicetypes[] | select(.name == $name)] | first | .identifier // empty')
|
||||
if [ -z "$device_type_id" ]; then
|
||||
echo "The $device_name simulator device type is not installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
udid=$(xcrun simctl list devices available --json | jq -r \
|
||||
--arg runtime "$runtime_id" \
|
||||
--arg name "$device_name" \
|
||||
'[.devices[$runtime][]? | select(.name == $name)] | first | .udid // empty')
|
||||
if [ -z "$udid" ]; then
|
||||
udid=$(xcrun simctl create "$device_name" "$device_type_id" "$runtime_id")
|
||||
fi
|
||||
|
||||
echo "IOS_UDID=$udid" >> "$GITHUB_ENV"
|
||||
xcrun simctl shutdown all || true
|
||||
xcrun simctl boot "$udid"
|
||||
xcrun simctl bootstatus "$udid" -b
|
||||
echo "Using $device_name on $runtime_name ($udid)"
|
||||
|
||||
- name: Mark iOS development client build phase
|
||||
run: echo "Building the iOS development client" > artifacts/ios/phase.txt
|
||||
|
||||
- name: Build iOS development client
|
||||
uses: ./.github/actions/eas-local-build
|
||||
with:
|
||||
platform: ios
|
||||
profile: e2e
|
||||
output: ${{ runner.temp }}/nightly-e2e-ios.tar.gz
|
||||
log-path: artifacts/ios/build.log
|
||||
|
||||
- name: Install iOS development client
|
||||
run: |
|
||||
build_contents="$RUNNER_TEMP/nightly-e2e-ios-build"
|
||||
mkdir -p "$build_contents"
|
||||
tar -xzf "$RUNNER_TEMP/nightly-e2e-ios.tar.gz" -C "$build_contents"
|
||||
app_path=$(find "$build_contents" -type d -name '*.app' -print -quit)
|
||||
if [ -z "$app_path" ]; then
|
||||
echo "The local EAS build did not contain an iOS simulator app" >&2
|
||||
exit 1
|
||||
fi
|
||||
xcrun simctl install "$IOS_UDID" "$app_path" 2>&1 | tee -a artifacts/ios/build.log
|
||||
|
||||
- name: Run iOS Maestro suite
|
||||
run: .github/scripts/run-nightly-e2e.sh ios "$IOS_UDID"
|
||||
|
||||
- name: Clean up iOS services and simulator
|
||||
if: always()
|
||||
run: .github/scripts/cleanup-nightly-e2e.sh ios "${IOS_UDID:-}"
|
||||
|
||||
- name: Upload iOS E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: nightly-e2e-ios-${{ github.run_id }}
|
||||
path: artifacts/ios
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
android:
|
||||
name: Android Maestro E2E
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
# Linux-x64-32core is a repository-managed runner label.
|
||||
runs-on: Linux-x64-32core
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Prepare E2E configuration
|
||||
run: |
|
||||
mkdir -p artifacts/android
|
||||
echo "Installing dependencies" > artifacts/android/phase.txt
|
||||
cp .env.example .env.test
|
||||
cp google-services.json.example google-services.json
|
||||
|
||||
- name: Set up Expo project
|
||||
uses: ./.github/actions/setup-expo-project
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install dev-env dependencies
|
||||
run: pnpm --dir dev-env install --frozen-lockfile 2>&1 | tee artifacts/android/dependencies.log
|
||||
|
||||
- name: Compile translations
|
||||
uses: ./.github/actions/compile-i18n
|
||||
|
||||
- name: Install Maestro 2.6.1
|
||||
run: |
|
||||
echo "Installing Maestro" > artifacts/android/phase.txt
|
||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
|
||||
| shasum -a 256 --check
|
||||
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
||||
"$RUNNER_TEMP/maestro/bin/maestro" --version | tee artifacts/android/maestro-version.log
|
||||
test "$("$RUNNER_TEMP/maestro/bin/maestro" --version)" = "$MAESTRO_VERSION"
|
||||
|
||||
- name: Install and boot one Android emulator
|
||||
run: |
|
||||
echo "Booting Android emulator" > artifacts/android/phase.txt
|
||||
android_sdk="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-/usr/local/lib/android/sdk}}"
|
||||
sdkmanager_bin="$android_sdk/cmdline-tools/latest/bin/sdkmanager"
|
||||
avdmanager_bin="$android_sdk/cmdline-tools/latest/bin/avdmanager"
|
||||
# API 35 emulator images have known stability problems in headless CI
|
||||
# (see flutter/flutter#153445); the qemu process died deterministically
|
||||
# on the first native stack-screen push with the API 35 image.
|
||||
system_image="system-images;android-34;google_apis;x86_64"
|
||||
|
||||
if [ ! -x "$sdkmanager_bin" ] || [ ! -x "$avdmanager_bin" ]; then
|
||||
echo "Android command-line tools were not found under $android_sdk" >&2
|
||||
find "$android_sdk/cmdline-tools" -maxdepth 3 -type f \( \
|
||||
-name sdkmanager -o -name avdmanager \
|
||||
\) -print >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export ANDROID_HOME="$android_sdk"
|
||||
export ANDROID_SDK_ROOT="$android_sdk"
|
||||
export PATH="$android_sdk/platform-tools:$android_sdk/emulator:$PATH"
|
||||
echo "ANDROID_HOME=$android_sdk" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_SDK_ROOT=$android_sdk" >> "$GITHUB_ENV"
|
||||
echo "$android_sdk/platform-tools" >> "$GITHUB_PATH"
|
||||
echo "$android_sdk/emulator" >> "$GITHUB_PATH"
|
||||
echo "Using Android SDK at $android_sdk"
|
||||
|
||||
yes | "$sdkmanager_bin" --sdk_root="$android_sdk" --licenses >/dev/null || true
|
||||
"$sdkmanager_bin" --sdk_root="$android_sdk" \
|
||||
"platform-tools" "emulator" "$system_image"
|
||||
|
||||
export ANDROID_AVD_HOME="$RUNNER_TEMP/.android/avd"
|
||||
mkdir -p "$ANDROID_AVD_HOME"
|
||||
echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV"
|
||||
echo no | "$avdmanager_bin" create avd \
|
||||
--force \
|
||||
--name nightly-e2e \
|
||||
--package "$system_image" \
|
||||
--device pixel_6
|
||||
|
||||
# Reduce resolution to lighten the SwiftShader software-rendering
|
||||
# workload, and raise RAM/cores/heap so the debug RN app has headroom.
|
||||
# The default 2560MB RAM led to silent qemu crashes mid-flow.
|
||||
printf 'hw.lcd.width=720\nhw.lcd.height=1600\nhw.lcd.density=280\nhw.ramSize=6144\nhw.cpu.ncore=4\nvm.heapSize=512\n' \
|
||||
>> "$ANDROID_AVD_HOME/nightly-e2e.avd/config.ini"
|
||||
|
||||
if [ -e /dev/kvm ] && [ ! -w /dev/kvm ]; then
|
||||
sudo chmod 666 /dev/kvm
|
||||
fi
|
||||
|
||||
# Disable the emulator's Vulkan feature so graphics goes through the
|
||||
# plain GLES SwiftShader translator. gfxstream Vulkan via SwiftShader
|
||||
# Subzero crashed qemu silently at a deterministic rendering step;
|
||||
# GLES-only is sufficient since the guest renders with skiagl.
|
||||
#
|
||||
# Run the launch in a background subshell so the emulator's exit
|
||||
# status is recorded when it dies (it is otherwise backgrounded and
|
||||
# its death is invisible). Write the emulator's real PID - not the
|
||||
# subshell's - to emulator.pid, since cleanup-nightly-e2e.sh kills the
|
||||
# PID from that file directly; killing the subshell would not kill the
|
||||
# emulator child.
|
||||
(
|
||||
# wait returns the emulator's non-zero status on crash; set -e would
|
||||
# abort the subshell before the status is logged.
|
||||
set +e
|
||||
"$android_sdk/emulator/emulator" @nightly-e2e \
|
||||
-port 5554 \
|
||||
-no-window \
|
||||
-gpu swiftshader_indirect \
|
||||
-feature -Vulkan \
|
||||
-no-snapshot \
|
||||
-noaudio \
|
||||
-no-boot-anim \
|
||||
-camera-back none \
|
||||
> artifacts/android/emulator.log 2>&1 &
|
||||
emulator_pid=$!
|
||||
echo "$emulator_pid" > artifacts/android/emulator.pid
|
||||
wait "$emulator_pid"
|
||||
echo "Emulator exited with status $?" >> artifacts/android/emulator.log
|
||||
) &
|
||||
|
||||
adb -s emulator-5554 wait-for-device
|
||||
booted=false
|
||||
for _ in $(seq 1 120); do
|
||||
if [ "$(adb -s emulator-5554 shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
|
||||
booted=true
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$booted" != "true" ]; then
|
||||
echo "Android emulator did not finish booting" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
adb -s emulator-5554 shell settings put global window_animation_scale 0
|
||||
adb -s emulator-5554 shell settings put global transition_animation_scale 0
|
||||
adb -s emulator-5554 shell settings put global animator_duration_scale 0
|
||||
|
||||
- name: Mark Android development client build phase
|
||||
run: echo "Building the Android development client" > artifacts/android/phase.txt
|
||||
|
||||
- name: Build Android development client
|
||||
uses: ./.github/actions/eas-local-build
|
||||
with:
|
||||
platform: android
|
||||
profile: e2e
|
||||
output: ${{ runner.temp }}/nightly-e2e-android.apk
|
||||
log-path: artifacts/android/build.log
|
||||
|
||||
- name: Install Android development client
|
||||
run: |
|
||||
adb -s emulator-5554 install -r "$RUNNER_TEMP/nightly-e2e-android.apk" \
|
||||
2>&1 | tee -a artifacts/android/build.log
|
||||
|
||||
- name: Run Android Maestro suite
|
||||
run: .github/scripts/run-nightly-e2e.sh android emulator-5554
|
||||
|
||||
- name: Capture emulator crash diagnostics
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "=== Emulator process status ==="
|
||||
pgrep -fa "emulator.*nightly-e2e" || echo "Emulator process not found"
|
||||
echo "=== Emulator exit status ==="
|
||||
grep "Emulator exited" artifacts/android/emulator.log || echo "No emulator exit status recorded"
|
||||
echo "=== OOM killer check (kernel) ==="
|
||||
oom_lines=$(sudo dmesg 2>/dev/null | grep -iE "oom|killed process|out of memory" | tail -20)
|
||||
echo "${oom_lines:-No kernel OOM evidence found (or dmesg unavailable)}"
|
||||
echo "=== systemd-oomd check ==="
|
||||
oomd_lines=$(journalctl -u systemd-oomd --no-pager 2>/dev/null | tail -20)
|
||||
echo "${oomd_lines:-No systemd-oomd journal entries (or journalctl unavailable)}"
|
||||
echo "=== journal kernel tail ==="
|
||||
journalctl -k --no-pager 2>/dev/null | tail -30 || echo "journalctl -k unavailable"
|
||||
echo "=== Emulator crash database ==="
|
||||
ls -la /tmp/android-runner/emu-crash-*.db 2>/dev/null || echo "No crash database found"
|
||||
} > artifacts/android/emulator-diagnostics.log 2>&1
|
||||
|
||||
- name: Clean up Android services and emulator
|
||||
if: always()
|
||||
run: .github/scripts/cleanup-nightly-e2e.sh android emulator-5554
|
||||
|
||||
- name: Upload Android E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: nightly-e2e-android-${{ github.run_id }}
|
||||
path: artifacts/android
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
report:
|
||||
name: Report E2E failures
|
||||
needs: [ios, android]
|
||||
if: ${{ always() && github.repository == 'bluesky-social/social-app' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download iOS artifacts
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: nightly-e2e-ios-${{ github.run_id }}
|
||||
path: downloaded-artifacts/ios
|
||||
|
||||
- name: Download Android artifacts
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: nightly-e2e-android-${{ github.run_id }}
|
||||
path: downloaded-artifacts/android
|
||||
|
||||
- name: Resolve artifact links
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}#artifacts"
|
||||
jq -n --arg run "$run_url" '{ios: $run, android: $run}' > artifact-links.json
|
||||
if gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100" \
|
||||
> artifact-response.json; then
|
||||
jq --arg base "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts" \
|
||||
--arg run "$run_url" \
|
||||
'{
|
||||
ios: (.artifacts | map(select(.name | startswith("nightly-e2e-ios-"))) | first | if . then ($base + "/" + (.id | tostring)) else $run end),
|
||||
android: (.artifacts | map(select(.name | startswith("nightly-e2e-android-"))) | first | if . then ($base + "/" + (.id | tostring)) else $run end)
|
||||
}' artifact-response.json > artifact-links.json
|
||||
fi
|
||||
|
||||
- name: Summarize platform results
|
||||
id: summary
|
||||
env:
|
||||
IOS_STATUS: ${{ needs.ios.result }}
|
||||
ANDROID_STATUS: ${{ needs.android.result }}
|
||||
run: |
|
||||
node .github/scripts/summarize-maestro.mjs \
|
||||
--ios-status "$IOS_STATUS" \
|
||||
--android-status "$ANDROID_STATUS" \
|
||||
--ios-root downloaded-artifacts/ios \
|
||||
--android-root downloaded-artifacts/android \
|
||||
--artifact-urls artifact-links.json \
|
||||
--sha "$GITHUB_SHA" \
|
||||
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
|
||||
> e2e-summary.json
|
||||
echo "notify=$(jq -r .notify e2e-summary.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "payload=$(jq -c .payload e2e-summary.json)" >> "$GITHUB_OUTPUT"
|
||||
jq -r .githubSummary e2e-summary.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Notify Slack of E2E failures
|
||||
if: steps.summary.outputs.notify == 'true'
|
||||
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
|
||||
with:
|
||||
webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: ${{ steps.summary.outputs.payload }}
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: 🔧 Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ appId: xyz.blueskyweb.app
|
||||
id: "e2eRefreshHome"
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
# Wait for the composer to fully open before typing. Tapping replyBtn right
|
||||
# after the previous publish can race the closing composer on Android.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 10000
|
||||
- inputText: "Reply text only"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
@@ -51,6 +57,11 @@ appId: xyz.blueskyweb.app
|
||||
id: "composeFAB"
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
# Wait for the composer to fully open before typing.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 10000
|
||||
- inputText: "Reply with an image"
|
||||
- tapOn:
|
||||
id: "openMediaBtn"
|
||||
@@ -63,6 +74,11 @@ appId: xyz.blueskyweb.app
|
||||
id: "composeFAB"
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
# Wait for the composer to fully open before typing.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 10000
|
||||
- inputText: "Reply with a https://example.com link card"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
|
||||
@@ -29,51 +29,88 @@ appId: xyz.blueskyweb.app
|
||||
id: "homeScreenFeedTabs-selector-1"
|
||||
text: "alice-favs"
|
||||
|
||||
# Set alice-favs first
|
||||
- tapOn: "Open drawer menu"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
- tapOn: "Go back"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-0"
|
||||
text: "alice-favs"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-1"
|
||||
text: "Following"
|
||||
# Reordering feeds is driven by a drag on the feed-drag-handle. Maestro cannot
|
||||
# activate the RNGH Pan gesture from a synthetic swipe on Android (proven
|
||||
# twice - coordinate swipes never register the pan), so the reorder
|
||||
# verification below runs on iOS only. If Android drag coverage is needed,
|
||||
# revisit with the SavedFeedsA11y move buttons rather than a swipe.
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
# Set alice-favs first
|
||||
- tapOn: "Open drawer menu"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- assertVisible:
|
||||
id: "saveChangesBtn"
|
||||
enabled: true
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
- tapOn: "Go back"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-0"
|
||||
text: "alice-favs"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-1"
|
||||
text: "Following"
|
||||
|
||||
# Set following first
|
||||
- tapOn: "Open drawer menu"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
- tapOn: "Go back"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-0"
|
||||
text: "Following"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-1"
|
||||
text: "alice-favs"
|
||||
# Set following first
|
||||
- tapOn: "Open drawer menu"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- swipe:
|
||||
label: "Drag feed down"
|
||||
from:
|
||||
id: "feed-drag-handle"
|
||||
direction: "DOWN"
|
||||
duration: 1000
|
||||
- assertVisible:
|
||||
id: "saveChangesBtn"
|
||||
enabled: true
|
||||
- tapOn:
|
||||
label: "Save button"
|
||||
id: "saveChangesBtn"
|
||||
- tapOn: "Go back"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-0"
|
||||
text: "Following"
|
||||
- assertVisible:
|
||||
id: "homeScreenFeedTabs-selector-1"
|
||||
text: "alice-favs"
|
||||
|
||||
# On Android, the reorder path above is skipped. Smoke-test that the feeds
|
||||
# edit screen opens and the pinned feeds render, then return to a valid state.
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- tapOn: "Open drawer menu"
|
||||
- tapOn:
|
||||
id: "menuItemButton-Feeds"
|
||||
- tapOn:
|
||||
id: "editFeedsBtn"
|
||||
- assertVisible: "Following"
|
||||
- assertVisible: "alice-favs"
|
||||
# Two back presses to reach Home: the first pops the saved-feeds editor
|
||||
# back to the Feeds screen, the second pops Feeds back to Home. On iOS the
|
||||
# equivalent path saves changes first (saveChangesBtn calls
|
||||
# navigation.goBack), so a single "Go back" there already lands on Home.
|
||||
# This Android smoke branch never saves, so it needs the extra pop to
|
||||
# leave the screen state on Home, which the shared steps below expect.
|
||||
- tapOn: "Go back"
|
||||
- tapOn: "Go back"
|
||||
|
||||
# Remove following
|
||||
- tapOn: "Open drawer menu"
|
||||
|
||||
@@ -15,6 +15,24 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "e2eStartOnboarding"
|
||||
- tapOn: "Open avatar creator"
|
||||
# The avatar-creator bottom sheet (Dialog.Inner, non-scrollable) opens only
|
||||
# half-expanded on the short E2E emulator (720x1600), a ~220px sliver with the
|
||||
# emoji grid below the fold. It is NOT a scroll view, so scrollUntilVisible's
|
||||
# swipe grabs the sheet's own drag gesture and flings it closed. Instead, drag
|
||||
# the sheet upward to expand it to full height, which brings the picker into
|
||||
# view. iOS opens the sheet fully already, so this is Android-only.
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- swipe:
|
||||
label: "Drag the bottom sheet up to expand it"
|
||||
start: "50%, 90%"
|
||||
end: "50%, 20%"
|
||||
duration: 600
|
||||
- extendedWaitUntil:
|
||||
visible: "Select an emoji"
|
||||
timeout: 10000
|
||||
- tapOn: "Select the zap emoji as your avatar"
|
||||
- tapOn:
|
||||
label: "Tap on yellow"
|
||||
@@ -22,6 +40,20 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Done"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Select an avatar"
|
||||
# Reopening the creator sheet lands on the same half-expanded sliver on
|
||||
# Android, so expand it again before reaching for the emoji grid. No-op on iOS.
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- swipe:
|
||||
label: "Drag the bottom sheet up to expand it"
|
||||
start: "50%, 90%"
|
||||
end: "50%, 20%"
|
||||
duration: 600
|
||||
- extendedWaitUntil:
|
||||
visible: "Select an emoji"
|
||||
timeout: 10000
|
||||
- tapOn: "Select the atom emoji as your avatar"
|
||||
- tapOn: "Done"
|
||||
- waitForAnimationToEnd
|
||||
|
||||
@@ -16,13 +16,28 @@ appId: xyz.blueskyweb.app
|
||||
id: "e2eStartOnboarding"
|
||||
- tapOn: "Select an avatar"
|
||||
- waitForAnimationToEnd
|
||||
- assertVisible: "Photos"
|
||||
- assertVisible: "Collections"
|
||||
- tapOn:
|
||||
point: "50%,22%"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Done"
|
||||
- waitForAnimationToEnd
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- assertVisible: "Photos"
|
||||
- assertVisible: "Collections"
|
||||
- tapOn:
|
||||
point: "50%,22%"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn: "Done"
|
||||
- waitForAnimationToEnd
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
# The system photo picker opened here shows MediaStore photos, which
|
||||
# the e2e run doesn't seed (media is seeded into app-scoped storage for
|
||||
# the composer's mocked picker instead). With no photo to pick, dismiss
|
||||
# the picker and continue - onContinue falls back to the generated
|
||||
# placeholder avatar, and nothing later in the flow depends on the image.
|
||||
- back
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
id: "onboardingContinue"
|
||||
- assertVisible: "What are your interests?"
|
||||
|
||||
@@ -45,7 +45,7 @@ appId: xyz.blueskyweb.app
|
||||
id: "editProfileSaveBtn"
|
||||
- assertNotVisible:
|
||||
id: "editProfileModal"
|
||||
- assertVisible: "Alicia"
|
||||
- assertVisible: ".*Alicia.*"
|
||||
- assertVisible: "One cool hacker"
|
||||
|
||||
# Remove display name and description via the edit profile modal
|
||||
@@ -64,7 +64,10 @@ appId: xyz.blueskyweb.app
|
||||
id: "editProfileSaveBtn"
|
||||
- assertNotVisible:
|
||||
id: "editProfileModal"
|
||||
- assertVisible: "alice.test"
|
||||
# The display-name node renders the handle as a Text with a nested badge View
|
||||
# once the display name is cleared, so the a11y text is not the bare handle
|
||||
# string on Android. Match it as a substring instead.
|
||||
- assertVisible: ".*alice\\.test.*"
|
||||
- assertNotVisible: "One cool hacker"
|
||||
|
||||
# Set avi and banner via the edit profile modal
|
||||
|
||||
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
|
||||
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
|
||||
@@ -39,5 +39,7 @@ appId: xyz.blueskyweb.app
|
||||
text: Your report will be sent to Dev-env Moderation.*
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
|
||||
@@ -29,5 +29,7 @@ appId: xyz.blueskyweb.app
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
id: "report:dialog"
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "report:dialog"
|
||||
timeout: 20000
|
||||
|
||||
@@ -20,6 +20,12 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "Test thread"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
# Wait for the composer to close and the home feed to settle before signing
|
||||
# out. Without a settle guard the next action can race the closing composer.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composeFAB"
|
||||
timeout: 10000
|
||||
|
||||
# Login, reply to the thread, and log out
|
||||
- tapOn:
|
||||
@@ -31,9 +37,19 @@ appId: xyz.blueskyweb.app
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
# Wait for the composer to fully open before typing.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 10000
|
||||
- inputText: "Reply 1"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
# Wait for the composer to close before signing out.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composeFAB"
|
||||
timeout: 10000
|
||||
|
||||
# Login, confirm notification exists, mute thread, and log out
|
||||
- tapOn:
|
||||
@@ -45,10 +61,8 @@ appId: xyz.blueskyweb.app
|
||||
id: "viewHeaderHomeFeedPrefsBtn"
|
||||
- tapOn:
|
||||
id: "bottomBarNotificationsBtn"
|
||||
- assertVisible:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "feedItem-by-bob.test"
|
||||
- assertVisible: ".*Reply 1.*"
|
||||
- tapOn: ".*Reply 1.*"
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
@@ -67,16 +81,78 @@ appId: xyz.blueskyweb.app
|
||||
id: "bottomBarProfileBtn"
|
||||
- tapOn:
|
||||
id: "profilePager-selector-1"
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
# Both replies target the thread root ("Test thread" by alice), which sits at
|
||||
# the top of bob's Replies tab. That tab renders each post in the thread with
|
||||
# its own replyBtn, so scope the tap to the root post's card
|
||||
# (feedItem-by-alice.test) rather than relying on which replyBtn Maestro picks
|
||||
# first. This keeps both reply taps deterministic regardless of list order or
|
||||
# how many posts have rendered.
|
||||
#
|
||||
# Even with the close-gating below, the replyBtn tap can land on a recycled list
|
||||
# row while the author feed re-renders after a publish, and be swallowed so the
|
||||
# composer never opens. Wrapping the tap + open-wait in retry makes opening the
|
||||
# composer idempotent: a swallowed tap just re-taps until the publish button
|
||||
# appears. A first-try success does not retry.
|
||||
- retry:
|
||||
maxRetries: 3
|
||||
commands:
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
childOf:
|
||||
id: "feedItem-by-alice.test"
|
||||
# Wait for the composer to fully open before typing.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 10000
|
||||
- inputText: "Reply 2"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
# Wait for the composer to actually close before opening it again. replyBtn
|
||||
# stays in the accessibility tree behind the open composer sheet, so waiting on
|
||||
# its visibility returns immediately and does not gate on the close animation or
|
||||
# the author-feed re-render that follows a post - the next replyBtn tap then
|
||||
# fires mid-transition and is swallowed, so the composer never opens. Gate on
|
||||
# the publish button disappearing (the composer is gone), then confirm the
|
||||
# reply button underneath is back and let animations settle.
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "replyBtn"
|
||||
timeout: 10000
|
||||
- waitForAnimationToEnd
|
||||
# As with Reply 2: even after gating on the composer close, this tap can hit a
|
||||
# recycled row during the post-publish feed re-render and be swallowed, so wrap
|
||||
# the open in retry to make it idempotent.
|
||||
- retry:
|
||||
maxRetries: 3
|
||||
commands:
|
||||
- tapOn:
|
||||
id: "replyBtn"
|
||||
childOf:
|
||||
id: "feedItem-by-alice.test"
|
||||
# Wait for the composer to fully open before typing.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 10000
|
||||
- inputText: "Reply 3"
|
||||
- tapOn:
|
||||
id: "composerPublishBtn"
|
||||
# Wait for the composer to actually close before signing out. As above,
|
||||
# replyBtn stays visible behind the sheet, so gate on the publish button
|
||||
# disappearing first, then confirm the reply button underneath has returned.
|
||||
- extendedWaitUntil:
|
||||
notVisible:
|
||||
id: "composerPublishBtn"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "replyBtn"
|
||||
timeout: 10000
|
||||
|
||||
# Login, confirm notifications dont exist, unmute the thread, ~~confirm notifications exist~~
|
||||
# Mute thread behaviour no longer change old notifications after muting/unmuting a thread -sfn
|
||||
@@ -92,10 +168,7 @@ appId: xyz.blueskyweb.app
|
||||
- assertVisible: ".*Reply 1.*"
|
||||
- assertNotVisible: ".*Reply 2.*"
|
||||
- assertNotVisible: ".*Reply 3.*"
|
||||
- assertVisible:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn:
|
||||
id: "feedItem-by-bob.test"
|
||||
- tapOn: ".*Reply 1.*"
|
||||
- tapOn:
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
|
||||
+17
-13
@@ -9,23 +9,27 @@ appId: xyz.blueskyweb.app
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: 'Open in "Bluesky"'
|
||||
commands:
|
||||
- tapOn: Open
|
||||
- extendedWaitUntil:
|
||||
visible: "http://localhost:8081"
|
||||
timeout: 60000
|
||||
- tapOn: "http://localhost:8081"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- tapOn: 'http://localhost:8081'
|
||||
- runFlow:
|
||||
label: "Dismiss Expo dev menu"
|
||||
when:
|
||||
visible: "Continue"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "http://10.0.2.2:8081"
|
||||
timeout: 60000
|
||||
- tapOn: "http://10.0.2.2:8081"
|
||||
- extendedWaitUntil:
|
||||
visible: "Continue"
|
||||
timeout: 180000
|
||||
- tapOn: "Continue"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: e2eProxyHeaderInput
|
||||
timeout: 180000
|
||||
- tapOn:
|
||||
id: e2eProxyHeaderInput
|
||||
- inputText: ${output.result}
|
||||
|
||||
@@ -328,6 +328,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/interests", server.WebGenericNoindex)
|
||||
e.GET("/settings/about", server.WebGenericNoindex)
|
||||
e.GET("/settings/notifications", server.WebGenericNoindex)
|
||||
e.GET("/settings/notifications/activity", server.WebGenericNoindex)
|
||||
e.GET("/sys/debug", server.WebGenericNoindex)
|
||||
e.GET("/sys/debug-mod", server.WebGenericNoindex)
|
||||
e.GET("/sys/log", server.WebGenericNoindex)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
|
||||
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts",
|
||||
"start:external": "NODE_ENV=development PGPORT=5433 PGHOST=localhost PGUSER=pg PGPASSWORD=password PGDATABASE=postgres DB_POSTGRES_URL=postgresql://pg:password@127.0.0.1:5433/postgres REDIS_HOST=127.0.0.1:6380 node ./mock-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.20.22",
|
||||
|
||||
@@ -29,6 +29,49 @@ adb reverse tcp:3000 tcp:3000
|
||||
- In a second tab, run `pnpm e2e:build`
|
||||
- In a third tab, run `pnpm e2e:run __e2e__`
|
||||
|
||||
## Nightly Maestro CI
|
||||
|
||||
The `Nightly Maestro E2E` GitHub Actions workflow runs every day at 04:00 UTC
|
||||
and can also be started from the Actions tab with **Run workflow**. It runs iOS
|
||||
and Android concurrently, but each platform runs all of `__e2e__/config.yml`
|
||||
sequentially on one explicitly selected simulator or emulator. The flows share a
|
||||
stateful mock-server manager, so the suite must not be sharded.
|
||||
|
||||
The jobs run Maestro CLI 2.6.1 locally on GitHub Actions; Maestro Cloud is not
|
||||
used. iOS runs on `macos-26-xlarge` with Xcode 26.4. Android runs on
|
||||
`Linux-x64-32core`. Both use Java 17 and the Node and pnpm versions declared in
|
||||
`package.json`. The iOS job selects an iPhone 17 simulator running iOS 26.5;
|
||||
Android directly provisions and boots a Pixel 6 AVD with the API 35 Google APIs
|
||||
x86_64 image using the Android SDK command-line tools.
|
||||
Both development clients use the `e2e` EAS profile and the same reusable local
|
||||
EAS build action as the release build workflows; the resulting simulator app
|
||||
and APK are installed directly on the selected devices.
|
||||
|
||||
The mock-server manager listens on host port 1986 and creates test services on
|
||||
port 3000. Metro listens on 8081. Android reverses ports 3000 and 8081 into the
|
||||
emulator; port 1986 remains host-side because Maestro JavaScript calls it from
|
||||
the runner. Android uses the existing Docker Compose PostgreSQL 14 and Redis 7
|
||||
services on ports 5433 and 6380. GitHub-hosted macOS cannot run nested Docker
|
||||
virtualization, so iOS provisions ephemeral native PostgreSQL 14.x and Redis
|
||||
7.4.7 on those same ports and starts `pnpm --dir dev-env start:external`.
|
||||
|
||||
Each platform uploads a `nightly-e2e-<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/)
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
"EXPO_PUBLIC_ENV": "production"
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"extends": "development",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_ENV": "e2e",
|
||||
"NODE_ENV": "test",
|
||||
"RN_SRC_EXT": "e2e.ts,e2e.tsx"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
|
||||
@@ -146,6 +146,7 @@ function BottomSheetNativeComponentInner({
|
||||
const insets = useSafeAreaInsets()
|
||||
const cornerRadius = rest.cornerRadius ?? 0
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
|
||||
|
||||
// sigh... on older Android versions, screenHeight does not include safe area insets
|
||||
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
|
||||
@@ -182,7 +183,7 @@ function BottomSheetNativeComponentInner({
|
||||
]}>
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={maxHeight == null ? undefined : {flex: 1}}>
|
||||
style={isHeightConstrained ? {flex: 1} : undefined}>
|
||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -244,14 +244,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/ImageEmbed.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 2
|
||||
@@ -290,19 +282,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/PostControls/ShareMenu/ShareMenuItems.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/components/PostControls/ShareMenu/index.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1055,11 +1034,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/screens/ProfileList/FeedSection.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1406,11 +1380,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/state/queries/feed.ts": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/queries/handle.ts": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -1617,14 +1586,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/text-input/TextInput.tsx": {
|
||||
"typescript/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/text-input/TextInput.web.tsx": {
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 1
|
||||
@@ -1764,11 +1725,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/posts/PostFeed.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/posts/PostFeedErrorMessage.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
+9
-8
@@ -63,9 +63,9 @@
|
||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||
"typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web",
|
||||
"typecheck:ios": "tsgo --project ./tsconfig.check.ios.json",
|
||||
"typecheck:android": "tsgo --project ./tsconfig.check.android.json",
|
||||
"typecheck:web": "tsgo --project ./tsconfig.check.web.json",
|
||||
"typecheck:ios": "tsc --project ./tsconfig.check.ios.json",
|
||||
"typecheck:android": "tsc --project ./tsconfig.check.android.json",
|
||||
"typecheck:web": "tsc --project ./tsconfig.check.web.json",
|
||||
"e2e:mock-server": "cd dev-env && pnpm start",
|
||||
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||
@@ -96,12 +96,12 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.28",
|
||||
"@atproto/api": "0.20.31",
|
||||
"@atproto/common-web": "0.5.6",
|
||||
"@atproto/syntax": "0.7.2",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.14",
|
||||
"@bsky.app/alf": "^0.1.15",
|
||||
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
|
||||
"@bsky.app/expo-guess-language": "^0.2.8",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
@@ -161,6 +161,7 @@
|
||||
"expo": "54.0.34",
|
||||
"expo-age-range": "0.2.18",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-asset": "~12.0.13",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-camera": "~17.0.10",
|
||||
@@ -273,7 +274,7 @@
|
||||
"@types/psl": "1.1.1",
|
||||
"@types/react": "^19.1.17",
|
||||
"@types/react-dom": "^19.1.11",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
|
||||
"@typescript/native": "npm:typescript@^7.0.2",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
@@ -292,13 +293,13 @@
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"oxlint": "^1.73.0",
|
||||
"oxlint-tsgolint": "^0.24.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"prettier": "^3.8.3",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
"svgo": "^4.0.2",
|
||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||
"webpack-bundle-analyzer": "^4.10.1"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
Generated
+244
-103
@@ -242,8 +242,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.28
|
||||
version: 0.20.28
|
||||
specifier: 0.20.31
|
||||
version: 0.20.31
|
||||
'@atproto/common-web':
|
||||
specifier: 0.5.6
|
||||
version: 0.5.6
|
||||
@@ -257,8 +257,8 @@ importers:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.4
|
||||
'@bsky.app/alf':
|
||||
specifier: ^0.1.14
|
||||
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
specifier: ^0.1.15
|
||||
version: 0.1.15(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@bsky.app/expo-dynamic-app-icon':
|
||||
specifier: ^1.8.5
|
||||
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -330,10 +330,10 @@ importers:
|
||||
version: 9.2.7
|
||||
'@lingui/core':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
|
||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
|
||||
'@lingui/react':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
|
||||
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))(react@19.1.0)
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
|
||||
@@ -436,6 +436,9 @@ importers:
|
||||
expo-application:
|
||||
specifier: ~7.0.8
|
||||
version: 7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
|
||||
expo-asset:
|
||||
specifier: ~12.0.13
|
||||
version: 12.0.13(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-blur:
|
||||
specifier: ~15.0.8
|
||||
version: 15.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -727,10 +730,10 @@ importers:
|
||||
version: 4.14.2
|
||||
'@lingui/babel-plugin-lingui-macro':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.5(typescript@6.0.3)
|
||||
version: 5.9.5(@typescript/typescript6@6.0.2)
|
||||
'@lingui/cli':
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.5(typescript@6.0.3)
|
||||
version: 5.9.5(@typescript/typescript6@6.0.2)
|
||||
'@pmmmwh/react-refresh-webpack-plugin':
|
||||
specifier: ^0.5.15
|
||||
version: 0.5.17(react-refresh@0.14.2)(type-fest@1.4.0)(webpack-dev-server@4.15.2(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14))
|
||||
@@ -767,9 +770,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.1.11
|
||||
version: 19.1.11(@types/react@19.1.17)
|
||||
'@typescript/native-preview':
|
||||
specifier: ^7.0.0-dev.20260428.1
|
||||
version: 7.0.0-dev.20260512.1
|
||||
'@typescript/native':
|
||||
specifier: npm:typescript@^7.0.2
|
||||
version: typescript@7.0.2
|
||||
babel-jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@babel/core@7.29.0)
|
||||
@@ -826,10 +829,10 @@ importers:
|
||||
version: runtime:24.18.0
|
||||
oxlint:
|
||||
specifier: ^1.73.0
|
||||
version: 1.73.0(oxlint-tsgolint@0.24.0)
|
||||
version: 1.73.0(oxlint-tsgolint@7.0.2001)
|
||||
oxlint-tsgolint:
|
||||
specifier: ^0.24.0
|
||||
version: 0.24.0
|
||||
specifier: ^7.0.2001
|
||||
version: 7.0.2001
|
||||
prettier:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
@@ -846,8 +849,8 @@ importers:
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4
|
||||
typescript:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
specifier: npm:@typescript/typescript6@^6.0.2
|
||||
version: '@typescript/typescript6@6.0.2'
|
||||
webpack-bundle-analyzer:
|
||||
specifier: ^4.10.1
|
||||
version: 4.10.2
|
||||
@@ -862,8 +865,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.28':
|
||||
resolution: {integrity: sha512-/Rvk8zt9mtRi9tlMD2Qg+NG2lMj3B0HDjmfswR5724pH7GsOEMDDHwleVlmOBSrgACIyVSa5tdCDJ+R+SEhwww==}
|
||||
'@atproto/api@0.20.31':
|
||||
resolution: {integrity: sha512-TovCQLQv5ti1jqh8UH6jJ0EFuWRjGdUtFyFR5xYC/IkIulwHDuyrVaXdCv7VLWiHftp92DevtZnFRm+BZsZZdw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.6':
|
||||
@@ -1603,8 +1606,8 @@ packages:
|
||||
'@braintree/sanitize-url@6.0.4':
|
||||
resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==}
|
||||
|
||||
'@bsky.app/alf@0.1.14':
|
||||
resolution: {integrity: sha512-c/KK6avyjEnYzhXDsN0rQaTFSbi8BPg4sJCW8aUk8AtKsO34GTaY6FU1adQ2i8xVfe5UIRu9FJjnBoGpuXj21A==}
|
||||
'@bsky.app/alf@0.1.15':
|
||||
resolution: {integrity: sha512-e6blt+oZ2klv+Cp7u11FeZckoILdXOtUZ11ATERgjZgb4zX3y7rt4e2fI03JkJO31wTvXHG8A1LnM96Az+ndJg==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
@@ -2242,33 +2245,33 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==}
|
||||
'@oxlint-tsgolint/darwin-arm64@7.0.2001':
|
||||
resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.24.0':
|
||||
resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==}
|
||||
'@oxlint-tsgolint/darwin-x64@7.0.2001':
|
||||
resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==}
|
||||
'@oxlint-tsgolint/linux-arm64@7.0.2001':
|
||||
resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.24.0':
|
||||
resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==}
|
||||
'@oxlint-tsgolint/linux-x64@7.0.2001':
|
||||
resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.24.0':
|
||||
resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==}
|
||||
'@oxlint-tsgolint/win32-arm64@7.0.2001':
|
||||
resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.24.0':
|
||||
resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==}
|
||||
'@oxlint-tsgolint/win32-x64@7.0.2001':
|
||||
resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -3715,51 +3718,128 @@ packages:
|
||||
'@types/yargs@17.0.35':
|
||||
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-l9AJi/TIVMPx5R1c7fxZCSA7eUaHeA0C9Mxdxx/oQJo1K/GtbI3mzYe/SiKNltko1KSdKUmWVhPwxTOS289REg==}
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-oABZLQrfB8JN2Ct2CiLK5PyE28Em3sIJlZsAMD45/A2ymtIaa5826dwv8vapE5Wjp54ao0LXxCSuKFm1A8zzCQ==}
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-xvbwzpTe+5N6bnBI/t9n4zsGzXxz3V6rVbvDUoJmRLfav5fz+ck0QDkGQGUPrQEEIp0KEzQvx7c+AEZnzdvTQA==}
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-0Hs1Gqa/t9cthoPdqHud1pFGUr9DgJivBTjwquTUh8jt/6PI2bQxoMNZLiN/bhqeDFDTzdxoMBfCaytsTMcXqw==}
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-qr5h6FPo74bN/U+EwRuayBhUbxaji8xzFbIbhMOA2oYSc/qozp5ia2g1+9xGw67MXxPPw/IPT+UGvrNK7K1NeQ==}
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-meNWxhNEfaqos2U0JXvfxWvy4JWrKE9fZepCndDZi+t04X+AIiLYp5s6crWnKP67nzVAzMNgTAc8mu8CnGM+/A==}
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-Hp6vBnxJSKEEAVWgIoWMmfqkZXCdkhm6XTivrwgRzBwWfiTVe2ZyZ7byWegIKeNnBbffg/K2KvoM8JAHl059GQ==}
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260512.1':
|
||||
resolution: {integrity: sha512-KIzYPGuxZnyiiYkYrozDT94Af2nwbdLXoY1cgGY66RRa9HSEw13RH9WHg8wA8fZhT4wYzF5uF7WY3hz0QhaxGg==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
'@typescript/typescript6@6.0.2':
|
||||
resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==}
|
||||
hasBin: true
|
||||
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
@@ -7060,8 +7140,8 @@ packages:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
oxlint-tsgolint@0.24.0:
|
||||
resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==}
|
||||
oxlint-tsgolint@7.0.2001:
|
||||
resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.73.0:
|
||||
@@ -8655,6 +8735,11 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
typescript@7.0.2:
|
||||
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@0.7.41:
|
||||
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
|
||||
hasBin: true
|
||||
@@ -9123,7 +9208,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.28':
|
||||
'@atproto/api@0.20.31':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.6
|
||||
'@atproto/lexicon': 0.7.7
|
||||
@@ -10069,7 +10154,7 @@ snapshots:
|
||||
|
||||
'@braintree/sanitize-url@6.0.4': {}
|
||||
|
||||
'@bsky.app/alf@0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
'@bsky.app/alf@0.1.15(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
@@ -10902,19 +10987,19 @@ snapshots:
|
||||
|
||||
'@lingui/babel-plugin-extract-messages@5.9.5': {}
|
||||
|
||||
'@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)':
|
||||
'@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/runtime': 7.29.2
|
||||
'@babel/types': 7.29.0
|
||||
'@lingui/conf': 5.9.5(typescript@6.0.3)
|
||||
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
|
||||
'@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2)
|
||||
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
|
||||
'@lingui/message-utils': 5.9.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@lingui/cli@5.9.5(typescript@6.0.3)':
|
||||
'@lingui/cli@5.9.5(@typescript/typescript6@6.0.2)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
@@ -10922,10 +11007,10 @@ snapshots:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@babel/types': 7.29.0
|
||||
'@lingui/babel-plugin-extract-messages': 5.9.5
|
||||
'@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3)
|
||||
'@lingui/conf': 5.9.5(typescript@6.0.3)
|
||||
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
|
||||
'@lingui/format-po': 5.9.5(typescript@6.0.3)
|
||||
'@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2)
|
||||
'@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2)
|
||||
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
|
||||
'@lingui/format-po': 5.9.5(@typescript/typescript6@6.0.2)
|
||||
'@lingui/message-utils': 5.9.5
|
||||
chokidar: 3.5.1
|
||||
cli-table: 0.3.11
|
||||
@@ -10948,26 +11033,26 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@lingui/conf@5.9.5(typescript@6.0.3)':
|
||||
'@lingui/conf@5.9.5(@typescript/typescript6@6.0.2)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
cosmiconfig: 8.3.6(typescript@6.0.3)
|
||||
cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2)
|
||||
jest-validate: 29.7.0
|
||||
jiti: 2.7.0
|
||||
picocolors: 1.1.1
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))':
|
||||
'@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@lingui/message-utils': 5.9.5
|
||||
optionalDependencies:
|
||||
'@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3)
|
||||
'@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2)
|
||||
|
||||
'@lingui/format-po@5.9.5(typescript@6.0.3)':
|
||||
'@lingui/format-po@5.9.5(@typescript/typescript6@6.0.2)':
|
||||
dependencies:
|
||||
'@lingui/conf': 5.9.5(typescript@6.0.3)
|
||||
'@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2)
|
||||
'@lingui/message-utils': 5.9.5
|
||||
date-fns: 3.6.0
|
||||
pofile: 1.1.4
|
||||
@@ -10979,13 +11064,13 @@ snapshots:
|
||||
'@messageformat/parser': 5.1.1
|
||||
js-sha256: 0.10.1
|
||||
|
||||
'@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)':
|
||||
'@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
|
||||
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
'@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3)
|
||||
'@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2)
|
||||
|
||||
'@messageformat/parser@5.1.1':
|
||||
dependencies:
|
||||
@@ -11003,22 +11088,22 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.24.0':
|
||||
'@oxlint-tsgolint/darwin-arm64@7.0.2001':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-x64@0.24.0':
|
||||
'@oxlint-tsgolint/darwin-x64@7.0.2001':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-arm64@0.24.0':
|
||||
'@oxlint-tsgolint/linux-arm64@7.0.2001':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/linux-x64@0.24.0':
|
||||
'@oxlint-tsgolint/linux-x64@7.0.2001':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-arm64@0.24.0':
|
||||
'@oxlint-tsgolint/win32-arm64@7.0.2001':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/win32-x64@0.24.0':
|
||||
'@oxlint-tsgolint/win32-x64@7.0.2001':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.73.0':
|
||||
@@ -12563,36 +12648,69 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1':
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1':
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1':
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1':
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1':
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1':
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1':
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260512.1':
|
||||
optionalDependencies:
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260512.1
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260512.1
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260512.1
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260512.1
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260512.1
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260512.1
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260512.1
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript6@6.0.2':
|
||||
dependencies:
|
||||
'@typescript/old': typescript@6.0.3
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
@@ -13434,14 +13552,14 @@ snapshots:
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cosmiconfig@8.3.6(typescript@6.0.3):
|
||||
cosmiconfig@8.3.6(@typescript/typescript6@6.0.2):
|
||||
dependencies:
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
optionalDependencies:
|
||||
typescript: 6.0.3
|
||||
typescript: '@typescript/typescript6@6.0.2'
|
||||
|
||||
create-jest@29.7.0(@types/node@24.12.4):
|
||||
dependencies:
|
||||
@@ -16340,16 +16458,16 @@ snapshots:
|
||||
object-keys: 1.1.1
|
||||
safe-push-apply: 1.0.0
|
||||
|
||||
oxlint-tsgolint@0.24.0:
|
||||
oxlint-tsgolint@7.0.2001:
|
||||
optionalDependencies:
|
||||
'@oxlint-tsgolint/darwin-arm64': 0.24.0
|
||||
'@oxlint-tsgolint/darwin-x64': 0.24.0
|
||||
'@oxlint-tsgolint/linux-arm64': 0.24.0
|
||||
'@oxlint-tsgolint/linux-x64': 0.24.0
|
||||
'@oxlint-tsgolint/win32-arm64': 0.24.0
|
||||
'@oxlint-tsgolint/win32-x64': 0.24.0
|
||||
'@oxlint-tsgolint/darwin-arm64': 7.0.2001
|
||||
'@oxlint-tsgolint/darwin-x64': 7.0.2001
|
||||
'@oxlint-tsgolint/linux-arm64': 7.0.2001
|
||||
'@oxlint-tsgolint/linux-x64': 7.0.2001
|
||||
'@oxlint-tsgolint/win32-arm64': 7.0.2001
|
||||
'@oxlint-tsgolint/win32-x64': 7.0.2001
|
||||
|
||||
oxlint@1.73.0(oxlint-tsgolint@0.24.0):
|
||||
oxlint@1.73.0(oxlint-tsgolint@7.0.2001):
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.73.0
|
||||
'@oxlint/binding-android-arm64': 1.73.0
|
||||
@@ -16370,7 +16488,7 @@ snapshots:
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.73.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.73.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.73.0
|
||||
oxlint-tsgolint: 0.24.0
|
||||
oxlint-tsgolint: 7.0.2001
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
@@ -18110,6 +18228,29 @@ snapshots:
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
typescript@7.0.2:
|
||||
optionalDependencies:
|
||||
'@typescript/typescript-aix-ppc64': 7.0.2
|
||||
'@typescript/typescript-darwin-arm64': 7.0.2
|
||||
'@typescript/typescript-darwin-x64': 7.0.2
|
||||
'@typescript/typescript-freebsd-arm64': 7.0.2
|
||||
'@typescript/typescript-freebsd-x64': 7.0.2
|
||||
'@typescript/typescript-linux-arm': 7.0.2
|
||||
'@typescript/typescript-linux-arm64': 7.0.2
|
||||
'@typescript/typescript-linux-loong64': 7.0.2
|
||||
'@typescript/typescript-linux-mips64el': 7.0.2
|
||||
'@typescript/typescript-linux-ppc64': 7.0.2
|
||||
'@typescript/typescript-linux-riscv64': 7.0.2
|
||||
'@typescript/typescript-linux-s390x': 7.0.2
|
||||
'@typescript/typescript-linux-x64': 7.0.2
|
||||
'@typescript/typescript-netbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-netbsd-x64': 7.0.2
|
||||
'@typescript/typescript-openbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-openbsd-x64': 7.0.2
|
||||
'@typescript/typescript-sunos-x64': 7.0.2
|
||||
'@typescript/typescript-win32-arm64': 7.0.2
|
||||
'@typescript/typescript-win32-x64': 7.0.2
|
||||
|
||||
ua-parser-js@0.7.41: {}
|
||||
|
||||
ua-parser-js@1.0.41: {}
|
||||
|
||||
@@ -43,3 +43,11 @@ patchedDependencies:
|
||||
minimumReleaseAgeExclude:
|
||||
- '@atproto/*'
|
||||
- '@bsky.app/*'
|
||||
# todo: remove when old enough
|
||||
- '@oxlint-tsgolint/darwin-arm64@7.0.2001'
|
||||
- '@oxlint-tsgolint/darwin-x64@7.0.2001'
|
||||
- '@oxlint-tsgolint/linux-arm64@7.0.2001'
|
||||
- '@oxlint-tsgolint/linux-x64@7.0.2001'
|
||||
- '@oxlint-tsgolint/win32-arm64@7.0.2001'
|
||||
- '@oxlint-tsgolint/win32-x64@7.0.2001'
|
||||
- oxlint-tsgolint@7.0.2001
|
||||
|
||||
@@ -117,6 +117,7 @@ import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
|
||||
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
|
||||
import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
|
||||
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
|
||||
import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
|
||||
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
|
||||
import {SettingsScreen} from '#/screens/Settings/Settings'
|
||||
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
|
||||
@@ -451,6 +452,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
getComponent={() => NotificationSettingsScreen}
|
||||
options={{title: title(msg`Notification settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ActivityNotificationSettings"
|
||||
getComponent={() => ActivityNotificationSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Activity notifications`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ContentAndMediaSettings"
|
||||
getComponent={() => ContentAndMediaSettingsScreen}
|
||||
|
||||
@@ -20,6 +20,7 @@ export enum Features {
|
||||
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
|
||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
|
||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ export type Events = {
|
||||
| 'ProgressGuide'
|
||||
location: 'Card' | 'Profile' | 'FollowAll'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -526,7 +526,7 @@ export type Events = {
|
||||
| 'ProfileHeader'
|
||||
| 'Onboarding'
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -541,7 +541,7 @@ export type Events = {
|
||||
| 'SeeMoreSuggestedUsers'
|
||||
| 'ProgressGuide'
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
category: string | null
|
||||
@@ -553,11 +553,11 @@ export type Events = {
|
||||
| 'ProfileInterstitial'
|
||||
| 'ProfileHeader'
|
||||
| 'Onboarding'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
}
|
||||
'suggestedUser:dismiss': {
|
||||
logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
position: number
|
||||
suggestedDid: string
|
||||
}
|
||||
@@ -608,7 +608,7 @@ export type Events = {
|
||||
|
||||
// Group chat adoption
|
||||
'groupchat:create': {
|
||||
logContext: 'NewChatDialog'
|
||||
logContext: 'NewChatDialog' | 'SendViaChatDialog'
|
||||
}
|
||||
'groupchat:landingPage:view': {
|
||||
hasSession: boolean
|
||||
@@ -746,9 +746,7 @@ export type Events = {
|
||||
}
|
||||
'trendingTopic:click': {
|
||||
context: 'sidebar' | 'interstitial' | 'explore'
|
||||
}
|
||||
'recommendedTopic:click': {
|
||||
context: 'explore'
|
||||
recId?: string
|
||||
}
|
||||
'trendingVideos:show': {
|
||||
context: 'settings'
|
||||
@@ -782,13 +780,13 @@ export type Events = {
|
||||
}
|
||||
|
||||
'search:results:loaded': {
|
||||
tab: 'top' | 'latest' | 'people' | 'feeds'
|
||||
tab: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
|
||||
initialCount: number
|
||||
}
|
||||
|
||||
'search:result:press': {
|
||||
tab?: 'top' | 'latest' | 'people' | 'feeds'
|
||||
resultType: 'post' | 'profile' | 'feed'
|
||||
tab?: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
|
||||
resultType: 'post' | 'profile' | 'feed' | 'starterPack'
|
||||
position: number
|
||||
uri: string
|
||||
}
|
||||
@@ -1349,6 +1347,26 @@ export type Events = {
|
||||
// user dismissed the empty-followers promo banner
|
||||
'invite:followersPromo:dismiss': {}
|
||||
|
||||
/**
|
||||
* Fired when a video fails terminally during playback: unreachable (404),
|
||||
* undecodable, or the client lacks the required codecs. Complements the
|
||||
* Sentry-only video.playback spans with a countable, unsampled event.
|
||||
*/
|
||||
'video:playback:failed': {
|
||||
surface: 'feed' | 'immersiveFeed'
|
||||
presentation: 'video' | 'gif'
|
||||
/**
|
||||
* Coarse failure bucket: VideoNotFoundError, HLSUnsupportedError, an
|
||||
* hls.js error details code (e.g. bufferAppendError), or PlayerError on
|
||||
* native.
|
||||
*/
|
||||
errorClass: string
|
||||
/** Truncated to 256 chars */
|
||||
errorMessage: string
|
||||
/** HLS playlist URL, identifies the exact video for server-side lookup */
|
||||
playlist: string
|
||||
}
|
||||
|
||||
// === Video upload funnel (Frontend Spec section D) ===
|
||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||
// session end-to-end) + engine (compression engine id, e.g.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {useState} from 'react'
|
||||
import {type Insets, Pressable, View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
|
||||
import * as Tooltip from '#/components/Tooltip'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* Whether to show the beta badge for a given profile. Only shown on the
|
||||
* viewer's own profile, and only when the viewer has opted in to beta features.
|
||||
*/
|
||||
export function useIsBetaBadgeVisible(
|
||||
profile: bsky.profile.AnyProfileView,
|
||||
): boolean {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const isBetaUser = preferences?.bskyAppState?.isBetaUser ?? false
|
||||
const isSelf = currentAccount?.did === profile.did
|
||||
|
||||
return isSelf && isBetaUser
|
||||
}
|
||||
|
||||
export function BetaBadge({
|
||||
profile,
|
||||
width,
|
||||
padding,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
width: number
|
||||
padding: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const isVisible = useIsBetaBadgeVisible(profile)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{backgroundColor: t.palette.primary_50, padding},
|
||||
]}>
|
||||
<BeakerIcon width={width} fill={t.palette.primary_500} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function BetaBadgeButton({
|
||||
profile,
|
||||
width,
|
||||
padding,
|
||||
hitSlop,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
width: number
|
||||
padding: number
|
||||
hitSlop: Insets
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const isVisible = useIsBetaBadgeVisible(profile)
|
||||
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<Tooltip.Outer
|
||||
color="primary"
|
||||
visible={tooltipVisible}
|
||||
onVisibleChange={setTooltipVisible}>
|
||||
<Tooltip.Target>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Beta features enabled`}
|
||||
accessibilityHint=""
|
||||
hitSlop={hitSlop}
|
||||
style={({hovered}) => [
|
||||
a.rounded_full,
|
||||
a.transition_transform,
|
||||
{
|
||||
backgroundColor: t.palette.primary_50,
|
||||
padding,
|
||||
transform: [
|
||||
{
|
||||
scale: hovered ? 1.1 : 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
onPress={() => setTooltipVisible(v => !v)}>
|
||||
<BeakerIcon width={width} fill={t.palette.primary_500} />
|
||||
</Pressable>
|
||||
</Tooltip.Target>
|
||||
<Tooltip.BubbleText label={l`Beta features enabled`}>
|
||||
<Trans>Beta features enabled</Trans>
|
||||
</Tooltip.BubbleText>
|
||||
</Tooltip.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {View} from 'react-native'
|
||||
import {type Insets, View} from 'react-native'
|
||||
import {type ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
@@ -44,9 +44,11 @@ export function BotBadge({
|
||||
export function BotBadgeButton({
|
||||
profile,
|
||||
width,
|
||||
hitSlop,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
width: number
|
||||
hitSlop: Insets
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
@@ -61,7 +63,7 @@ export function BotBadgeButton({
|
||||
<>
|
||||
<Button
|
||||
label={l`Automated account`}
|
||||
hitSlop={20}
|
||||
hitSlop={hitSlop}
|
||||
onPress={evt => {
|
||||
evt.preventDefault()
|
||||
ax.metric('bot:badge:click', {})
|
||||
|
||||
@@ -286,7 +286,7 @@ export function Composer({
|
||||
ref={IS_WEB ? sift.refs.setAnchor : undefined}
|
||||
style={
|
||||
node.type === 'facet' && {
|
||||
color: t.palette.primary_500,
|
||||
color: t.atoms.text_link.color,
|
||||
}
|
||||
}>
|
||||
{node.raw}
|
||||
|
||||
@@ -157,7 +157,8 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const isHeightConstrained = nativeOptions?.maxHeight != null
|
||||
const isHeightConstrained =
|
||||
nativeOptions?.maxHeight != null || nativeOptions?.fullHeight === true
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -481,11 +481,11 @@ export function ProfileGrid({
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
{color: t.palette.primary_500},
|
||||
t.atoms.text_link,
|
||||
hovered &&
|
||||
web({
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
textDecorationColor: t.atoms.text_link.color,
|
||||
}),
|
||||
]}>
|
||||
<Trans>See more</Trans>
|
||||
|
||||
@@ -78,6 +78,7 @@ export function ImageMenu({onPressShare, onPressSave}: Props) {
|
||||
visible={isMounted}
|
||||
animationType="none"
|
||||
onRequestClose={close}
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
statusBarTranslucent>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -415,7 +415,7 @@ function LinkPeek({
|
||||
// dialog can show.
|
||||
useInAppBrowser: useInAppBrowserPref === true,
|
||||
browserToolbarColor: t.atoms.bg.backgroundColor,
|
||||
browserControlsColor: t.palette.primary_500,
|
||||
browserControlsColor: t.atoms.text_link.color,
|
||||
}}
|
||||
borderRadius={borderRadius}
|
||||
// Fires only when not morphing natively (in-app browser off/unset).
|
||||
@@ -487,14 +487,14 @@ export function InlineLinkText({
|
||||
accessibilityLabel={label}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
t.atoms.text_link,
|
||||
interacted &&
|
||||
!disableUnderline && {
|
||||
...web({
|
||||
outline: 0,
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor:
|
||||
flattenedStyle.color ?? t.palette.primary_500,
|
||||
flattenedStyle.color ?? t.atoms.text_link.color,
|
||||
}),
|
||||
},
|
||||
flattenedStyle,
|
||||
@@ -591,14 +591,14 @@ export function SimpleInlineLinkText({
|
||||
accessibilityLabel={label}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
t.atoms.text_link,
|
||||
interacted &&
|
||||
!disableUnderline && {
|
||||
...web({
|
||||
outline: 0,
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor:
|
||||
flattenedStyle.color ?? t.palette.primary_500,
|
||||
flattenedStyle.color ?? t.atoms.text_link.color,
|
||||
}),
|
||||
},
|
||||
flattenedStyle,
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ImageEmbed({
|
||||
|
||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
|
||||
const singleContainerRef = useRef<AnimatedRef<React.Component> | null>(null)
|
||||
const singleDimsRef = useRef<Dimensions | null>(null)
|
||||
|
||||
if (images.length > 0) {
|
||||
@@ -71,7 +71,7 @@ export function ImageEmbed({
|
||||
}))
|
||||
const onPress = (
|
||||
index: number,
|
||||
refs: AnimatedRef<any>[],
|
||||
refs: AnimatedRef<React.Component>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
if (postContext) {
|
||||
@@ -97,7 +97,7 @@ export function ImageEmbed({
|
||||
}
|
||||
const onPressIn = (_: number) => {
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
Image.prefetch(
|
||||
void Image.prefetch(
|
||||
items.map(i => i.uri),
|
||||
'memory',
|
||||
)
|
||||
@@ -115,6 +115,7 @@ export function ImageEmbed({
|
||||
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.mt_sm, rest.style]}>
|
||||
<ImageContextMenu
|
||||
@@ -127,9 +128,7 @@ export function ImageEmbed({
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.isWithinQuote
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onContainerRef={ref => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
|
||||
|
||||
export {
|
||||
HLSFatalError,
|
||||
HLSUnsupportedError,
|
||||
VideoNotFoundError,
|
||||
} from './VideoEmbedInnerWeb.shared'
|
||||
|
||||
@@ -19,3 +19,16 @@ export class VideoNotFoundError extends Error {
|
||||
super('Video not found')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fatal hls.js playback error. `detail` is the hls.js error details code
|
||||
* (e.g. bufferAppendError), which buckets failures more usefully than the
|
||||
* error message.
|
||||
*/
|
||||
export class HLSFatalError extends Error {
|
||||
detail: string
|
||||
constructor(detail: string, cause: Error) {
|
||||
super(cause.message, {cause})
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||
import {
|
||||
HLSFatalError,
|
||||
HLSUnsupportedError,
|
||||
type VideoEmbedInnerWebProps,
|
||||
VideoNotFoundError,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
|
||||
export {
|
||||
HLSFatalError,
|
||||
HLSUnsupportedError,
|
||||
VideoNotFoundError,
|
||||
} from './VideoEmbedInnerWeb.shared'
|
||||
@@ -306,7 +308,7 @@ function useHLS({
|
||||
) {
|
||||
setError(new VideoNotFoundError())
|
||||
} else {
|
||||
setError(data.error)
|
||||
setError(new HLSFatalError(data.details, data.error))
|
||||
}
|
||||
} else {
|
||||
console.error(data.error)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {Button} from '#/components/Button'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {GifPresentationControls} from './GifPresentationControls'
|
||||
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
@@ -70,6 +71,7 @@ export function VideoEmbed({embed}: Props) {
|
||||
|
||||
function InnerWrapper({embed}: Props) {
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const ref = useRef<{togglePlayback: () => void}>(null)
|
||||
|
||||
const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
|
||||
@@ -130,6 +132,13 @@ function InnerWrapper({embed}: Props) {
|
||||
}}
|
||||
onError={error => {
|
||||
telemetryRef.current?.error(error)
|
||||
ax.metric('video:playback:failed', {
|
||||
surface: 'feed',
|
||||
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
|
||||
errorClass: 'PlayerError',
|
||||
errorMessage: error.slice(0, 256),
|
||||
playlist: embed.playlist,
|
||||
})
|
||||
}}
|
||||
ref={ref}
|
||||
/>
|
||||
|
||||
@@ -18,16 +18,25 @@ import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {
|
||||
HLSFatalError,
|
||||
HLSUnsupportedError,
|
||||
VideoEmbedInnerWeb,
|
||||
VideoNotFoundError,
|
||||
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB_FIREFOX} from '#/env'
|
||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* Minimum card width for the overlay controls (play, time, CC, volume,
|
||||
* fullscreen) to fit without crowding. Narrower cards fall back to the
|
||||
* full-width pillarbox.
|
||||
*/
|
||||
const MIN_CARD_WIDTH = 280
|
||||
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -69,9 +78,9 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const [key, setKey] = useState(0)
|
||||
const renderError = useCallback(
|
||||
(error: unknown) => (
|
||||
<VideoError error={error} retry={() => setKey(key + 1)} />
|
||||
<VideoError embed={embed} error={error} retry={() => setKey(key + 1)} />
|
||||
),
|
||||
[key],
|
||||
[key, embed],
|
||||
)
|
||||
|
||||
let aspectRatio: number | undefined
|
||||
@@ -89,6 +98,21 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
constrained = Math.max(aspectRatio, ratio)
|
||||
}
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
/*
|
||||
* Portrait videos render at their own ratio instead of pillarboxed, but
|
||||
* only when the resulting card fits the overlay controls. Videos taller
|
||||
* than 1:2 would still show bars inside a ratio-fit card, and an unknown
|
||||
* ratio can't be fit, so both keep the full-width pillarbox - a narrow
|
||||
* card with black slices down the sides looks broken (see #9371).
|
||||
*/
|
||||
const cardWidth = containerWidth * Math.min(aspectRatio ?? 1, 1)
|
||||
const fullBleed =
|
||||
aspectRatio === undefined ||
|
||||
aspectRatio < 1 / 2 ||
|
||||
(containerWidth > 0 && cardWidth < MIN_CARD_WIDTH)
|
||||
|
||||
const contents = (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -96,6 +120,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
cursor: 'default',
|
||||
position: 'relative',
|
||||
backgroundColor: t.palette.black,
|
||||
backgroundImage: `url(${embed.thumbnail})`,
|
||||
backgroundSize: 'contain',
|
||||
@@ -103,6 +128,36 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
onClick={evt => evt.stopPropagation()}>
|
||||
{fullBleed && embed.thumbnail && (
|
||||
<>
|
||||
{/* blurred backdrop fills the bars when the video is boxed */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `url(${embed.thumbnail})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: 'blur(32px)',
|
||||
// hide the transparent fade the blur creates at the edges
|
||||
transform: 'scale(1.2)',
|
||||
}}
|
||||
/>
|
||||
{/* redraw the sharp thumbnail above the blur */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `url(${embed.thumbnail})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
<OnlyNearScreen>
|
||||
<VideoEmbedInnerWeb
|
||||
@@ -118,12 +173,14 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.pt_xs]}>
|
||||
<View
|
||||
style={[a.pt_xs]}
|
||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
|
||||
<ViewportObserver
|
||||
sendPosition={isGif ? noop : sendPosition}
|
||||
isAnyViewActive={currentActiveView !== null}>
|
||||
<ConstrainedImage
|
||||
fullBleed
|
||||
fullBleed={fullBleed}
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
@@ -222,23 +279,63 @@ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
|
||||
return nearScreen ? children : null
|
||||
}
|
||||
|
||||
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
|
||||
function VideoError({
|
||||
embed,
|
||||
error,
|
||||
retry,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
error: unknown
|
||||
retry: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
|
||||
let showRetryButton = true
|
||||
let text = null
|
||||
let errorClass: string
|
||||
|
||||
if (error instanceof VideoNotFoundError) {
|
||||
text = _(msg`Video not found.`)
|
||||
errorClass = 'VideoNotFoundError'
|
||||
} else if (error instanceof HLSUnsupportedError) {
|
||||
showRetryButton = false
|
||||
text = _(
|
||||
msg`This video can’t be played on your device. Your browser or system may be missing the required video codecs (H.264/AAC).`,
|
||||
)
|
||||
errorClass = 'HLSUnsupportedError'
|
||||
} else {
|
||||
text = _(msg`An error occurred while loading the video. Please try again.`)
|
||||
if (error instanceof HLSFatalError) {
|
||||
errorClass = error.detail
|
||||
} else if (error instanceof Error) {
|
||||
errorClass = error.name || 'Error'
|
||||
} else {
|
||||
errorClass = 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const presentation = embed.presentation === 'gif' ? 'gif' : 'video'
|
||||
const playlist = embed.playlist
|
||||
/*
|
||||
* Fire exactly once per failure - the analytics context identity can change
|
||||
* (session/geolocation updates) while this fallback stays mounted, which
|
||||
* would otherwise re-run the effect and double-count.
|
||||
*/
|
||||
const fired = useRef(false)
|
||||
useEffect(() => {
|
||||
if (fired.current) return
|
||||
fired.current = true
|
||||
ax.metric('video:playback:failed', {
|
||||
surface: 'feed',
|
||||
presentation,
|
||||
errorClass,
|
||||
errorMessage: errorMessage.slice(0, 256),
|
||||
playlist,
|
||||
})
|
||||
}, [ax, presentation, playlist, errorClass, errorMessage])
|
||||
|
||||
return (
|
||||
<VideoFallback.Container>
|
||||
<VideoFallback.Text>{text}</VideoFallback.Text>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
|
||||
export enum PostEmbedViewContext {
|
||||
ThreadHighlighted = 'ThreadHighlighted',
|
||||
Feed = 'Feed',
|
||||
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
|
||||
ChatMessage = 'ChatMessage',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {LayoutAnimation, type TextStyle} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf'
|
||||
@@ -14,7 +12,7 @@ export function ShowMoreTextButton({
|
||||
style,
|
||||
}: TextStyleProp & {onPress: () => void}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
@@ -30,7 +28,7 @@ export function ShowMoreTextButton({
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Expand post text`)}
|
||||
label={l`Expand post text`}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
a.self_start,
|
||||
@@ -43,13 +41,13 @@ export function ShowMoreTextButton({
|
||||
<Text
|
||||
style={[
|
||||
textStyle,
|
||||
t.atoms.text_link,
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
textDecorationLine: hovered ? 'underline' : undefined,
|
||||
},
|
||||
]}>
|
||||
<Trans>Show More</Trans>
|
||||
<Trans>Show more</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -150,10 +150,10 @@ function TranslationLink({
|
||||
label={l`Translate`}
|
||||
hoverStyle={[
|
||||
native({opacity: 0.5}),
|
||||
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
|
||||
web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
|
||||
]}
|
||||
hitSlop={HITSLOP_30}>
|
||||
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_link]}>
|
||||
<Trans>Translate</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
@@ -229,7 +229,7 @@ function TranslationError({
|
||||
label={l`Try Google Translate`}
|
||||
hoverStyle={[
|
||||
native({opacity: 0.5}),
|
||||
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
|
||||
web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
|
||||
]}
|
||||
hitSlop={HITSLOP_30}>
|
||||
<Text
|
||||
@@ -237,7 +237,7 @@ function TranslationError({
|
||||
a.text_xs,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
{color: t.palette.primary_500},
|
||||
t.atoms.text_link,
|
||||
]}>
|
||||
<Trans>Try Google Translate</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {memo, useMemo} from 'react'
|
||||
import * as ExpoClipboard from 'expo-clipboard'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -37,7 +35,7 @@ let ShareMenuItems = ({
|
||||
}: ShareMenuItemsProps): React.ReactNode => {
|
||||
const ax = useAnalytics()
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const sendViaChatControl = useDialogControl()
|
||||
const [devModeEnabled] = useDevMode()
|
||||
@@ -61,7 +59,7 @@ let ShareMenuItems = ({
|
||||
const onSharePost = () => {
|
||||
ax.metric('share:press:nativeShare', {})
|
||||
const url = toShareUrl(href)
|
||||
shareUrl(url)
|
||||
void shareUrl(url)
|
||||
onShareProp()
|
||||
}
|
||||
|
||||
@@ -74,7 +72,7 @@ let ShareMenuItems = ({
|
||||
} else {
|
||||
await ExpoClipboard.setStringAsync(url)
|
||||
}
|
||||
Toast.show(_(msg`Copied to clipboard`), {
|
||||
Toast.show(l`Copied to clipboard`, {
|
||||
type: 'success',
|
||||
})
|
||||
onShareProp()
|
||||
@@ -93,11 +91,11 @@ let ShareMenuItems = ({
|
||||
}
|
||||
|
||||
const onShareATURI = () => {
|
||||
shareText(postUri)
|
||||
void shareText(postUri)
|
||||
}
|
||||
|
||||
const onShareAuthorDID = () => {
|
||||
shareText(postAuthor.did)
|
||||
void shareText(postAuthor.did)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -113,13 +111,13 @@ let ShareMenuItems = ({
|
||||
</Menu.ContainerItem>
|
||||
<Menu.Item
|
||||
testID="postDropdownSendViaDMBtn"
|
||||
label={_(msg`Send via direct message`)}
|
||||
label={l`Send via chat`}
|
||||
onPress={() => {
|
||||
ax.metric('share:press:openDmSearch', {})
|
||||
sendViaChatControl.open()
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Send via direct message</Trans>
|
||||
<Trans>Send via chat</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={PaperPlaneIcon} position="right" />
|
||||
</Menu.Item>
|
||||
@@ -129,7 +127,7 @@ let ShareMenuItems = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="postDropdownShareBtn"
|
||||
label={_(msg`Share via...`)}
|
||||
label={l`Share via...`}
|
||||
onPress={onSharePost}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Share via...</Trans>
|
||||
@@ -139,8 +137,8 @@ let ShareMenuItems = ({
|
||||
|
||||
<Menu.Item
|
||||
testID="postDropdownShareBtn"
|
||||
label={_(msg`Copy link to post`)}
|
||||
onPress={onCopyLink}>
|
||||
label={l`Copy link to post`}
|
||||
onPress={() => void onCopyLink()}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Copy link to post</Trans>
|
||||
</Menu.ItemText>
|
||||
@@ -164,7 +162,7 @@ let ShareMenuItems = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="postAtUriShareBtn"
|
||||
label={_(msg`Share post at:// URI`)}
|
||||
label={l`Share post at:// URI`}
|
||||
onPress={onShareATURI}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Share post at:// URI</Trans>
|
||||
@@ -173,7 +171,7 @@ let ShareMenuItems = ({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="postAuthorDIDShareBtn"
|
||||
label={_(msg`Share author DID`)}
|
||||
label={l`Share author DID`}
|
||||
onPress={onShareAuthorDID}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Share author DID</Trans>
|
||||
@@ -183,7 +181,6 @@ let ShareMenuItems = ({
|
||||
</Menu.Group>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
|
||||
<SendViaChatDialog
|
||||
control={sendViaChatControl}
|
||||
onSelectChat={onSelectChatToShareTo}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {memo, useMemo} from 'react'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
@@ -35,7 +33,7 @@ let ShareMenuItems = ({
|
||||
const ax = useAnalytics()
|
||||
const {hasSession} = useSession()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedPostControl = useDialogControl()
|
||||
const sendViaChatControl = useDialogControl()
|
||||
@@ -60,7 +58,7 @@ let ShareMenuItems = ({
|
||||
const onCopyLink = () => {
|
||||
ax.metric('share:press:copyLink', {})
|
||||
const url = toShareUrl(href)
|
||||
shareUrl(url)
|
||||
void shareUrl(url)
|
||||
onShareProp()
|
||||
}
|
||||
|
||||
@@ -75,17 +73,17 @@ let ShareMenuItems = ({
|
||||
const canEmbed = IS_WEB && gtMobile && !hideInPWI
|
||||
|
||||
const onShareATURI = () => {
|
||||
shareText(postUri)
|
||||
void shareText(postUri)
|
||||
}
|
||||
|
||||
const onShareAuthorDID = () => {
|
||||
shareText(postAuthor.did)
|
||||
void shareText(postAuthor.did)
|
||||
}
|
||||
|
||||
const copyLinkItem = (
|
||||
<Menu.Item
|
||||
testID="postDropdownShareBtn"
|
||||
label={_(msg`Copy link to post`)}
|
||||
label={l`Copy link to post`}
|
||||
onPress={onCopyLink}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Copy link to post</Trans>
|
||||
@@ -102,13 +100,13 @@ let ShareMenuItems = ({
|
||||
{hasSession && aa.state.access === aa.Access.Full && (
|
||||
<Menu.Item
|
||||
testID="postDropdownSendViaDMBtn"
|
||||
label={_(msg`Send via direct message`)}
|
||||
label={l`Send via chat`}
|
||||
onPress={() => {
|
||||
ax.metric('share:press:openDmSearch', {})
|
||||
sendViaChatControl.open()
|
||||
}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Send via direct message</Trans>
|
||||
<Trans>Send via chat</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Send} position="right" />
|
||||
</Menu.Item>
|
||||
@@ -117,12 +115,12 @@ let ShareMenuItems = ({
|
||||
{canEmbed && (
|
||||
<Menu.Item
|
||||
testID="postDropdownEmbedBtn"
|
||||
label={_(msg`Embed post`)}
|
||||
label={l`Embed post`}
|
||||
onPress={() => {
|
||||
ax.metric('share:press:embed', {})
|
||||
embedPostControl.open()
|
||||
}}>
|
||||
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
|
||||
<Menu.ItemText>{l`Embed post`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={CodeBracketsIcon} position="right" />
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -142,7 +140,7 @@ let ShareMenuItems = ({
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
testID="postAtUriShareBtn"
|
||||
label={_(msg`Copy post at:// URI`)}
|
||||
label={l`Copy post at:// URI`}
|
||||
onPress={onShareATURI}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Copy post at:// URI</Trans>
|
||||
@@ -151,7 +149,7 @@ let ShareMenuItems = ({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="postAuthorDIDShareBtn"
|
||||
label={_(msg`Copy author DID`)}
|
||||
label={l`Copy author DID`}
|
||||
onPress={onShareAuthorDID}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Copy author DID</Trans>
|
||||
@@ -161,7 +159,6 @@ let ShareMenuItems = ({
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
|
||||
{canEmbed && (
|
||||
<EmbedDialog
|
||||
control={embedPostControl}
|
||||
@@ -172,7 +169,6 @@ let ShareMenuItems = ({
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SendViaChatDialog
|
||||
control={sendViaChatControl}
|
||||
onSelectChat={onSelectChatToShareTo}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
|
||||
import {useNativeFontScale} from '#/alf/util/dimensions'
|
||||
@@ -8,6 +9,7 @@ import {useSimpleVerificationState} from '#/components/verification'
|
||||
import {VerificationCheck} from '#/components/verification/VerificationCheck'
|
||||
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {BetaBadge, BetaBadgeButton, useIsBetaBadgeVisible} from './BetaBadge'
|
||||
|
||||
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
@@ -27,6 +29,22 @@ const botIconSizes: Record<Size, number> = {
|
||||
xl: 23,
|
||||
} as const
|
||||
|
||||
const betaIconSizes: Record<Size, number> = {
|
||||
xs: 8,
|
||||
sm: 8,
|
||||
md: 8,
|
||||
lg: 10,
|
||||
xl: 12,
|
||||
} as const
|
||||
|
||||
const betaBadgePadding: Record<Size, number> = {
|
||||
xs: 1,
|
||||
sm: 2,
|
||||
md: 3,
|
||||
lg: 4,
|
||||
xl: 5,
|
||||
} as const
|
||||
|
||||
export function ProfileBadges({
|
||||
profile,
|
||||
interactive = false,
|
||||
@@ -41,13 +59,19 @@ export function ProfileBadges({
|
||||
}) {
|
||||
const shadowed = useProfileShadow(profile)
|
||||
const verification = useSimpleVerificationState({profile})
|
||||
const badgeVisibility = [
|
||||
verification.showBadge,
|
||||
useIsBetaBadgeVisible(profile),
|
||||
isBotAccount(shadowed),
|
||||
]
|
||||
const badgeCount = badgeVisibility.filter(Boolean).length
|
||||
const nativeScaleMultiplier = useNativeFontScale()
|
||||
const {
|
||||
fonts: {scaleMultiplier: alfScaleMultiplier},
|
||||
} = useAlf()
|
||||
|
||||
// if nothing to show, don't render the container at all
|
||||
if (!verification.showBadge && !isBotAccount(shadowed)) return null
|
||||
if (badgeCount < 1) return null
|
||||
|
||||
const isOnTheSmallSide = size === 'xs' || size === 'sm'
|
||||
|
||||
@@ -57,31 +81,57 @@ export function ProfileBadges({
|
||||
|
||||
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
|
||||
const botIconWidth = botIconSizes[size] * scaleMultiplier
|
||||
const betaIconWidth = betaIconSizes[size] * scaleMultiplier
|
||||
const betaBadgeScaledPadding = betaBadgePadding[size] * scaleMultiplier
|
||||
|
||||
const gap = isOnTheSmallSide ? a.gap_2xs : a.gap_xs
|
||||
const padding = gap.gap / 2
|
||||
let visibleBadgeIndex = 0
|
||||
const hitSlops = badgeVisibility.map(isVisible => {
|
||||
if (!isVisible) return HITSLOP_20
|
||||
|
||||
const index = visibleBadgeIndex++
|
||||
return {
|
||||
...HITSLOP_20,
|
||||
left: index === 0 ? HITSLOP_20.left : padding,
|
||||
right: index === badgeCount - 1 ? HITSLOP_20.right : padding,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
isOnTheSmallSide ? a.gap_2xs : a.gap_xs,
|
||||
style,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, gap, style]}>
|
||||
{interactive ? (
|
||||
<>
|
||||
<VerificationCheckButton
|
||||
profile={shadowed}
|
||||
width={verificationIconWidth}
|
||||
hitSlop={hitSlops[0]}
|
||||
/>
|
||||
<BetaBadgeButton
|
||||
profile={shadowed}
|
||||
width={betaIconWidth}
|
||||
padding={betaBadgeScaledPadding}
|
||||
hitSlop={hitSlops[1]}
|
||||
/>
|
||||
<BotBadgeButton
|
||||
profile={shadowed}
|
||||
width={botIconWidth}
|
||||
hitSlop={hitSlops[2]}
|
||||
/>
|
||||
<BotBadgeButton profile={shadowed} width={botIconWidth} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{verification.showBadge && (
|
||||
{verification.showBadge ? (
|
||||
<VerificationCheck
|
||||
verifier={verification.role === 'verifier'}
|
||||
width={verificationIconWidth}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<BetaBadge
|
||||
profile={shadowed}
|
||||
width={betaIconWidth}
|
||||
padding={betaBadgeScaledPadding}
|
||||
/>
|
||||
<BotBadge profile={shadowed} width={botIconWidth} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,143 +1,20 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AtUri} from '@atproto/api'
|
||||
import {type AppBskyUnspeccedDefs, type AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
// import {makeProfileLink} from '#/lib/routes/links'
|
||||
// import {feedUriToHref} from '#/lib/strings/url-helpers'
|
||||
// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote'
|
||||
// import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {type TrendingTopic} from '#/state/queries/trending/useTrendingTopics'
|
||||
import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
|
||||
import {native} from '#/alf'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function TrendingTopic({
|
||||
topic: raw,
|
||||
size,
|
||||
style,
|
||||
hovered,
|
||||
}: {
|
||||
topic: TrendingTopic
|
||||
size?: 'large' | 'small'
|
||||
hovered?: boolean
|
||||
} & ViewStyleProp) {
|
||||
const topic = useTopic(raw)
|
||||
|
||||
const isSmall = size === 'small'
|
||||
const hasIcon = topic.type === 'starter-pack' && !isSmall
|
||||
const iconSize = 20
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
isSmall
|
||||
? [
|
||||
{
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
]
|
||||
: [a.py_xs, a.px_sm],
|
||||
hasIcon && {gap: 6},
|
||||
style,
|
||||
]}>
|
||||
{hasIcon && topic.type === 'starter-pack' && (
|
||||
<StarterPackIcon
|
||||
gradient="sky"
|
||||
width={iconSize}
|
||||
style={{marginLeft: -3, marginVertical: -1}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
},
|
||||
]}>
|
||||
{topic.type === 'tag' ? (
|
||||
<Hashtag width={iconSize} />
|
||||
) : topic.type === 'topic' ? (
|
||||
<Quote width={iconSize - 2} />
|
||||
) : topic.type === 'feed' ? (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
*/}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
|
||||
hovered && {textDecorationLine: 'underline'},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{topic.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrendingTopicSkeleton({
|
||||
size = 'large',
|
||||
index = 0,
|
||||
}: {
|
||||
size?: 'large' | 'small'
|
||||
index?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const isSmall = size === 'small'
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg_contrast_25,
|
||||
isSmall
|
||||
? {
|
||||
width: index % 2 === 0 ? 75 : 90,
|
||||
height: 27,
|
||||
}
|
||||
: {
|
||||
width: index % 2 === 0 ? 90 : 110,
|
||||
height: 36,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrendingTopicLink({
|
||||
topic: raw,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
topic: TrendingTopic
|
||||
topic: AppBskyUnspeccedDefs.TrendView
|
||||
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const topic = useTopic(raw)
|
||||
|
||||
@@ -168,7 +45,9 @@ type ParsedTrendingTopic =
|
||||
uri: AtUri
|
||||
}
|
||||
|
||||
export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
|
||||
export function useTopic(
|
||||
raw: AppBskyUnspeccedDefs.TrendView,
|
||||
): ParsedTrendingTopic {
|
||||
const {_} = useLingui()
|
||||
return useMemo(() => {
|
||||
const {topic: displayName, link} = raw
|
||||
|
||||
@@ -11,19 +11,33 @@ import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type ListMethods} from '#/view/com/util/List'
|
||||
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {canBeAddedToGroup, canBeMessaged} from '#/components/dms/util'
|
||||
import {ChatProfileTabs} from '#/components/dms/ChatProfileTabs'
|
||||
import {EmptyMemberList} from '#/components/dms/components/EmptyMemberList'
|
||||
import {GroupChatProfileCard} from '#/components/dms/components/GroupChatProfileCard'
|
||||
import {ProfileCardSkeleton} from '#/components/dms/components/ProfileCardSkeleton'
|
||||
import {UserLabel} from '#/components/dms/components/UserLabel'
|
||||
import {UserSearchInput} from '#/components/dms/components/UserSearchInput'
|
||||
import {
|
||||
canBeAddedToGroup,
|
||||
canBeMessaged,
|
||||
type ConvoWithDetails,
|
||||
parseConvoView,
|
||||
} from '#/components/dms/util'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {
|
||||
@@ -33,18 +47,13 @@ import {
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ChatProfileTabs} from './ChatProfileTabs'
|
||||
import {EmptyMemberList} from './components/EmptyMemberList'
|
||||
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
|
||||
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
|
||||
import {UserLabel} from './components/UserLabel'
|
||||
import {UserSearchInput} from './components/UserSearchInput'
|
||||
|
||||
type NewGroupChatItem = {
|
||||
type: 'newGroupChat'
|
||||
@@ -63,6 +72,12 @@ type ProfileItem = {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
|
||||
type ExistingChatItem = {
|
||||
type: 'existingChat'
|
||||
key: string
|
||||
convo: ConvoWithDetails
|
||||
}
|
||||
|
||||
type EmptyItem = {
|
||||
type: 'empty'
|
||||
key: string
|
||||
@@ -83,6 +98,7 @@ type Item =
|
||||
| NewGroupChatItem
|
||||
| LabelItem
|
||||
| ProfileItem
|
||||
| ExistingChatItem
|
||||
| EmptyItem
|
||||
| PlaceholderItem
|
||||
| ErrorItem
|
||||
@@ -212,11 +228,17 @@ export function InitiateChatFlow({
|
||||
onSelectChat,
|
||||
onSelectGroupChat,
|
||||
startInGroupChat = false,
|
||||
showRecentConvos = false,
|
||||
onSelectExistingChat,
|
||||
sortByMessageDeclaration = false,
|
||||
}: {
|
||||
title: string
|
||||
onSelectChat: (did: string) => void
|
||||
onSelectGroupChat: (dids: string[], groupName: string) => void
|
||||
startInGroupChat?: boolean
|
||||
showRecentConvos?: boolean
|
||||
onSelectExistingChat?: (convoId: string) => void
|
||||
sortByMessageDeclaration?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
@@ -230,6 +252,12 @@ export function InitiateChatFlow({
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const accountTooNewPromptControl = Dialog.useDialogControl()
|
||||
|
||||
const {data: convos} = useListConvosQuery({
|
||||
enabled: showRecentConvos,
|
||||
status: 'accepted',
|
||||
lockStatus: 'unlocked',
|
||||
})
|
||||
|
||||
const {data: chatStatus} = useChatActorStatusQuery()
|
||||
const canCreateGroups = chatStatus?.canCreateGroups ?? true
|
||||
const groupMemberLimit = chatStatus?.groupMemberLimit
|
||||
@@ -281,6 +309,10 @@ export function InitiateChatFlow({
|
||||
let _items: Item[] = []
|
||||
const checker =
|
||||
chatState === ChatState.NEW_GROUP_CHAT ? canBeAddedToGroup : canBeMessaged
|
||||
const messageDeclarationRank = (item: Item) =>
|
||||
item.type === 'profile' && checker(item.profile) ? 0 : 1
|
||||
const compareByMessageDeclaration = (a: Item, b: Item) =>
|
||||
messageDeclarationRank(a) - messageDeclarationRank(b)
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
@@ -310,9 +342,9 @@ export function InitiateChatFlow({
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(item => {
|
||||
return item.type === 'profile' && checker(item.profile) ? -1 : 1
|
||||
})
|
||||
if (sortByMessageDeclaration) {
|
||||
_items = _items.sort(compareByMessageDeclaration)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
@@ -322,7 +354,57 @@ export function InitiateChatFlow({
|
||||
key: i + '',
|
||||
}))
|
||||
|
||||
if (follows) {
|
||||
if (
|
||||
chatState === ChatState.NEW_CHAT &&
|
||||
showRecentConvos &&
|
||||
convos &&
|
||||
follows
|
||||
) {
|
||||
const usedDids = new Set()
|
||||
|
||||
for (const page of convos.pages) {
|
||||
for (const convoView of page.convos) {
|
||||
const convo = parseConvoView(convoView, currentAccount?.did)
|
||||
|
||||
if (!convo) continue
|
||||
|
||||
if (convo.kind === 'group') {
|
||||
_items.push({
|
||||
type: 'existingChat',
|
||||
key: convo.view.id,
|
||||
convo,
|
||||
})
|
||||
} else {
|
||||
if (convo.primaryMember.handle === 'missing.invalid') continue
|
||||
if (usedDids.has(convo.primaryMember.did)) continue
|
||||
|
||||
usedDids.add(convo.primaryMember.did)
|
||||
|
||||
_items.push({
|
||||
type: 'existingChat',
|
||||
key: convo.view.id,
|
||||
convo,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let followsItems: ProfileItem[] = []
|
||||
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
if (!checker(profile)) continue
|
||||
followsItems.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items.push(...followsItems)
|
||||
} else if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (!checker(profile)) continue
|
||||
@@ -359,10 +441,19 @@ export function InitiateChatFlow({
|
||||
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
|
||||
}
|
||||
|
||||
return _items
|
||||
const profileDids = new Set<string>()
|
||||
|
||||
return _items.filter(item => {
|
||||
if (item.type !== 'profile') return true
|
||||
if (profileDids.has(item.profile.did)) return false
|
||||
|
||||
profileDids.add(item.profile.did)
|
||||
return true
|
||||
})
|
||||
}, [
|
||||
isError,
|
||||
chatState,
|
||||
convos,
|
||||
searchText,
|
||||
l,
|
||||
groupChatProfiles,
|
||||
@@ -370,6 +461,8 @@ export function InitiateChatFlow({
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
aa.flags.groupChatDisabled,
|
||||
showRecentConvos,
|
||||
sortByMessageDeclaration,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
@@ -429,6 +522,16 @@ export function InitiateChatFlow({
|
||||
case 'label': {
|
||||
return <UserLabel key={item.key} message={item.message} />
|
||||
}
|
||||
case 'existingChat': {
|
||||
return showRecentConvos && onSelectExistingChat ? (
|
||||
<ExistingChatCard
|
||||
key={item.key}
|
||||
convo={item.convo}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectExistingChat}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
case 'profile': {
|
||||
switch (chatState) {
|
||||
case ChatState.NEW_CHAT:
|
||||
@@ -474,6 +577,8 @@ export function InitiateChatFlow({
|
||||
handlePressNewGroupChat,
|
||||
moderationOpts,
|
||||
onSelectChat,
|
||||
onSelectExistingChat,
|
||||
showRecentConvos,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -845,6 +950,114 @@ function NewGroupChatButton({
|
||||
)
|
||||
}
|
||||
|
||||
function ExistingChatCard({
|
||||
convo,
|
||||
moderationOpts,
|
||||
onPress,
|
||||
}: {
|
||||
convo: ConvoWithDetails
|
||||
moderationOpts: ModerationOpts
|
||||
onPress: (convoId: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const enabled =
|
||||
convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true
|
||||
const name =
|
||||
convo.kind === 'group'
|
||||
? convo.details.name
|
||||
: createSanitizedDisplayName(
|
||||
convo.primaryMember,
|
||||
true,
|
||||
moderateProfile(convo.primaryMember, moderationOpts).ui(
|
||||
'displayName',
|
||||
),
|
||||
)
|
||||
|
||||
const handleOnPress = useCallback(() => {
|
||||
onPress(convo.view.id)
|
||||
}, [onPress, convo.view.id])
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={!enabled}
|
||||
label={l`Select chat "${name}"`}
|
||||
onPress={handleOnPress}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_sm,
|
||||
a.px_lg,
|
||||
!enabled
|
||||
? {opacity: 0.5}
|
||||
: pressed || focused || hovered
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<ProfileCard.Header>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles profiles={convo.members} size={40} />
|
||||
) : (
|
||||
<ProfileCard.Avatar
|
||||
profile={convo.primaryMember}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
/>
|
||||
)}
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
a.leading_snug,
|
||||
a.self_start,
|
||||
a.flex_shrink,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
{convo.kind === 'direct' && (
|
||||
<ProfileBadges
|
||||
profile={convo.primaryMember}
|
||||
size="md"
|
||||
style={[a.pl_xs]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{convo.kind === 'direct' ? (
|
||||
<ProfileCard.Handle profile={convo.primaryMember} />
|
||||
) : (
|
||||
<>
|
||||
{enabled ? (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={2}>
|
||||
<Plural
|
||||
value={convo.details.memberCount}
|
||||
one="# member"
|
||||
other="# members"
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
<Trans>Group is locked</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultProfileCard({
|
||||
profile,
|
||||
moderationOpts,
|
||||
|
||||
@@ -89,7 +89,7 @@ export function NewChat({
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Failed to create groupchat', {safeMessage: error})
|
||||
let errorMessage = l`An issue occurred creating the group chat, please try again.`
|
||||
let errorMessage = l`An issue occurred starting the group chat, please try again.`
|
||||
if (isNetworkError(error)) {
|
||||
errorMessage = l`A network error occurred. Please check your internet connection.`
|
||||
} else if (
|
||||
@@ -184,6 +184,7 @@ export function NewChat({
|
||||
title={l`New chat`}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectGroupChat={onCreateGroupChat}
|
||||
sortByMessageDeclaration
|
||||
startInGroupChat={startInGroupChat}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useCallback, useState} from 'react'
|
||||
import {
|
||||
ChatBskyConvoGetConvoForMembers,
|
||||
ChatBskyGroupCreateGroup,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
|
||||
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
@@ -16,26 +22,39 @@ export function SendViaChatDialog({
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
const [flowKey, setFlowKey] = useState(0)
|
||||
const onClose = useCallback(() => setFlowKey(key => key + 1), [])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{fullHeight: true}}>
|
||||
nativeOptions={{fullHeight: true}}
|
||||
onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
|
||||
<SendViaChatDialogInner
|
||||
control={control}
|
||||
flowKey={flowKey}
|
||||
onSelectChat={onSelectChat}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function SendViaChatDialogInner({
|
||||
control,
|
||||
flowKey,
|
||||
onSelectChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
flowKey: number
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
|
||||
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onSelectChat(data.convo.id)
|
||||
@@ -46,8 +65,74 @@ function SendViaChatDialogInner({
|
||||
ax.metric('chat:open', {logContext: 'SendViaChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Failed to share post to chat', {message: error})
|
||||
Toast.show(_(msg`An issue occurred while trying to open the chat`), {
|
||||
logger.error('Failed to share post to chat', {safeMessage: error})
|
||||
let errorMessage = l`An issue occurred starting the chat, please try again.`
|
||||
if (isNetworkError(error)) {
|
||||
errorMessage = l`A network error occurred. Please check your internet connection.`
|
||||
} else if (
|
||||
error instanceof ChatBskyConvoGetConvoForMembers.AccountSuspendedError
|
||||
) {
|
||||
errorMessage = l`Suspended accounts cannot participate in chat.`
|
||||
} else if (
|
||||
error instanceof ChatBskyConvoGetConvoForMembers.BlockedActorError
|
||||
) {
|
||||
errorMessage = l`This user has blocked you and cannot be messaged.`
|
||||
} else if (
|
||||
error instanceof ChatBskyConvoGetConvoForMembers.MessagesDisabledError
|
||||
) {
|
||||
errorMessage = l`This user has disabled chat and cannot be messaged.`
|
||||
} else if (
|
||||
error instanceof
|
||||
ChatBskyConvoGetConvoForMembers.NotFollowedBySenderError
|
||||
) {
|
||||
errorMessage = l`Chat recipient is not followed by the sender.`
|
||||
} else if (
|
||||
error instanceof ChatBskyConvoGetConvoForMembers.RecipientNotFoundError
|
||||
) {
|
||||
errorMessage = l`Unable to find the selected recipient.`
|
||||
}
|
||||
Toast.show(errorMessage, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: createGroupChat} = useCreateGroupChat({
|
||||
onSuccess: data => {
|
||||
onSelectChat(data.convo.id)
|
||||
|
||||
ax.metric('groupchat:create', {logContext: 'SendViaChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Failed to share post to group chat', {safeMessage: error})
|
||||
let errorMessage = l`An issue occurred starting the group chat, please try again.`
|
||||
if (isNetworkError(error)) {
|
||||
errorMessage = l`A network error occurred. Please check your internet connection.`
|
||||
} else if (
|
||||
error instanceof ChatBskyGroupCreateGroup.AccountSuspendedError
|
||||
) {
|
||||
errorMessage = l`Suspended accounts cannot participate in a group chat.`
|
||||
} else if (error instanceof ChatBskyGroupCreateGroup.BlockedActorError) {
|
||||
errorMessage = l`One of the selected recipients has blocked you and cannot be messaged.`
|
||||
} else if (
|
||||
error instanceof
|
||||
ChatBskyGroupCreateGroup.NewAccountCannotCreateGroupError
|
||||
) {
|
||||
errorMessage = l`You cannot create a group chat yet.`
|
||||
} else if (
|
||||
error instanceof ChatBskyGroupCreateGroup.NotFollowedBySenderError
|
||||
) {
|
||||
errorMessage = l`A selected recipient is not followed by the sender.`
|
||||
} else if (
|
||||
error instanceof ChatBskyGroupCreateGroup.RecipientNotFoundError
|
||||
) {
|
||||
errorMessage = l`Unable to find a selected recipient.`
|
||||
} else if (
|
||||
error instanceof ChatBskyGroupCreateGroup.UserForbidsGroupsError
|
||||
) {
|
||||
errorMessage = l`One of the selected recipients does not allow group chats.`
|
||||
}
|
||||
Toast.show(errorMessage, {
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
@@ -67,9 +152,28 @@ function SendViaChatDialogInner({
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
const onCreateGroupChat = useCallback(
|
||||
(members: string[], name: string) => {
|
||||
control.close(() => {
|
||||
createGroupChat({members, name})
|
||||
})
|
||||
},
|
||||
[control, createGroupChat],
|
||||
)
|
||||
|
||||
return isGroupChatEnabled ? (
|
||||
<InitiateChatFlow
|
||||
key={flowKey}
|
||||
title={l`Send post to...`}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectExistingChat={onSelectExistingChat}
|
||||
onSelectGroupChat={onCreateGroupChat}
|
||||
showRecentConvos
|
||||
sortByMessageDeclaration
|
||||
/>
|
||||
) : (
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
title={l`Send post to...`}
|
||||
onSelectChat={chat => {
|
||||
if (chat.kind === 'user') {
|
||||
onCreateChat(chat.did)
|
||||
|
||||
@@ -5,7 +5,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
|
||||
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {type Dimensions} from '#/components/Lightbox/types'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {GalleryItem} from './ImageLayoutGridItem'
|
||||
|
||||
interface ImageLayoutGridProps {
|
||||
@@ -28,9 +28,7 @@ export function ImageLayoutGrid({
|
||||
...props
|
||||
}: ImageLayoutGridProps) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const isWithinQuote =
|
||||
isWithinQuoteProp ??
|
||||
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const isWithinQuote = isWithinQuoteProp
|
||||
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import {useMemo} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
|
||||
import {useTrendingConfig} from '#/state/service-config'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {
|
||||
atoms as a,
|
||||
useGutters,
|
||||
useLayoutBreakpoints,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
} from '#/alf'
|
||||
import {alpha} from '#/alf/utils'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
|
||||
import {Link} from '#/components/Link'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
const TOPIC_COUNT = 3
|
||||
|
||||
export function FeedTrendingTopicsInterstitial() {
|
||||
const {enabled} = useTrendingConfig()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
|
||||
return enabled && !trendingDisabled && !rightNavVisible ? <Inner /> : null
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const ax = useAnalytics()
|
||||
const {
|
||||
data: trending,
|
||||
error,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
} = useGetTrendsQuery({limit: TOPIC_COUNT})
|
||||
const noTopics = !isLoading && !error && !trending?.trends?.length
|
||||
|
||||
const shadowColor = alpha(t.palette.primary_100, 0.5)
|
||||
|
||||
const gradient = {
|
||||
values: [
|
||||
[0, t.atoms.bg.backgroundColor],
|
||||
[0.1, t.palette.primary_25],
|
||||
[0.9, t.palette.primary_25],
|
||||
[1, t.atoms.bg.backgroundColor],
|
||||
],
|
||||
hover_value: t.palette.white,
|
||||
}
|
||||
|
||||
if (error || noTopics) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
gutters,
|
||||
a.pt_lg,
|
||||
a.pb_xl,
|
||||
a.gap_sm,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={gradient.values.map(c => c[1]) as [string, string, ...string[]]}
|
||||
locations={
|
||||
gradient.values.map(c => c[0]) as [number, number, ...number[]]
|
||||
}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.z_20,
|
||||
a.px_xs,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_xs]}>
|
||||
<TrendingIcon width={18} />
|
||||
<Text
|
||||
style={[a.text_md, a.font_medium, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Trending</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Link label={l`See more trending topics`} to="/search">
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
<Trans>See more</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.z_10,
|
||||
a.border,
|
||||
a.rounded_xl,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderColor: t.palette.primary_100,
|
||||
boxShadow: `0 0 16px 0 ${shadowColor}`,
|
||||
elevation: 8,
|
||||
shadowColor: shadowColor,
|
||||
shadowOffset: {width: 0, height: 0},
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
]}>
|
||||
{isLoading || isRefetching
|
||||
? Array.from({length: TOPIC_COUNT}).map((_, i) => (
|
||||
<TrendingTopicRowSkeleton key={i} rank={i + 1} />
|
||||
))
|
||||
: trending?.trends?.map((trend, index) => (
|
||||
<TrendRow
|
||||
key={trend.link}
|
||||
trend={trend}
|
||||
rank={index + 1}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {context: 'interstitial'})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendRow({
|
||||
trend,
|
||||
rank,
|
||||
onPress,
|
||||
}: ViewStyleProp & {
|
||||
trend: AppBskyUnspeccedDefs.TrendView
|
||||
rank: number
|
||||
children?: React.ReactNode
|
||||
onPress?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l, i18n} = useLingui()
|
||||
|
||||
const actors = useModerateTrendingActors(trend.actors)
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={trend.link}
|
||||
label={l`Browse topic ${trend.displayName}`}
|
||||
to={trend.link}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
rank < TOPIC_COUNT && a.border_b,
|
||||
{
|
||||
borderColor: t.palette.primary_100,
|
||||
},
|
||||
]}
|
||||
PressableComponent={Pressable}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<SubtleHover hover={hovered || pressed} native />
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.flex_row,
|
||||
{
|
||||
gap: 6,
|
||||
padding: 14,
|
||||
paddingLeft: 16,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_low,
|
||||
{
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
]}>
|
||||
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
|
||||
{rank}.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_medium]} numberOfLines={1}>
|
||||
{trend.displayName}
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
{actors.length > 0 ? (
|
||||
<AvatarStack size={24} profiles={actors} />
|
||||
) : null}
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{trend.postCount >= 1000 ? (
|
||||
<Trans comment="Over 1,000 posts">1K+ posts</Trans>
|
||||
) : (
|
||||
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
|
||||
{formatCount(i18n, trend.postCount)}{' '}
|
||||
{plural(trend.postCount, {one: 'post', other: 'posts'})}
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
function TrendingTopicRowSkeleton({rank}: {rank: number}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.flex_row,
|
||||
rank < TOPIC_COUNT && a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
gap: 6,
|
||||
},
|
||||
]}>
|
||||
<LoadingPlaceholder width={17} height={17} style={[a.rounded_full]} />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<LoadingPlaceholder width={70} height={17} />
|
||||
<LoadingPlaceholder width={40} height={17} />
|
||||
<LoadingPlaceholder width={60} height={17} />
|
||||
</View>
|
||||
<LoadingPlaceholder width={24} height={24} style={[a.rounded_full]} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function useModerateTrendingActors(
|
||||
actors: AppBskyUnspeccedDefs.TrendView['actors'],
|
||||
) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!moderationOpts) return []
|
||||
|
||||
return actors
|
||||
.filter(actor => {
|
||||
const decision = moderateProfile(actor, moderationOpts)
|
||||
return !decision.ui('avatar').filter && !decision.ui('avatar').blur
|
||||
})
|
||||
.slice(0, 3)
|
||||
}, [actors, moderationOpts])
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useTrendingSettings,
|
||||
useTrendingSettingsApi,
|
||||
} from '#/state/preferences/trending'
|
||||
import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
|
||||
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
|
||||
import {useTrendingConfig} from '#/state/service-config'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
@@ -20,6 +20,8 @@ import {TrendingTopicLink} from '#/components/TrendingTopics'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
const TRENDING_LIMIT = 14
|
||||
|
||||
export function TrendingInterstitial() {
|
||||
const {enabled} = useTrendingConfig()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
@@ -33,8 +35,15 @@ export function Inner() {
|
||||
const gutters = useGutters([0, 'base', 0, 'base'])
|
||||
const trendingPrompt = Prompt.usePromptControl()
|
||||
const {setTrendingDisabled} = useTrendingSettingsApi()
|
||||
const {data: trending, error, isLoading} = useTrendingTopics()
|
||||
const noTopics = !isLoading && !error && !trending?.topics?.length
|
||||
const {
|
||||
data: trending,
|
||||
error,
|
||||
isLoading,
|
||||
} = useGetTrendsQuery({
|
||||
limit: TRENDING_LIMIT,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
const noTopics = !isLoading && !error && !trending?.trends?.length
|
||||
|
||||
const onConfirmHide = useCallback(() => {
|
||||
ax.metric('trendingTopics:hide', {context: 'interstitial'})
|
||||
@@ -88,15 +97,16 @@ export function Inner() {
|
||||
{' '}
|
||||
</Text>
|
||||
</View>
|
||||
) : !trending?.topics ? null : (
|
||||
) : !trending?.trends ? null : (
|
||||
<>
|
||||
{trending.topics.map(topic => (
|
||||
{trending.trends.map(topic => (
|
||||
<TrendingTopicLink
|
||||
key={topic.link}
|
||||
topic={topic}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {
|
||||
context: 'interstitial',
|
||||
recId: trending.recId,
|
||||
})
|
||||
}}>
|
||||
<View style={[a.py_lg]}>
|
||||
|
||||
@@ -181,6 +181,7 @@ function BlockDialogInner({
|
||||
const footer = (
|
||||
<View style={[a.w_full, a.gap_sm, a.justify_end]}>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
color={profile.viewer?.blocking ? undefined : 'negative'}
|
||||
size="large"
|
||||
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
|
||||
@@ -192,6 +193,7 @@ function BlockDialogInner({
|
||||
<Trans>Block</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
{isLoading ? <ButtonIcon icon={Loader} /> : null}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -211,11 +213,6 @@ function BlockDialogInner({
|
||||
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
|
||||
style={[web([{maxWidth: 420}])]}>
|
||||
{listHeader}
|
||||
{isLoading ? (
|
||||
<View style={[a.pb_2xl, a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : null}
|
||||
{footer}
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {type ModerationUI} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
ADULT_CONTENT_LABELS,
|
||||
@@ -78,7 +76,7 @@ function ContentHiderActive({
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [override, setOverride] = useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
@@ -97,7 +95,7 @@ function ContentHiderActive({
|
||||
(blur.type === 'label' && blur.source.type !== 'user')
|
||||
) {
|
||||
if (desc.isSubjectAccount) {
|
||||
return _(msg`${desc.name} (Account)`)
|
||||
return l`${desc.name} (Account)`
|
||||
} else {
|
||||
return desc.name
|
||||
}
|
||||
@@ -128,7 +126,7 @@ function ContentHiderActive({
|
||||
|
||||
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
|
||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||
return _(msg`Adult Content`)
|
||||
return l`Adult Content`
|
||||
}
|
||||
return getLabelStrings(i18n.locale, globalLabelStrings, def).name
|
||||
})
|
||||
@@ -138,7 +136,7 @@ function ContentHiderActive({
|
||||
}
|
||||
return [...new Set(selfBlurNames)].join(', ')
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
modui.blurs,
|
||||
blur,
|
||||
desc.name,
|
||||
@@ -151,7 +149,6 @@ function ContentHiderActive({
|
||||
return (
|
||||
<View testID={testID} style={[a.overflow_hidden, style]}>
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
|
||||
<Button
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
@@ -166,10 +163,10 @@ function ContentHiderActive({
|
||||
label={desc.name}
|
||||
accessibilityHint={
|
||||
modui.noOverride
|
||||
? _(msg`Learn more about the moderation applied to this content`)
|
||||
? l`Learn more about the moderation applied to this content`
|
||||
: override
|
||||
? _(msg`Hides the content`)
|
||||
: _(msg`Shows the content`)
|
||||
? l`Hides the content`
|
||||
: l`Shows the content`
|
||||
}>
|
||||
{state => (
|
||||
<View
|
||||
@@ -223,7 +220,6 @@ function ContentHiderActive({
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{desc.source && blur.type === 'label' && !override && (
|
||||
<Button
|
||||
onPress={e => {
|
||||
@@ -231,9 +227,7 @@ function ContentHiderActive({
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}
|
||||
label={_(
|
||||
msg`Learn more about the moderation applied to this content`,
|
||||
)}
|
||||
label={l`Learn more about the moderation applied to this content`}
|
||||
style={[a.pt_sm]}>
|
||||
{state => (
|
||||
<Text
|
||||
@@ -252,7 +246,7 @@ function ContentHiderActive({
|
||||
)}{' '}
|
||||
<Text
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
t.atoms.text_link,
|
||||
a.text_sm,
|
||||
state.hovered && [web({textDecoration: 'underline'})],
|
||||
]}>
|
||||
@@ -262,7 +256,6 @@ function ContentHiderActive({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{override && <View style={childContainerStyle}>{children}</View>}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -54,7 +54,15 @@ export function PostAlerts({
|
||||
const isOwnPost = !!post && post.author.did === currentAccount?.did
|
||||
const allLabels: ComAtprotoLabelDefs.Label[] =
|
||||
isOwnPost && view === 'expanded'
|
||||
? [...(post.labels ?? []), ...(post.author.labels ?? [])]
|
||||
? [
|
||||
...(post.labels ?? []),
|
||||
/*
|
||||
* Account labels appear on Profile. We don't show them here unless the
|
||||
* user's mod settings are configured such that the labels land in the
|
||||
* modui handling.
|
||||
*/
|
||||
// ...(post.author.labels ?? [])
|
||||
]
|
||||
: []
|
||||
/*
|
||||
* Labels that the moderation system already surfaces in this context -
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {type ModerationUI} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
@@ -38,7 +36,7 @@ export function ScreenHider({
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const [override, setOverride] = useState(false)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -131,15 +129,13 @@ export function ScreenHider({
|
||||
control.open()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Learn more about this warning`)}
|
||||
accessibilityLabel={l`Learn more about this warning`}
|
||||
accessibilityHint="">
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.leading_snug,
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
t.atoms.text_link,
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
@@ -158,7 +154,7 @@ export function ScreenHider({
|
||||
color="primary"
|
||||
size="large"
|
||||
style={[a.rounded_full]}
|
||||
label={_(msg`Go back`)}
|
||||
label={l`Go back`}
|
||||
onPress={() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
@@ -176,7 +172,7 @@ export function ScreenHider({
|
||||
color="secondary"
|
||||
size="large"
|
||||
style={[a.rounded_full]}
|
||||
label={_(msg`Show anyway`)}
|
||||
label={l`Show anyway`}
|
||||
onPress={() => setOverride(v => !v)}>
|
||||
<ButtonText>
|
||||
<Trans>Show anyway</Trans>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {type Insets, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -52,16 +51,25 @@ export function shouldShowVerificationCheckButton(
|
||||
export function VerificationCheckButton({
|
||||
profile,
|
||||
width,
|
||||
hitSlop,
|
||||
}: {
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
width: number
|
||||
hitSlop: Insets
|
||||
}) {
|
||||
const state = useFullVerificationState({
|
||||
profile,
|
||||
})
|
||||
|
||||
if (shouldShowVerificationCheckButton(state)) {
|
||||
return <Badge profile={profile} verificationState={state} width={width} />
|
||||
return (
|
||||
<Badge
|
||||
profile={profile}
|
||||
verificationState={state}
|
||||
width={width}
|
||||
hitSlop={hitSlop}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -71,14 +79,16 @@ function Badge({
|
||||
profile,
|
||||
verificationState: state,
|
||||
width,
|
||||
hitSlop,
|
||||
}: {
|
||||
profile: Shadow<bsky.profile.AnyProfileView>
|
||||
verificationState: FullVerificationState
|
||||
width: number
|
||||
hitSlop: Insets
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const verificationsDialogControl = useDialogControl()
|
||||
const verifierDialogControl = useDialogControl()
|
||||
|
||||
@@ -89,10 +99,10 @@ function Badge({
|
||||
<Button
|
||||
label={
|
||||
state.profile.isViewer
|
||||
? _(msg`View your verifications`)
|
||||
: _(msg`View this user's verifications`)
|
||||
? l`View your verifications`
|
||||
: l`View this user's verifications`
|
||||
}
|
||||
hitSlop={20}
|
||||
hitSlop={hitSlop}
|
||||
onPress={evt => {
|
||||
evt.preventDefault()
|
||||
ax.metric('verification:badge:click', {})
|
||||
@@ -132,13 +142,11 @@ function Badge({
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<VerificationsDialog
|
||||
control={verificationsDialogControl}
|
||||
profile={profile}
|
||||
verificationState={state}
|
||||
/>
|
||||
|
||||
<VerifierDialog
|
||||
control={verifierDialogControl}
|
||||
profile={profile}
|
||||
|
||||
@@ -3,11 +3,9 @@ import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||
import {atoms as a, useLayoutBreakpoints} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
|
||||
import {TrendingInterstitial} from '#/components/interstitials/Trending'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
|
||||
import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context'
|
||||
@@ -17,16 +15,10 @@ import {type LiveEventFeed} from '#/features/liveEvents/types'
|
||||
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
|
||||
const events = useUserPreferencedLiveEvents()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
|
||||
if (!events.feeds.length) {
|
||||
if (!rightNavVisible && !trendingDisabled) {
|
||||
// only show trending on mobile when live event banner is not shown
|
||||
return <TrendingInterstitial />
|
||||
} else {
|
||||
// no feed, no trending
|
||||
return null
|
||||
}
|
||||
// no feed
|
||||
return null
|
||||
}
|
||||
|
||||
// On desktop, we show in the sidebar
|
||||
|
||||
@@ -146,6 +146,9 @@ export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
||||
export const TRENDING_DID = 'did:plc:qrz3lhbyuxbeilrc6nekdqme'
|
||||
export const TRENDING_HANDLE = 'trending.bsky.app'
|
||||
|
||||
export const DISCOVER_FEED_URI =
|
||||
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
|
||||
export const VIDEO_FEED_URI =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {Asset} from 'expo-asset'
|
||||
import {
|
||||
documentDirectory,
|
||||
getInfoAsync,
|
||||
@@ -9,10 +10,15 @@ import ExpoImageCropTool, {
|
||||
} from '@bsky.app/expo-image-crop-tool'
|
||||
|
||||
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
|
||||
import {IS_ANDROID} from '#/env'
|
||||
import {compressIfNeeded} from './manip'
|
||||
import {type PickerImage} from './picker.shared'
|
||||
|
||||
async function getFile() {
|
||||
if (IS_ANDROID) {
|
||||
return await getAndroidFile()
|
||||
}
|
||||
|
||||
const imagesDir = documentDirectory!
|
||||
.split('/')
|
||||
.slice(0, -6)
|
||||
@@ -41,6 +47,44 @@ async function getFile() {
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* The Android emulator can't reach the iOS simulator's sample photo library,
|
||||
* so we load a jpg bundled with the app instead. It is bundled via require()
|
||||
* (resolved by Metro), so it survives `pm clear`, which Maestro's clearState
|
||||
* runs at the start of every flow. An adb-seeded file in app-scoped external
|
||||
* storage does not survive: pm clear wipes that directory each flow, so the
|
||||
* seeded file is gone before the picker mock ever reads it.
|
||||
*/
|
||||
async function getAndroidFile() {
|
||||
const asset = Asset.fromModule(
|
||||
require('../../../assets/images/welcome-modal-bg.jpg'),
|
||||
)
|
||||
await asset.downloadAsync()
|
||||
|
||||
const path = asset.localUri!
|
||||
const fileInfo = await getInfoAsync(path)
|
||||
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Failed to get file info')
|
||||
}
|
||||
|
||||
/*
|
||||
* Dimensions of the bundled asset (assets/images/welcome-modal-bg.jpg). Only
|
||||
* used for downstream aspect-ratio display; the actual bytes are read from
|
||||
* disk by compressIfNeeded.
|
||||
*/
|
||||
return await compressIfNeeded(
|
||||
{
|
||||
path,
|
||||
mime: 'image/jpeg',
|
||||
size: fileInfo.size,
|
||||
width: 1432,
|
||||
height: 1025,
|
||||
},
|
||||
IMAGE_SIZE_CONFIG_2K_1MB,
|
||||
)
|
||||
}
|
||||
|
||||
export async function openPicker(): Promise<PickerImage[]> {
|
||||
return [await getFile()]
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export type CommonNavigatorParams = {
|
||||
ActivityPrivacySettings: undefined
|
||||
ContentAndMediaSettings: undefined
|
||||
NotificationSettings: undefined
|
||||
ActivityNotificationSettings: undefined
|
||||
InterestsSettings: undefined
|
||||
AboutSettings: undefined
|
||||
AppIconSettings: undefined
|
||||
|
||||
+549
-514
File diff suppressed because one or more lines are too long
@@ -61,6 +61,7 @@ export const router = new Router<AllNavigatableRoutes>({
|
||||
AboutSettings: '/settings/about',
|
||||
AppIconSettings: '/settings/app-icon',
|
||||
NotificationSettings: '/settings/notifications',
|
||||
ActivityNotificationSettings: '/settings/notifications/activity',
|
||||
FindContactsSettings: '/settings/find-contacts',
|
||||
// support
|
||||
Support: '/support',
|
||||
|
||||
@@ -370,7 +370,7 @@ function SuggestedProfileCard({
|
||||
category: string | null
|
||||
onSeen: (did: string, position: number) => void
|
||||
recSource?: 'Search'
|
||||
recId?: number | string
|
||||
recId?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useLikedBySampleQuery} from '#/state/queries/post-liked-by'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {useFormatPostStatCount} from '#/components/PostControls/util'
|
||||
@@ -18,27 +16,56 @@ import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
const AVI_SIZE = 20
|
||||
const MAX_NAME_LENGTH = 16
|
||||
|
||||
/**
|
||||
* The likes stat for the expanded anchor post. When the viewer follows some
|
||||
* of the post's recent likers, renders social proof - a face pile plus
|
||||
* "Liked by A, B, and N others" - in place of the plain "N likes" text,
|
||||
* which it falls back to otherwise.
|
||||
*
|
||||
* Known likers are sourced client-side from a single `getLikes` request (100
|
||||
* likes, the API max per page), so they are a sample of the most recent
|
||||
* likers, not an exhaustive list. Only the faces and names are affected by
|
||||
* sampling - the "N others" count is derived from the post's total like
|
||||
* count.
|
||||
* The plain "N likes" stat for the expanded anchor post, linking to the likes
|
||||
* list. Renders nothing when the post has no likes.
|
||||
*/
|
||||
export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {t: l} = useLingui()
|
||||
const formatPostStatCount = useFormatPostStatCount()
|
||||
const ax = useAnalytics()
|
||||
|
||||
const likeCount = post.likeCount ?? 0
|
||||
if (likeCount === 0) return null
|
||||
|
||||
const urip = new AtUri(post.uri)
|
||||
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={likesHref}
|
||||
label={l`Likes on this post`}
|
||||
onPress={() => ax.metric('post:likedBy:click', {})}>
|
||||
<Text
|
||||
testID="likeCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{formatPostStatCount(likeCount)}
|
||||
</Text>{' '}
|
||||
<Plural value={likeCount} one="like" other="likes" />
|
||||
</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Social proof for the expanded anchor post. When the viewer follows some of
|
||||
* the post's recent likers, renders a face pile plus "Liked by A and B" on
|
||||
* its own row below the interaction stats line. Renders nothing otherwise.
|
||||
*
|
||||
* Known likers are sourced client-side from a single `getLikes` request (100
|
||||
* likes, the API max per page), so they are a sample of the most recent
|
||||
* likers, not an exhaustive list.
|
||||
*/
|
||||
export function KnownLikers({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const formatPostStatCount = useFormatPostStatCount()
|
||||
const ax = useAnalytics()
|
||||
|
||||
const likeCount = post.likeCount ?? 0
|
||||
@@ -78,67 +105,34 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
knownLikersAndModeration.length > 0 &&
|
||||
ax.features.enabled(ax.features.PostThreadKnownLikersEnable)
|
||||
|
||||
if (!showKnownLikers) {
|
||||
return (
|
||||
<Link
|
||||
to={likesHref}
|
||||
label={l`Likes on this post`}
|
||||
onPress={onPressLikedBy}>
|
||||
<Text
|
||||
testID="likeCount-expanded"
|
||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
{formatPostStatCount(likeCount)}
|
||||
</Text>{' '}
|
||||
<Plural value={likeCount} one="like" other="likes" />
|
||||
</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
if (!showKnownLikers) return null
|
||||
|
||||
const aviStackProfiles = knownLikersAndModeration
|
||||
.slice(0, 3)
|
||||
.map(({actor}) => actor)
|
||||
const maxNames = gtMobile ? 2 : 1
|
||||
const names = knownLikersAndModeration
|
||||
.slice(0, maxNames)
|
||||
.slice(0, 2)
|
||||
.map(({actor, moderation}) => {
|
||||
return {
|
||||
did: actor.did,
|
||||
href: makeProfileLink(actor),
|
||||
displayName: enforceLen(
|
||||
sanitizeDisplayName(
|
||||
actor.displayName || actor.handle,
|
||||
moderation.ui('displayName'),
|
||||
),
|
||||
MAX_NAME_LENGTH,
|
||||
true,
|
||||
displayName: sanitizeDisplayName(
|
||||
actor.displayName || actor.handle,
|
||||
moderation.ui('displayName'),
|
||||
),
|
||||
}
|
||||
})
|
||||
const others = likeCount - names.length
|
||||
|
||||
/*
|
||||
* The row link's a11y label mirrors the visible sentence so screen readers
|
||||
* announce the social proof.
|
||||
*/
|
||||
const othersLabel = plural(others, {
|
||||
one: `${formatPostStatCount(others)} other`,
|
||||
other: `${formatPostStatCount(others)} others`,
|
||||
})
|
||||
const rowLabel =
|
||||
names.length >= 2
|
||||
? others > 0
|
||||
? l`${names[0].displayName}, ${names[1].displayName}, and ${othersLabel} like this`
|
||||
: l`${names[0].displayName} and ${names[1].displayName} like this`
|
||||
: others > 0
|
||||
? l`${names[0].displayName} and ${othersLabel} like this`
|
||||
: l`${names[0].displayName} likes this`
|
||||
? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
|
||||
: l`Liked by ${names[0].displayName}`
|
||||
|
||||
const textStyle = [a.text_md, t.atoms.text_contrast_medium]
|
||||
const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text]
|
||||
const textStyle = [a.text_sm, t.atoms.text_contrast_medium]
|
||||
const nameStyle = [a.text_sm, a.font_semi_bold, t.atoms.text]
|
||||
|
||||
/*
|
||||
* Nested inside the row link, but the deepest link claims the press, so
|
||||
@@ -160,10 +154,8 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
|
||||
return (
|
||||
/*
|
||||
* The full-width wrapper keeps the social proof on its own line within
|
||||
* the wrapping stats row, rather than wrapping mid-row and orphaning
|
||||
* whichever count stat comes last. The link itself hugs its content so
|
||||
* the empty space to the right of the text is not pressable.
|
||||
* The full-width wrapper forces the social proof onto its own line below
|
||||
* the count stats within the wrapping stats row.
|
||||
*/
|
||||
<View style={[a.w_full, a.flex_row]}>
|
||||
<Link
|
||||
@@ -172,39 +164,14 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_shrink]}
|
||||
onPress={onPressLikedBy}>
|
||||
<AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} />
|
||||
<Text
|
||||
testID="knownLikersStat"
|
||||
numberOfLines={1}
|
||||
style={[a.flex_shrink, textStyle]}>
|
||||
<Text testID="knownLikersStat" style={[a.flex_shrink, textStyle]}>
|
||||
{names.length >= 2 ? (
|
||||
others > 0 ? (
|
||||
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post, and the count is the remaining number of likes">
|
||||
{nameLink(names[0])}, {nameLink(names[1])}, and{' '}
|
||||
<Plural
|
||||
value={others}
|
||||
one={`${formatPostStatCount(others)} other`}
|
||||
other={`${formatPostStatCount(others)} others`}
|
||||
/>{' '}
|
||||
like this
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post and are its only likes">
|
||||
{nameLink(names[0])} and {nameLink(names[1])} like this
|
||||
</Trans>
|
||||
)
|
||||
) : others > 0 ? (
|
||||
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post, and the count is the remaining number of likes">
|
||||
{nameLink(names[0])} and{' '}
|
||||
<Plural
|
||||
value={others}
|
||||
one={`${formatPostStatCount(others)} other`}
|
||||
other={`${formatPostStatCount(others)} others`}
|
||||
/>{' '}
|
||||
like this
|
||||
<Trans comment="Social proof below the post stats; the bolded names are people the viewer follows who liked the post">
|
||||
Liked by {nameLink(names[0])} and {nameLink(names[1])}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post and is its only like">
|
||||
{nameLink(names[0])} likes this
|
||||
<Trans comment="Social proof below the post stats; the bolded name is a person the viewer follows who liked the post">
|
||||
Liked by {nameLink(names[0])}
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {type PostSource} from '#/state/unstable-post-source'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {LikesStat} from '#/screens/PostThread/components/LikesStat'
|
||||
import {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat'
|
||||
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
|
||||
import {
|
||||
LINEAR_AVI_WIDTH,
|
||||
@@ -440,7 +440,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
a.py_md,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<LikesStat post={post} />
|
||||
{post.repostCount != null && post.repostCount !== 0 ? (
|
||||
<Link to={repostsHref} label={l`Reposts of this post`}>
|
||||
<Text
|
||||
@@ -481,6 +480,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
<LikesStat post={post} />
|
||||
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
|
||||
<Text
|
||||
testID="bookmarkCount-expanded"
|
||||
@@ -497,6 +497,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
<KnownLikers post={post} />
|
||||
</View>
|
||||
) : null}
|
||||
<View
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {useAnimatedRef} from 'react-native-reanimated'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {VIDEO_FEED_URIS} from '#/lib/constants'
|
||||
import {TRENDING_DID, TRENDING_HANDLE, VIDEO_FEED_URIS} from '#/lib/constants'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
@@ -52,7 +51,7 @@ export function ProfileFeedScreen(props: Props) {
|
||||
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
|
||||
? {feedCacheKey: props.route.params.feedCacheKey}
|
||||
: undefined
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const uri = useMemo(
|
||||
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
|
||||
@@ -70,7 +69,7 @@ export function ProfileFeedScreen(props: Props) {
|
||||
<Layout.Screen testID="profileFeedScreenError">
|
||||
<ErrorScreen
|
||||
showHeader
|
||||
title={_(msg`Could not load feed`)}
|
||||
title={l`Could not load feed`}
|
||||
message={cleanError(error)}
|
||||
onPressTryAgain={() => void refetch()}
|
||||
/>
|
||||
@@ -131,7 +130,7 @@ export function ProfileFeedScreenInner({
|
||||
feedInfo: FeedSourceFeedInfo
|
||||
feedParams: FeedParams | undefined
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {hasSession} = useSession()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const isScreenFocused = useIsFocused()
|
||||
@@ -168,10 +167,10 @@ export function ProfileFeedScreenInner({
|
||||
<EmptyState
|
||||
icon={HashtagWideIcon}
|
||||
iconSize="2xl"
|
||||
message={_(msg`This feed is empty.`)}
|
||||
message={l`This feed is empty.`}
|
||||
/>
|
||||
)
|
||||
}, [_])
|
||||
}, [l])
|
||||
|
||||
const isVideoFeed = useMemo(() => {
|
||||
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
|
||||
@@ -181,13 +180,17 @@ export function ProfileFeedScreenInner({
|
||||
return IS_NATIVE && _isVideoFeed
|
||||
}, [feedInfo])
|
||||
|
||||
const isTrending =
|
||||
feedInfo.creatorDid.toLowerCase() === TRENDING_DID ||
|
||||
feedInfo.creatorHandle.toLowerCase() === TRENDING_HANDLE
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileFeedHeader info={feedInfo} />
|
||||
|
||||
<ProfileFeedHeader info={feedInfo} isTrending={isTrending} />
|
||||
<FeedFeedbackProvider value={feedFeedback}>
|
||||
<PostFeed
|
||||
enabled
|
||||
description={isTrending ? feedInfo.description : undefined}
|
||||
feed={feed}
|
||||
feedParams={feedParams}
|
||||
pollInterval={60e3}
|
||||
@@ -199,22 +202,20 @@ export function ProfileFeedScreenInner({
|
||||
isVideoFeed={isVideoFeed}
|
||||
/>
|
||||
</FeedFeedbackProvider>
|
||||
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
onPress={onScrollToTop}
|
||||
label={_(msg`Load new posts`)}
|
||||
label={l`Load new posts`}
|
||||
showIndicator={hasNew}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasSession && (
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => openComposer({logContext: 'Fab'})}
|
||||
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityLabel={l`New post`}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {View} from 'react-native'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {TRENDING_HANDLE} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
@@ -24,20 +25,20 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
|
||||
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilledIcon,
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIcon,
|
||||
} from '#/components/icons/Heart2'
|
||||
import {
|
||||
Pin_Filled_Corner0_Rounded as PinFilled,
|
||||
Pin_Stroke2_Corner0_Rounded as Pin,
|
||||
Pin_Filled_Corner0_Rounded as PinFilledIcon,
|
||||
Pin_Stroke2_Corner0_Rounded as PinIcon,
|
||||
} from '#/components/icons/Pin'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as Menu from '#/components/Menu'
|
||||
@@ -74,14 +75,20 @@ export function ProfileFeedHeaderSkeleton() {
|
||||
width: 34,
|
||||
},
|
||||
]}>
|
||||
<Pin size="lg" fill={t.atoms.text_contrast_low.color} />
|
||||
<PinIcon size="lg" fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
</Layout.Header.Slot>
|
||||
</Layout.Header.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
export function ProfileFeedHeader({
|
||||
info,
|
||||
isTrending,
|
||||
}: {
|
||||
info: FeedSourceFeedInfo
|
||||
isTrending: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
@@ -188,105 +195,143 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content align="left">
|
||||
<Button
|
||||
label={l`Open feed info screen`}
|
||||
style={[
|
||||
a.justify_start,
|
||||
{
|
||||
paddingVertical: IS_WEB ? 2 : 4,
|
||||
paddingRight: 8,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
playHaptic()
|
||||
infoControl.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<View
|
||||
{isTrending ? (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_sm,
|
||||
a.transition_all,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
opacity: 0,
|
||||
left: IS_WEB ? -2 : -4,
|
||||
right: 0,
|
||||
},
|
||||
pressed && {
|
||||
opacity: 1,
|
||||
},
|
||||
hovered && {
|
||||
opacity: 1,
|
||||
transform: [{scaleX: 1.01}, {scaleY: 1.1}],
|
||||
},
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
gtMobile && a.text_lg,
|
||||
]}
|
||||
/>
|
||||
numberOfLines={2}
|
||||
emoji>
|
||||
{info.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={l`Open feed info screen`}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
playHaptic()
|
||||
infoControl.open()
|
||||
}}>
|
||||
<ButtonIcon icon={EllipsisIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
label={l`Open feed info screen`}
|
||||
style={[
|
||||
a.justify_start,
|
||||
{
|
||||
paddingVertical: IS_WEB ? 2 : 4,
|
||||
paddingRight: 8,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
playHaptic()
|
||||
infoControl.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_sm,
|
||||
a.transition_all,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
opacity: 0,
|
||||
left: IS_WEB ? -2 : -4,
|
||||
right: 0,
|
||||
},
|
||||
pressed && {
|
||||
opacity: 1,
|
||||
},
|
||||
hovered && {
|
||||
opacity: 1,
|
||||
transform: [{scaleX: 1.01}, {scaleY: 1.1}],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{info.avatar && (
|
||||
<UserAvatar size={36} type="algo" avatar={info.avatar} />
|
||||
)}
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{info.avatar && (
|
||||
<UserAvatar
|
||||
size={36}
|
||||
type="algo"
|
||||
avatar={info.avatar}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
gtMobile && a.text_lg,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
emoji>
|
||||
{info.displayName}
|
||||
</Text>
|
||||
<View style={[a.flex_row, {gap: 6}]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_shrink,
|
||||
a.text_sm,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
gtMobile && a.text_lg,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(info.creatorHandle, '@')}
|
||||
numberOfLines={2}
|
||||
emoji>
|
||||
{info.displayName}
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_center, {gap: 2}]}>
|
||||
<HeartFilled
|
||||
size="xs"
|
||||
fill={
|
||||
likeUri
|
||||
? t.palette.pink
|
||||
: t.atoms.text_contrast_low.color
|
||||
}
|
||||
/>
|
||||
<View style={[a.flex_row, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_shrink,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
t.atoms.text_contrast_high,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{formatCount(i18n, likeCount)}
|
||||
{sanitizeHandle(info.creatorHandle, '@')}
|
||||
</Text>
|
||||
{likeCount > 0 ? (
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, {gap: 2}]}>
|
||||
<HeartFilledIcon
|
||||
size="xs"
|
||||
fill={
|
||||
likeUri
|
||||
? t.palette.pink
|
||||
: t.atoms.text_contrast_low.color
|
||||
}
|
||||
style={[{width: 14, height: 14}]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{formatCount(i18n, likeCount)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Ellipsis
|
||||
size="md"
|
||||
fill={t.atoms.text_contrast_low.color}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<EllipsisIcon
|
||||
size="md"
|
||||
fill={t.atoms.text_contrast_high.color}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Layout.Header.Content>
|
||||
|
||||
{hasSession && (
|
||||
{!isTrending && hasSession ? (
|
||||
<Layout.Header.Slot>
|
||||
{isPinned ? (
|
||||
<Menu.Root>
|
||||
@@ -300,7 +345,10 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
color="secondary">
|
||||
<PinFilled size="lg" fill={t.palette.primary_500} />
|
||||
<PinFilledIcon
|
||||
size="lg"
|
||||
fill={t.palette.primary_500}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}}
|
||||
@@ -310,23 +358,23 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
<Menu.Item
|
||||
disabled={isFeedStateChangePending}
|
||||
label={l`Unpin from home`}
|
||||
onPress={onTogglePinned}>
|
||||
onPress={() => void onTogglePinned()}>
|
||||
<Menu.ItemText>{l`Unpin from home`}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={X} position="right" />
|
||||
<Menu.ItemIcon icon={XIcon} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={isFeedStateChangePending}
|
||||
label={
|
||||
isSaved ? l`Remove from my feeds` : l`Save to my feeds`
|
||||
}
|
||||
onPress={onToggleSaved}>
|
||||
onPress={() => void onToggleSaved()}>
|
||||
<Menu.ItemText>
|
||||
{isSaved
|
||||
? l`Remove from my feeds`
|
||||
: l`Save to my feeds`}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isSaved ? Trash : Plus}
|
||||
icon={isSaved ? TrashIcon : PlusIcon}
|
||||
position="right"
|
||||
/>
|
||||
</Menu.Item>
|
||||
@@ -339,12 +387,12 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
variant="ghost"
|
||||
shape="square"
|
||||
color="secondary"
|
||||
onPress={onTogglePinned}>
|
||||
<ButtonIcon icon={Pin} size="lg" />
|
||||
onPress={() => void onTogglePinned()}>
|
||||
<ButtonIcon icon={PinIcon} size="lg" />
|
||||
</Button>
|
||||
)}
|
||||
</Layout.Header.Slot>
|
||||
)}
|
||||
) : null}
|
||||
</Layout.Header.Outer>
|
||||
</Layout.Center>
|
||||
<Dialog.Outer control={infoControl}>
|
||||
@@ -358,7 +406,8 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
|
||||
setLikeUri={setLikeUri}
|
||||
likeCount={likeCount}
|
||||
isPinned={isPinned}
|
||||
onTogglePinned={onTogglePinned}
|
||||
isTrending={isTrending}
|
||||
onTogglePinned={() => void onTogglePinned()}
|
||||
isFeedStateChangePending={isFeedStateChangePending}
|
||||
/>
|
||||
</Dialog.ScrollableInner>
|
||||
@@ -373,6 +422,7 @@ function DialogInner({
|
||||
setLikeUri,
|
||||
likeCount,
|
||||
isPinned,
|
||||
isTrending,
|
||||
onTogglePinned,
|
||||
isFeedStateChangePending,
|
||||
}: {
|
||||
@@ -381,6 +431,7 @@ function DialogInner({
|
||||
setLikeUri: (uri: string) => void
|
||||
likeCount: number
|
||||
isPinned: boolean
|
||||
isTrending: boolean
|
||||
onTogglePinned: () => void
|
||||
isFeedStateChangePending: boolean
|
||||
}) {
|
||||
@@ -459,7 +510,9 @@ function DialogInner({
|
||||
style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}
|
||||
onPress={() => control.close()}>
|
||||
{sanitizeHandle(info.creatorHandle, '@')}
|
||||
{info.creatorHandle === TRENDING_HANDLE
|
||||
? l`Bluesky`
|
||||
: sanitizeHandle(info.creatorHandle, '@')}
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
@@ -472,12 +525,13 @@ function DialogInner({
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={onPressShare}>
|
||||
<ButtonIcon icon={Share} size="lg" />
|
||||
<ButtonIcon icon={ShareIcon} size="lg" />
|
||||
</Button>
|
||||
</View>
|
||||
<RichText value={info.description} style={[a.text_md]} />
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
{typeof likeCount === 'number' && (
|
||||
|
||||
{typeof likeCount === 'number' && likeCount > 0 ? (
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<InlineLinkText
|
||||
label={l`View users who like this feed`}
|
||||
to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')}
|
||||
@@ -487,41 +541,47 @@ function DialogInner({
|
||||
Liked by <Plural value={likeCount} one="# user" other="# users" />
|
||||
</Trans>
|
||||
</InlineLinkText>
|
||||
)}
|
||||
</View>
|
||||
{hasSession && (
|
||||
</View>
|
||||
) : null}
|
||||
{hasSession ? (
|
||||
<>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
|
||||
<Button
|
||||
disabled={isLikePending || isUnlikePending}
|
||||
label={l`Like this feed`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
onPress={onToggleLiked}
|
||||
style={[a.flex_1]}>
|
||||
{isLiked ? (
|
||||
<HeartFilled size="sm" fill={t.palette.pink} />
|
||||
) : (
|
||||
<ButtonIcon icon={Heart} />
|
||||
)}
|
||||
{!isTrending ? (
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
|
||||
<Button
|
||||
disabled={isLikePending || isUnlikePending}
|
||||
label={l`Like this feed`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
onPress={() => void onToggleLiked()}
|
||||
style={[a.flex_1]}>
|
||||
{isLiked ? (
|
||||
<HeartFilledIcon size="sm" fill={t.palette.pink} />
|
||||
) : (
|
||||
<ButtonIcon icon={HeartIcon} />
|
||||
)}
|
||||
|
||||
<ButtonText>
|
||||
{isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isFeedStateChangePending}
|
||||
label={isPinned ? l`Unpin feed` : l`Pin feed`}
|
||||
size="small"
|
||||
color={isPinned ? 'secondary' : 'primary'}
|
||||
onPress={onTogglePinned}
|
||||
style={[a.flex_1]}>
|
||||
<ButtonText>
|
||||
{isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>}
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={Pin} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
<ButtonText>
|
||||
{isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isFeedStateChangePending}
|
||||
label={isPinned ? l`Unpin feed` : l`Pin feed`}
|
||||
size="small"
|
||||
color={isPinned ? 'secondary' : 'primary'}
|
||||
onPress={onTogglePinned}
|
||||
style={[a.flex_1]}>
|
||||
<ButtonText>
|
||||
{isPinned ? (
|
||||
<Trans>Unpin feed</Trans>
|
||||
) : (
|
||||
<Trans>Pin feed</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={PinIcon} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={[a.pt_xs, a.gap_lg]}>
|
||||
<Divider />
|
||||
@@ -541,7 +601,7 @@ function DialogInner({
|
||||
<ButtonText>
|
||||
<Trans>Report feed</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={CircleInfo} position="right" />
|
||||
<ButtonIcon icon={CircleInfoIcon} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -556,7 +616,7 @@ function DialogInner({
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import * as bcp47Match from 'bcp-47-match'
|
||||
|
||||
@@ -48,7 +46,6 @@ import {
|
||||
StarterPackCardSkeleton,
|
||||
} from '#/screens/Search/components/StarterPackCard'
|
||||
import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard'
|
||||
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
|
||||
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
|
||||
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
|
||||
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||
@@ -79,7 +76,7 @@ import {
|
||||
|
||||
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const handleOnPress = () => {
|
||||
void item.onLoadMore()
|
||||
@@ -87,7 +84,7 @@ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Load more`)}
|
||||
label={l`Load more`}
|
||||
onPress={handleOnPress}
|
||||
style={[a.relative, a.w_full]}>
|
||||
{({hovered, pressed}) => (
|
||||
@@ -139,6 +136,7 @@ type ExploreScreenItems =
|
||||
key: string
|
||||
title: string
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
iconSize?: IcoProps['size']
|
||||
searchButton?: {
|
||||
label: string
|
||||
metricsTag: Metrics['explore:module:searchButtonPress']['module']
|
||||
@@ -154,10 +152,6 @@ type ExploreScreenItems =
|
||||
type: 'trendingVideos'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'recommendations'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
@@ -220,7 +214,7 @@ export function Explore({
|
||||
headerHeight: number
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {data: preferences, error: preferencesError} = usePreferencesQuery()
|
||||
const moderationOpts = useModerationOpts()
|
||||
@@ -372,10 +366,11 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'tabbedHeader',
|
||||
key: 'suggested-accounts-header',
|
||||
title: _(msg`Suggested accounts`),
|
||||
title: l`Suggested accounts`,
|
||||
icon: Person,
|
||||
iconSize: 'md',
|
||||
searchButton: {
|
||||
label: _(msg`Search for more accounts`),
|
||||
label: l`Search for more accounts`,
|
||||
metricsTag: 'suggestedAccounts',
|
||||
tab: 'user',
|
||||
},
|
||||
@@ -388,7 +383,7 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'suggestedUsersError',
|
||||
message: _(msg`Failed to load suggested follows`),
|
||||
message: l`Failed to load suggested follows`,
|
||||
error: cleanError(suggestedUsersError),
|
||||
})
|
||||
} else {
|
||||
@@ -436,7 +431,7 @@ export function Explore({
|
||||
}
|
||||
return i
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
moderationOpts,
|
||||
suggestedUsers,
|
||||
suggestedUsersIsLoading,
|
||||
@@ -450,10 +445,11 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'header',
|
||||
key: 'suggested-feeds-header',
|
||||
title: _(msg`Discover new feeds`),
|
||||
title: l`Discover feeds`,
|
||||
icon: ListSparkle,
|
||||
iconSize: 'md',
|
||||
searchButton: {
|
||||
label: _(msg`Search for more feeds`),
|
||||
label: l`Search for more feeds`,
|
||||
metricsTag: 'suggestedFeeds',
|
||||
tab: 'feed',
|
||||
},
|
||||
@@ -479,14 +475,14 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'suggestedFeedsError',
|
||||
message: _(msg`Failed to load suggested feeds`),
|
||||
message: l`Failed to load suggested feeds`,
|
||||
error: cleanError(suggestedFeedsError),
|
||||
})
|
||||
} else if (preferencesError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'preferencesError',
|
||||
message: _(msg`Failed to load feeds preferences`),
|
||||
message: l`Failed to load feeds preferences`,
|
||||
error: cleanError(preferencesError),
|
||||
})
|
||||
} else {
|
||||
@@ -516,7 +512,7 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreFeeds',
|
||||
message: _(msg`Load more suggested feeds`),
|
||||
message: l`Load more suggested feeds`,
|
||||
isLoadingMore: isLoadingMoreFeeds,
|
||||
onLoadMore: onLoadMoreFeeds,
|
||||
})
|
||||
@@ -527,21 +523,21 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'feedsError',
|
||||
message: _(msg`Failed to load feeds`),
|
||||
message: l`Failed to load feeds`,
|
||||
error: cleanError(feedsError),
|
||||
})
|
||||
} else if (suggestedFeedsError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'suggestedFeedsError',
|
||||
message: _(msg`Failed to load suggested feeds`),
|
||||
message: l`Failed to load suggested feeds`,
|
||||
error: cleanError(suggestedFeedsError),
|
||||
})
|
||||
} else if (preferencesError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'preferencesError',
|
||||
message: _(msg`Failed to load feeds preferences`),
|
||||
message: l`Failed to load feeds preferences`,
|
||||
error: cleanError(preferencesError),
|
||||
})
|
||||
} else {
|
||||
@@ -572,21 +568,21 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'feedsError',
|
||||
message: _(msg`Failed to load feeds`),
|
||||
message: l`Failed to load feeds`,
|
||||
error: cleanError(feedsError),
|
||||
})
|
||||
} else if (suggestedFeedsError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'suggestedFeedsError',
|
||||
message: _(msg`Failed to load suggested feeds`),
|
||||
message: l`Failed to load suggested feeds`,
|
||||
error: cleanError(suggestedFeedsError),
|
||||
})
|
||||
} else if (preferencesError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'preferencesError',
|
||||
message: _(msg`Failed to load feeds preferences`),
|
||||
message: l`Failed to load feeds preferences`,
|
||||
error: cleanError(preferencesError),
|
||||
})
|
||||
} else {
|
||||
@@ -607,7 +603,7 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'loadMore',
|
||||
key: 'loadMoreFeeds',
|
||||
message: _(msg`Load more suggested feeds`),
|
||||
message: l`Load more suggested feeds`,
|
||||
isLoadingMore: isLoadingMoreFeeds,
|
||||
onLoadMore: onLoadMoreFeeds,
|
||||
})
|
||||
@@ -618,21 +614,21 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'feedsError',
|
||||
message: _(msg`Failed to load feeds`),
|
||||
message: l`Failed to load feeds`,
|
||||
error: cleanError(feedsError),
|
||||
})
|
||||
} else if (suggestedFeedsError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'feedsError',
|
||||
message: _(msg`Failed to load suggested feeds`),
|
||||
message: l`Failed to load suggested feeds`,
|
||||
error: cleanError(suggestedFeedsError),
|
||||
})
|
||||
} else if (preferencesError) {
|
||||
i.push({
|
||||
type: 'error',
|
||||
key: 'preferencesError',
|
||||
message: _(msg`Failed to load feeds preferences`),
|
||||
message: l`Failed to load feeds preferences`,
|
||||
error: cleanError(preferencesError),
|
||||
})
|
||||
} else {
|
||||
@@ -642,7 +638,7 @@ export function Explore({
|
||||
}
|
||||
return i
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
ax,
|
||||
useFullExperience,
|
||||
suggestedFeeds,
|
||||
@@ -662,9 +658,9 @@ export function Explore({
|
||||
i.push({
|
||||
type: 'header',
|
||||
key: 'suggested-starterPacks-header',
|
||||
title: _(msg`Starter Packs`),
|
||||
title: l`Starter Packs`,
|
||||
icon: StarterPack,
|
||||
iconSize: 'xl',
|
||||
iconSize: 'md',
|
||||
})
|
||||
|
||||
if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) {
|
||||
@@ -689,7 +685,7 @@ export function Explore({
|
||||
return i
|
||||
}, [
|
||||
suggestedSPs,
|
||||
_,
|
||||
l,
|
||||
isLoadingSuggestedSPs,
|
||||
suggestedSPsError,
|
||||
isRefetchingSuggestedSPs,
|
||||
@@ -778,7 +774,7 @@ export function Explore({
|
||||
return (
|
||||
<View style={[a.pb_md]}>
|
||||
<ModuleHeader.Container style={[a.pb_xs]}>
|
||||
<ModuleHeader.Icon icon={item.icon} />
|
||||
<ModuleHeader.Icon icon={item.icon} size={item.iconSize} />
|
||||
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
|
||||
{item.searchButton && (
|
||||
<ModuleHeader.SearchButton
|
||||
@@ -798,18 +794,11 @@ export function Explore({
|
||||
)
|
||||
}
|
||||
case 'trendingTopics': {
|
||||
return (
|
||||
<View style={[a.pb_md]}>
|
||||
<ExploreTrendingTopics />
|
||||
</View>
|
||||
)
|
||||
return <ExploreTrendingTopics />
|
||||
}
|
||||
case 'trendingVideos': {
|
||||
return <ExploreTrendingVideos />
|
||||
}
|
||||
case 'recommendations': {
|
||||
return <ExploreRecommendations />
|
||||
}
|
||||
case 'profile': {
|
||||
return (
|
||||
<SuggestedProfileCard
|
||||
@@ -1023,9 +1012,7 @@ export function Explore({
|
||||
case 'preview:loadMoreError': {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label={_(
|
||||
msg`There was an issue fetching posts. Tap here to try again.`,
|
||||
)}
|
||||
label={l`There was an issue fetching posts. Tap here to try again.`}
|
||||
onPress={handleOnPressRetry}
|
||||
/>
|
||||
)
|
||||
@@ -1050,7 +1037,7 @@ export function Explore({
|
||||
moderationOpts,
|
||||
interestsDisplayNames,
|
||||
useFullExperience,
|
||||
_,
|
||||
l,
|
||||
fetchNextPageFeedPreviews,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {urls} from '#/lib/constants'
|
||||
@@ -15,6 +15,7 @@ import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||
import {useActorSearch} from '#/state/queries/actor-search'
|
||||
import {usePopularFeedsSearch} from '#/state/queries/feed'
|
||||
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
|
||||
import {useStarterPackSearch} from '#/state/queries/starter-pack-search'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
@@ -23,6 +24,7 @@ import {TabBar} from '#/view/com/pager/TabBar'
|
||||
import {Post} from '#/view/com/post/Post'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {StarterPackCard} from '#/screens/Search/components/StarterPackCard'
|
||||
import {
|
||||
hasPostOnlyFilters,
|
||||
type SearchFilters,
|
||||
@@ -41,6 +43,7 @@ let SearchResults = ({
|
||||
query,
|
||||
filters,
|
||||
hasFilters,
|
||||
fromMe,
|
||||
activeTab,
|
||||
onPageSelected,
|
||||
headerHeight,
|
||||
@@ -48,20 +51,26 @@ let SearchResults = ({
|
||||
query: string
|
||||
filters: SearchFilters
|
||||
hasFilters: boolean
|
||||
fromMe: boolean
|
||||
activeTab: number
|
||||
onPageSelected: (page: number) => void
|
||||
headerHeight: number
|
||||
}): React.ReactNode => {
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
/*
|
||||
* People/Feeds visibility keys off post-only filters: a `lang` filter applies
|
||||
* to people and feeds too, so it must not hide those tabs (which would also
|
||||
* regress the non-v2 legacy language dropdown). Other filters are post-only.
|
||||
*/
|
||||
const hasPostFilters = hasPostOnlyFilters(filters)
|
||||
const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
|
||||
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
|
||||
const tabShape = hasPostFilters ? 'filtered' : 'plain'
|
||||
|
||||
const isStarterPacksEnabled = ax.features.enabled(
|
||||
ax.features.SearchStarterPacksV2Enable,
|
||||
)
|
||||
|
||||
const sections = useMemo(() => {
|
||||
if (!query && !hasFilters) return []
|
||||
/*
|
||||
@@ -106,11 +115,29 @@ let SearchResults = ({
|
||||
<SearchScreenFeedsResults query={query} active={activePage === 3} />
|
||||
),
|
||||
},
|
||||
noFilters &&
|
||||
isStarterPacksEnabled && {
|
||||
title: l`Starter packs`,
|
||||
component: (
|
||||
<SearchScreenStarterPackResults
|
||||
query={query}
|
||||
active={activePage === 4}
|
||||
/>
|
||||
),
|
||||
},
|
||||
].filter(Boolean) as {
|
||||
title: string
|
||||
component: React.ReactNode
|
||||
}[]
|
||||
}, [l, query, filters, hasFilters, hasPostFilters, activePage])
|
||||
}, [
|
||||
l,
|
||||
query,
|
||||
filters,
|
||||
hasFilters,
|
||||
hasPostFilters,
|
||||
activePage,
|
||||
isStarterPacksEnabled,
|
||||
])
|
||||
|
||||
// There may be fewer tabs after changing the search options.
|
||||
const selectedPage = activePage > sections.length - 1 ? 0 : activePage
|
||||
@@ -692,3 +719,123 @@ function SearchFeedCard({
|
||||
|
||||
return <FeedCard.Default view={view} onPress={handleOnPress} />
|
||||
}
|
||||
|
||||
let SearchScreenStarterPackResults = ({
|
||||
query,
|
||||
active,
|
||||
}: {
|
||||
query: string
|
||||
active: boolean
|
||||
}): React.ReactNode => {
|
||||
const ax = useAnalytics()
|
||||
const {t: l} = useLingui()
|
||||
const [isPTR, setIsPTR] = useState(false)
|
||||
|
||||
const {
|
||||
isFetched,
|
||||
data: results,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
} = useStarterPackSearch({
|
||||
query,
|
||||
enabled: active,
|
||||
})
|
||||
|
||||
const onPullToRefresh = useCallback(async () => {
|
||||
setIsPTR(true)
|
||||
await refetch()
|
||||
setIsPTR(false)
|
||||
}, [setIsPTR, refetch])
|
||||
const onEndReached = useCallback(() => {
|
||||
if (isFetching || !hasNextPage || error) return
|
||||
void fetchNextPage()
|
||||
}, [isFetching, error, hasNextPage, fetchNextPage])
|
||||
const starterPacks = useMemo(() => {
|
||||
return results?.pages.flatMap(page => page.starterPacks) || []
|
||||
}, [results])
|
||||
|
||||
const fireTracking = useCallOnce(() => {
|
||||
ax.metric('search:results:loaded', {
|
||||
tab: 'starterPacks',
|
||||
initialCount: starterPacks.length,
|
||||
})
|
||||
})
|
||||
if (isFetched) {
|
||||
fireTracking()
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
messageText={
|
||||
shouldRetryError(error) || isNetworkError(error)
|
||||
? l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`
|
||||
: l`We’re sorry, but your search could not be completed.`
|
||||
}
|
||||
error={cleanError(error)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return isFetched ? (
|
||||
<>
|
||||
{starterPacks.length ? (
|
||||
<List
|
||||
data={starterPacks}
|
||||
renderItem={({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: AppBskyGraphDefs.StarterPackView
|
||||
index: number
|
||||
}) => (
|
||||
<View style={[a.px_lg, a.pb_lg, index === 0 && a.pt_lg]}>
|
||||
<SearchStarterPack position={index} view={item} />
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(item: AppBskyGraphDefs.StarterPackView) => item.uri}
|
||||
refreshing={isPTR}
|
||||
onRefresh={() => void onPullToRefresh()}
|
||||
onEndReached={onEndReached}
|
||||
desktopFixedHeight
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState messageText={<NoResultsText query={query} />} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)
|
||||
}
|
||||
SearchScreenStarterPackResults = memo(SearchScreenStarterPackResults)
|
||||
|
||||
function SearchStarterPack({
|
||||
position,
|
||||
view,
|
||||
}: {
|
||||
position: number
|
||||
view: AppBskyGraphDefs.StarterPackView
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
|
||||
const handleOnPress = () => {
|
||||
ax.metric('search:result:press', {
|
||||
tab: 'starterPacks',
|
||||
resultType: 'starterPack',
|
||||
position,
|
||||
uri: view.uri,
|
||||
})
|
||||
}
|
||||
|
||||
return <StarterPackCard view={view} onPress={handleOnPress} />
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
unstableCacheProfileView,
|
||||
useProfilesQuery,
|
||||
} from '#/state/queries/profile'
|
||||
import {extractFromMe} from '#/state/queries/search-posts-params'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
countActiveFilters,
|
||||
@@ -123,8 +124,18 @@ export function SearchScreenShell({
|
||||
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
|
||||
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
|
||||
|
||||
/*
|
||||
* A raw `from:me` operator stays visible in the search input. Submitting the
|
||||
* advanced dialog promotes it to a structured `from=me` filter and removes
|
||||
* it from `q`; the API layer reconstructs the operator for post search.
|
||||
*/
|
||||
const {query, fromMe, filters, setFilters, hasFilters} = useQueryManager({
|
||||
initialQuery: queryParam,
|
||||
fixedParams,
|
||||
})
|
||||
|
||||
// Query terms
|
||||
const [searchText, setSearchText] = useState<string>(queryParam)
|
||||
const [searchText, setSearchText] = useState<string>(query)
|
||||
const searchTextRef = useRef(searchText)
|
||||
const updateSearchText = useCallback((text: string) => {
|
||||
searchTextRef.current = text
|
||||
@@ -200,10 +211,6 @@ export function SearchScreenShell({
|
||||
[accountHistory, setAccountHistory],
|
||||
)
|
||||
|
||||
const {query, filters, setFilters, hasFilters} = useQueryManager({
|
||||
initialQuery: queryParam,
|
||||
fixedParams,
|
||||
})
|
||||
const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
|
||||
|
||||
const onChangeLang = useCallback(
|
||||
@@ -233,13 +240,13 @@ export function SearchScreenShell({
|
||||
useEffect(() => {
|
||||
if (IS_NATIVE) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
updateSearchText(queryParam)
|
||||
updateSearchText(query)
|
||||
}
|
||||
}, [queryParam, updateSearchText])
|
||||
}, [query, updateSearchText])
|
||||
useFocusEffect(
|
||||
useNonReactiveCallback(() => {
|
||||
if (IS_WEB) {
|
||||
updateSearchText(queryParam)
|
||||
updateSearchText(query)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -329,11 +336,12 @@ export function SearchScreenShell({
|
||||
])
|
||||
|
||||
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
|
||||
const nextQuery = searchTextRef.current
|
||||
ax.metric('search:query', {
|
||||
source,
|
||||
filterCount: countActiveFilters(filters),
|
||||
})
|
||||
navigateToItem(searchTextRef.current)
|
||||
navigateToItem(nextQuery)
|
||||
}
|
||||
|
||||
const onSubmitAdvanced = useCallback(
|
||||
@@ -474,17 +482,6 @@ export function SearchScreenShell({
|
||||
}
|
||||
}, [setShowAutocomplete])
|
||||
|
||||
const onSearchInputBlur = useCallback(() => {
|
||||
/*
|
||||
* Bind autocomplete visibility to focus state on native. On web this
|
||||
* doesn't work because of focus management, which would render the
|
||||
* autocomplete results uninteractable.
|
||||
*/
|
||||
if (IS_NATIVE) {
|
||||
setShowAutocomplete(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const focusSearchInput = useCallback(
|
||||
(tab?: TabParam) => {
|
||||
textInput.current?.focus()
|
||||
@@ -609,7 +606,6 @@ export function SearchScreenShell({
|
||||
ref={textInput}
|
||||
value={searchText}
|
||||
onFocus={onSearchInputFocus}
|
||||
onBlur={onSearchInputBlur}
|
||||
onChangeText={onChangeText}
|
||||
onClearText={onPressClearQuery}
|
||||
onSubmitEditing={onSubmit('typed')}
|
||||
@@ -656,6 +652,7 @@ export function SearchScreenShell({
|
||||
query={query}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
fromMe={fromMe}
|
||||
headerHeight={headerHeight}
|
||||
focusSearchInput={focusSearchInput}
|
||||
/>
|
||||
@@ -707,6 +704,7 @@ let SearchScreenInner = ({
|
||||
query,
|
||||
filters,
|
||||
hasFilters,
|
||||
fromMe,
|
||||
headerHeight,
|
||||
focusSearchInput,
|
||||
}: {
|
||||
@@ -715,6 +713,7 @@ let SearchScreenInner = ({
|
||||
query: string
|
||||
filters: SearchFilters
|
||||
hasFilters: boolean
|
||||
fromMe: boolean
|
||||
headerHeight: number
|
||||
focusSearchInput: (tab?: TabParam) => void
|
||||
}): React.ReactNode => {
|
||||
@@ -731,6 +730,7 @@ let SearchScreenInner = ({
|
||||
query={query}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
fromMe={fromMe}
|
||||
activeTab={activeTab}
|
||||
headerHeight={headerHeight}
|
||||
onPageSelected={onPageSelected}
|
||||
@@ -781,8 +781,13 @@ function useQueryManager({
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const route = useRoute()
|
||||
|
||||
// Free text only - structured filters live in sibling route params now.
|
||||
// A raw Me operator remains part of the query until the advanced dialog
|
||||
// promotes it to the structured `from` filter.
|
||||
const query = initialQuery
|
||||
const fromMe = useMemo(
|
||||
() => extractFromMe(initialQuery).fromMe,
|
||||
[initialQuery],
|
||||
)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
|
||||
@@ -814,11 +819,12 @@ function useQueryManager({
|
||||
return useMemo(
|
||||
() => ({
|
||||
query,
|
||||
fromMe,
|
||||
filters,
|
||||
setFilters,
|
||||
hasFilters: hasActiveFilters(filters),
|
||||
}),
|
||||
[query, filters, setFilters],
|
||||
[query, fromMe, filters, setFilters],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
countActiveFilters,
|
||||
definedFilterParams,
|
||||
filtersToApiParams,
|
||||
hasActiveFilters,
|
||||
hasPostOnlyFilters,
|
||||
parseHistoryEntry,
|
||||
readSearchFilters,
|
||||
@@ -55,6 +56,14 @@ describe(`searchParams`, () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe(`hasActiveFilters`, () => {
|
||||
it(`includes the structured Me author filter`, () => {
|
||||
expect(hasActiveFilters({})).toBe(false)
|
||||
expect(hasActiveFilters({from: 'me'})).toBe(true)
|
||||
expect(hasActiveFilters({author: 'alice'})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`definedFilterParams`, () => {
|
||||
it(`omits absent keys entirely`, () => {
|
||||
expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'})
|
||||
@@ -130,8 +139,9 @@ describe(`searchParams`, () => {
|
||||
})
|
||||
|
||||
describe(`countActiveFilters`, () => {
|
||||
it(`counts each set filter key once`, () => {
|
||||
it(`counts each structured filter key once`, () => {
|
||||
expect(countActiveFilters({})).toBe(0)
|
||||
expect(countActiveFilters({from: 'me'})).toBe(1)
|
||||
expect(
|
||||
countActiveFilters({author: 'alice bob', domain: 'bsky.app'}),
|
||||
).toBe(2)
|
||||
@@ -152,6 +162,14 @@ describe(`searchParams`, () => {
|
||||
})
|
||||
})
|
||||
|
||||
it(`round-trips a promoted Me-only search`, () => {
|
||||
const stored = serializeHistoryEntry('', {from: 'me'})
|
||||
expect(parseHistoryEntry(stored)).toEqual({
|
||||
q: '',
|
||||
filters: {from: 'me'},
|
||||
})
|
||||
})
|
||||
|
||||
it(`round-trips query + filters`, () => {
|
||||
const filters = {
|
||||
author: 'alice',
|
||||
|
||||
+7
-6
@@ -7,20 +7,21 @@ import {
|
||||
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {type FollowingFilter} from './utils'
|
||||
import {type FromFilter} from './utils'
|
||||
|
||||
export function FollowingDropdown({
|
||||
export function FromDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FollowingFilter
|
||||
onChange: (value: FollowingFilter) => void
|
||||
value: FromFilter
|
||||
onChange: (value: FromFilter) => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const options: {value: FollowingFilter; label: string}[] = [
|
||||
const options: {value: FromFilter; label: string}[] = [
|
||||
{value: 'anyone', label: l`Anyone`},
|
||||
{value: 'following', label: l`People you follow`},
|
||||
{value: 'following', label: l`People I follow`},
|
||||
{value: 'me', label: l`Me`},
|
||||
]
|
||||
const currentLabel = options.find(o => o.value === value)?.label ?? l`Anyone`
|
||||
|
||||
@@ -73,12 +73,68 @@ describe(`AdvancedSearchDialog serialize/parse`, () => {
|
||||
expect(state.until).toBe('2024-02-01')
|
||||
})
|
||||
|
||||
it(`keeps from:me in the query box rather than lifting it into a row`, () => {
|
||||
const state = parseAdvancedSearch('from:me', {})
|
||||
expect(state.query).toBe('from:me')
|
||||
it(`lifts from:me typed into the query box into the "me" following filter`, () => {
|
||||
const state = parseAdvancedSearch('cats from:me', {})
|
||||
expect(state.query).toBe('cats')
|
||||
expect(state.following).toBe('me')
|
||||
expect(state.filters.find(f => f.field === 'authors')).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`promotes the "me" filter from q to a structured filter`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats',
|
||||
following: 'me',
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
expect(out.filters.following).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`strips from:me typed after selecting Me`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats from:me',
|
||||
following: 'me',
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
})
|
||||
|
||||
it(`promotes from:me typed after the dialog opens`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats from:me',
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
})
|
||||
|
||||
it(`keeps a quoted from:me as query text`, () => {
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: 'cats "from:me"',
|
||||
})
|
||||
expect(out.q).toBe('cats "from:me"')
|
||||
expect(out.filters.from).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`round-trips cats from:me through the "me" following filter`, () => {
|
||||
const state = parseAdvancedSearch('cats from:me', {})
|
||||
const out = serializeAdvancedSearch({
|
||||
...emptySerializeState,
|
||||
query: state.query,
|
||||
following: state.following,
|
||||
})
|
||||
expect(out.q).toBe('cats')
|
||||
expect(out.filters.from).toBe('me')
|
||||
expect(out.filters.following).toBeUndefined()
|
||||
})
|
||||
|
||||
it(`parses the structured Me filter into the From dropdown`, () => {
|
||||
expect(parseAdvancedSearch('cats', {from: 'me'}).following).toBe('me')
|
||||
})
|
||||
|
||||
it(`merges a query-box operator with the matching filter param`, () => {
|
||||
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
|
||||
expect(state.query).toBe('hi')
|
||||
|
||||
@@ -22,12 +22,12 @@ import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
|
||||
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
|
||||
import {ClearableInput} from './ClearableInput'
|
||||
import {FilterBlock} from './FilterBlock'
|
||||
import {FollowingDropdown} from './FollowingDropdown'
|
||||
import {FromDropdown} from './FromDropdown'
|
||||
import {MediaDropdown} from './MediaDropdown'
|
||||
import {RepliesDropdown} from './RepliesDropdown'
|
||||
import {
|
||||
type AdvancedFilter,
|
||||
type FollowingFilter,
|
||||
type FromFilter,
|
||||
makeFilter,
|
||||
type MediaFilter,
|
||||
parseAdvancedSearch,
|
||||
@@ -124,7 +124,7 @@ function DialogInner({
|
||||
|
||||
const [media, setMedia] = useState<MediaFilter>(parsed.media)
|
||||
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies)
|
||||
const [following, setFollowing] = useState<FollowingFilter>(parsed.following)
|
||||
const [following, setFollowing] = useState<FromFilter>(parsed.following)
|
||||
|
||||
/*
|
||||
* The date picker requires a valid date, so these always hold one. The
|
||||
@@ -389,7 +389,9 @@ function DialogInner({
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_sm,
|
||||
]}>
|
||||
<Trans>Include</Trans>
|
||||
<Trans comment="Include search results with or without replies">
|
||||
Include
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row]}>
|
||||
<RepliesDropdown value={replies} onChange={setReplies} />
|
||||
@@ -403,10 +405,12 @@ function DialogInner({
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_sm,
|
||||
]}>
|
||||
<Trans>From</Trans>
|
||||
<Trans comment="Filter search results by a specific post author">
|
||||
From
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row]}>
|
||||
<FollowingDropdown value={following} onChange={setFollowing} />
|
||||
<FromDropdown value={following} onChange={setFollowing} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
extractFromMe,
|
||||
extractSearchPostsParams,
|
||||
tokenizeQuery,
|
||||
} from '#/state/queries/search-posts-params'
|
||||
@@ -14,10 +15,11 @@ export type RepliesFilter = 'all' | 'none' | 'only'
|
||||
export type MediaFilter = 'all' | 'media' | 'video'
|
||||
|
||||
/**
|
||||
* Whether to limit results to authors the user follows. Serializes into the
|
||||
* `following` sibling param ('following' -> following:true, anyone -> unset).
|
||||
* Which authors to limit results to. 'following' serializes into the
|
||||
* `following` sibling param (following=true); 'me' serializes into the `from`
|
||||
* sibling param (from=me); 'anyone' leaves both unset.
|
||||
*/
|
||||
export type FollowingFilter = 'anyone' | 'following'
|
||||
export type FromFilter = 'anyone' | 'following' | 'me'
|
||||
|
||||
export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
|
||||
|
||||
@@ -93,7 +95,7 @@ export type DialogState = {
|
||||
language: string
|
||||
replies: RepliesFilter
|
||||
media: MediaFilter
|
||||
following: FollowingFilter
|
||||
following: FromFilter
|
||||
since: string
|
||||
until: string
|
||||
filters: AdvancedFilter[]
|
||||
@@ -141,18 +143,27 @@ function isSimpleWord(word: string): boolean {
|
||||
* populate "none of these words" - but only when their contents are simple
|
||||
* words. Anything that wouldn't round-trip (embedded quotes, a negated phrase
|
||||
* like -"a b", etc.) is left verbatim in the main "all of these words" query
|
||||
* text instead of parsed.
|
||||
* text instead of parsed. A bare `from:me` is pulled out into the `fromMe`
|
||||
* flag, which drives the "Me" author filter (the backend resolves `me` to the
|
||||
* viewer, so it never becomes a structured `author` value).
|
||||
*/
|
||||
function parseFreeText(raw: string): {
|
||||
query: string
|
||||
exactPhrase: string
|
||||
negatedWords: string
|
||||
fromMe: boolean
|
||||
} {
|
||||
const queryParts: string[] = []
|
||||
const negatedWords: string[] = []
|
||||
let exactPhrase = ''
|
||||
let fromMe = false
|
||||
|
||||
for (const token of tokenizeQuery(raw)) {
|
||||
// from:me -> "Me" author filter rather than free text.
|
||||
if (token === 'from:me') {
|
||||
fromMe = true
|
||||
continue
|
||||
}
|
||||
// "phrase" -> "exact phrase", only if it has no inner quote.
|
||||
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
|
||||
const inner = token.slice(1, -1)
|
||||
@@ -176,6 +187,7 @@ function parseFreeText(raw: string): {
|
||||
query: queryParts.join(' '),
|
||||
exactPhrase,
|
||||
negatedWords: negatedWords.join(' '),
|
||||
fromMe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +220,7 @@ export function parseAdvancedSearch(
|
||||
* can be expressed as operators; exclude rows come solely from filter params.
|
||||
*/
|
||||
const lifted = extractSearchPostsParams(q)
|
||||
const freeText = parseFreeText(lifted.q)
|
||||
const {fromMe, ...freeText} = parseFreeText(lifted.q)
|
||||
|
||||
const includeValues: Record<FilterField, string> = {
|
||||
authors: mergeValues(filters.author, lifted.author),
|
||||
@@ -255,12 +267,24 @@ export function parseAdvancedSearch(
|
||||
const since = filters.since ?? lifted.since
|
||||
const until = filters.until ?? lifted.until
|
||||
|
||||
/*
|
||||
* A raw `from:me` operator and the structured `from=me` filter both map to
|
||||
* the "Me" author filter. The raw operator is promoted to the structured
|
||||
* filter when the dialog is submitted.
|
||||
*/
|
||||
let following: FromFilter = 'anyone'
|
||||
if (fromMe || filters.from === 'me') {
|
||||
following = 'me'
|
||||
} else if (filters.following === 'true') {
|
||||
following = 'following'
|
||||
}
|
||||
|
||||
return {
|
||||
...freeText,
|
||||
language: lang,
|
||||
replies,
|
||||
media,
|
||||
following: filters.following === 'true' ? 'following' : 'anyone',
|
||||
following,
|
||||
since: since && isValidDate(since) ? since : '',
|
||||
until: until && isValidDate(until) ? until : '',
|
||||
filters: filterRows,
|
||||
@@ -279,7 +303,7 @@ export function serializeAdvancedSearch(state: {
|
||||
language: string
|
||||
replies: RepliesFilter
|
||||
media: MediaFilter
|
||||
following: FollowingFilter
|
||||
following: FromFilter
|
||||
dateSince: string
|
||||
dateSinceActive: boolean
|
||||
dateUntil: string
|
||||
@@ -352,7 +376,21 @@ export function serializeAdvancedSearch(state: {
|
||||
if (state.replies === 'only') filters.replies = 'only'
|
||||
if (state.media === 'media') filters.media = 'true'
|
||||
else if (state.media === 'video') filters.video = 'true'
|
||||
if (state.following === 'following') filters.following = 'true'
|
||||
|
||||
return {q: parts.join(' '), filters}
|
||||
/*
|
||||
* Re-parse the final text because a user can type `from:me` after the dialog
|
||||
* has opened. Advanced submission removes every bare token from the search
|
||||
* input and promotes it to the structured From filter. Quoted `"from:me"`
|
||||
* remains ordinary query text.
|
||||
*/
|
||||
const {q, fromMe} = extractFromMe(parts.join(' '))
|
||||
if (state.following === 'following') filters.following = 'true'
|
||||
else if (state.following === 'me' || fromMe) filters.from = 'me'
|
||||
|
||||
/*
|
||||
* Submitting the dialog promotes a raw `from:me` operator to `from=me`, so it
|
||||
* leaves the search text and is represented by the From dropdown. The query
|
||||
* hook reconstructs the backend operator at the API boundary.
|
||||
*/
|
||||
return {q, filters}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Container({
|
||||
a.px_lg,
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
a.gap_sm,
|
||||
a.gap_xs,
|
||||
t.atoms.bg,
|
||||
bottomBorder && [a.border_b, t.atoms.border_contrast_low],
|
||||
style,
|
||||
@@ -82,11 +82,13 @@ export function Icon({
|
||||
icon: Comp,
|
||||
size = 'lg',
|
||||
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
|
||||
const t = useTheme()
|
||||
|
||||
const iconSize = iconSizes[size]
|
||||
|
||||
return (
|
||||
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
|
||||
<Comp width={iconSize} />
|
||||
<Comp width={iconSize} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -94,7 +96,7 @@ export function Icon({
|
||||
export function TitleText({style, ...props}: TextProps) {
|
||||
return (
|
||||
<Text
|
||||
style={[a.font_semi_bold, a.flex_1, a.text_xl, style]}
|
||||
style={[a.font_semi_bold, a.flex_1, a.text_lg, style]}
|
||||
emoji
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -26,8 +26,10 @@ import * as bsky from '#/types/bsky'
|
||||
|
||||
export function StarterPackCard({
|
||||
view,
|
||||
onPress,
|
||||
}: {
|
||||
view: AppBskyGraphDefs.StarterPackView
|
||||
onPress?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -55,7 +57,10 @@ export function StarterPackCard({
|
||||
to={link.to}
|
||||
label={link.label}
|
||||
onHoverIn={link.precache}
|
||||
onPress={link.precache}>
|
||||
onPress={() => {
|
||||
link.precache()
|
||||
onPress?.()
|
||||
}}>
|
||||
{s => (
|
||||
<>
|
||||
<SubtleHover hover={s.hovered || s.pressed} />
|
||||
@@ -111,7 +116,10 @@ export function StarterPackCard({
|
||||
to={link.to}
|
||||
label={link.label}
|
||||
onHoverIn={link.precache}
|
||||
onPress={link.precache}
|
||||
onPress={() => {
|
||||
link.precache()
|
||||
onPress?.()
|
||||
}}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import {useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useInterestsDisplayNames} from '#/lib/interests'
|
||||
import {Nux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Shapes_Stroke2_Corner0_Rounded as Shapes} from '#/components/icons/Shapes'
|
||||
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Link} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
@@ -17,7 +15,7 @@ import {Text} from '#/components/Typography'
|
||||
|
||||
export function ExploreInterestsCard() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const interestsDisplayNames = useInterestsDisplayNames()
|
||||
const {mutateAsync: saveNux} = useSaveNux()
|
||||
@@ -41,16 +39,12 @@ export function ExploreInterestsCard() {
|
||||
<>
|
||||
<Prompt.Basic
|
||||
control={trendingPrompt}
|
||||
title={_(msg`Dismiss interests`)}
|
||||
description={_(
|
||||
msg`You can adjust your interests at any time from "Content and media" settings.`,
|
||||
)}
|
||||
confirmButtonCta={_(
|
||||
msg({
|
||||
message: `OK`,
|
||||
comment: `Confirm button text.`,
|
||||
}),
|
||||
)}
|
||||
title={l`Dismiss interests`}
|
||||
description={l`You can adjust your interests at any time from "Content and media" settings.`}
|
||||
confirmButtonCta={l({
|
||||
message: `OK`,
|
||||
comment: `Confirm button text.`,
|
||||
})}
|
||||
onConfirm={onConfirmClose}
|
||||
/>
|
||||
|
||||
@@ -63,8 +57,8 @@ export function ExploreInterestsCard() {
|
||||
t.atoms.border_contrast_medium,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<Shapes />
|
||||
<Text style={[a.text_xl, a.font_semi_bold, a.leading_tight]}>
|
||||
<ShapesIcon fill={t.atoms.text.color} />
|
||||
<Text style={[a.text_lg, a.font_semi_bold]}>
|
||||
<Trans>Your interests</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
@@ -96,7 +90,7 @@ export function ExploreInterestsCard() {
|
||||
</Text>
|
||||
|
||||
<Link
|
||||
label={_(msg`Edit interests`)}
|
||||
label={l`Edit interests`}
|
||||
to="/settings/interests"
|
||||
size="small"
|
||||
variant="solid"
|
||||
@@ -108,7 +102,7 @@ export function ExploreInterestsCard() {
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
label={_(msg`Hide this card`)}
|
||||
label={l`Hide this card`}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyUnspeccedDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
|
||||
useTrendingTopics,
|
||||
} from '#/state/queries/trending/useTrendingTopics'
|
||||
import {useTrendingConfig} from '#/state/service-config'
|
||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
|
||||
import {
|
||||
TrendingTopic,
|
||||
TrendingTopicLink,
|
||||
TrendingTopicSkeleton,
|
||||
} from '#/components/TrendingTopics'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
// Note: This module is not currently used and may be removed in the future.
|
||||
|
||||
export function ExploreRecommendations() {
|
||||
const {enabled} = useTrendingConfig()
|
||||
return enabled ? <Inner /> : null
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const gutters = useGutters([0, 'compact'])
|
||||
const {data: trending, error, isLoading} = useTrendingTopics()
|
||||
const noRecs = !isLoading && !error && !trending?.suggested?.length
|
||||
const allFeeds = trending?.suggested && isAllFeeds(trending.suggested)
|
||||
|
||||
return error || noRecs ? null : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
IS_WEB
|
||||
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
|
||||
: [a.p_lg, a.pt_2xl, a.gap_md],
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Hashtag_Stroke2_Corner0_Rounded
|
||||
size="lg"
|
||||
fill={t.palette.primary_500}
|
||||
style={{marginLeft: -2}}
|
||||
/>
|
||||
<Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>
|
||||
<Trans>Recommended</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{!allFeeds ? (
|
||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||
<Trans>
|
||||
Content from across the network we think you might like.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||
<Trans>Feeds we think you might like.</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_md, a.pb_lg]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.flex_wrap,
|
||||
{rowGap: 8, columnGap: 6},
|
||||
gutters,
|
||||
]}>
|
||||
{isLoading ? (
|
||||
Array(RECOMMENDATIONS_COUNT)
|
||||
.fill(0)
|
||||
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
|
||||
) : !trending?.suggested ? null : (
|
||||
<>
|
||||
{trending.suggested.map(topic => (
|
||||
<TrendingTopicLink
|
||||
key={topic.link}
|
||||
topic={topic}
|
||||
onPress={() => {
|
||||
ax.metric('recommendedTopic:click', {context: 'explore'})
|
||||
}}>
|
||||
{({hovered}) => (
|
||||
<TrendingTopic
|
||||
topic={topic}
|
||||
style={[
|
||||
hovered && [
|
||||
t.atoms.border_contrast_high,
|
||||
t.atoms.bg_contrast_25,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</TrendingTopicLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) {
|
||||
return topics.every(topic => {
|
||||
const segments = topic.link.split('/').slice(1)
|
||||
return segments[0] === 'profile' && segments[2] === 'feed'
|
||||
})
|
||||
}
|
||||
@@ -1,27 +1,34 @@
|
||||
import {useMemo} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
type AppBskyUnspeccedDefs,
|
||||
moderateProfile,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
|
||||
import {useTrendingConfig} from '#/state/service-config'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {atoms as a, useGutters, useTheme, type ViewStyleProp} from '#/alf'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame'
|
||||
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
|
||||
import {Link} from '#/components/Link'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import * as ModuleHeader from '../components/ModuleHeader'
|
||||
|
||||
const TOPIC_COUNT = 5
|
||||
|
||||
const IMAGE_SIZE = 56
|
||||
|
||||
export function ExploreTrendingTopics() {
|
||||
const {enabled} = useTrendingConfig()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
@@ -32,24 +39,36 @@ function Inner() {
|
||||
const ax = useAnalytics()
|
||||
const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery()
|
||||
const noTopics = !isLoading && !error && !trending?.trends?.length
|
||||
const showLoading = isLoading || isRefetching
|
||||
|
||||
return isLoading || isRefetching ? (
|
||||
Array.from({length: TOPIC_COUNT}).map((__, i) => (
|
||||
<TrendingTopicRowSkeleton key={i} withPosts={i === 0} />
|
||||
))
|
||||
) : error || !trending?.trends || noTopics ? null : (
|
||||
<>
|
||||
{trending.trends.map((trend, index) => (
|
||||
<TrendRow
|
||||
key={trend.link}
|
||||
trend={trend}
|
||||
rank={index + 1}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {context: 'explore'})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
if (!showLoading && (error || !trending?.trends || noTopics)) return null
|
||||
|
||||
return (
|
||||
<View style={[a.pb_md]}>
|
||||
<ModuleHeader.Container bottomBorder>
|
||||
<ModuleHeader.Icon icon={TrendingIcon} size="md" />
|
||||
<ModuleHeader.TitleText>
|
||||
<Trans>Trending</Trans>
|
||||
</ModuleHeader.TitleText>
|
||||
</ModuleHeader.Container>
|
||||
{showLoading
|
||||
? Array.from({length: TOPIC_COUNT}).map((__, i) => (
|
||||
<TrendingTopicRowSkeleton key={i} />
|
||||
))
|
||||
: trending?.trends.map((trend, index) => (
|
||||
<TrendRow
|
||||
key={trend.link}
|
||||
trend={trend}
|
||||
rank={index + 1}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {
|
||||
context: 'explore',
|
||||
recId: trending.recId,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,22 +84,24 @@ export function TrendRow({
|
||||
onPress?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
|
||||
const category = useCategoryDisplayName(trend?.category || 'other')
|
||||
const age = Math.floor(
|
||||
(Date.now() - new Date(trend.startedAt || Date.now()).getTime()) /
|
||||
(1000 * 60 * 60),
|
||||
)
|
||||
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
|
||||
|
||||
const actors = useModerateTrendingActors(trend.actors)
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!trend.description) return
|
||||
const rt = new RichTextApi({text: trend.description})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}, [trend.description])
|
||||
|
||||
let imageUrl = null // TODO Image URL goes here when available. -dsb
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={trend.link}
|
||||
label={_(msg`Browse topic ${trend.displayName}`)}
|
||||
label={l`Browse topic ${trend.displayName}`}
|
||||
to={trend.link}
|
||||
onPress={onPress}
|
||||
style={[a.border_b, t.atoms.border_contrast_low]}
|
||||
@@ -88,52 +109,75 @@ export function TrendRow({
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<SubtleHover hover={hovered || pressed} native />
|
||||
<View style={[gutters, a.w_full, a.py_lg, a.flex_row, a.gap_2xs]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View style={[a.flex_row]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
{width: 20},
|
||||
]}>
|
||||
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
|
||||
{rank}.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, a.font_semi_bold, a.leading_tight]}
|
||||
numberOfLines={1}>
|
||||
{trend.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
{paddingLeft: 20},
|
||||
]}>
|
||||
{actors.length > 0 && (
|
||||
<AvatarStack size={20} profiles={actors} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
web(a.leading_snug),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{category}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_shrink_0]}>
|
||||
<TrendingIndicator type={badgeType} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={[gutters, a.w_full, a.flex_row, a.py_md, a.gap_sm]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
|
||||
t.atoms.text_contrast_low,
|
||||
{
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
]}>
|
||||
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
|
||||
{rank}.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[a.text_sm, a.font_semi_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{trend.displayName}
|
||||
</Text>
|
||||
{description ? (
|
||||
<RichText
|
||||
value={description}
|
||||
disableLinks
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={2}
|
||||
/>
|
||||
) : null}
|
||||
<View style={[a.mt_xs, a.flex_row, a.gap_sm, a.align_center]}>
|
||||
{actors.length > 0 ? (
|
||||
<AvatarStack size={24} profiles={actors} />
|
||||
) : null}
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{trend.postCount >= 1000 ? (
|
||||
<Trans comment="Over 1,000 posts">1K+ posts</Trans>
|
||||
) : (
|
||||
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
|
||||
{formatCount(i18n, trend.postCount)}{' '}
|
||||
{plural(trend.postCount, {one: 'post', other: 'posts'})}
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: imageUrl,
|
||||
}}
|
||||
alt={trend.topic}
|
||||
style={[
|
||||
a.flex_0,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: IMAGE_SIZE,
|
||||
height: IMAGE_SIZE,
|
||||
},
|
||||
]}
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
@@ -141,96 +185,30 @@ export function TrendRow({
|
||||
)
|
||||
}
|
||||
|
||||
type TrendingIndicatorType = 'hot' | 'new' | number
|
||||
|
||||
function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const pillStyles = [
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
a.rounded_full,
|
||||
{height: 28, paddingHorizontal: 10},
|
||||
]
|
||||
|
||||
let Icon: React.ComponentType<SVGIconProps> | null = null
|
||||
let text: string | null = null
|
||||
let color: string | null = null
|
||||
let backgroundColor: string | null = null
|
||||
|
||||
switch (type) {
|
||||
case 'skeleton': {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pillStyles,
|
||||
{backgroundColor: t.palette.contrast_25, width: 65, height: 28},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'hot': {
|
||||
Icon = FlameIcon
|
||||
color =
|
||||
t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950
|
||||
backgroundColor =
|
||||
t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200
|
||||
text = _(msg`Hot`)
|
||||
break
|
||||
}
|
||||
case 'new': {
|
||||
Icon = TrendingIcon
|
||||
text = _(msg`New`)
|
||||
color = t.palette.positive_600
|
||||
backgroundColor = t.palette.positive_50
|
||||
break
|
||||
}
|
||||
default: {
|
||||
text = _(
|
||||
msg({
|
||||
message: `${type}h ago`,
|
||||
comment:
|
||||
'trending topic time spent trending. should be as short as possible to fit in a pill',
|
||||
}),
|
||||
)
|
||||
color = t.atoms.text_contrast_medium.color
|
||||
backgroundColor = t.atoms.bg_contrast_25.backgroundColor
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[pillStyles, {backgroundColor}]}>
|
||||
{Icon && <Icon size="sm" style={{color}} />}
|
||||
<Text style={[a.text_sm, a.font_medium, {color}]}>{text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function useCategoryDisplayName(
|
||||
// Unused atm, but leaving here so we don't lose localization. -dsb
|
||||
export function useCategoryDisplayName(
|
||||
category: AppBskyUnspeccedDefs.TrendView['category'],
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
switch (category) {
|
||||
case 'sports':
|
||||
return _(msg`Sports`)
|
||||
return l`Sports`
|
||||
case 'politics':
|
||||
return _(msg`Politics`)
|
||||
return l`Politics`
|
||||
case 'video-games':
|
||||
return _(msg`Video Games`)
|
||||
return l`Video Games`
|
||||
case 'pop-culture':
|
||||
return _(msg`Entertainment`)
|
||||
return l`Entertainment`
|
||||
case 'news':
|
||||
return _(msg`News`)
|
||||
return l`News`
|
||||
case 'other':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
|
||||
export function TrendingTopicRowSkeleton() {
|
||||
const t = useTheme()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
|
||||
@@ -239,32 +217,39 @@ export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
|
||||
style={[
|
||||
gutters,
|
||||
a.w_full,
|
||||
a.py_lg,
|
||||
a.py_md,
|
||||
a.flex_row,
|
||||
a.gap_2xs,
|
||||
a.gap_sm,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center]}>
|
||||
<View style={[{width: 20}]}>
|
||||
<LoadingPlaceholder
|
||||
width={12}
|
||||
height={12}
|
||||
style={[a.rounded_full]}
|
||||
/>
|
||||
</View>
|
||||
<LoadingPlaceholder width={90} height={17} />
|
||||
</View>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}>
|
||||
<View style={[{width: 20}]}>
|
||||
<LoadingPlaceholder width={17} height={17} style={[a.rounded_full]} />
|
||||
</View>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<LoadingPlaceholder width={90} height={17} />
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<LoadingPlaceholder width={70} height={16} />
|
||||
<LoadingPlaceholder width={40} height={16} />
|
||||
<LoadingPlaceholder width={60} height={16} />
|
||||
</View>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<LoadingPlaceholder width={50} height={16} />
|
||||
<LoadingPlaceholder width={70} height={16} />
|
||||
<LoadingPlaceholder width={30} height={16} />
|
||||
</View>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<View style={[a.mt_xs, a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<LoadingPlaceholder
|
||||
width={24}
|
||||
height={24}
|
||||
style={[a.rounded_full]}
|
||||
/>
|
||||
<LoadingPlaceholder width={60} height={16} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.flex_shrink_0]}>
|
||||
<TrendingIndicator type="skeleton" />
|
||||
</View>
|
||||
{/* TODO Image placeholder goes here when images are available. -dsb */}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type SearchFilters = {
|
||||
video?: string
|
||||
/** 'true' */
|
||||
following?: string
|
||||
/** 'me' */
|
||||
from?: string
|
||||
}
|
||||
|
||||
export const FILTER_PARAM_KEYS = [
|
||||
@@ -51,6 +53,7 @@ export const FILTER_PARAM_KEYS = [
|
||||
'media',
|
||||
'video',
|
||||
'following',
|
||||
'from',
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -76,13 +79,14 @@ export function readSearchFilters(
|
||||
}
|
||||
|
||||
export function hasActiveFilters(filters: SearchFilters): boolean {
|
||||
return FILTER_PARAM_KEYS.some(key => filters[key])
|
||||
return countActiveFilters(filters) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of active filter params, used for the "[+N filters]" pill in search
|
||||
* history. Each set key counts once (a multi-value field like author counts as
|
||||
* one filter regardless of how many handles it holds).
|
||||
* one filter regardless of how many handles it holds). Raw query operators do
|
||||
* not count until the advanced dialog promotes them to structured params.
|
||||
*/
|
||||
export function countActiveFilters(filters: SearchFilters): number {
|
||||
return FILTER_PARAM_KEYS.filter(key => filters[key]).length
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {type ListRenderItemInfo, Text as RNText, View} from 'react-native'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActivitySubscriptionsQuery} from '#/state/queries/activity-subscriptions'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {SubscribeProfileDialog} from '#/components/activity-notifications/SubscribeProfileDialog'
|
||||
import * as Admonition from '#/components/Admonition'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {
|
||||
BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon,
|
||||
BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon,
|
||||
} from '#/components/icons/BellRinging'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {PreferenceControls} from './components/PreferenceControls'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
AllNavigatorParams,
|
||||
'ActivityNotificationSettings'
|
||||
>
|
||||
|
||||
export function ActivityNotificationSettingsScreen({}: Props) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {data: preferences, isError: isPreferencesError} =
|
||||
useNotificationSettingsQuery()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const {
|
||||
data: subscriptions,
|
||||
isPending,
|
||||
isError: isSubscriptionsError,
|
||||
error,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useActivitySubscriptionsQuery()
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!subscriptions) return []
|
||||
return subscriptions.pages.flatMap(page => page.subscriptions)
|
||||
}, [subscriptions])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
|
||||
if (!moderationOpts) return null
|
||||
return (
|
||||
<ActivitySubscriptionCard
|
||||
profile={item}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[moderationOpts],
|
||||
)
|
||||
|
||||
const onEndReached = useCallback(() => {
|
||||
if (isFetchingNextPage || !hasNextPage || isSubscriptionsError) return
|
||||
void fetchNextPage().catch(err => {
|
||||
logger.error('Failed to load more activity subscriptions', {
|
||||
message: err,
|
||||
})
|
||||
})
|
||||
}, [isFetchingNextPage, hasNextPage, isSubscriptionsError, fetchNextPage])
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>
|
||||
<Trans>Notifications</Trans>
|
||||
</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
<List
|
||||
ListHeaderComponent={
|
||||
<SettingsList.Container>
|
||||
<SettingsList.Item style={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={BellRingingIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
bold
|
||||
titleText={l`Activity from others`}
|
||||
subtitleText={l`Get notified about posts and replies from accounts you choose.`}
|
||||
/>
|
||||
</SettingsList.Item>
|
||||
{isPreferencesError ? (
|
||||
<View style={[a.px_xl, a.pt_md]}>
|
||||
<Admonition.Admonition type="error">
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Admonition.Admonition>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[a.px_xl]}>
|
||||
<PreferenceControls
|
||||
name="subscribedPost"
|
||||
preference={preferences?.subscribedPost}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
}
|
||||
data={items}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListEmptyComponent={
|
||||
error ? null : (
|
||||
<View style={[a.px_xl, a.py_md]}>
|
||||
{!isPending ? (
|
||||
<Admonition.Outer type="tip">
|
||||
<Admonition.Row>
|
||||
<Admonition.Icon />
|
||||
<Admonition.Content>
|
||||
<Admonition.Text>
|
||||
<Trans>
|
||||
Enable notifications for an account by visiting their
|
||||
profile and pressing the{' '}
|
||||
<RNText
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
bell icon
|
||||
</RNText>{' '}
|
||||
<BellRingingFilledIcon
|
||||
size="xs"
|
||||
style={t.atoms.text_contrast_high}
|
||||
/>
|
||||
.
|
||||
</Trans>
|
||||
</Admonition.Text>
|
||||
<Admonition.Text>
|
||||
<Trans>
|
||||
If you want to restrict who can receive notifications
|
||||
for your account's activity, you can change this in{' '}
|
||||
<InlineLinkText
|
||||
label={l`Privacy and Security settings`}
|
||||
to={{screen: 'ActivityPrivacySettings'}}
|
||||
style={[a.font_semi_bold]}>
|
||||
Settings → Privacy and Security
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Admonition.Text>
|
||||
</Admonition.Content>
|
||||
</Admonition.Row>
|
||||
</Admonition.Outer>
|
||||
) : (
|
||||
<View style={[a.flex_1, a.align_center, a.pt_xl]}>
|
||||
<Loader size="lg" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
style={[items.length === 0 && a.border_transparent]}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
/>
|
||||
}
|
||||
windowSize={11}
|
||||
/>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function keyExtractor(item: bsky.profile.AnyProfileView) {
|
||||
return item.did
|
||||
}
|
||||
|
||||
function ActivitySubscriptionCard({
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const control = useDialogControl()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const actSub = profile.viewer?.activitySubscription
|
||||
if (actSub?.post && actSub?.reply) {
|
||||
return l`Posts, Replies`
|
||||
} else if (actSub?.post) {
|
||||
return l`Posts`
|
||||
} else if (actSub?.reply) {
|
||||
return l`Replies`
|
||||
}
|
||||
return l`None`
|
||||
}, [l, profile.viewer?.activitySubscription])
|
||||
|
||||
return (
|
||||
<View style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
inline
|
||||
/>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
{preview}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={l`Edit notifications from ${createSanitizedDisplayName(
|
||||
profile,
|
||||
)}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={control.open}>
|
||||
<ButtonText>
|
||||
<Trans>Edit</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</ProfileCard.Header>
|
||||
</ProfileCard.Outer>
|
||||
<SubscribeProfileDialog
|
||||
control={control}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
includeProfile
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -156,7 +156,9 @@ export function Inner({
|
||||
<>
|
||||
<Divider />
|
||||
<Text style={[a.font_semi_bold, a.text_md]}>
|
||||
<Trans>From</Trans>
|
||||
<Trans comment="Filter who you receive notifications from">
|
||||
From
|
||||
</Trans>
|
||||
</Text>
|
||||
<Toggle.Group
|
||||
type="radio"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user