Merge remote-tracking branch 'origin/main' into app-2670

# Conflicts:
#	src/analytics/features/types.ts
This commit is contained in:
vineyardbovines
2026-07-23 09:01:58 -04:00
115 changed files with 5172 additions and 2360 deletions
+15
View File
@@ -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 }}
+63
View File
@@ -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
+50
View File
@@ -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
+179
View File
@@ -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"
+356
View File
@@ -0,0 +1,356 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const ENTITY_REPLACEMENTS = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
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`)
}
+124 -84
View File
@@ -10,12 +10,25 @@ on:
options: options:
- testflight-android - testflight-android
- production - production
submit:
type: boolean
description: Submit the build to Google Play (disable to only produce the APK artifact)
default: true
workflow_call: workflow_call:
inputs: inputs:
profile: profile:
type: string type: string
description: Build profile to use description: Build profile to use
required: true 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: outputs:
package-version: package-version:
description: Version from package.json description: Version from package.json
@@ -56,93 +69,92 @@ permissions:
jobs: jobs:
build: build:
if: github.repository == 'bluesky-social/social-app' if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android name: Build Android
runs-on: Linux-x64-32core runs-on: ${{ inputs.runner || 'Linux-x64-32core' }}
concurrency: concurrency:
group: android-build group: android-build
cancel-in-progress: false cancel-in-progress: false
outputs: outputs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }} version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
steps: 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 - name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 5 fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json expo-token: ${{ secrets.EXPO_TOKEN }}
cache: pnpm
- name: 🪛 Setup jq - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
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
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"
- name: 🔤 Compile translations - name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log uses: ./.github/actions/compile-i18n
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
# EXPO_PUBLIC_ENV is handled in eas.json # EXPO_PUBLIC_ENV is handled in eas.json
- name: Env - name: ✏️ Write environment variables
id: env id: env
run: | uses: ./.github/actions/write-env
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' with:
echo "${{ secrets.ENV_TOKEN }}" > .env env-token: ${{ secrets.ENV_TOKEN }}
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env sentry-dsn: ${{ secrets.SENTRY_DSN }}
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
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 - name: 🏗️ EAS Build
env: uses: ./.github/actions/eas-local-build
PROFILE: ${{ inputs.profile || 'testflight-android' }} with:
run: > platform: android
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} profile: ${{ inputs.profile || 'testflight-android' }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }} output: build.aab
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }} bump-build-number: "true"
pnpm use-build-number-with-bump sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm eas build -p android sentry-release: ${{ steps.env.outputs.release-version }}
--profile $PROFILE sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
--local --output build.aab --non-interactive
- name: 📚 Get version from package.json - name: 📚 Get version from package.json
id: get-build-info id: get-build-info
run: bash scripts/setGitHubOutput.sh 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 - name: 🚀 Submit to Google Play
env: env:
PROFILE: ${{ inputs.profile || 'testflight-android' }} PROFILE: ${{ inputs.profile || 'testflight-android' }}
@@ -150,13 +162,49 @@ jobs:
- name: 🔔 Notify Slack of Play Store Submission - name: 🔔 Notify Slack of Play Store Submission
if: ${{ inputs.profile == 'production' }} if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
payload-templated: true payload-templated: true
payload: | 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 - name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1 uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
@@ -164,19 +212,24 @@ jobs:
version: "1.18.3" version: "1.18.3"
- name: 🔑 Decode keystore - name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > env:
keystore.jks ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > keystore.jks
- name: 📦 Build signed universal APK - 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: | run: |
bundletool build-apks \ bundletool build-apks \
--bundle=build.aab \ --bundle=build.aab \
--output=universal.apks \ --output=universal.apks \
--mode=universal \ --mode=universal \
--ks=keystore.jks \ --ks=keystore.jks \
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \ --ks-pass=pass:"$ANDROID_KEYSTORE_PASSWORD" \
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \ --ks-key-alias="$ANDROID_KEY_ALIAS" \
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }} --key-pass=pass:"$ANDROID_KEY_PASSWORD"
- name: 📋 Rename to .zip for extraction - name: 📋 Rename to .zip for extraction
run: mv universal.apks universal.zip run: mv universal.apks universal.zip
@@ -198,27 +251,13 @@ jobs:
path: build.apk path: build.apk
- name: 🔔 Notify Slack of APK Artifact - 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: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
payload-templated: true payload-templated: true
payload: | 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 }}```"} {"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 }}```"}
- 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
# Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production # 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 # build is dispatched against such a tag we attach the APK to the matching release. This runs
@@ -226,7 +265,7 @@ jobs:
attachToRelease: attachToRelease:
name: Attach APK to GitHub Release name: Attach APK to GitHub Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build] needs: [build, universalApk]
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }} if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
permissions: permissions:
contents: write contents: write
@@ -254,7 +293,7 @@ jobs:
if: ${{ steps.release-check.outputs.exists == 'true' }} if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: ${{ needs.build.outputs.apk-artifact-name }} name: ${{ needs.universalApk.outputs.apk-artifact-name }}
- name: 🏷️ Rename APK for release - name: 🏷️ Rename APK for release
if: ${{ steps.release-check.outputs.exists == 'true' }} if: ${{ steps.release-check.outputs.exists == 'true' }}
@@ -265,6 +304,7 @@ jobs:
if: ${{ steps.release-check.outputs.exists == 'true' }} if: ${{ steps.release-check.outputs.exists == 'true' }}
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
APK: Bluesky-${{ needs.build.outputs.package-version }}.apk APK: Bluesky-${{ needs.build.outputs.package-version }}.apk
run: | run: |
@@ -274,7 +314,7 @@ jobs:
- name: 🔔 Notify Slack of Release Attachment - name: 🔔 Notify Slack of Release Attachment
if: ${{ steps.release-check.outputs.exists == 'true' }} 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: with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
+111 -91
View File
@@ -28,6 +28,11 @@ on:
type: string type: string
description: TestFlight group to assign the build to after submitting ("none" to skip) description: TestFlight group to assign the build to after submitting ("none" to skip)
default: none default: none
runner:
type: string
description: Runner for the build job (defaults to macos-26-xlarge)
required: false
default: ''
outputs: outputs:
package-version: package-version:
description: Version from package.json description: Version from package.json
@@ -66,8 +71,8 @@ permissions:
jobs: jobs:
build: build:
if: github.repository == 'bluesky-social/social-app' if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS name: Build iOS
runs-on: macos-26-xlarge runs-on: ${{ inputs.runner || 'macos-26-xlarge' }}
concurrency: concurrency:
group: ios-build group: ios-build
cancel-in-progress: false cancel-in-progress: false
@@ -75,38 +80,15 @@ jobs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
build-number: ${{ steps.ipa-build-number.outputs.build-number }} build-number: ${{ steps.ipa-build-number.outputs.build-number }}
steps: 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 - name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 5 fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version-file: package.json expo-token: ${{ secrets.EXPO_TOKEN }}
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 }}
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with: with:
@@ -133,38 +115,29 @@ jobs:
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }} key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
- name: 🔤 Compile translations - name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log uses: ./.github/actions/compile-i18n
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
# EXPO_PUBLIC_ENV is handled in eas.json # EXPO_PUBLIC_ENV is handled in eas.json
- name: ✏️ Write environment variables - name: ✏️ Write environment variables
id: env id: env
run: | uses: ./.github/actions/write-env
echo "${{ secrets.ENV_TOKEN }}" > .env with:
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env env-token: ${{ secrets.ENV_TOKEN }}
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT sentry-dsn: ${{ secrets.SENTRY_DSN }}
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
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 - name: 🏗️ EAS Build
env: uses: ./.github/actions/eas-local-build
PROFILE: ${{ inputs.profile || 'testflight' }} with:
run: > platform: ios
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} profile: ${{ inputs.profile || 'testflight' }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }} output: build.tar.gz
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }} bump-build-number: "true"
pnpm use-build-number-with-bump sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm eas build -p ios sentry-release: ${{ steps.env.outputs.release-version }}
--profile $PROFILE sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact - name: 📂 Extract build artifact
run: | run: |
@@ -201,16 +174,6 @@ jobs:
exit 1 exit 1
fi 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 - name: 📚 Get version from package.json
id: get-build-info id: get-build-info
run: bash scripts/setGitHubOutput.sh run: bash scripts/setGitHubOutput.sh
@@ -220,6 +183,7 @@ jobs:
# number that actually lands in App Store Connect. `eas build:version:get` reads the # 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 — # 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. # 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 - name: 🔢 Read build number from IPA
id: ipa-build-number id: ipa-build-number
run: | run: |
@@ -235,18 +199,98 @@ jobs:
echo "IPA build number: $build_number" echo "IPA build number: $build_number"
echo "build-number=$build_number" >> "$GITHUB_OUTPUT" 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 # 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 # 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. # already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group - name: 🧪 Assign build to TestFlight group
if: ${{ inputs.testFlightGroup != 'none' }}
env: env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }} TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }} ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }} APP_VERSION: ${{ needs.build.outputs.package-version }}
BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }} BUILD_NUMBER: ${{ needs.build.outputs.build-number }}
run: | run: |
# Ensure the API key material is removed even if fastlane exits non-zero # 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). # (the step runs under `bash -e`, which would otherwise abort before cleanup).
@@ -271,27 +315,3 @@ jobs:
build_number:"$BUILD_NUMBER" \ build_number:"$BUILD_NUMBER" \
groups:"$TESTFLIGHT_GROUP" \ groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true 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
+60 -314
View File
@@ -86,7 +86,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -99,11 +99,7 @@ jobs:
previous-commit-tag: ${{ inputs.runtimeVersion }} previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: 🔤 Compile translations - name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log uses: ./.github/actions/compile-i18n
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
- name: Lint check - name: Lint check
run: pnpm lint run: pnpm lint
@@ -128,35 +124,26 @@ jobs:
!steps.version.outputs.version-changed }} !steps.version.outputs.version-changed }}
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1 uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
# eas.json not used here, set EXPO_PUBLIC_ENV # eas.json not used here, so EXPO_PUBLIC_ENV must be written explicitly
- name: Env - name: ✏️ Write environment variables
env:
CHANNEL: ${{ inputs.channel || 'testflight' }}
GITHUB_SHA: ${{ github.sha }}
id: env id: env
if: ${{ !steps.fingerprint.outputs.includes-changes && if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }}
!steps.version.outputs.version-changed }} uses: ./.github/actions/write-env
run: | with:
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' env-token: ${{ secrets.ENV_TOKEN }}
echo "${{ secrets.ENV_TOKEN }}" > .env sentry-dsn: ${{ secrets.SENTRY_DSN }}
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env expo-public-env: ${{ inputs.channel || 'testflight' }}
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
- name: 🏗️ Create Bundle - name: 🏗️ Create Bundle
if: ${{ !steps.fingerprint.outputs.includes-changes && if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }} !steps.version.outputs.version-changed }}
run: > run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }} SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }} SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
pnpm export pnpm export
- name: 📦 Package Bundle and 🚀 Deploy - name: 📦 Package Bundle and 🚀 Deploy
@@ -182,305 +169,64 @@ jobs:
!steps.version.outputs.version-changed }} !steps.version.outputs.version-changed }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS: buildIfNecessaryIOS:
name: Build and Submit iOS name: Build and Submit iOS
runs-on: macos-26
concurrency:
group: ios-build
cancel-in-progress: false
needs: [bundleDeploy] needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here # available here
if: ${{ inputs.channel != 'production' && if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository == needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app' }} 'bluesky-social/social-app' }}
steps: uses: ./.github/workflows/build-submit-ios.yml
- name: Check for EXPO_TOKEN with:
run: > profile: testflight
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then testFlightGroup: none
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" # OTA rebuilds don't need the xlarge builder used for releases
exit 1 runner: macos-26
fi # 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.
- name: ⬇️ Checkout secrets:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
with: ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
fetch-depth: 5 SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
- name: 🔧 Setup Node SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
with: ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
node-version-file: package.json ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
cache: pnpm SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
- 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
buildIfNecessaryAndroid: buildIfNecessaryAndroid:
name: Build and Submit Android name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: android-build
cancel-in-progress: false
needs: [bundleDeploy] needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here # available here
if: ${{ inputs.channel != 'production' && if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository == needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app'}} 'bluesky-social/social-app' }}
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
steps: # That job is skipped here (it needs a production tag build), but GitHub statically
- name: Check for EXPO_TOKEN # validates the reusable-workflow permission ceiling, so the caller must grant it.
run: > permissions:
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then contents: write
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" uses: ./.github/workflows/build-submit-android.yml
exit 1 with:
fi profile: testflight-android
runner: ubuntu-latest
- name: ⬇️ Checkout # Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # so this workflow never hands the reusable workflow the entire repo secret store.
with: secrets:
fetch-depth: 5 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
- name: 🔧 Setup Node EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
with: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
node-version-file: package.json SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
cache: pnpm ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
- name: 🔨 Setup EAS ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0 ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
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
+2 -2
View File
@@ -59,13 +59,13 @@ jobs:
fetch-depth: 1 fetch-depth: 1
- name: Configure AWS credentials (OIDC) - 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: with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2 aws-region: us-east-2
- name: Claude - name: Claude
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166 uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+2 -2
View File
@@ -45,13 +45,13 @@ jobs:
fetch-depth: 1 fetch-depth: 1
- name: Configure AWS credentials (OIDC) - 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: with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }} role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2 aws-region: us-east-2
- name: Claude review - name: Claude review
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166 uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
exit $rc exit $rc
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -91,7 +91,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
+2 -2
View File
@@ -144,7 +144,7 @@ jobs:
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack - name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with: with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }} webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
@@ -174,7 +174,7 @@ jobs:
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack - name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with: with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }} webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook webhook-type: incoming-webhook
+424
View File
@@ -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}} ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
+1 -1
View File
@@ -132,7 +132,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -73,7 +73,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
@@ -168,7 +168,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node - name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
cache: pnpm cache: pnpm
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: package.json node-version-file: package.json
+16
View File
@@ -44,6 +44,12 @@ appId: xyz.blueskyweb.app
id: "e2eRefreshHome" id: "e2eRefreshHome"
- tapOn: - tapOn:
id: "replyBtn" 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" - inputText: "Reply text only"
- tapOn: - tapOn:
id: "composerPublishBtn" id: "composerPublishBtn"
@@ -51,6 +57,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB" id: "composeFAB"
- tapOn: - tapOn:
id: "replyBtn" id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply with an image" - inputText: "Reply with an image"
- tapOn: - tapOn:
id: "openMediaBtn" id: "openMediaBtn"
@@ -63,6 +74,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB" id: "composeFAB"
- tapOn: - tapOn:
id: "replyBtn" 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" - inputText: "Reply with a https://example.com link card"
- tapOn: - tapOn:
id: "composerPublishBtn" id: "composerPublishBtn"
+81 -44
View File
@@ -29,51 +29,88 @@ appId: xyz.blueskyweb.app
id: "homeScreenFeedTabs-selector-1" id: "homeScreenFeedTabs-selector-1"
text: "alice-favs" text: "alice-favs"
# Set alice-favs first # Reordering feeds is driven by a drag on the feed-drag-handle. Maestro cannot
- tapOn: "Open drawer menu" # activate the RNGH Pan gesture from a synthetic swipe on Android (proven
- tapOn: # twice - coordinate swipes never register the pan), so the reorder
id: "menuItemButton-Feeds" # verification below runs on iOS only. If Android drag coverage is needed,
- tapOn: # revisit with the SavedFeedsA11y move buttons rather than a swipe.
id: "editFeedsBtn" - runFlow:
- swipe: when:
label: "Drag feed down" platform: iOS
from: commands:
id: "feed-drag-handle" # Set alice-favs first
direction: "DOWN" - tapOn: "Open drawer menu"
duration: 1000 - tapOn:
- tapOn: id: "menuItemButton-Feeds"
label: "Save button" - tapOn:
id: "saveChangesBtn" id: "editFeedsBtn"
- tapOn: "Go back" - swipe:
- assertVisible: label: "Drag feed down"
id: "homeScreenFeedTabs-selector-0" from:
text: "alice-favs" id: "feed-drag-handle"
- assertVisible: direction: "DOWN"
id: "homeScreenFeedTabs-selector-1" duration: 1000
text: "Following" - 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 # Set following first
- tapOn: "Open drawer menu" - tapOn: "Open drawer menu"
- tapOn: - tapOn:
id: "menuItemButton-Feeds" id: "menuItemButton-Feeds"
- tapOn: - tapOn:
id: "editFeedsBtn" id: "editFeedsBtn"
- swipe: - swipe:
label: "Drag feed down" label: "Drag feed down"
from: from:
id: "feed-drag-handle" id: "feed-drag-handle"
direction: "DOWN" direction: "DOWN"
duration: 1000 duration: 1000
- tapOn: - assertVisible:
label: "Save button" id: "saveChangesBtn"
id: "saveChangesBtn" enabled: true
- tapOn: "Go back" - tapOn:
- assertVisible: label: "Save button"
id: "homeScreenFeedTabs-selector-0" id: "saveChangesBtn"
text: "Following" - tapOn: "Go back"
- assertVisible: - assertVisible:
id: "homeScreenFeedTabs-selector-1" id: "homeScreenFeedTabs-selector-0"
text: "alice-favs" 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 # Remove following
- tapOn: "Open drawer menu" - tapOn: "Open drawer menu"
@@ -15,6 +15,24 @@ appId: xyz.blueskyweb.app
- tapOn: - tapOn:
id: "e2eStartOnboarding" id: "e2eStartOnboarding"
- tapOn: "Open avatar creator" - 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: "Select the zap emoji as your avatar"
- tapOn: - tapOn:
label: "Tap on yellow" label: "Tap on yellow"
@@ -22,6 +40,20 @@ appId: xyz.blueskyweb.app
- tapOn: "Done" - tapOn: "Done"
- waitForAnimationToEnd - waitForAnimationToEnd
- tapOn: "Select an avatar" - 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: "Select the atom emoji as your avatar"
- tapOn: "Done" - tapOn: "Done"
- waitForAnimationToEnd - waitForAnimationToEnd
+22 -7
View File
@@ -16,13 +16,28 @@ appId: xyz.blueskyweb.app
id: "e2eStartOnboarding" id: "e2eStartOnboarding"
- tapOn: "Select an avatar" - tapOn: "Select an avatar"
- waitForAnimationToEnd - waitForAnimationToEnd
- assertVisible: "Photos" - runFlow:
- assertVisible: "Collections" when:
- tapOn: platform: iOS
point: "50%,22%" commands:
- waitForAnimationToEnd - assertVisible: "Photos"
- tapOn: "Done" - assertVisible: "Collections"
- waitForAnimationToEnd - 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: - tapOn:
id: "onboardingContinue" id: "onboardingContinue"
- assertVisible: "What are your interests?" - assertVisible: "What are your interests?"
+5 -2
View File
@@ -45,7 +45,7 @@ appId: xyz.blueskyweb.app
id: "editProfileSaveBtn" id: "editProfileSaveBtn"
- assertNotVisible: - assertNotVisible:
id: "editProfileModal" id: "editProfileModal"
- assertVisible: "Alicia" - assertVisible: ".*Alicia.*"
- assertVisible: "One cool hacker" - assertVisible: "One cool hacker"
# Remove display name and description via the edit profile modal # Remove display name and description via the edit profile modal
@@ -64,7 +64,10 @@ appId: xyz.blueskyweb.app
id: "editProfileSaveBtn" id: "editProfileSaveBtn"
- assertNotVisible: - assertNotVisible:
id: "editProfileModal" 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" - assertNotVisible: "One cool hacker"
# Set avi and banner via the edit profile modal # Set avi and banner via the edit profile modal
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
text: "Send report to Dev-env Moderation" text: "Send report to Dev-env Moderation"
- tapOn: - tapOn:
id: "report:submit" id: "report:submit"
- assertNotVisible: - extendedWaitUntil:
id: "report:dialog" notVisible:
id: "report:dialog"
timeout: 20000
+4 -2
View File
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
text: "Send report to Dev-env Moderation" text: "Send report to Dev-env Moderation"
- tapOn: - tapOn:
id: "report:submit" id: "report:submit"
- assertNotVisible: - extendedWaitUntil:
id: "report:dialog" notVisible:
id: "report:dialog"
timeout: 20000
@@ -39,5 +39,7 @@ appId: xyz.blueskyweb.app
text: Your report will be sent to Dev-env Moderation.* text: Your report will be sent to Dev-env Moderation.*
- tapOn: - tapOn:
id: "report:submit" id: "report:submit"
- assertNotVisible: - extendedWaitUntil:
id: "report:dialog" notVisible:
id: "report:dialog"
timeout: 20000
@@ -29,5 +29,7 @@ appId: xyz.blueskyweb.app
- hideKeyboard - hideKeyboard
- tapOn: - tapOn:
id: "report:submit" id: "report:submit"
- assertNotVisible: - extendedWaitUntil:
id: "report:dialog" notVisible:
id: "report:dialog"
timeout: 20000
+85 -12
View File
@@ -20,6 +20,12 @@ appId: xyz.blueskyweb.app
- inputText: "Test thread" - inputText: "Test thread"
- tapOn: - tapOn:
id: "composerPublishBtn" 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 # Login, reply to the thread, and log out
- tapOn: - tapOn:
@@ -31,9 +37,19 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn" id: "viewHeaderHomeFeedPrefsBtn"
- tapOn: - tapOn:
id: "replyBtn" id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 1" - inputText: "Reply 1"
- tapOn: - tapOn:
id: "composerPublishBtn" 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 # Login, confirm notification exists, mute thread, and log out
- tapOn: - tapOn:
@@ -45,10 +61,8 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn" id: "viewHeaderHomeFeedPrefsBtn"
- tapOn: - tapOn:
id: "bottomBarNotificationsBtn" id: "bottomBarNotificationsBtn"
- assertVisible: - assertVisible: ".*Reply 1.*"
id: "feedItem-by-bob.test" - tapOn: ".*Reply 1.*"
- tapOn:
id: "feedItem-by-bob.test"
- tapOn: - tapOn:
id: "postDropdownBtn" id: "postDropdownBtn"
childOf: childOf:
@@ -67,16 +81,78 @@ appId: xyz.blueskyweb.app
id: "bottomBarProfileBtn" id: "bottomBarProfileBtn"
- tapOn: - tapOn:
id: "profilePager-selector-1" id: "profilePager-selector-1"
- tapOn: # Both replies target the thread root ("Test thread" by alice), which sits at
id: "replyBtn" # 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" - inputText: "Reply 2"
- tapOn: - tapOn:
id: "composerPublishBtn" id: "composerPublishBtn"
- tapOn: # Wait for the composer to actually close before opening it again. replyBtn
id: "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" - inputText: "Reply 3"
- tapOn: - tapOn:
id: "composerPublishBtn" 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~~ # 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 # 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.*" - assertVisible: ".*Reply 1.*"
- assertNotVisible: ".*Reply 2.*" - assertNotVisible: ".*Reply 2.*"
- assertNotVisible: ".*Reply 3.*" - assertNotVisible: ".*Reply 3.*"
- assertVisible: - tapOn: ".*Reply 1.*"
id: "feedItem-by-bob.test"
- tapOn:
id: "feedItem-by-bob.test"
- tapOn: - tapOn:
id: "postDropdownBtn" id: "postDropdownBtn"
childOf: childOf:
+17 -13
View File
@@ -9,23 +9,27 @@ appId: xyz.blueskyweb.app
when: when:
platform: iOS platform: iOS
commands: commands:
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081" - extendedWaitUntil:
- runFlow: visible: "http://localhost:8081"
when: timeout: 60000
visible: 'Open in "Bluesky"' - tapOn: "http://localhost:8081"
commands:
- tapOn: Open
- runFlow: - runFlow:
when: when:
platform: Android platform: Android
commands: commands:
- tapOn: 'http://localhost:8081' - extendedWaitUntil:
- runFlow: visible: "http://10.0.2.2:8081"
label: "Dismiss Expo dev menu" timeout: 60000
when: - tapOn: "http://10.0.2.2:8081"
visible: "Continue" - extendedWaitUntil:
commands: visible: "Continue"
- back timeout: 180000
- tapOn: "Continue"
- back
- extendedWaitUntil:
visible:
id: e2eProxyHeaderInput
timeout: 180000
- tapOn: - tapOn:
id: e2eProxyHeaderInput id: e2eProxyHeaderInput
- inputText: ${output.result} - inputText: ${output.result}
+1
View File
@@ -328,6 +328,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/interests", server.WebGenericNoindex) e.GET("/settings/interests", server.WebGenericNoindex)
e.GET("/settings/about", server.WebGenericNoindex) e.GET("/settings/about", server.WebGenericNoindex)
e.GET("/settings/notifications", 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", server.WebGenericNoindex)
e.GET("/sys/debug-mod", server.WebGenericNoindex) e.GET("/sys/debug-mod", server.WebGenericNoindex)
e.GET("/sys/log", server.WebGenericNoindex) e.GET("/sys/log", server.WebGenericNoindex)
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "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": { "dependencies": {
"@atproto/api": "^0.20.22", "@atproto/api": "^0.20.22",
+43
View File
@@ -29,6 +29,49 @@ adb reverse tcp:3000 tcp:3000
- In a second tab, run `pnpm e2e:build` - In a second tab, run `pnpm e2e:build`
- In a third tab, run `pnpm e2e:run __e2e__` - 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 ## Using Flashlight for Performance Testing
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above 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/) 2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
+11
View File
@@ -21,6 +21,17 @@
"EXPO_PUBLIC_ENV": "production" "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": { "preview": {
"extends": "base", "extends": "base",
"distribution": "internal", "distribution": "internal",
@@ -146,6 +146,7 @@ function BottomSheetNativeComponentInner({
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0 const cornerRadius = rest.cornerRadius ?? 0
const {height: screenHeight} = useWindowDimensions() const {height: screenHeight} = useWindowDimensions()
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
// sigh... on older Android versions, screenHeight does not include safe area insets // 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 // on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
@@ -182,7 +183,7 @@ function BottomSheetNativeComponentInner({
]}> ]}>
<View <View
onLayout={onLayout} onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}> style={isHeightConstrained ? {flex: 1} : undefined}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider> <BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View> </View>
</View> </View>
-44
View File
@@ -244,14 +244,6 @@
"count": 3 "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": { "src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 2 "count": 2
@@ -290,19 +282,6 @@
"count": 1 "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": { "src/components/PostControls/ShareMenu/index.tsx": {
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 1 "count": 1
@@ -1055,11 +1034,6 @@
"count": 1 "count": 1
} }
}, },
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
"typescript/no-misused-promises": {
"count": 5
}
},
"src/screens/ProfileList/FeedSection.tsx": { "src/screens/ProfileList/FeedSection.tsx": {
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 1 "count": 1
@@ -1406,11 +1380,6 @@
"count": 2 "count": 2
} }
}, },
"src/state/queries/feed.ts": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/state/queries/handle.ts": { "src/state/queries/handle.ts": {
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 1 "count": 1
@@ -1617,14 +1586,6 @@
"count": 1 "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": { "src/view/com/composer/text-input/TextInput.web.tsx": {
"typescript/no-misused-promises": { "typescript/no-misused-promises": {
"count": 1 "count": 1
@@ -1764,11 +1725,6 @@
"count": 2 "count": 2
} }
}, },
"src/view/com/posts/PostFeed.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/PostFeedErrorMessage.tsx": { "src/view/com/posts/PostFeedErrorMessage.tsx": {
"typescript/no-explicit-any": { "typescript/no-explicit-any": {
"count": 1 "count": 1
+9 -8
View File
@@ -63,9 +63,9 @@
"lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web", "typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web",
"typecheck:ios": "tsgo --project ./tsconfig.check.ios.json", "typecheck:ios": "tsc --project ./tsconfig.check.ios.json",
"typecheck:android": "tsgo --project ./tsconfig.check.android.json", "typecheck:android": "tsc --project ./tsconfig.check.android.json",
"typecheck:web": "tsgo --project ./tsconfig.check.web.json", "typecheck:web": "tsc --project ./tsconfig.check.web.json",
"e2e:mock-server": "cd dev-env && pnpm start", "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": "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", "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 ." "prettier": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.28", "@atproto/api": "0.20.31",
"@atproto/common-web": "0.5.6", "@atproto/common-web": "0.5.6",
"@atproto/syntax": "0.7.2", "@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@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-dynamic-app-icon": "^1.8.5",
"@bsky.app/expo-guess-language": "^0.2.8", "@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1", "@bsky.app/expo-image-crop-tool": "^0.5.1",
@@ -161,6 +161,7 @@
"expo": "54.0.34", "expo": "54.0.34",
"expo-age-range": "0.2.18", "expo-age-range": "0.2.18",
"expo-application": "~7.0.8", "expo-application": "~7.0.8",
"expo-asset": "~12.0.13",
"expo-blur": "~15.0.8", "expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10", "expo-camera": "~17.0.10",
@@ -273,7 +274,7 @@
"@types/psl": "1.1.1", "@types/psl": "1.1.1",
"@types/react": "^19.1.17", "@types/react": "^19.1.17",
"@types/react-dom": "^19.1.11", "@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-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2", "babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3", "babel-plugin-react-compiler": "19.1.0-rc.3",
@@ -292,13 +293,13 @@
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"lint-staged": "^17.0.8", "lint-staged": "^17.0.8",
"oxlint": "^1.73.0", "oxlint": "^1.73.0",
"oxlint-tsgolint": "^0.24.0", "oxlint-tsgolint": "^7.0.2001",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"react-native-dotenv": "^3.4.11", "react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"svgo": "^4.0.2", "svgo": "^4.0.2",
"ts-plugin-sort-import-suggestions": "^1.0.4", "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" "webpack-bundle-analyzer": "^4.10.1"
}, },
"jest": { "jest": {
+244 -103
View File
@@ -242,8 +242,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.28 specifier: 0.20.31
version: 0.20.28 version: 0.20.31
'@atproto/common-web': '@atproto/common-web':
specifier: 0.5.6 specifier: 0.5.6
version: 0.5.6 version: 0.5.6
@@ -257,8 +257,8 @@ importers:
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.4 version: 6.0.4
'@bsky.app/alf': '@bsky.app/alf':
specifier: ^0.1.14 specifier: ^0.1.15
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) 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': '@bsky.app/expo-dynamic-app-icon':
specifier: ^1.8.5 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) 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 version: 9.2.7
'@lingui/core': '@lingui/core':
specifier: ^5.9.2 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': '@lingui/react':
specifier: ^5.9.2 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': '@react-native-async-storage/async-storage':
specifier: 2.2.0 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)) 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: expo-application:
specifier: ~7.0.8 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)) 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: expo-blur:
specifier: ~15.0.8 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) 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 version: 4.14.2
'@lingui/babel-plugin-lingui-macro': '@lingui/babel-plugin-lingui-macro':
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.5(typescript@6.0.3) version: 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/cli': '@lingui/cli':
specifier: ^5.9.2 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': '@pmmmwh/react-refresh-webpack-plugin':
specifier: ^0.5.15 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)) 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': '@types/react-dom':
specifier: ^19.1.11 specifier: ^19.1.11
version: 19.1.11(@types/react@19.1.17) version: 19.1.11(@types/react@19.1.17)
'@typescript/native-preview': '@typescript/native':
specifier: ^7.0.0-dev.20260428.1 specifier: npm:typescript@^7.0.2
version: 7.0.0-dev.20260512.1 version: typescript@7.0.2
babel-jest: babel-jest:
specifier: ^29.7.0 specifier: ^29.7.0
version: 29.7.0(@babel/core@7.29.0) version: 29.7.0(@babel/core@7.29.0)
@@ -826,10 +829,10 @@ importers:
version: runtime:24.18.0 version: runtime:24.18.0
oxlint: oxlint:
specifier: ^1.73.0 specifier: ^1.73.0
version: 1.73.0(oxlint-tsgolint@0.24.0) version: 1.73.0(oxlint-tsgolint@7.0.2001)
oxlint-tsgolint: oxlint-tsgolint:
specifier: ^0.24.0 specifier: ^7.0.2001
version: 0.24.0 version: 7.0.2001
prettier: prettier:
specifier: ^3.8.3 specifier: ^3.8.3
version: 3.8.3 version: 3.8.3
@@ -846,8 +849,8 @@ importers:
specifier: ^1.0.4 specifier: ^1.0.4
version: 1.0.4 version: 1.0.4
typescript: typescript:
specifier: ^6.0.2 specifier: npm:@typescript/typescript6@^6.0.2
version: 6.0.3 version: '@typescript/typescript6@6.0.2'
webpack-bundle-analyzer: webpack-bundle-analyzer:
specifier: ^4.10.1 specifier: ^4.10.1
version: 4.10.2 version: 4.10.2
@@ -862,8 +865,8 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.28': '@atproto/api@0.20.31':
resolution: {integrity: sha512-/Rvk8zt9mtRi9tlMD2Qg+NG2lMj3B0HDjmfswR5724pH7GsOEMDDHwleVlmOBSrgACIyVSa5tdCDJ+R+SEhwww==} resolution: {integrity: sha512-TovCQLQv5ti1jqh8UH6jJ0EFuWRjGdUtFyFR5xYC/IkIulwHDuyrVaXdCv7VLWiHftp92DevtZnFRm+BZsZZdw==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.6': '@atproto/common-web@0.5.6':
@@ -1603,8 +1606,8 @@ packages:
'@braintree/sanitize-url@6.0.4': '@braintree/sanitize-url@6.0.4':
resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==}
'@bsky.app/alf@0.1.14': '@bsky.app/alf@0.1.15':
resolution: {integrity: sha512-c/KK6avyjEnYzhXDsN0rQaTFSbi8BPg4sJCW8aUk8AtKsO34GTaY6FU1adQ2i8xVfe5UIRu9FJjnBoGpuXj21A==} resolution: {integrity: sha512-e6blt+oZ2klv+Cp7u11FeZckoILdXOtUZ11ATERgjZgb4zX3y7rt4e2fI03JkJO31wTvXHG8A1LnM96Az+ndJg==}
peerDependencies: peerDependencies:
react: '*' react: '*'
react-native: '*' react-native: '*'
@@ -2242,33 +2245,33 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@oxlint-tsgolint/darwin-arm64@0.24.0': '@oxlint-tsgolint/darwin-arm64@7.0.2001':
resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.24.0': '@oxlint-tsgolint/darwin-x64@7.0.2001':
resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.24.0': '@oxlint-tsgolint/linux-arm64@7.0.2001':
resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@oxlint-tsgolint/linux-x64@0.24.0': '@oxlint-tsgolint/linux-x64@7.0.2001':
resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@oxlint-tsgolint/win32-arm64@0.24.0': '@oxlint-tsgolint/win32-arm64@7.0.2001':
resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@oxlint-tsgolint/win32-x64@0.24.0': '@oxlint-tsgolint/win32-x64@7.0.2001':
resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -3715,51 +3718,128 @@ packages:
'@types/yargs@17.0.35': '@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1': '@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-l9AJi/TIVMPx5R1c7fxZCSA7eUaHeA0C9Mxdxx/oQJo1K/GtbI3mzYe/SiKNltko1KSdKUmWVhPwxTOS289REg==} 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'} engines: {node: '>=16.20.0'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1': '@typescript/typescript-darwin-x64@7.0.2':
resolution: {integrity: sha512-oABZLQrfB8JN2Ct2CiLK5PyE28Em3sIJlZsAMD45/A2ymtIaa5826dwv8vapE5Wjp54ao0LXxCSuKFm1A8zzCQ==} resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1': '@typescript/typescript-freebsd-arm64@7.0.2':
resolution: {integrity: sha512-xvbwzpTe+5N6bnBI/t9n4zsGzXxz3V6rVbvDUoJmRLfav5fz+ck0QDkGQGUPrQEEIp0KEzQvx7c+AEZnzdvTQA==} 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'} engines: {node: '>=16.20.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1': '@typescript/typescript-linux-arm@7.0.2':
resolution: {integrity: sha512-0Hs1Gqa/t9cthoPdqHud1pFGUr9DgJivBTjwquTUh8jt/6PI2bQxoMNZLiN/bhqeDFDTzdxoMBfCaytsTMcXqw==} resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1': '@typescript/typescript-linux-loong64@7.0.2':
resolution: {integrity: sha512-qr5h6FPo74bN/U+EwRuayBhUbxaji8xzFbIbhMOA2oYSc/qozp5ia2g1+9xGw67MXxPPw/IPT+UGvrNK7K1NeQ==} 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'} engines: {node: '>=16.20.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1': '@typescript/typescript-netbsd-arm64@7.0.2':
resolution: {integrity: sha512-meNWxhNEfaqos2U0JXvfxWvy4JWrKE9fZepCndDZi+t04X+AIiLYp5s6crWnKP67nzVAzMNgTAc8mu8CnGM+/A==} 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'} engines: {node: '>=16.20.0'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1': '@typescript/typescript-win32-x64@7.0.2':
resolution: {integrity: sha512-Hp6vBnxJSKEEAVWgIoWMmfqkZXCdkhm6XTivrwgRzBwWfiTVe2ZyZ7byWegIKeNnBbffg/K2KvoM8JAHl059GQ==} resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@typescript/native-preview@7.0.0-dev.20260512.1': '@typescript/typescript6@6.0.2':
resolution: {integrity: sha512-KIzYPGuxZnyiiYkYrozDT94Af2nwbdLXoY1cgGY66RRa9HSEw13RH9WHg8wA8fZhT4wYzF5uF7WY3hz0QhaxGg==} resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==}
engines: {node: '>=16.20.0'}
hasBin: true hasBin: true
'@ungap/structured-clone@1.3.1': '@ungap/structured-clone@1.3.1':
@@ -7060,8 +7140,8 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
oxlint-tsgolint@0.24.0: oxlint-tsgolint@7.0.2001:
resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==}
hasBin: true hasBin: true
oxlint@1.73.0: oxlint@1.73.0:
@@ -8655,6 +8735,11 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true 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: ua-parser-js@0.7.41:
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
hasBin: true hasBin: true
@@ -9123,7 +9208,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.28': '@atproto/api@0.20.31':
dependencies: dependencies:
'@atproto/common-web': 0.5.6 '@atproto/common-web': 0.5.6
'@atproto/lexicon': 0.7.7 '@atproto/lexicon': 0.7.7
@@ -10069,7 +10154,7 @@ snapshots:
'@braintree/sanitize-url@6.0.4': {} '@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: dependencies:
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-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-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: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@lingui/conf': 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@6.0.3)) '@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
'@lingui/message-utils': 5.9.5 '@lingui/message-utils': 5.9.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
'@lingui/cli@5.9.5(typescript@6.0.3)': '@lingui/cli@5.9.5(@typescript/typescript6@6.0.2)':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/generator': 7.29.1 '@babel/generator': 7.29.1
@@ -10922,10 +11007,10 @@ snapshots:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@lingui/babel-plugin-extract-messages': 5.9.5 '@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)
'@lingui/conf': 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@6.0.3)) '@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@6.0.3) '@lingui/format-po': 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/message-utils': 5.9.5 '@lingui/message-utils': 5.9.5
chokidar: 3.5.1 chokidar: 3.5.1
cli-table: 0.3.11 cli-table: 0.3.11
@@ -10948,26 +11033,26 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
'@lingui/conf@5.9.5(typescript@6.0.3)': '@lingui/conf@5.9.5(@typescript/typescript6@6.0.2)':
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@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 jest-validate: 29.7.0
jiti: 2.7.0 jiti: 2.7.0
picocolors: 1.1.1 picocolors: 1.1.1
transitivePeerDependencies: transitivePeerDependencies:
- typescript - 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: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
'@lingui/message-utils': 5.9.5 '@lingui/message-utils': 5.9.5
optionalDependencies: 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: 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 '@lingui/message-utils': 5.9.5
date-fns: 3.6.0 date-fns: 3.6.0
pofile: 1.1.4 pofile: 1.1.4
@@ -10979,13 +11064,13 @@ snapshots:
'@messageformat/parser': 5.1.1 '@messageformat/parser': 5.1.1
js-sha256: 0.10.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: dependencies:
'@babel/runtime': 7.29.2 '@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 react: 19.1.0
optionalDependencies: 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': '@messageformat/parser@5.1.1':
dependencies: dependencies:
@@ -11003,22 +11088,22 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 fastq: 1.20.1
'@oxlint-tsgolint/darwin-arm64@0.24.0': '@oxlint-tsgolint/darwin-arm64@7.0.2001':
optional: true optional: true
'@oxlint-tsgolint/darwin-x64@0.24.0': '@oxlint-tsgolint/darwin-x64@7.0.2001':
optional: true optional: true
'@oxlint-tsgolint/linux-arm64@0.24.0': '@oxlint-tsgolint/linux-arm64@7.0.2001':
optional: true optional: true
'@oxlint-tsgolint/linux-x64@0.24.0': '@oxlint-tsgolint/linux-x64@7.0.2001':
optional: true optional: true
'@oxlint-tsgolint/win32-arm64@0.24.0': '@oxlint-tsgolint/win32-arm64@7.0.2001':
optional: true optional: true
'@oxlint-tsgolint/win32-x64@0.24.0': '@oxlint-tsgolint/win32-x64@7.0.2001':
optional: true optional: true
'@oxlint/binding-android-arm-eabi@1.73.0': '@oxlint/binding-android-arm-eabi@1.73.0':
@@ -12563,36 +12648,69 @@ snapshots:
dependencies: dependencies:
'@types/yargs-parser': 21.0.3 '@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 optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1': '@typescript/typescript-darwin-arm64@7.0.2':
optional: true optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1': '@typescript/typescript-darwin-x64@7.0.2':
optional: true optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1': '@typescript/typescript-freebsd-arm64@7.0.2':
optional: true optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1': '@typescript/typescript-freebsd-x64@7.0.2':
optional: true optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1': '@typescript/typescript-linux-arm64@7.0.2':
optional: true optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1': '@typescript/typescript-linux-arm@7.0.2':
optional: true optional: true
'@typescript/native-preview@7.0.0-dev.20260512.1': '@typescript/typescript-linux-loong64@7.0.2':
optionalDependencies: optional: true
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260512.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260512.1 '@typescript/typescript-linux-mips64el@7.0.2':
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260512.1 optional: true
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260512.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260512.1 '@typescript/typescript-linux-ppc64@7.0.2':
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260512.1 optional: true
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260512.1
'@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': {} '@ungap/structured-clone@1.3.1': {}
@@ -13434,14 +13552,14 @@ snapshots:
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
cosmiconfig@8.3.6(typescript@6.0.3): cosmiconfig@8.3.6(@typescript/typescript6@6.0.2):
dependencies: dependencies:
import-fresh: 3.3.1 import-fresh: 3.3.1
js-yaml: 4.1.1 js-yaml: 4.1.1
parse-json: 5.2.0 parse-json: 5.2.0
path-type: 4.0.0 path-type: 4.0.0
optionalDependencies: optionalDependencies:
typescript: 6.0.3 typescript: '@typescript/typescript6@6.0.2'
create-jest@29.7.0(@types/node@24.12.4): create-jest@29.7.0(@types/node@24.12.4):
dependencies: dependencies:
@@ -16340,16 +16458,16 @@ snapshots:
object-keys: 1.1.1 object-keys: 1.1.1
safe-push-apply: 1.0.0 safe-push-apply: 1.0.0
oxlint-tsgolint@0.24.0: oxlint-tsgolint@7.0.2001:
optionalDependencies: optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.24.0 '@oxlint-tsgolint/darwin-arm64': 7.0.2001
'@oxlint-tsgolint/darwin-x64': 0.24.0 '@oxlint-tsgolint/darwin-x64': 7.0.2001
'@oxlint-tsgolint/linux-arm64': 0.24.0 '@oxlint-tsgolint/linux-arm64': 7.0.2001
'@oxlint-tsgolint/linux-x64': 0.24.0 '@oxlint-tsgolint/linux-x64': 7.0.2001
'@oxlint-tsgolint/win32-arm64': 0.24.0 '@oxlint-tsgolint/win32-arm64': 7.0.2001
'@oxlint-tsgolint/win32-x64': 0.24.0 '@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: optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.73.0 '@oxlint/binding-android-arm-eabi': 1.73.0
'@oxlint/binding-android-arm64': 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-arm64-msvc': 1.73.0
'@oxlint/binding-win32-ia32-msvc': 1.73.0 '@oxlint/binding-win32-ia32-msvc': 1.73.0
'@oxlint/binding-win32-x64-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: p-limit@2.3.0:
dependencies: dependencies:
@@ -18110,6 +18228,29 @@ snapshots:
typescript@6.0.3: {} 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@0.7.41: {}
ua-parser-js@1.0.41: {} ua-parser-js@1.0.41: {}
+8
View File
@@ -43,3 +43,11 @@ patchedDependencies:
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- '@atproto/*' - '@atproto/*'
- '@bsky.app/*' - '@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
+9
View File
@@ -117,6 +117,7 @@ import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings' import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings' import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings' import {SettingsScreen} from '#/screens/Settings/Settings'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
@@ -451,6 +452,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => NotificationSettingsScreen} getComponent={() => NotificationSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}} options={{title: title(msg`Notification settings`), requireAuth: true}}
/> />
<Stack.Screen
name="ActivityNotificationSettings"
getComponent={() => ActivityNotificationSettingsScreen}
options={{
title: title(msg`Activity notifications`),
requireAuth: true,
}}
/>
<Stack.Screen <Stack.Screen
name="ContentAndMediaSettings" name="ContentAndMediaSettings"
getComponent={() => ContentAndMediaSettingsScreen} getComponent={() => ContentAndMediaSettingsScreen}
+1
View File
@@ -20,6 +20,7 @@ export enum Features {
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable', PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable', CustomLogoJapanEnable = 'custom_logo:japan:enable',
VideoMultipartUploadEnable = 'video:multipart_upload:enable', VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+30 -12
View File
@@ -513,7 +513,7 @@ export type Events = {
| 'ProgressGuide' | 'ProgressGuide'
location: 'Card' | 'Profile' | 'FollowAll' location: 'Card' | 'Profile' | 'FollowAll'
recSource?: 'Search' recSource?: 'Search'
recId?: number | string recId?: string
position: number position: number
suggestedDid: string suggestedDid: string
category: string | null category: string | null
@@ -526,7 +526,7 @@ export type Events = {
| 'ProfileHeader' | 'ProfileHeader'
| 'Onboarding' | 'Onboarding'
| 'SeeMoreSuggestedUsers' | 'SeeMoreSuggestedUsers'
recId?: number | string recId?: string
position: number position: number
suggestedDid: string suggestedDid: string
category: string | null category: string | null
@@ -541,7 +541,7 @@ export type Events = {
| 'SeeMoreSuggestedUsers' | 'SeeMoreSuggestedUsers'
| 'ProgressGuide' | 'ProgressGuide'
recSource?: 'Search' recSource?: 'Search'
recId?: number | string recId?: string
position: number position: number
suggestedDid: string suggestedDid: string
category: string | null category: string | null
@@ -553,11 +553,11 @@ export type Events = {
| 'ProfileInterstitial' | 'ProfileInterstitial'
| 'ProfileHeader' | 'ProfileHeader'
| 'Onboarding' | 'Onboarding'
recId?: number | string recId?: string
} }
'suggestedUser:dismiss': { 'suggestedUser:dismiss': {
logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader' logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader'
recId?: number | string recId?: string
position: number position: number
suggestedDid: string suggestedDid: string
} }
@@ -608,7 +608,7 @@ export type Events = {
// Group chat adoption // Group chat adoption
'groupchat:create': { 'groupchat:create': {
logContext: 'NewChatDialog' logContext: 'NewChatDialog' | 'SendViaChatDialog'
} }
'groupchat:landingPage:view': { 'groupchat:landingPage:view': {
hasSession: boolean hasSession: boolean
@@ -746,9 +746,7 @@ export type Events = {
} }
'trendingTopic:click': { 'trendingTopic:click': {
context: 'sidebar' | 'interstitial' | 'explore' context: 'sidebar' | 'interstitial' | 'explore'
} recId?: string
'recommendedTopic:click': {
context: 'explore'
} }
'trendingVideos:show': { 'trendingVideos:show': {
context: 'settings' context: 'settings'
@@ -782,13 +780,13 @@ export type Events = {
} }
'search:results:loaded': { 'search:results:loaded': {
tab: 'top' | 'latest' | 'people' | 'feeds' tab: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
initialCount: number initialCount: number
} }
'search:result:press': { 'search:result:press': {
tab?: 'top' | 'latest' | 'people' | 'feeds' tab?: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
resultType: 'post' | 'profile' | 'feed' resultType: 'post' | 'profile' | 'feed' | 'starterPack'
position: number position: number
uri: string uri: string
} }
@@ -1349,6 +1347,26 @@ export type Events = {
// user dismissed the empty-followers promo banner // user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {} '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) === // === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload // Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g. // session end-to-end) + engine (compression engine id, e.g.
+104
View File
@@ -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>
)
}
+4 -2
View File
@@ -1,4 +1,4 @@
import {View} from 'react-native' import {type Insets, View} from 'react-native'
import {type ComAtprotoLabelDefs} from '@atproto/api' import {type ComAtprotoLabelDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
@@ -44,9 +44,11 @@ export function BotBadge({
export function BotBadgeButton({ export function BotBadgeButton({
profile, profile,
width, width,
hitSlop,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
width: number width: number
hitSlop: Insets
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
@@ -61,7 +63,7 @@ export function BotBadgeButton({
<> <>
<Button <Button
label={l`Automated account`} label={l`Automated account`}
hitSlop={20} hitSlop={hitSlop}
onPress={evt => { onPress={evt => {
evt.preventDefault() evt.preventDefault()
ax.metric('bot:badge:click', {}) ax.metric('bot:badge:click', {})
+1 -1
View File
@@ -286,7 +286,7 @@ export function Composer({
ref={IS_WEB ? sift.refs.setAnchor : undefined} ref={IS_WEB ? sift.refs.setAnchor : undefined}
style={ style={
node.type === 'facet' && { node.type === 'facet' && {
color: t.palette.primary_500, color: t.atoms.text_link.color,
} }
}> }>
{node.raw} {node.raw}
+2 -1
View File
@@ -157,7 +157,8 @@ export function Outer({
[open, close], [open, close],
) )
const isHeightConstrained = nativeOptions?.maxHeight != null const isHeightConstrained =
nativeOptions?.maxHeight != null || nativeOptions?.fullHeight === true
const context = useMemo( const context = useMemo(
() => ({ () => ({
+2 -2
View File
@@ -481,11 +481,11 @@ export function ProfileGrid({
<Text <Text
style={[ style={[
a.text_sm, a.text_sm,
{color: t.palette.primary_500}, t.atoms.text_link,
hovered && hovered &&
web({ web({
textDecorationLine: 'underline', textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500, textDecorationColor: t.atoms.text_link.color,
}), }),
]}> ]}>
<Trans>See more</Trans> <Trans>See more</Trans>
@@ -78,6 +78,7 @@ export function ImageMenu({onPressShare, onPressSave}: Props) {
visible={isMounted} visible={isMounted}
animationType="none" animationType="none"
onRequestClose={close} onRequestClose={close}
supportedOrientations={['portrait', 'landscape']}
statusBarTranslucent> statusBarTranslucent>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
+5 -5
View File
@@ -415,7 +415,7 @@ function LinkPeek({
// dialog can show. // dialog can show.
useInAppBrowser: useInAppBrowserPref === true, useInAppBrowser: useInAppBrowserPref === true,
browserToolbarColor: t.atoms.bg.backgroundColor, browserToolbarColor: t.atoms.bg.backgroundColor,
browserControlsColor: t.palette.primary_500, browserControlsColor: t.atoms.text_link.color,
}} }}
borderRadius={borderRadius} borderRadius={borderRadius}
// Fires only when not morphing natively (in-app browser off/unset). // Fires only when not morphing natively (in-app browser off/unset).
@@ -487,14 +487,14 @@ export function InlineLinkText({
accessibilityLabel={label} accessibilityLabel={label}
{...rest} {...rest}
style={[ style={[
{color: t.palette.primary_500}, t.atoms.text_link,
interacted && interacted &&
!disableUnderline && { !disableUnderline && {
...web({ ...web({
outline: 0, outline: 0,
textDecorationLine: 'underline', textDecorationLine: 'underline',
textDecorationColor: textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500, flattenedStyle.color ?? t.atoms.text_link.color,
}), }),
}, },
flattenedStyle, flattenedStyle,
@@ -591,14 +591,14 @@ export function SimpleInlineLinkText({
accessibilityLabel={label} accessibilityLabel={label}
{...rest} {...rest}
style={[ style={[
{color: t.palette.primary_500}, t.atoms.text_link,
interacted && interacted &&
!disableUnderline && { !disableUnderline && {
...web({ ...web({
outline: 0, outline: 0,
textDecorationLine: 'underline', textDecorationLine: 'underline',
textDecorationColor: textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500, flattenedStyle.color ?? t.atoms.text_link.color,
}), }),
}, },
flattenedStyle, flattenedStyle,
+5 -6
View File
@@ -59,7 +59,7 @@ export function ImageEmbed({
// Captured from AutoSizedImage so the peek-commit handler can reuse the same // 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. // 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) const singleDimsRef = useRef<Dimensions | null>(null)
if (images.length > 0) { if (images.length > 0) {
@@ -71,7 +71,7 @@ export function ImageEmbed({
})) }))
const onPress = ( const onPress = (
index: number, index: number,
refs: AnimatedRef<any>[], refs: AnimatedRef<React.Component>[],
fetchedDims: (Dimensions | null)[], fetchedDims: (Dimensions | null)[],
) => { ) => {
if (postContext) { if (postContext) {
@@ -97,7 +97,7 @@ export function ImageEmbed({
} }
const onPressIn = (_: number) => { const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
Image.prefetch( void Image.prefetch(
items.map(i => i.uri), items.map(i => i.uri),
'memory', 'memory',
) )
@@ -115,6 +115,7 @@ export function ImageEmbed({
onPress(0, [singleContainerRef.current], [singleDimsRef.current]) onPress(0, [singleContainerRef.current], [singleDimsRef.current])
} }
} }
return ( return (
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<ImageContextMenu <ImageContextMenu
@@ -127,9 +128,7 @@ export function ImageEmbed({
crop={ crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none' ? 'none'
: rest.isWithinQuote : 'constrained'
? 'square'
: 'constrained'
} }
image={image} image={image}
onContainerRef={ref => { onContainerRef={ref => {
@@ -1,6 +1,7 @@
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared' import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
export { export {
HLSFatalError,
HLSUnsupportedError, HLSUnsupportedError,
VideoNotFoundError, VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared' } from './VideoEmbedInnerWeb.shared'
@@ -19,3 +19,16 @@ export class VideoNotFoundError extends Error {
super('Video not found') 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 {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate' import * as BandwidthEstimate from './bandwidth-estimate'
import { import {
HLSFatalError,
HLSUnsupportedError, HLSUnsupportedError,
type VideoEmbedInnerWebProps, type VideoEmbedInnerWebProps,
VideoNotFoundError, VideoNotFoundError,
@@ -17,6 +18,7 @@ import {
import {Controls} from './web-controls/VideoControls' import {Controls} from './web-controls/VideoControls'
export { export {
HLSFatalError,
HLSUnsupportedError, HLSUnsupportedError,
VideoNotFoundError, VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared' } from './VideoEmbedInnerWeb.shared'
@@ -306,7 +308,7 @@ function useHLS({
) { ) {
setError(new VideoNotFoundError()) setError(new VideoNotFoundError())
} else { } else {
setError(data.error) setError(new HLSFatalError(data.details, data.error))
} }
} else { } else {
console.error(data.error) console.error(data.error)
@@ -16,6 +16,7 @@ import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ConstrainedImage} from '#/components/images/AutoSizedImage' import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {GifPresentationControls} from './GifPresentationControls' import {GifPresentationControls} from './GifPresentationControls'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative' import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -70,6 +71,7 @@ export function VideoEmbed({embed}: Props) {
function InnerWrapper({embed}: Props) { function InnerWrapper({embed}: Props) {
const {_} = useLingui() const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null) const ref = useRef<{togglePlayback: () => void}>(null)
const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>( const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
@@ -130,6 +132,13 @@ function InnerWrapper({embed}: Props) {
}} }}
onError={error => { onError={error => {
telemetryRef.current?.error(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} ref={ref}
/> />
@@ -18,16 +18,25 @@ import {useFullscreen} from '#/components/hooks/useFullscreen'
import {ConstrainedImage} from '#/components/images/AutoSizedImage' import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import { import {
HLSFatalError,
HLSUnsupportedError, HLSUnsupportedError,
VideoEmbedInnerWeb, VideoEmbedInnerWeb,
VideoNotFoundError, VideoNotFoundError,
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' } from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
import {useAnalytics} from '#/analytics'
import {IS_WEB_FIREFOX} from '#/env' import {IS_WEB_FIREFOX} from '#/env'
import {useActiveVideoWeb} from './ActiveVideoWebContext' import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
const noop = () => {} 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}) { export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme() const t = useTheme()
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -69,9 +78,9 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const [key, setKey] = useState(0) const [key, setKey] = useState(0)
const renderError = useCallback( const renderError = useCallback(
(error: unknown) => ( (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 let aspectRatio: number | undefined
@@ -89,6 +98,21 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
constrained = Math.max(aspectRatio, ratio) 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 = ( const contents = (
<div <div
ref={ref} ref={ref}
@@ -96,6 +120,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
display: 'flex', display: 'flex',
flex: 1, flex: 1,
cursor: 'default', cursor: 'default',
position: 'relative',
backgroundColor: t.palette.black, backgroundColor: t.palette.black,
backgroundImage: `url(${embed.thumbnail})`, backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain', backgroundSize: 'contain',
@@ -103,6 +128,36 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
}} }}
onClick={evt => evt.stopPropagation()}> 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}> <ErrorBoundary renderError={renderError} key={key}>
<OnlyNearScreen> <OnlyNearScreen>
<VideoEmbedInnerWeb <VideoEmbedInnerWeb
@@ -118,12 +173,14 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
) )
return ( return (
<View style={[a.pt_xs]}> <View
style={[a.pt_xs]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
<ViewportObserver <ViewportObserver
sendPosition={isGif ? noop : sendPosition} sendPosition={isGif ? noop : sendPosition}
isAnyViewActive={currentActiveView !== null}> isAnyViewActive={currentActiveView !== null}>
<ConstrainedImage <ConstrainedImage
fullBleed fullBleed={fullBleed}
aspectRatio={constrained || 1} aspectRatio={constrained || 1}
// slightly smaller max height than images // slightly smaller max height than images
// images use 16 / 9, for reference // images use 16 / 9, for reference
@@ -222,23 +279,63 @@ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
return nearScreen ? children : null 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 {_} = useLingui()
const ax = useAnalytics()
let showRetryButton = true let showRetryButton = true
let text = null let text = null
let errorClass: string
if (error instanceof VideoNotFoundError) { if (error instanceof VideoNotFoundError) {
text = _(msg`Video not found.`) text = _(msg`Video not found.`)
errorClass = 'VideoNotFoundError'
} else if (error instanceof HLSUnsupportedError) { } else if (error instanceof HLSUnsupportedError) {
showRetryButton = false showRetryButton = false
text = _( 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).`, 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 { } else {
text = _(msg`An error occurred while loading the video. Please try again.`) 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 ( return (
<VideoFallback.Container> <VideoFallback.Container>
<VideoFallback.Text>{text}</VideoFallback.Text> <VideoFallback.Text>{text}</VideoFallback.Text>
-1
View File
@@ -4,7 +4,6 @@ import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
export enum PostEmbedViewContext { export enum PostEmbedViewContext {
ThreadHighlighted = 'ThreadHighlighted', ThreadHighlighted = 'ThreadHighlighted',
Feed = 'Feed', Feed = 'Feed',
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
ChatMessage = 'ChatMessage', ChatMessage = 'ChatMessage',
} }
+5 -7
View File
@@ -1,8 +1,6 @@
import {useCallback, useMemo} from 'react' import {useCallback, useMemo} from 'react'
import {LayoutAnimation, type TextStyle} from 'react-native' import {LayoutAnimation, type TextStyle} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf' import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf'
@@ -14,7 +12,7 @@ export function ShowMoreTextButton({
style, style,
}: TextStyleProp & {onPress: () => void}) { }: TextStyleProp & {onPress: () => void}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const onPress = useCallback(() => { const onPress = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
@@ -30,7 +28,7 @@ export function ShowMoreTextButton({
return ( return (
<Button <Button
label={_(msg`Expand post text`)} label={l`Expand post text`}
onPress={onPress} onPress={onPress}
style={[ style={[
a.self_start, a.self_start,
@@ -43,13 +41,13 @@ export function ShowMoreTextButton({
<Text <Text
style={[ style={[
textStyle, textStyle,
t.atoms.text_link,
{ {
color: t.palette.primary_500,
opacity: pressed ? 0.6 : 1, opacity: pressed ? 0.6 : 1,
textDecorationLine: hovered ? 'underline' : undefined, textDecorationLine: hovered ? 'underline' : undefined,
}, },
]}> ]}>
<Trans>Show More</Trans> <Trans>Show more</Trans>
</Text> </Text>
)} )}
</Button> </Button>
+4 -4
View File
@@ -150,10 +150,10 @@ function TranslationLink({
label={l`Translate`} label={l`Translate`}
hoverStyle={[ hoverStyle={[
native({opacity: 0.5}), native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]), web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
]} ]}
hitSlop={HITSLOP_30}> 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> <Trans>Translate</Trans>
</Text> </Text>
</Link> </Link>
@@ -229,7 +229,7 @@ function TranslationError({
label={l`Try Google Translate`} label={l`Try Google Translate`}
hoverStyle={[ hoverStyle={[
native({opacity: 0.5}), native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]), web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
]} ]}
hitSlop={HITSLOP_30}> hitSlop={HITSLOP_30}>
<Text <Text
@@ -237,7 +237,7 @@ function TranslationError({
a.text_xs, a.text_xs,
a.font_medium, a.font_medium,
a.leading_snug, a.leading_snug,
{color: t.palette.primary_500}, t.atoms.text_link,
]}> ]}>
<Trans>Try Google Translate</Trans> <Trans>Try Google Translate</Trans>
</Text> </Text>
@@ -1,9 +1,7 @@
import {memo, useMemo} from 'react' import {memo, useMemo} from 'react'
import * as ExpoClipboard from 'expo-clipboard' import * as ExpoClipboard from 'expo-clipboard'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -37,7 +35,7 @@ let ShareMenuItems = ({
}: ShareMenuItemsProps): React.ReactNode => { }: ShareMenuItemsProps): React.ReactNode => {
const ax = useAnalytics() const ax = useAnalytics()
const {hasSession} = useSession() const {hasSession} = useSession()
const {_} = useLingui() const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const sendViaChatControl = useDialogControl() const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode() const [devModeEnabled] = useDevMode()
@@ -61,7 +59,7 @@ let ShareMenuItems = ({
const onSharePost = () => { const onSharePost = () => {
ax.metric('share:press:nativeShare', {}) ax.metric('share:press:nativeShare', {})
const url = toShareUrl(href) const url = toShareUrl(href)
shareUrl(url) void shareUrl(url)
onShareProp() onShareProp()
} }
@@ -74,7 +72,7 @@ let ShareMenuItems = ({
} else { } else {
await ExpoClipboard.setStringAsync(url) await ExpoClipboard.setStringAsync(url)
} }
Toast.show(_(msg`Copied to clipboard`), { Toast.show(l`Copied to clipboard`, {
type: 'success', type: 'success',
}) })
onShareProp() onShareProp()
@@ -93,11 +91,11 @@ let ShareMenuItems = ({
} }
const onShareATURI = () => { const onShareATURI = () => {
shareText(postUri) void shareText(postUri)
} }
const onShareAuthorDID = () => { const onShareAuthorDID = () => {
shareText(postAuthor.did) void shareText(postAuthor.did)
} }
return ( return (
@@ -113,13 +111,13 @@ let ShareMenuItems = ({
</Menu.ContainerItem> </Menu.ContainerItem>
<Menu.Item <Menu.Item
testID="postDropdownSendViaDMBtn" testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)} label={l`Send via chat`}
onPress={() => { onPress={() => {
ax.metric('share:press:openDmSearch', {}) ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open() sendViaChatControl.open()
}}> }}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Send via direct message</Trans> <Trans>Send via chat</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={PaperPlaneIcon} position="right" /> <Menu.ItemIcon icon={PaperPlaneIcon} position="right" />
</Menu.Item> </Menu.Item>
@@ -129,7 +127,7 @@ let ShareMenuItems = ({
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="postDropdownShareBtn" testID="postDropdownShareBtn"
label={_(msg`Share via...`)} label={l`Share via...`}
onPress={onSharePost}> onPress={onSharePost}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Share via...</Trans> <Trans>Share via...</Trans>
@@ -139,8 +137,8 @@ let ShareMenuItems = ({
<Menu.Item <Menu.Item
testID="postDropdownShareBtn" testID="postDropdownShareBtn"
label={_(msg`Copy link to post`)} label={l`Copy link to post`}
onPress={onCopyLink}> onPress={() => void onCopyLink()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Copy link to post</Trans> <Trans>Copy link to post</Trans>
</Menu.ItemText> </Menu.ItemText>
@@ -164,7 +162,7 @@ let ShareMenuItems = ({
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="postAtUriShareBtn" testID="postAtUriShareBtn"
label={_(msg`Share post at:// URI`)} label={l`Share post at:// URI`}
onPress={onShareATURI}> onPress={onShareATURI}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Share post at:// URI</Trans> <Trans>Share post at:// URI</Trans>
@@ -173,7 +171,7 @@ let ShareMenuItems = ({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
testID="postAuthorDIDShareBtn" testID="postAuthorDIDShareBtn"
label={_(msg`Share author DID`)} label={l`Share author DID`}
onPress={onShareAuthorDID}> onPress={onShareAuthorDID}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Share author DID</Trans> <Trans>Share author DID</Trans>
@@ -183,7 +181,6 @@ let ShareMenuItems = ({
</Menu.Group> </Menu.Group>
)} )}
</Menu.Outer> </Menu.Outer>
<SendViaChatDialog <SendViaChatDialog
control={sendViaChatControl} control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo} onSelectChat={onSelectChatToShareTo}
@@ -1,8 +1,6 @@
import {memo, useMemo} from 'react' import {memo, useMemo} from 'react'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
@@ -35,7 +33,7 @@ let ShareMenuItems = ({
const ax = useAnalytics() const ax = useAnalytics()
const {hasSession} = useSession() const {hasSession} = useSession()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {_} = useLingui() const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const embedPostControl = useDialogControl() const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl() const sendViaChatControl = useDialogControl()
@@ -60,7 +58,7 @@ let ShareMenuItems = ({
const onCopyLink = () => { const onCopyLink = () => {
ax.metric('share:press:copyLink', {}) ax.metric('share:press:copyLink', {})
const url = toShareUrl(href) const url = toShareUrl(href)
shareUrl(url) void shareUrl(url)
onShareProp() onShareProp()
} }
@@ -75,17 +73,17 @@ let ShareMenuItems = ({
const canEmbed = IS_WEB && gtMobile && !hideInPWI const canEmbed = IS_WEB && gtMobile && !hideInPWI
const onShareATURI = () => { const onShareATURI = () => {
shareText(postUri) void shareText(postUri)
} }
const onShareAuthorDID = () => { const onShareAuthorDID = () => {
shareText(postAuthor.did) void shareText(postAuthor.did)
} }
const copyLinkItem = ( const copyLinkItem = (
<Menu.Item <Menu.Item
testID="postDropdownShareBtn" testID="postDropdownShareBtn"
label={_(msg`Copy link to post`)} label={l`Copy link to post`}
onPress={onCopyLink}> onPress={onCopyLink}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Copy link to post</Trans> <Trans>Copy link to post</Trans>
@@ -102,13 +100,13 @@ let ShareMenuItems = ({
{hasSession && aa.state.access === aa.Access.Full && ( {hasSession && aa.state.access === aa.Access.Full && (
<Menu.Item <Menu.Item
testID="postDropdownSendViaDMBtn" testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)} label={l`Send via chat`}
onPress={() => { onPress={() => {
ax.metric('share:press:openDmSearch', {}) ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open() sendViaChatControl.open()
}}> }}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Send via direct message</Trans> <Trans>Send via chat</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Send} position="right" /> <Menu.ItemIcon icon={Send} position="right" />
</Menu.Item> </Menu.Item>
@@ -117,12 +115,12 @@ let ShareMenuItems = ({
{canEmbed && ( {canEmbed && (
<Menu.Item <Menu.Item
testID="postDropdownEmbedBtn" testID="postDropdownEmbedBtn"
label={_(msg`Embed post`)} label={l`Embed post`}
onPress={() => { onPress={() => {
ax.metric('share:press:embed', {}) ax.metric('share:press:embed', {})
embedPostControl.open() embedPostControl.open()
}}> }}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> <Menu.ItemText>{l`Embed post`}</Menu.ItemText>
<Menu.ItemIcon icon={CodeBracketsIcon} position="right" /> <Menu.ItemIcon icon={CodeBracketsIcon} position="right" />
</Menu.Item> </Menu.Item>
)} )}
@@ -142,7 +140,7 @@ let ShareMenuItems = ({
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
testID="postAtUriShareBtn" testID="postAtUriShareBtn"
label={_(msg`Copy post at:// URI`)} label={l`Copy post at:// URI`}
onPress={onShareATURI}> onPress={onShareATURI}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Copy post at:// URI</Trans> <Trans>Copy post at:// URI</Trans>
@@ -151,7 +149,7 @@ let ShareMenuItems = ({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
testID="postAuthorDIDShareBtn" testID="postAuthorDIDShareBtn"
label={_(msg`Copy author DID`)} label={l`Copy author DID`}
onPress={onShareAuthorDID}> onPress={onShareAuthorDID}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Copy author DID</Trans> <Trans>Copy author DID</Trans>
@@ -161,7 +159,6 @@ let ShareMenuItems = ({
</> </>
)} )}
</Menu.Outer> </Menu.Outer>
{canEmbed && ( {canEmbed && (
<EmbedDialog <EmbedDialog
control={embedPostControl} control={embedPostControl}
@@ -172,7 +169,6 @@ let ShareMenuItems = ({
timestamp={timestamp} timestamp={timestamp}
/> />
)} )}
<SendViaChatDialog <SendViaChatDialog
control={sendViaChatControl} control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo} onSelectChat={onSelectChatToShareTo}
+61 -11
View File
@@ -1,5 +1,6 @@
import {View} from 'react-native' import {View} from 'react-native'
import {HITSLOP_20} from '#/lib/constants'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf' import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {useNativeFontScale} from '#/alf/util/dimensions' import {useNativeFontScale} from '#/alf/util/dimensions'
@@ -8,6 +9,7 @@ import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck' import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {BetaBadge, BetaBadgeButton, useIsBetaBadgeVisible} from './BetaBadge'
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
@@ -27,6 +29,22 @@ const botIconSizes: Record<Size, number> = {
xl: 23, xl: 23,
} as const } 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({ export function ProfileBadges({
profile, profile,
interactive = false, interactive = false,
@@ -41,13 +59,19 @@ export function ProfileBadges({
}) { }) {
const shadowed = useProfileShadow(profile) const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile}) const verification = useSimpleVerificationState({profile})
const badgeVisibility = [
verification.showBadge,
useIsBetaBadgeVisible(profile),
isBotAccount(shadowed),
]
const badgeCount = badgeVisibility.filter(Boolean).length
const nativeScaleMultiplier = useNativeFontScale() const nativeScaleMultiplier = useNativeFontScale()
const { const {
fonts: {scaleMultiplier: alfScaleMultiplier}, fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf() } = useAlf()
// if nothing to show, don't render the container at all // 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' const isOnTheSmallSide = size === 'xs' || size === 'sm'
@@ -57,31 +81,57 @@ export function ProfileBadges({
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[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 ( return (
<View <View style={[a.flex_row, a.align_center, gap, style]}>
style={[
a.flex_row,
a.align_center,
isOnTheSmallSide ? a.gap_2xs : a.gap_xs,
style,
]}>
{interactive ? ( {interactive ? (
<> <>
<VerificationCheckButton <VerificationCheckButton
profile={shadowed} profile={shadowed}
width={verificationIconWidth} 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 <VerificationCheck
verifier={verification.role === 'verifier'} verifier={verification.role === 'verifier'}
width={verificationIconWidth} width={verificationIconWidth}
/> />
)} ) : null}
<BetaBadge
profile={shadowed}
width={betaIconWidth}
padding={betaBadgeScaledPadding}
/>
<BotBadge profile={shadowed} width={botIconWidth} /> <BotBadge profile={shadowed} width={botIconWidth} />
</> </>
)} )}
+6 -127
View File
@@ -1,143 +1,20 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {View} from 'react-native' import {type AppBskyUnspeccedDefs, type AtUri} from '@atproto/api'
import {type AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale' import {PressableScale} from '#/lib/custom-animations/PressableScale'
// import {makeProfileLink} from '#/lib/routes/links' // import {makeProfileLink} from '#/lib/routes/links'
// import {feedUriToHref} from '#/lib/strings/url-helpers' // import {feedUriToHref} from '#/lib/strings/url-helpers'
// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {native} from '#/alf'
// 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 {Link as InternalLink, type LinkProps} from '#/components/Link' 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({ export function TrendingTopicLink({
topic: raw, topic: raw,
children, children,
...rest ...rest
}: { }: {
topic: TrendingTopic topic: AppBskyUnspeccedDefs.TrendView
} & Omit<LinkProps, 'to' | 'label'>) { } & Omit<LinkProps, 'to' | 'label'>) {
const topic = useTopic(raw) const topic = useTopic(raw)
@@ -168,7 +45,9 @@ type ParsedTrendingTopic =
uri: AtUri uri: AtUri
} }
export function useTopic(raw: TrendingTopic): ParsedTrendingTopic { export function useTopic(
raw: AppBskyUnspeccedDefs.TrendView,
): ParsedTrendingTopic {
const {_} = useLingui() const {_} = useLingui()
return useMemo(() => { return useMemo(() => {
const {topic: displayName, link} = raw const {topic: displayName, link} = raw
+225 -12
View File
@@ -11,19 +11,33 @@ import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants' 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 {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status' import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {type ListMethods} from '#/view/com/util/List' import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf' import {android, atoms as a, native, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' 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 TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import { import {
@@ -33,18 +47,13 @@ import {
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {ProfileBadges} from '#/components/ProfileBadges'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky' 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 NewGroupChatItem = {
type: 'newGroupChat' type: 'newGroupChat'
@@ -63,6 +72,12 @@ type ProfileItem = {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
} }
type ExistingChatItem = {
type: 'existingChat'
key: string
convo: ConvoWithDetails
}
type EmptyItem = { type EmptyItem = {
type: 'empty' type: 'empty'
key: string key: string
@@ -83,6 +98,7 @@ type Item =
| NewGroupChatItem | NewGroupChatItem
| LabelItem | LabelItem
| ProfileItem | ProfileItem
| ExistingChatItem
| EmptyItem | EmptyItem
| PlaceholderItem | PlaceholderItem
| ErrorItem | ErrorItem
@@ -212,11 +228,17 @@ export function InitiateChatFlow({
onSelectChat, onSelectChat,
onSelectGroupChat, onSelectGroupChat,
startInGroupChat = false, startInGroupChat = false,
showRecentConvos = false,
onSelectExistingChat,
sortByMessageDeclaration = false,
}: { }: {
title: string title: string
onSelectChat: (did: string) => void onSelectChat: (did: string) => void
onSelectGroupChat: (dids: string[], groupName: string) => void onSelectGroupChat: (dids: string[], groupName: string) => void
startInGroupChat?: boolean startInGroupChat?: boolean
showRecentConvos?: boolean
onSelectExistingChat?: (convoId: string) => void
sortByMessageDeclaration?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -230,6 +252,12 @@ export function InitiateChatFlow({
const inputRef = useRef<TextInput>(null) const inputRef = useRef<TextInput>(null)
const accountTooNewPromptControl = Dialog.useDialogControl() const accountTooNewPromptControl = Dialog.useDialogControl()
const {data: convos} = useListConvosQuery({
enabled: showRecentConvos,
status: 'accepted',
lockStatus: 'unlocked',
})
const {data: chatStatus} = useChatActorStatusQuery() const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit const groupMemberLimit = chatStatus?.groupMemberLimit
@@ -281,6 +309,10 @@ export function InitiateChatFlow({
let _items: Item[] = [] let _items: Item[] = []
const checker = const checker =
chatState === ChatState.NEW_GROUP_CHAT ? canBeAddedToGroup : canBeMessaged 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) { if (isError) {
_items.push({ _items.push({
@@ -310,9 +342,9 @@ export function InitiateChatFlow({
}) })
} }
_items = _items.sort(item => { if (sortByMessageDeclaration) {
return item.type === 'profile' && checker(item.profile) ? -1 : 1 _items = _items.sort(compareByMessageDeclaration)
}) }
} }
} else { } else {
const placeholders: Item[] = Array(10) const placeholders: Item[] = Array(10)
@@ -322,7 +354,57 @@ export function InitiateChatFlow({
key: i + '', 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 page of follows.pages) {
for (const profile of page.follows) { for (const profile of page.follows) {
if (!checker(profile)) continue if (!checker(profile)) continue
@@ -359,10 +441,19 @@ export function InitiateChatFlow({
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) _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, isError,
chatState, chatState,
convos,
searchText, searchText,
l, l,
groupChatProfiles, groupChatProfiles,
@@ -370,6 +461,8 @@ export function InitiateChatFlow({
currentAccount?.did, currentAccount?.did,
follows, follows,
aa.flags.groupChatDisabled, aa.flags.groupChatDisabled,
showRecentConvos,
sortByMessageDeclaration,
]) ])
if (searchText && !isFetching && !items.length && !isError) { if (searchText && !isFetching && !items.length && !isError) {
@@ -429,6 +522,16 @@ export function InitiateChatFlow({
case 'label': { case 'label': {
return <UserLabel key={item.key} message={item.message} /> 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': { case 'profile': {
switch (chatState) { switch (chatState) {
case ChatState.NEW_CHAT: case ChatState.NEW_CHAT:
@@ -474,6 +577,8 @@ export function InitiateChatFlow({
handlePressNewGroupChat, handlePressNewGroupChat,
moderationOpts, moderationOpts,
onSelectChat, 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({ function DefaultProfileCard({
profile, profile,
moderationOpts, moderationOpts,
+2 -1
View File
@@ -89,7 +89,7 @@ export function NewChat({
}, },
onError: error => { onError: error => {
logger.error('Failed to create groupchat', {safeMessage: 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)) { if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.` errorMessage = l`A network error occurred. Please check your internet connection.`
} else if ( } else if (
@@ -184,6 +184,7 @@ export function NewChat({
title={l`New chat`} title={l`New chat`}
onSelectChat={onCreateChat} onSelectChat={onCreateChat}
onSelectGroupChat={onCreateGroupChat} onSelectGroupChat={onCreateGroupChat}
sortByMessageDeclaration
startInGroupChat={startInGroupChat} startInGroupChat={startInGroupChat}
/> />
) : ( ) : (
+114 -10
View File
@@ -1,11 +1,17 @@
import {useCallback} from 'react' import {useCallback, useState} from 'react'
import {msg} from '@lingui/core/macro' import {
import {useLingui} from '@lingui/react' ChatBskyConvoGetConvoForMembers,
ChatBskyGroupCreateGroup,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -16,26 +22,39 @@ export function SendViaChatDialog({
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
onSelectChat: (chatId: string) => void onSelectChat: (chatId: string) => void
}) { }) {
const [flowKey, setFlowKey] = useState(0)
const onClose = useCallback(() => setFlowKey(key => key + 1), [])
return ( return (
<Dialog.Outer <Dialog.Outer
control={control} control={control}
testID="sendViaChatChatDialog" testID="sendViaChatChatDialog"
nativeOptions={{fullHeight: true}}> nativeOptions={{fullHeight: true}}
onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} /> <SendViaChatDialogInner
control={control}
flowKey={flowKey}
onSelectChat={onSelectChat}
/>
</Dialog.Outer> </Dialog.Outer>
) )
} }
function SendViaChatDialogInner({ function SendViaChatDialogInner({
control, control,
flowKey,
onSelectChat, onSelectChat,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
flowKey: number
onSelectChat: (chatId: string) => void onSelectChat: (chatId: string) => void
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
const {mutate: createChat} = useGetConvoForMembers({ const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => { onSuccess: data => {
onSelectChat(data.convo.id) onSelectChat(data.convo.id)
@@ -46,8 +65,74 @@ function SendViaChatDialogInner({
ax.metric('chat:open', {logContext: 'SendViaChatDialog'}) ax.metric('chat:open', {logContext: 'SendViaChatDialog'})
}, },
onError: error => { onError: error => {
logger.error('Failed to share post to chat', {message: error}) logger.error('Failed to share post to chat', {safeMessage: error})
Toast.show(_(msg`An issue occurred while trying to open the chat`), { 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', type: 'error',
}) })
}, },
@@ -67,9 +152,28 @@ function SendViaChatDialogInner({
[control, createChat], [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 <SearchablePeopleList
title={_(msg`Send post to...`)} title={l`Send post to...`}
onSelectChat={chat => { onSelectChat={chat => {
if (chat.kind === 'user') { if (chat.kind === 'user') {
onCreateChat(chat.did) onCreateChat(chat.did)
+2 -4
View File
@@ -5,7 +5,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints} from '#/alf'
import {type Dimensions} from '#/components/Lightbox/types' 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' import {GalleryItem} from './ImageLayoutGridItem'
interface ImageLayoutGridProps { interface ImageLayoutGridProps {
@@ -28,9 +28,7 @@ export function ImageLayoutGrid({
...props ...props
}: ImageLayoutGridProps) { }: ImageLayoutGridProps) {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const isWithinQuote = const isWithinQuote = isWithinQuoteProp
isWithinQuoteProp ??
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
return ( 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])
}
+15 -5
View File
@@ -7,7 +7,7 @@ import {
useTrendingSettings, useTrendingSettings,
useTrendingSettingsApi, useTrendingSettingsApi,
} from '#/state/preferences/trending' } 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 {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
@@ -20,6 +20,8 @@ import {TrendingTopicLink} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
const TRENDING_LIMIT = 14
export function TrendingInterstitial() { export function TrendingInterstitial() {
const {enabled} = useTrendingConfig() const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings() const {trendingDisabled} = useTrendingSettings()
@@ -33,8 +35,15 @@ export function Inner() {
const gutters = useGutters([0, 'base', 0, 'base']) const gutters = useGutters([0, 'base', 0, 'base'])
const trendingPrompt = Prompt.usePromptControl() const trendingPrompt = Prompt.usePromptControl()
const {setTrendingDisabled} = useTrendingSettingsApi() const {setTrendingDisabled} = useTrendingSettingsApi()
const {data: trending, error, isLoading} = useTrendingTopics() const {
const noTopics = !isLoading && !error && !trending?.topics?.length data: trending,
error,
isLoading,
} = useGetTrendsQuery({
limit: TRENDING_LIMIT,
refetchOnWindowFocus: true,
})
const noTopics = !isLoading && !error && !trending?.trends?.length
const onConfirmHide = useCallback(() => { const onConfirmHide = useCallback(() => {
ax.metric('trendingTopics:hide', {context: 'interstitial'}) ax.metric('trendingTopics:hide', {context: 'interstitial'})
@@ -88,15 +97,16 @@ export function Inner() {
{' '} {' '}
</Text> </Text>
</View> </View>
) : !trending?.topics ? null : ( ) : !trending?.trends ? null : (
<> <>
{trending.topics.map(topic => ( {trending.trends.map(topic => (
<TrendingTopicLink <TrendingTopicLink
key={topic.link} key={topic.link}
topic={topic} topic={topic}
onPress={() => { onPress={() => {
ax.metric('trendingTopic:click', { ax.metric('trendingTopic:click', {
context: 'interstitial', context: 'interstitial',
recId: trending.recId,
}) })
}}> }}>
<View style={[a.py_lg]}> <View style={[a.py_lg]}>
+2 -5
View File
@@ -181,6 +181,7 @@ function BlockDialogInner({
const footer = ( const footer = (
<View style={[a.w_full, a.gap_sm, a.justify_end]}> <View style={[a.w_full, a.gap_sm, a.justify_end]}>
<Button <Button
disabled={isLoading}
color={profile.viewer?.blocking ? undefined : 'negative'} color={profile.viewer?.blocking ? undefined : 'negative'}
size="large" size="large"
label={profile.viewer?.blocking ? l`Unblock` : l`Block`} label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
@@ -192,6 +193,7 @@ function BlockDialogInner({
<Trans>Block</Trans> <Trans>Block</Trans>
)} )}
</ButtonText> </ButtonText>
{isLoading ? <ButtonIcon icon={Loader} /> : null}
</Button> </Button>
<Button <Button
color="secondary" color="secondary"
@@ -211,11 +213,6 @@ function BlockDialogInner({
label={profile.viewer?.blocking ? l`Unblock` : l`Block`} label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
style={[web([{maxWidth: 420}])]}> style={[web([{maxWidth: 420}])]}>
{listHeader} {listHeader}
{isLoading ? (
<View style={[a.pb_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : null}
{footer} {footer}
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
+10 -17
View File
@@ -6,9 +6,7 @@ import {
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {type ModerationUI} from '@atproto/api' import {type ModerationUI} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import { import {
ADULT_CONTENT_LABELS, ADULT_CONTENT_LABELS,
@@ -78,7 +76,7 @@ function ContentHiderActive({
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [override, setOverride] = useState(false) const [override, setOverride] = useState(false)
const control = useModerationDetailsDialogControl() const control = useModerationDetailsDialogControl()
@@ -97,7 +95,7 @@ function ContentHiderActive({
(blur.type === 'label' && blur.source.type !== 'user') (blur.type === 'label' && blur.source.type !== 'user')
) { ) {
if (desc.isSubjectAccount) { if (desc.isSubjectAccount) {
return _(msg`${desc.name} (Account)`) return l`${desc.name} (Account)`
} else { } else {
return desc.name return desc.name
} }
@@ -128,7 +126,7 @@ function ContentHiderActive({
const def = cause.labelDef || getDefinition(labelDefs, cause.label) const def = cause.labelDef || getDefinition(labelDefs, cause.label)
if (def.identifier === 'porn' || def.identifier === 'sexual') { if (def.identifier === 'porn' || def.identifier === 'sexual') {
return _(msg`Adult Content`) return l`Adult Content`
} }
return getLabelStrings(i18n.locale, globalLabelStrings, def).name return getLabelStrings(i18n.locale, globalLabelStrings, def).name
}) })
@@ -138,7 +136,7 @@ function ContentHiderActive({
} }
return [...new Set(selfBlurNames)].join(', ') return [...new Set(selfBlurNames)].join(', ')
}, [ }, [
_, l,
modui.blurs, modui.blurs,
blur, blur,
desc.name, desc.name,
@@ -151,7 +149,6 @@ function ContentHiderActive({
return ( return (
<View testID={testID} style={[a.overflow_hidden, style]}> <View testID={testID} style={[a.overflow_hidden, style]}>
<ModerationDetailsDialog control={control} modcause={blur} /> <ModerationDetailsDialog control={control} modcause={blur} />
<Button <Button
onPress={e => { onPress={e => {
e.preventDefault() e.preventDefault()
@@ -166,10 +163,10 @@ function ContentHiderActive({
label={desc.name} label={desc.name}
accessibilityHint={ accessibilityHint={
modui.noOverride modui.noOverride
? _(msg`Learn more about the moderation applied to this content`) ? l`Learn more about the moderation applied to this content`
: override : override
? _(msg`Hides the content`) ? l`Hides the content`
: _(msg`Shows the content`) : l`Shows the content`
}> }>
{state => ( {state => (
<View <View
@@ -223,7 +220,6 @@ function ContentHiderActive({
</View> </View>
)} )}
</Button> </Button>
{desc.source && blur.type === 'label' && !override && ( {desc.source && blur.type === 'label' && !override && (
<Button <Button
onPress={e => { onPress={e => {
@@ -231,9 +227,7 @@ function ContentHiderActive({
e.stopPropagation() e.stopPropagation()
control.open() control.open()
}} }}
label={_( label={l`Learn more about the moderation applied to this content`}
msg`Learn more about the moderation applied to this content`,
)}
style={[a.pt_sm]}> style={[a.pt_sm]}>
{state => ( {state => (
<Text <Text
@@ -252,7 +246,7 @@ function ContentHiderActive({
)}{' '} )}{' '}
<Text <Text
style={[ style={[
{color: t.palette.primary_500}, t.atoms.text_link,
a.text_sm, a.text_sm,
state.hovered && [web({textDecoration: 'underline'})], state.hovered && [web({textDecoration: 'underline'})],
]}> ]}>
@@ -262,7 +256,6 @@ function ContentHiderActive({
)} )}
</Button> </Button>
)} )}
{override && <View style={childContainerStyle}>{children}</View>} {override && <View style={childContainerStyle}>{children}</View>}
</View> </View>
) )
+9 -1
View File
@@ -54,7 +54,15 @@ export function PostAlerts({
const isOwnPost = !!post && post.author.did === currentAccount?.did const isOwnPost = !!post && post.author.did === currentAccount?.did
const allLabels: ComAtprotoLabelDefs.Label[] = const allLabels: ComAtprotoLabelDefs.Label[] =
isOwnPost && view === 'expanded' 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 - * Labels that the moderation system already surfaces in this context -
+6 -10
View File
@@ -6,9 +6,7 @@ import {
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {type ModerationUI} from '@atproto/api' import {type ModerationUI} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
@@ -38,7 +36,7 @@ export function ScreenHider({
containerStyle?: StyleProp<ViewStyle> containerStyle?: StyleProp<ViewStyle>
}>) { }>) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const [override, setOverride] = useState(false) const [override, setOverride] = useState(false)
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
@@ -131,15 +129,13 @@ export function ScreenHider({
control.open() control.open()
}} }}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityLabel={l`Learn more about this warning`}
accessibilityHint=""> accessibilityHint="">
<Text <Text
style={[ style={[
a.text_lg, a.text_lg,
a.leading_snug, a.leading_snug,
{ t.atoms.text_link,
color: t.palette.primary_500,
},
web({ web({
cursor: 'pointer', cursor: 'pointer',
}), }),
@@ -158,7 +154,7 @@ export function ScreenHider({
color="primary" color="primary"
size="large" size="large"
style={[a.rounded_full]} style={[a.rounded_full]}
label={_(msg`Go back`)} label={l`Go back`}
onPress={() => { onPress={() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
navigation.goBack() navigation.goBack()
@@ -176,7 +172,7 @@ export function ScreenHider({
color="secondary" color="secondary"
size="large" size="large"
style={[a.rounded_full]} style={[a.rounded_full]}
label={_(msg`Show anyway`)} label={l`Show anyway`}
onPress={() => setOverride(v => !v)}> onPress={() => setOverride(v => !v)}>
<ButtonText> <ButtonText>
<Trans>Show anyway</Trans> <Trans>Show anyway</Trans>
@@ -1,6 +1,5 @@
import {View} from 'react-native' import {type Insets, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -52,16 +51,25 @@ export function shouldShowVerificationCheckButton(
export function VerificationCheckButton({ export function VerificationCheckButton({
profile, profile,
width, width,
hitSlop,
}: { }: {
profile: Shadow<bsky.profile.AnyProfileView> profile: Shadow<bsky.profile.AnyProfileView>
width: number width: number
hitSlop: Insets
}) { }) {
const state = useFullVerificationState({ const state = useFullVerificationState({
profile, profile,
}) })
if (shouldShowVerificationCheckButton(state)) { if (shouldShowVerificationCheckButton(state)) {
return <Badge profile={profile} verificationState={state} width={width} /> return (
<Badge
profile={profile}
verificationState={state}
width={width}
hitSlop={hitSlop}
/>
)
} }
return null return null
@@ -71,14 +79,16 @@ function Badge({
profile, profile,
verificationState: state, verificationState: state,
width, width,
hitSlop,
}: { }: {
profile: Shadow<bsky.profile.AnyProfileView> profile: Shadow<bsky.profile.AnyProfileView>
verificationState: FullVerificationState verificationState: FullVerificationState
width: number width: number
hitSlop: Insets
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const verificationsDialogControl = useDialogControl() const verificationsDialogControl = useDialogControl()
const verifierDialogControl = useDialogControl() const verifierDialogControl = useDialogControl()
@@ -89,10 +99,10 @@ function Badge({
<Button <Button
label={ label={
state.profile.isViewer state.profile.isViewer
? _(msg`View your verifications`) ? l`View your verifications`
: _(msg`View this user's verifications`) : l`View this user's verifications`
} }
hitSlop={20} hitSlop={hitSlop}
onPress={evt => { onPress={evt => {
evt.preventDefault() evt.preventDefault()
ax.metric('verification:badge:click', {}) ax.metric('verification:badge:click', {})
@@ -132,13 +142,11 @@ function Badge({
</View> </View>
)} )}
</Button> </Button>
<VerificationsDialog <VerificationsDialog
control={verificationsDialogControl} control={verificationsDialogControl}
profile={profile} profile={profile}
verificationState={state} verificationState={state}
/> />
<VerifierDialog <VerifierDialog
control={verifierDialogControl} control={verifierDialogControl}
profile={profile} profile={profile}
@@ -3,11 +3,9 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {useTrendingSettings} from '#/state/preferences/trending'
import {atoms as a, useLayoutBreakpoints} from '#/alf' import {atoms as a, useLayoutBreakpoints} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide' import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context' import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context'
@@ -17,16 +15,10 @@ import {type LiveEventFeed} from '#/features/liveEvents/types'
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() { export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
const events = useUserPreferencedLiveEvents() const events = useUserPreferencedLiveEvents()
const {rightNavVisible} = useLayoutBreakpoints() const {rightNavVisible} = useLayoutBreakpoints()
const {trendingDisabled} = useTrendingSettings()
if (!events.feeds.length) { if (!events.feeds.length) {
if (!rightNavVisible && !trendingDisabled) { // no feed
// only show trending on mobile when live event banner is not shown return null
return <TrendingInterstitial />
} else {
// no feed, no trending
return null
}
} }
// On desktop, we show in the sidebar // On desktop, we show in the sidebar
+3
View File
@@ -146,6 +146,9 @@ export const BSKY_FEED_OWNER_DIDS = [
'did:plc:q6gjnaw2blty4crticxkmujt', 'did:plc:q6gjnaw2blty4crticxkmujt',
] ]
export const TRENDING_DID = 'did:plc:qrz3lhbyuxbeilrc6nekdqme'
export const TRENDING_HANDLE = 'trending.bsky.app'
export const DISCOVER_FEED_URI = export const DISCOVER_FEED_URI =
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot' 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
export const VIDEO_FEED_URI = export const VIDEO_FEED_URI =
+44
View File
@@ -1,3 +1,4 @@
import {Asset} from 'expo-asset'
import { import {
documentDirectory, documentDirectory,
getInfoAsync, getInfoAsync,
@@ -9,10 +10,15 @@ import ExpoImageCropTool, {
} from '@bsky.app/expo-image-crop-tool' } from '@bsky.app/expo-image-crop-tool'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {IS_ANDROID} from '#/env'
import {compressIfNeeded} from './manip' import {compressIfNeeded} from './manip'
import {type PickerImage} from './picker.shared' import {type PickerImage} from './picker.shared'
async function getFile() { async function getFile() {
if (IS_ANDROID) {
return await getAndroidFile()
}
const imagesDir = documentDirectory! const imagesDir = documentDirectory!
.split('/') .split('/')
.slice(0, -6) .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[]> { export async function openPicker(): Promise<PickerImage[]> {
return [await getFile()] return [await getFile()]
} }
+1
View File
@@ -70,6 +70,7 @@ export type CommonNavigatorParams = {
ActivityPrivacySettings: undefined ActivityPrivacySettings: undefined
ContentAndMediaSettings: undefined ContentAndMediaSettings: undefined
NotificationSettings: undefined NotificationSettings: undefined
ActivityNotificationSettings: undefined
InterestsSettings: undefined InterestsSettings: undefined
AboutSettings: undefined AboutSettings: undefined
AppIconSettings: undefined AppIconSettings: undefined
File diff suppressed because one or more lines are too long
+1
View File
@@ -61,6 +61,7 @@ export const router = new Router<AllNavigatableRoutes>({
AboutSettings: '/settings/about', AboutSettings: '/settings/about',
AppIconSettings: '/settings/app-icon', AppIconSettings: '/settings/app-icon',
NotificationSettings: '/settings/notifications', NotificationSettings: '/settings/notifications',
ActivityNotificationSettings: '/settings/notifications/activity',
FindContactsSettings: '/settings/find-contacts', FindContactsSettings: '/settings/find-contacts',
// support // support
Support: '/support', Support: '/support',
@@ -370,7 +370,7 @@ function SuggestedProfileCard({
category: string | null category: string | null
onSeen: (did: string, position: number) => void onSeen: (did: string, position: number) => void
recSource?: 'Search' recSource?: 'Search'
recId?: number | string recId?: string
}) { }) {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
+59 -92
View File
@@ -1,15 +1,13 @@
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api' import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {enforceLen} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLikedBySampleQuery} from '#/state/queries/post-liked-by' import {useLikedBySampleQuery} from '#/state/queries/post-liked-by'
import {useSession} from '#/state/session' 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 {AvatarStack} from '#/components/AvatarStack'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import {useFormatPostStatCount} from '#/components/PostControls/util' import {useFormatPostStatCount} from '#/components/PostControls/util'
@@ -18,27 +16,56 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
const AVI_SIZE = 20 const AVI_SIZE = 20
const MAX_NAME_LENGTH = 16
/** /**
* The likes stat for the expanded anchor post. When the viewer follows some * The plain "N likes" stat for the expanded anchor post, linking to the likes
* of the post's recent likers, renders social proof - a face pile plus * list. Renders nothing when the post has no likes.
* "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.
*/ */
export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) { export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme() 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 {t: l} = useLingui()
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const formatPostStatCount = useFormatPostStatCount()
const ax = useAnalytics() const ax = useAnalytics()
const likeCount = post.likeCount ?? 0 const likeCount = post.likeCount ?? 0
@@ -78,67 +105,34 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
knownLikersAndModeration.length > 0 && knownLikersAndModeration.length > 0 &&
ax.features.enabled(ax.features.PostThreadKnownLikersEnable) ax.features.enabled(ax.features.PostThreadKnownLikersEnable)
if (!showKnownLikers) { if (!showKnownLikers) return null
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>
)
}
const aviStackProfiles = knownLikersAndModeration const aviStackProfiles = knownLikersAndModeration
.slice(0, 3) .slice(0, 3)
.map(({actor}) => actor) .map(({actor}) => actor)
const maxNames = gtMobile ? 2 : 1
const names = knownLikersAndModeration const names = knownLikersAndModeration
.slice(0, maxNames) .slice(0, 2)
.map(({actor, moderation}) => { .map(({actor, moderation}) => {
return { return {
did: actor.did, did: actor.did,
href: makeProfileLink(actor), href: makeProfileLink(actor),
displayName: enforceLen( displayName: sanitizeDisplayName(
sanitizeDisplayName( actor.displayName || actor.handle,
actor.displayName || actor.handle, moderation.ui('displayName'),
moderation.ui('displayName'),
),
MAX_NAME_LENGTH,
true,
), ),
} }
}) })
const others = likeCount - names.length
/* /*
* The row link's a11y label mirrors the visible sentence so screen readers * The row link's a11y label mirrors the visible sentence so screen readers
* announce the social proof. * announce the social proof.
*/ */
const othersLabel = plural(others, {
one: `${formatPostStatCount(others)} other`,
other: `${formatPostStatCount(others)} others`,
})
const rowLabel = const rowLabel =
names.length >= 2 names.length >= 2
? others > 0 ? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
? l`${names[0].displayName}, ${names[1].displayName}, and ${othersLabel} like this` : l`Liked by ${names[0].displayName}`
: 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`
const textStyle = [a.text_md, t.atoms.text_contrast_medium] const textStyle = [a.text_sm, t.atoms.text_contrast_medium]
const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text] 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 * 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 ( return (
/* /*
* The full-width wrapper keeps the social proof on its own line within * The full-width wrapper forces the social proof onto its own line below
* the wrapping stats row, rather than wrapping mid-row and orphaning * the count stats within the wrapping stats row.
* whichever count stat comes last. The link itself hugs its content so
* the empty space to the right of the text is not pressable.
*/ */
<View style={[a.w_full, a.flex_row]}> <View style={[a.w_full, a.flex_row]}>
<Link <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]} style={[a.flex_row, a.align_center, a.gap_sm, a.flex_shrink]}
onPress={onPressLikedBy}> onPress={onPressLikedBy}>
<AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} /> <AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} />
<Text <Text testID="knownLikersStat" style={[a.flex_shrink, textStyle]}>
testID="knownLikersStat"
numberOfLines={1}
style={[a.flex_shrink, textStyle]}>
{names.length >= 2 ? ( {names.length >= 2 ? (
others > 0 ? ( <Trans comment="Social proof below the post stats; the bolded names are people the viewer follows who liked the post">
<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"> Liked by {nameLink(names[0])} and {nameLink(names[1])}
{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> </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"> <Trans comment="Social proof below the post stats; the bolded name is a person the viewer follows who liked the post">
{nameLink(names[0])} likes this Liked by {nameLink(names[0])}
</Trans> </Trans>
)} )}
</Text> </Text>
@@ -28,7 +28,7 @@ import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {type PostSource} from '#/state/unstable-post-source' import {type PostSource} from '#/state/unstable-post-source'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 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 {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
import { import {
LINEAR_AVI_WIDTH, LINEAR_AVI_WIDTH,
@@ -440,7 +440,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
a.py_md, a.py_md,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
]}> ]}>
<LikesStat post={post} />
{post.repostCount != null && post.repostCount !== 0 ? ( {post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={l`Reposts of this post`}> <Link to={repostsHref} label={l`Reposts of this post`}>
<Text <Text
@@ -481,6 +480,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Text> </Text>
</Link> </Link>
) : null} ) : null}
<LikesStat post={post} />
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( {post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
<Text <Text
testID="bookmarkCount-expanded" testID="bookmarkCount-expanded"
@@ -497,6 +497,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Trans> </Trans>
</Text> </Text>
) : null} ) : null}
<KnownLikers post={post} />
</View> </View>
) : null} ) : null}
<View <View
+15 -14
View File
@@ -1,13 +1,12 @@
import {useCallback, useEffect, useMemo, useState} from 'react' import {useCallback, useEffect, useMemo, useState} from 'react'
import {useAnimatedRef} from 'react-native-reanimated' import {useAnimatedRef} from 'react-native-reanimated'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused} from '@react-navigation/native' import {useIsFocused} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query' 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 {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {type CommonNavigatorParams} from '#/lib/routes/types' import {type CommonNavigatorParams} from '#/lib/routes/types'
@@ -52,7 +51,7 @@ export function ProfileFeedScreen(props: Props) {
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
? {feedCacheKey: props.route.params.feedCacheKey} ? {feedCacheKey: props.route.params.feedCacheKey}
: undefined : undefined
const {_} = useLingui() const {t: l} = useLingui()
const uri = useMemo( const uri = useMemo(
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
@@ -70,7 +69,7 @@ export function ProfileFeedScreen(props: Props) {
<Layout.Screen testID="profileFeedScreenError"> <Layout.Screen testID="profileFeedScreenError">
<ErrorScreen <ErrorScreen
showHeader showHeader
title={_(msg`Could not load feed`)} title={l`Could not load feed`}
message={cleanError(error)} message={cleanError(error)}
onPressTryAgain={() => void refetch()} onPressTryAgain={() => void refetch()}
/> />
@@ -131,7 +130,7 @@ export function ProfileFeedScreenInner({
feedInfo: FeedSourceFeedInfo feedInfo: FeedSourceFeedInfo
feedParams: FeedParams | undefined feedParams: FeedParams | undefined
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const {hasSession} = useSession() const {hasSession} = useSession()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const isScreenFocused = useIsFocused() const isScreenFocused = useIsFocused()
@@ -168,10 +167,10 @@ export function ProfileFeedScreenInner({
<EmptyState <EmptyState
icon={HashtagWideIcon} icon={HashtagWideIcon}
iconSize="2xl" iconSize="2xl"
message={_(msg`This feed is empty.`)} message={l`This feed is empty.`}
/> />
) )
}, [_]) }, [l])
const isVideoFeed = useMemo(() => { const isVideoFeed = useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri) const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
@@ -181,13 +180,17 @@ export function ProfileFeedScreenInner({
return IS_NATIVE && _isVideoFeed return IS_NATIVE && _isVideoFeed
}, [feedInfo]) }, [feedInfo])
const isTrending =
feedInfo.creatorDid.toLowerCase() === TRENDING_DID ||
feedInfo.creatorHandle.toLowerCase() === TRENDING_HANDLE
return ( return (
<> <>
<ProfileFeedHeader info={feedInfo} /> <ProfileFeedHeader info={feedInfo} isTrending={isTrending} />
<FeedFeedbackProvider value={feedFeedback}> <FeedFeedbackProvider value={feedFeedback}>
<PostFeed <PostFeed
enabled enabled
description={isTrending ? feedInfo.description : undefined}
feed={feed} feed={feed}
feedParams={feedParams} feedParams={feedParams}
pollInterval={60e3} pollInterval={60e3}
@@ -199,22 +202,20 @@ export function ProfileFeedScreenInner({
isVideoFeed={isVideoFeed} isVideoFeed={isVideoFeed}
/> />
</FeedFeedbackProvider> </FeedFeedbackProvider>
{(isScrolledDown || hasNew) && ( {(isScrolledDown || hasNew) && (
<LoadLatestBtn <LoadLatestBtn
onPress={onScrollToTop} onPress={onScrollToTop}
label={_(msg`Load new posts`)} label={l`Load new posts`}
showIndicator={hasNew} showIndicator={hasNew}
/> />
)} )}
{hasSession && ( {hasSession && (
<FAB <FAB
testID="composeFAB" testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})} onPress={() => openComposer({logContext: 'Fab'})}
icon={<EditBigIcon size="lg" fill={t.palette.white} />} icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`New post`)} accessibilityLabel={l`New post`}
accessibilityHint="" accessibilityHint=""
/> />
)} )}
@@ -3,6 +3,7 @@ import {View} from 'react-native'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro' import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {TRENDING_HANDLE} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links' import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing' 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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import { import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilledIcon,
Heart2_Stroke2_Corner0_Rounded as Heart, Heart2_Stroke2_Corner0_Rounded as HeartIcon,
} from '#/components/icons/Heart2' } from '#/components/icons/Heart2'
import { import {
Pin_Filled_Corner0_Rounded as PinFilled, Pin_Filled_Corner0_Rounded as PinFilledIcon,
Pin_Stroke2_Corner0_Rounded as Pin, Pin_Stroke2_Corner0_Rounded as PinIcon,
} from '#/components/icons/Pin' } from '#/components/icons/Pin'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
@@ -74,14 +75,20 @@ export function ProfileFeedHeaderSkeleton() {
width: 34, width: 34,
}, },
]}> ]}>
<Pin size="lg" fill={t.atoms.text_contrast_low.color} /> <PinIcon size="lg" fill={t.atoms.text_contrast_low.color} />
</View> </View>
</Layout.Header.Slot> </Layout.Header.Slot>
</Layout.Header.Outer> </Layout.Header.Outer>
) )
} }
export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { export function ProfileFeedHeader({
info,
isTrending,
}: {
info: FeedSourceFeedInfo
isTrending: boolean
}) {
const t = useTheme() const t = useTheme()
const {t: l, i18n} = useLingui() const {t: l, i18n} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
@@ -188,105 +195,143 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
<Layout.Header.Outer> <Layout.Header.Outer>
<Layout.Header.BackButton /> <Layout.Header.BackButton />
<Layout.Header.Content align="left"> <Layout.Header.Content align="left">
<Button {isTrending ? (
label={l`Open feed info screen`} <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
style={[ <View style={[a.flex_1]}>
a.justify_start, <Text
{
paddingVertical: IS_WEB ? 2 : 4,
paddingRight: 8,
},
]}
onPress={() => {
playHaptic()
infoControl.open()
}}>
{({hovered, pressed}) => (
<>
<View
style={[ style={[
a.absolute, a.text_md,
a.inset_0, a.font_bold,
a.rounded_sm, a.leading_snug,
a.transition_all, gtMobile && a.text_lg,
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}],
},
]} ]}
/> 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 <View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{info.avatar && ( {info.avatar && (
<UserAvatar size={36} type="algo" avatar={info.avatar} /> <UserAvatar
)} size={36}
type="algo"
avatar={info.avatar}
/>
)}
<View style={[a.flex_1]}> <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}]}>
<Text <Text
style={[ style={[
a.flex_shrink, a.text_md,
a.text_sm, a.font_bold,
a.leading_snug, a.leading_snug,
t.atoms.text_contrast_medium, gtMobile && a.text_lg,
]} ]}
numberOfLines={1}> numberOfLines={2}
{sanitizeHandle(info.creatorHandle, '@')} emoji>
{info.displayName}
</Text> </Text>
<View style={[a.flex_row, a.align_center, {gap: 2}]}> <View style={[a.flex_row, a.gap_2xs]}>
<HeartFilled
size="xs"
fill={
likeUri
? t.palette.pink
: t.atoms.text_contrast_low.color
}
/>
<Text <Text
style={[ style={[
a.flex_shrink,
a.text_sm, a.text_sm,
a.leading_snug, a.leading_snug,
t.atoms.text_contrast_medium, t.atoms.text_contrast_high,
]} ]}
numberOfLines={1}> numberOfLines={1}>
{formatCount(i18n, likeCount)} {sanitizeHandle(info.creatorHandle, '@')}
</Text> </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> </View>
</View>
<Ellipsis <EllipsisIcon
size="md" size="md"
fill={t.atoms.text_contrast_low.color} fill={t.atoms.text_contrast_high.color}
/> />
</View> </View>
</> </>
)} )}
</Button> </Button>
)}
</Layout.Header.Content> </Layout.Header.Content>
{hasSession && ( {!isTrending && hasSession ? (
<Layout.Header.Slot> <Layout.Header.Slot>
{isPinned ? ( {isPinned ? (
<Menu.Root> <Menu.Root>
@@ -300,7 +345,10 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
variant="ghost" variant="ghost"
shape="square" shape="square"
color="secondary"> color="secondary">
<PinFilled size="lg" fill={t.palette.primary_500} /> <PinFilledIcon
size="lg"
fill={t.palette.primary_500}
/>
</Button> </Button>
) )
}} }}
@@ -310,23 +358,23 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
<Menu.Item <Menu.Item
disabled={isFeedStateChangePending} disabled={isFeedStateChangePending}
label={l`Unpin from home`} label={l`Unpin from home`}
onPress={onTogglePinned}> onPress={() => void onTogglePinned()}>
<Menu.ItemText>{l`Unpin from home`}</Menu.ItemText> <Menu.ItemText>{l`Unpin from home`}</Menu.ItemText>
<Menu.ItemIcon icon={X} position="right" /> <Menu.ItemIcon icon={XIcon} position="right" />
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
disabled={isFeedStateChangePending} disabled={isFeedStateChangePending}
label={ label={
isSaved ? l`Remove from my feeds` : l`Save to my feeds` isSaved ? l`Remove from my feeds` : l`Save to my feeds`
} }
onPress={onToggleSaved}> onPress={() => void onToggleSaved()}>
<Menu.ItemText> <Menu.ItemText>
{isSaved {isSaved
? l`Remove from my feeds` ? l`Remove from my feeds`
: l`Save to my feeds`} : l`Save to my feeds`}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon <Menu.ItemIcon
icon={isSaved ? Trash : Plus} icon={isSaved ? TrashIcon : PlusIcon}
position="right" position="right"
/> />
</Menu.Item> </Menu.Item>
@@ -339,12 +387,12 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
variant="ghost" variant="ghost"
shape="square" shape="square"
color="secondary" color="secondary"
onPress={onTogglePinned}> onPress={() => void onTogglePinned()}>
<ButtonIcon icon={Pin} size="lg" /> <ButtonIcon icon={PinIcon} size="lg" />
</Button> </Button>
)} )}
</Layout.Header.Slot> </Layout.Header.Slot>
)} ) : null}
</Layout.Header.Outer> </Layout.Header.Outer>
</Layout.Center> </Layout.Center>
<Dialog.Outer control={infoControl}> <Dialog.Outer control={infoControl}>
@@ -358,7 +406,8 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
setLikeUri={setLikeUri} setLikeUri={setLikeUri}
likeCount={likeCount} likeCount={likeCount}
isPinned={isPinned} isPinned={isPinned}
onTogglePinned={onTogglePinned} isTrending={isTrending}
onTogglePinned={() => void onTogglePinned()}
isFeedStateChangePending={isFeedStateChangePending} isFeedStateChangePending={isFeedStateChangePending}
/> />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
@@ -373,6 +422,7 @@ function DialogInner({
setLikeUri, setLikeUri,
likeCount, likeCount,
isPinned, isPinned,
isTrending,
onTogglePinned, onTogglePinned,
isFeedStateChangePending, isFeedStateChangePending,
}: { }: {
@@ -381,6 +431,7 @@ function DialogInner({
setLikeUri: (uri: string) => void setLikeUri: (uri: string) => void
likeCount: number likeCount: number
isPinned: boolean isPinned: boolean
isTrending: boolean
onTogglePinned: () => void onTogglePinned: () => void
isFeedStateChangePending: boolean isFeedStateChangePending: boolean
}) { }) {
@@ -459,7 +510,9 @@ function DialogInner({
style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]} style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}
numberOfLines={1} numberOfLines={1}
onPress={() => control.close()}> onPress={() => control.close()}>
{sanitizeHandle(info.creatorHandle, '@')} {info.creatorHandle === TRENDING_HANDLE
? l`Bluesky`
: sanitizeHandle(info.creatorHandle, '@')}
</InlineLinkText> </InlineLinkText>
</Trans> </Trans>
</Text> </Text>
@@ -472,12 +525,13 @@ function DialogInner({
color="secondary" color="secondary"
shape="round" shape="round"
onPress={onPressShare}> onPress={onPressShare}>
<ButtonIcon icon={Share} size="lg" /> <ButtonIcon icon={ShareIcon} size="lg" />
</Button> </Button>
</View> </View>
<RichText value={info.description} style={[a.text_md]} /> <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 <InlineLinkText
label={l`View users who like this feed`} label={l`View users who like this feed`}
to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')} to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')}
@@ -487,41 +541,47 @@ function DialogInner({
Liked by <Plural value={likeCount} one="# user" other="# users" /> Liked by <Plural value={likeCount} one="# user" other="# users" />
</Trans> </Trans>
</InlineLinkText> </InlineLinkText>
)} </View>
</View> ) : null}
{hasSession && ( {hasSession ? (
<> <>
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}> {!isTrending ? (
<Button <View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
disabled={isLikePending || isUnlikePending} <Button
label={l`Like this feed`} disabled={isLikePending || isUnlikePending}
size="small" label={l`Like this feed`}
color="secondary" size="small"
onPress={onToggleLiked} color="secondary"
style={[a.flex_1]}> onPress={() => void onToggleLiked()}
{isLiked ? ( style={[a.flex_1]}>
<HeartFilled size="sm" fill={t.palette.pink} /> {isLiked ? (
) : ( <HeartFilledIcon size="sm" fill={t.palette.pink} />
<ButtonIcon icon={Heart} /> ) : (
)} <ButtonIcon icon={HeartIcon} />
)}
<ButtonText> <ButtonText>
{isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>} {isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
</ButtonText> </ButtonText>
</Button> </Button>
<Button <Button
disabled={isFeedStateChangePending} disabled={isFeedStateChangePending}
label={isPinned ? l`Unpin feed` : l`Pin feed`} label={isPinned ? l`Unpin feed` : l`Pin feed`}
size="small" size="small"
color={isPinned ? 'secondary' : 'primary'} color={isPinned ? 'secondary' : 'primary'}
onPress={onTogglePinned} onPress={onTogglePinned}
style={[a.flex_1]}> style={[a.flex_1]}>
<ButtonText> <ButtonText>
{isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>} {isPinned ? (
</ButtonText> <Trans>Unpin feed</Trans>
<ButtonIcon icon={Pin} position="right" /> ) : (
</Button> <Trans>Pin feed</Trans>
</View> )}
</ButtonText>
<ButtonIcon icon={PinIcon} position="right" />
</Button>
</View>
) : null}
<View style={[a.pt_xs, a.gap_lg]}> <View style={[a.pt_xs, a.gap_lg]}>
<Divider /> <Divider />
@@ -541,7 +601,7 @@ function DialogInner({
<ButtonText> <ButtonText>
<Trans>Report feed</Trans> <Trans>Report feed</Trans>
</ButtonText> </ButtonText>
<ButtonIcon icon={CircleInfo} position="right" /> <ButtonIcon icon={CircleInfoIcon} position="right" />
</Button> </Button>
</View> </View>
@@ -556,7 +616,7 @@ function DialogInner({
)} )}
</View> </View>
</> </>
)} ) : null}
</View> </View>
) )
} }
+34 -47
View File
@@ -5,9 +5,7 @@ import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyGraphDefs, type AppBskyGraphDefs,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match' import * as bcp47Match from 'bcp-47-match'
@@ -48,7 +46,6 @@ import {
StarterPackCardSkeleton, StarterPackCardSkeleton,
} from '#/screens/Search/components/StarterPackCard' } from '#/screens/Search/components/StarterPackCard'
import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard' import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard'
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {atoms as a, native, platform, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme} from '#/alf'
@@ -79,7 +76,7 @@ import {
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) { function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const handleOnPress = () => { const handleOnPress = () => {
void item.onLoadMore() void item.onLoadMore()
@@ -87,7 +84,7 @@ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
return ( return (
<Button <Button
label={_(msg`Load more`)} label={l`Load more`}
onPress={handleOnPress} onPress={handleOnPress}
style={[a.relative, a.w_full]}> style={[a.relative, a.w_full]}>
{({hovered, pressed}) => ( {({hovered, pressed}) => (
@@ -139,6 +136,7 @@ type ExploreScreenItems =
key: string key: string
title: string title: string
icon: React.ComponentType<SVGIconProps> icon: React.ComponentType<SVGIconProps>
iconSize?: IcoProps['size']
searchButton?: { searchButton?: {
label: string label: string
metricsTag: Metrics['explore:module:searchButtonPress']['module'] metricsTag: Metrics['explore:module:searchButtonPress']['module']
@@ -154,10 +152,6 @@ type ExploreScreenItems =
type: 'trendingVideos' type: 'trendingVideos'
key: string key: string
} }
| {
type: 'recommendations'
key: string
}
| { | {
type: 'profile' type: 'profile'
key: string key: string
@@ -220,7 +214,7 @@ export function Explore({
headerHeight: number headerHeight: number
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {data: preferences, error: preferencesError} = usePreferencesQuery() const {data: preferences, error: preferencesError} = usePreferencesQuery()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@@ -372,10 +366,11 @@ export function Explore({
i.push({ i.push({
type: 'tabbedHeader', type: 'tabbedHeader',
key: 'suggested-accounts-header', key: 'suggested-accounts-header',
title: _(msg`Suggested accounts`), title: l`Suggested accounts`,
icon: Person, icon: Person,
iconSize: 'md',
searchButton: { searchButton: {
label: _(msg`Search for more accounts`), label: l`Search for more accounts`,
metricsTag: 'suggestedAccounts', metricsTag: 'suggestedAccounts',
tab: 'user', tab: 'user',
}, },
@@ -388,7 +383,7 @@ export function Explore({
i.push({ i.push({
type: 'error', type: 'error',
key: 'suggestedUsersError', key: 'suggestedUsersError',
message: _(msg`Failed to load suggested follows`), message: l`Failed to load suggested follows`,
error: cleanError(suggestedUsersError), error: cleanError(suggestedUsersError),
}) })
} else { } else {
@@ -436,7 +431,7 @@ export function Explore({
} }
return i return i
}, [ }, [
_, l,
moderationOpts, moderationOpts,
suggestedUsers, suggestedUsers,
suggestedUsersIsLoading, suggestedUsersIsLoading,
@@ -450,10 +445,11 @@ export function Explore({
i.push({ i.push({
type: 'header', type: 'header',
key: 'suggested-feeds-header', key: 'suggested-feeds-header',
title: _(msg`Discover new feeds`), title: l`Discover feeds`,
icon: ListSparkle, icon: ListSparkle,
iconSize: 'md',
searchButton: { searchButton: {
label: _(msg`Search for more feeds`), label: l`Search for more feeds`,
metricsTag: 'suggestedFeeds', metricsTag: 'suggestedFeeds',
tab: 'feed', tab: 'feed',
}, },
@@ -479,14 +475,14 @@ export function Explore({
i.push({ i.push({
type: 'error', type: 'error',
key: 'suggestedFeedsError', key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`), message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError), error: cleanError(suggestedFeedsError),
}) })
} else if (preferencesError) { } else if (preferencesError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'preferencesError', key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`), message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError), error: cleanError(preferencesError),
}) })
} else { } else {
@@ -516,7 +512,7 @@ export function Explore({
i.push({ i.push({
type: 'loadMore', type: 'loadMore',
key: 'loadMoreFeeds', key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`), message: l`Load more suggested feeds`,
isLoadingMore: isLoadingMoreFeeds, isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds, onLoadMore: onLoadMoreFeeds,
}) })
@@ -527,21 +523,21 @@ export function Explore({
i.push({ i.push({
type: 'error', type: 'error',
key: 'feedsError', key: 'feedsError',
message: _(msg`Failed to load feeds`), message: l`Failed to load feeds`,
error: cleanError(feedsError), error: cleanError(feedsError),
}) })
} else if (suggestedFeedsError) { } else if (suggestedFeedsError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'suggestedFeedsError', key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`), message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError), error: cleanError(suggestedFeedsError),
}) })
} else if (preferencesError) { } else if (preferencesError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'preferencesError', key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`), message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError), error: cleanError(preferencesError),
}) })
} else { } else {
@@ -572,21 +568,21 @@ export function Explore({
i.push({ i.push({
type: 'error', type: 'error',
key: 'feedsError', key: 'feedsError',
message: _(msg`Failed to load feeds`), message: l`Failed to load feeds`,
error: cleanError(feedsError), error: cleanError(feedsError),
}) })
} else if (suggestedFeedsError) { } else if (suggestedFeedsError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'suggestedFeedsError', key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`), message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError), error: cleanError(suggestedFeedsError),
}) })
} else if (preferencesError) { } else if (preferencesError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'preferencesError', key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`), message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError), error: cleanError(preferencesError),
}) })
} else { } else {
@@ -607,7 +603,7 @@ export function Explore({
i.push({ i.push({
type: 'loadMore', type: 'loadMore',
key: 'loadMoreFeeds', key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`), message: l`Load more suggested feeds`,
isLoadingMore: isLoadingMoreFeeds, isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds, onLoadMore: onLoadMoreFeeds,
}) })
@@ -618,21 +614,21 @@ export function Explore({
i.push({ i.push({
type: 'error', type: 'error',
key: 'feedsError', key: 'feedsError',
message: _(msg`Failed to load feeds`), message: l`Failed to load feeds`,
error: cleanError(feedsError), error: cleanError(feedsError),
}) })
} else if (suggestedFeedsError) { } else if (suggestedFeedsError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'feedsError', key: 'feedsError',
message: _(msg`Failed to load suggested feeds`), message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError), error: cleanError(suggestedFeedsError),
}) })
} else if (preferencesError) { } else if (preferencesError) {
i.push({ i.push({
type: 'error', type: 'error',
key: 'preferencesError', key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`), message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError), error: cleanError(preferencesError),
}) })
} else { } else {
@@ -642,7 +638,7 @@ export function Explore({
} }
return i return i
}, [ }, [
_, l,
ax, ax,
useFullExperience, useFullExperience,
suggestedFeeds, suggestedFeeds,
@@ -662,9 +658,9 @@ export function Explore({
i.push({ i.push({
type: 'header', type: 'header',
key: 'suggested-starterPacks-header', key: 'suggested-starterPacks-header',
title: _(msg`Starter Packs`), title: l`Starter Packs`,
icon: StarterPack, icon: StarterPack,
iconSize: 'xl', iconSize: 'md',
}) })
if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) { if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) {
@@ -689,7 +685,7 @@ export function Explore({
return i return i
}, [ }, [
suggestedSPs, suggestedSPs,
_, l,
isLoadingSuggestedSPs, isLoadingSuggestedSPs,
suggestedSPsError, suggestedSPsError,
isRefetchingSuggestedSPs, isRefetchingSuggestedSPs,
@@ -778,7 +774,7 @@ export function Explore({
return ( return (
<View style={[a.pb_md]}> <View style={[a.pb_md]}>
<ModuleHeader.Container style={[a.pb_xs]}> <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> <ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
{item.searchButton && ( {item.searchButton && (
<ModuleHeader.SearchButton <ModuleHeader.SearchButton
@@ -798,18 +794,11 @@ export function Explore({
) )
} }
case 'trendingTopics': { case 'trendingTopics': {
return ( return <ExploreTrendingTopics />
<View style={[a.pb_md]}>
<ExploreTrendingTopics />
</View>
)
} }
case 'trendingVideos': { case 'trendingVideos': {
return <ExploreTrendingVideos /> return <ExploreTrendingVideos />
} }
case 'recommendations': {
return <ExploreRecommendations />
}
case 'profile': { case 'profile': {
return ( return (
<SuggestedProfileCard <SuggestedProfileCard
@@ -1023,9 +1012,7 @@ export function Explore({
case 'preview:loadMoreError': { case 'preview:loadMoreError': {
return ( return (
<LoadMoreRetryBtn <LoadMoreRetryBtn
label={_( label={l`There was an issue fetching posts. Tap here to try again.`}
msg`There was an issue fetching posts. Tap here to try again.`,
)}
onPress={handleOnPressRetry} onPress={handleOnPressRetry}
/> />
) )
@@ -1050,7 +1037,7 @@ export function Explore({
moderationOpts, moderationOpts,
interestsDisplayNames, interestsDisplayNames,
useFullExperience, useFullExperience,
_, l,
fetchNextPageFeedPreviews, fetchNextPageFeedPreviews,
], ],
) )
+150 -3
View File
@@ -1,6 +1,6 @@
import {memo, useCallback, useMemo, useState} from 'react' import {memo, useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, View} from 'react-native' 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 {Trans, useLingui} from '@lingui/react/macro'
import {urls} from '#/lib/constants' import {urls} from '#/lib/constants'
@@ -15,6 +15,7 @@ import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useActorSearch} from '#/state/queries/actor-search' import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed' import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2' import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
import {useStarterPackSearch} from '#/state/queries/starter-pack-search'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
@@ -23,6 +24,7 @@ import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post' import {Post} from '#/view/com/post/Post'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import {StarterPackCard} from '#/screens/Search/components/StarterPackCard'
import { import {
hasPostOnlyFilters, hasPostOnlyFilters,
type SearchFilters, type SearchFilters,
@@ -41,6 +43,7 @@ let SearchResults = ({
query, query,
filters, filters,
hasFilters, hasFilters,
fromMe,
activeTab, activeTab,
onPageSelected, onPageSelected,
headerHeight, headerHeight,
@@ -48,20 +51,26 @@ let SearchResults = ({
query: string query: string
filters: SearchFilters filters: SearchFilters
hasFilters: boolean hasFilters: boolean
fromMe: boolean
activeTab: number activeTab: number
onPageSelected: (page: number) => void onPageSelected: (page: number) => void
headerHeight: number headerHeight: number
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui() const {t: l} = useLingui()
/* /*
* People/Feeds visibility keys off post-only filters: a `lang` filter applies * 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 * 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. * 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 activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain' const tabShape = hasPostFilters ? 'filtered' : 'plain'
const isStarterPacksEnabled = ax.features.enabled(
ax.features.SearchStarterPacksV2Enable,
)
const sections = useMemo(() => { const sections = useMemo(() => {
if (!query && !hasFilters) return [] if (!query && !hasFilters) return []
/* /*
@@ -106,11 +115,29 @@ let SearchResults = ({
<SearchScreenFeedsResults query={query} active={activePage === 3} /> <SearchScreenFeedsResults query={query} active={activePage === 3} />
), ),
}, },
noFilters &&
isStarterPacksEnabled && {
title: l`Starter packs`,
component: (
<SearchScreenStarterPackResults
query={query}
active={activePage === 4}
/>
),
},
].filter(Boolean) as { ].filter(Boolean) as {
title: string title: string
component: React.ReactNode 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. // There may be fewer tabs after changing the search options.
const selectedPage = activePage > sections.length - 1 ? 0 : activePage const selectedPage = activePage > sections.length - 1 ? 0 : activePage
@@ -692,3 +719,123 @@ function SearchFeedCard({
return <FeedCard.Default view={view} onPress={handleOnPress} /> 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} />
}
+29 -23
View File
@@ -32,6 +32,7 @@ import {
unstableCacheProfileView, unstableCacheProfileView,
useProfilesQuery, useProfilesQuery,
} from '#/state/queries/profile' } from '#/state/queries/profile'
import {extractFromMe} from '#/state/queries/search-posts-params'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import { import {
countActiveFilters, countActiveFilters,
@@ -123,8 +124,18 @@ export function SearchScreenShell({
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam)) 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 // Query terms
const [searchText, setSearchText] = useState<string>(queryParam) const [searchText, setSearchText] = useState<string>(query)
const searchTextRef = useRef(searchText) const searchTextRef = useRef(searchText)
const updateSearchText = useCallback((text: string) => { const updateSearchText = useCallback((text: string) => {
searchTextRef.current = text searchTextRef.current = text
@@ -200,10 +211,6 @@ export function SearchScreenShell({
[accountHistory, setAccountHistory], [accountHistory, setAccountHistory],
) )
const {query, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean((query || hasFilters) && !showAutocomplete) const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
const onChangeLang = useCallback( const onChangeLang = useCallback(
@@ -233,13 +240,13 @@ export function SearchScreenShell({
useEffect(() => { useEffect(() => {
if (IS_NATIVE) { if (IS_NATIVE) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
updateSearchText(queryParam) updateSearchText(query)
} }
}, [queryParam, updateSearchText]) }, [query, updateSearchText])
useFocusEffect( useFocusEffect(
useNonReactiveCallback(() => { useNonReactiveCallback(() => {
if (IS_WEB) { if (IS_WEB) {
updateSearchText(queryParam) updateSearchText(query)
} }
}), }),
) )
@@ -329,11 +336,12 @@ export function SearchScreenShell({
]) ])
const onSubmit = (source: 'typed' | 'autocomplete') => () => { const onSubmit = (source: 'typed' | 'autocomplete') => () => {
const nextQuery = searchTextRef.current
ax.metric('search:query', { ax.metric('search:query', {
source, source,
filterCount: countActiveFilters(filters), filterCount: countActiveFilters(filters),
}) })
navigateToItem(searchTextRef.current) navigateToItem(nextQuery)
} }
const onSubmitAdvanced = useCallback( const onSubmitAdvanced = useCallback(
@@ -474,17 +482,6 @@ export function SearchScreenShell({
} }
}, [setShowAutocomplete]) }, [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( const focusSearchInput = useCallback(
(tab?: TabParam) => { (tab?: TabParam) => {
textInput.current?.focus() textInput.current?.focus()
@@ -609,7 +606,6 @@ export function SearchScreenShell({
ref={textInput} ref={textInput}
value={searchText} value={searchText}
onFocus={onSearchInputFocus} onFocus={onSearchInputFocus}
onBlur={onSearchInputBlur}
onChangeText={onChangeText} onChangeText={onChangeText}
onClearText={onPressClearQuery} onClearText={onPressClearQuery}
onSubmitEditing={onSubmit('typed')} onSubmitEditing={onSubmit('typed')}
@@ -656,6 +652,7 @@ export function SearchScreenShell({
query={query} query={query}
filters={filters} filters={filters}
hasFilters={hasFilters} hasFilters={hasFilters}
fromMe={fromMe}
headerHeight={headerHeight} headerHeight={headerHeight}
focusSearchInput={focusSearchInput} focusSearchInput={focusSearchInput}
/> />
@@ -707,6 +704,7 @@ let SearchScreenInner = ({
query, query,
filters, filters,
hasFilters, hasFilters,
fromMe,
headerHeight, headerHeight,
focusSearchInput, focusSearchInput,
}: { }: {
@@ -715,6 +713,7 @@ let SearchScreenInner = ({
query: string query: string
filters: SearchFilters filters: SearchFilters
hasFilters: boolean hasFilters: boolean
fromMe: boolean
headerHeight: number headerHeight: number
focusSearchInput: (tab?: TabParam) => void focusSearchInput: (tab?: TabParam) => void
}): React.ReactNode => { }): React.ReactNode => {
@@ -731,6 +730,7 @@ let SearchScreenInner = ({
query={query} query={query}
filters={filters} filters={filters}
hasFilters={hasFilters} hasFilters={hasFilters}
fromMe={fromMe}
activeTab={activeTab} activeTab={activeTab}
headerHeight={headerHeight} headerHeight={headerHeight}
onPageSelected={onPageSelected} onPageSelected={onPageSelected}
@@ -781,8 +781,13 @@ function useQueryManager({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const route = useRoute() 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 query = initialQuery
const fromMe = useMemo(
() => extractFromMe(initialQuery).fromMe,
[initialQuery],
)
const filters = useMemo(() => { const filters = useMemo(() => {
const fromRoute = readSearchFilters(route.params as Record<string, unknown>) const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
@@ -814,11 +819,12 @@ function useQueryManager({
return useMemo( return useMemo(
() => ({ () => ({
query, query,
fromMe,
filters, filters,
setFilters, setFilters,
hasFilters: hasActiveFilters(filters), hasFilters: hasActiveFilters(filters),
}), }),
[query, filters, setFilters], [query, fromMe, filters, setFilters],
) )
} }
@@ -4,6 +4,7 @@ import {
countActiveFilters, countActiveFilters,
definedFilterParams, definedFilterParams,
filtersToApiParams, filtersToApiParams,
hasActiveFilters,
hasPostOnlyFilters, hasPostOnlyFilters,
parseHistoryEntry, parseHistoryEntry,
readSearchFilters, 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`, () => { describe(`definedFilterParams`, () => {
it(`omits absent keys entirely`, () => { it(`omits absent keys entirely`, () => {
expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'}) expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'})
@@ -130,8 +139,9 @@ describe(`searchParams`, () => {
}) })
describe(`countActiveFilters`, () => { describe(`countActiveFilters`, () => {
it(`counts each set filter key once`, () => { it(`counts each structured filter key once`, () => {
expect(countActiveFilters({})).toBe(0) expect(countActiveFilters({})).toBe(0)
expect(countActiveFilters({from: 'me'})).toBe(1)
expect( expect(
countActiveFilters({author: 'alice bob', domain: 'bsky.app'}), countActiveFilters({author: 'alice bob', domain: 'bsky.app'}),
).toBe(2) ).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`, () => { it(`round-trips query + filters`, () => {
const filters = { const filters = {
author: 'alice', author: 'alice',
@@ -7,20 +7,21 @@ import {
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
} from '#/components/icons/Chevron' } from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {type FollowingFilter} from './utils' import {type FromFilter} from './utils'
export function FollowingDropdown({ export function FromDropdown({
value, value,
onChange, onChange,
}: { }: {
value: FollowingFilter value: FromFilter
onChange: (value: FollowingFilter) => void onChange: (value: FromFilter) => void
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const options: {value: FollowingFilter; label: string}[] = [ const options: {value: FromFilter; label: string}[] = [
{value: 'anyone', label: l`Anyone`}, {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` 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') expect(state.until).toBe('2024-02-01')
}) })
it(`keeps from:me in the query box rather than lifting it into a row`, () => { it(`lifts from:me typed into the query box into the "me" following filter`, () => {
const state = parseAdvancedSearch('from:me', {}) const state = parseAdvancedSearch('cats from:me', {})
expect(state.query).toBe('from:me') expect(state.query).toBe('cats')
expect(state.following).toBe('me')
expect(state.filters.find(f => f.field === 'authors')).toBeUndefined() 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`, () => { it(`merges a query-box operator with the matching filter param`, () => {
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'}) const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
expect(state.query).toBe('hi') expect(state.query).toBe('hi')
@@ -22,12 +22,12 @@ import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField' import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
import {ClearableInput} from './ClearableInput' import {ClearableInput} from './ClearableInput'
import {FilterBlock} from './FilterBlock' import {FilterBlock} from './FilterBlock'
import {FollowingDropdown} from './FollowingDropdown' import {FromDropdown} from './FromDropdown'
import {MediaDropdown} from './MediaDropdown' import {MediaDropdown} from './MediaDropdown'
import {RepliesDropdown} from './RepliesDropdown' import {RepliesDropdown} from './RepliesDropdown'
import { import {
type AdvancedFilter, type AdvancedFilter,
type FollowingFilter, type FromFilter,
makeFilter, makeFilter,
type MediaFilter, type MediaFilter,
parseAdvancedSearch, parseAdvancedSearch,
@@ -124,7 +124,7 @@ function DialogInner({
const [media, setMedia] = useState<MediaFilter>(parsed.media) const [media, setMedia] = useState<MediaFilter>(parsed.media)
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies) 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 * The date picker requires a valid date, so these always hold one. The
@@ -389,7 +389,9 @@ function DialogInner({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.mb_sm, a.mb_sm,
]}> ]}>
<Trans>Include</Trans> <Trans comment="Include search results with or without replies">
Include
</Trans>
</Text> </Text>
<View style={[a.flex_row]}> <View style={[a.flex_row]}>
<RepliesDropdown value={replies} onChange={setReplies} /> <RepliesDropdown value={replies} onChange={setReplies} />
@@ -403,10 +405,12 @@ function DialogInner({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.mb_sm, a.mb_sm,
]}> ]}>
<Trans>From</Trans> <Trans comment="Filter search results by a specific post author">
From
</Trans>
</Text> </Text>
<View style={[a.flex_row]}> <View style={[a.flex_row]}>
<FollowingDropdown value={following} onChange={setFollowing} /> <FromDropdown value={following} onChange={setFollowing} />
</View> </View>
</View> </View>
</View> </View>
@@ -1,4 +1,5 @@
import { import {
extractFromMe,
extractSearchPostsParams, extractSearchPostsParams,
tokenizeQuery, tokenizeQuery,
} from '#/state/queries/search-posts-params' } from '#/state/queries/search-posts-params'
@@ -14,10 +15,11 @@ export type RepliesFilter = 'all' | 'none' | 'only'
export type MediaFilter = 'all' | 'media' | 'video' export type MediaFilter = 'all' | 'media' | 'video'
/** /**
* Whether to limit results to authors the user follows. Serializes into the * Which authors to limit results to. 'following' serializes into the
* `following` sibling param ('following' -> following:true, anyone -> unset). * `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' export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
@@ -93,7 +95,7 @@ export type DialogState = {
language: string language: string
replies: RepliesFilter replies: RepliesFilter
media: MediaFilter media: MediaFilter
following: FollowingFilter following: FromFilter
since: string since: string
until: string until: string
filters: AdvancedFilter[] filters: AdvancedFilter[]
@@ -141,18 +143,27 @@ function isSimpleWord(word: string): boolean {
* populate "none of these words" - but only when their contents are simple * populate "none of these words" - but only when their contents are simple
* words. Anything that wouldn't round-trip (embedded quotes, a negated phrase * 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 * 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): { function parseFreeText(raw: string): {
query: string query: string
exactPhrase: string exactPhrase: string
negatedWords: string negatedWords: string
fromMe: boolean
} { } {
const queryParts: string[] = [] const queryParts: string[] = []
const negatedWords: string[] = [] const negatedWords: string[] = []
let exactPhrase = '' let exactPhrase = ''
let fromMe = false
for (const token of tokenizeQuery(raw)) { 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. // "phrase" -> "exact phrase", only if it has no inner quote.
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) { if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
const inner = token.slice(1, -1) const inner = token.slice(1, -1)
@@ -176,6 +187,7 @@ function parseFreeText(raw: string): {
query: queryParts.join(' '), query: queryParts.join(' '),
exactPhrase, exactPhrase,
negatedWords: negatedWords.join(' '), negatedWords: negatedWords.join(' '),
fromMe,
} }
} }
@@ -208,7 +220,7 @@ export function parseAdvancedSearch(
* can be expressed as operators; exclude rows come solely from filter params. * can be expressed as operators; exclude rows come solely from filter params.
*/ */
const lifted = extractSearchPostsParams(q) const lifted = extractSearchPostsParams(q)
const freeText = parseFreeText(lifted.q) const {fromMe, ...freeText} = parseFreeText(lifted.q)
const includeValues: Record<FilterField, string> = { const includeValues: Record<FilterField, string> = {
authors: mergeValues(filters.author, lifted.author), authors: mergeValues(filters.author, lifted.author),
@@ -255,12 +267,24 @@ export function parseAdvancedSearch(
const since = filters.since ?? lifted.since const since = filters.since ?? lifted.since
const until = filters.until ?? lifted.until 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 { return {
...freeText, ...freeText,
language: lang, language: lang,
replies, replies,
media, media,
following: filters.following === 'true' ? 'following' : 'anyone', following,
since: since && isValidDate(since) ? since : '', since: since && isValidDate(since) ? since : '',
until: until && isValidDate(until) ? until : '', until: until && isValidDate(until) ? until : '',
filters: filterRows, filters: filterRows,
@@ -279,7 +303,7 @@ export function serializeAdvancedSearch(state: {
language: string language: string
replies: RepliesFilter replies: RepliesFilter
media: MediaFilter media: MediaFilter
following: FollowingFilter following: FromFilter
dateSince: string dateSince: string
dateSinceActive: boolean dateSinceActive: boolean
dateUntil: string dateUntil: string
@@ -352,7 +376,21 @@ export function serializeAdvancedSearch(state: {
if (state.replies === 'only') filters.replies = 'only' if (state.replies === 'only') filters.replies = 'only'
if (state.media === 'media') filters.media = 'true' if (state.media === 'media') filters.media = 'true'
else if (state.media === 'video') filters.video = '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.px_lg,
a.pt_2xl, a.pt_2xl,
a.pb_md, a.pb_md,
a.gap_sm, a.gap_xs,
t.atoms.bg, t.atoms.bg,
bottomBorder && [a.border_b, t.atoms.border_contrast_low], bottomBorder && [a.border_b, t.atoms.border_contrast_low],
style, style,
@@ -82,11 +82,13 @@ export function Icon({
icon: Comp, icon: Comp,
size = 'lg', size = 'lg',
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) { }: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
const t = useTheme()
const iconSize = iconSizes[size] const iconSize = iconSizes[size]
return ( return (
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}> <View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
<Comp width={iconSize} /> <Comp width={iconSize} fill={t.atoms.text.color} />
</View> </View>
) )
} }
@@ -94,7 +96,7 @@ export function Icon({
export function TitleText({style, ...props}: TextProps) { export function TitleText({style, ...props}: TextProps) {
return ( return (
<Text <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 emoji
{...props} {...props}
/> />
@@ -26,8 +26,10 @@ import * as bsky from '#/types/bsky'
export function StarterPackCard({ export function StarterPackCard({
view, view,
onPress,
}: { }: {
view: AppBskyGraphDefs.StarterPackView view: AppBskyGraphDefs.StarterPackView
onPress?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -55,7 +57,10 @@ export function StarterPackCard({
to={link.to} to={link.to}
label={link.label} label={link.label}
onHoverIn={link.precache} onHoverIn={link.precache}
onPress={link.precache}> onPress={() => {
link.precache()
onPress?.()
}}>
{s => ( {s => (
<> <>
<SubtleHover hover={s.hovered || s.pressed} /> <SubtleHover hover={s.hovered || s.pressed} />
@@ -111,7 +116,10 @@ export function StarterPackCard({
to={link.to} to={link.to}
label={link.label} label={link.label}
onHoverIn={link.precache} onHoverIn={link.precache}
onPress={link.precache} onPress={() => {
link.precache()
onPress?.()
}}
variant="solid" variant="solid"
color="secondary" color="secondary"
size="small" size="small"
@@ -1,15 +1,13 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useInterestsDisplayNames} from '#/lib/interests' import {useInterestsDisplayNames} from '#/lib/interests'
import {Nux, useSaveNux} from '#/state/queries/nuxs' import {Nux, useSaveNux} from '#/state/queries/nuxs'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
@@ -17,7 +15,7 @@ import {Text} from '#/components/Typography'
export function ExploreInterestsCard() { export function ExploreInterestsCard() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const interestsDisplayNames = useInterestsDisplayNames() const interestsDisplayNames = useInterestsDisplayNames()
const {mutateAsync: saveNux} = useSaveNux() const {mutateAsync: saveNux} = useSaveNux()
@@ -41,16 +39,12 @@ export function ExploreInterestsCard() {
<> <>
<Prompt.Basic <Prompt.Basic
control={trendingPrompt} control={trendingPrompt}
title={_(msg`Dismiss interests`)} title={l`Dismiss interests`}
description={_( description={l`You can adjust your interests at any time from "Content and media" settings.`}
msg`You can adjust your interests at any time from "Content and media" settings.`, confirmButtonCta={l({
)} message: `OK`,
confirmButtonCta={_( comment: `Confirm button text.`,
msg({ })}
message: `OK`,
comment: `Confirm button text.`,
}),
)}
onConfirm={onConfirmClose} onConfirm={onConfirmClose}
/> />
@@ -63,8 +57,8 @@ export function ExploreInterestsCard() {
t.atoms.border_contrast_medium, t.atoms.border_contrast_medium,
]}> ]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}> <View style={[a.flex_row, a.gap_sm, a.align_center]}>
<Shapes /> <ShapesIcon fill={t.atoms.text.color} />
<Text style={[a.text_xl, a.font_semi_bold, a.leading_tight]}> <Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Your interests</Trans> <Trans>Your interests</Trans>
</Text> </Text>
</View> </View>
@@ -96,7 +90,7 @@ export function ExploreInterestsCard() {
</Text> </Text>
<Link <Link
label={_(msg`Edit interests`)} label={l`Edit interests`}
to="/settings/interests" to="/settings/interests"
size="small" size="small"
variant="solid" variant="solid"
@@ -108,7 +102,7 @@ export function ExploreInterestsCard() {
</Link> </Link>
<Button <Button
label={_(msg`Hide this card`)} label={l`Hide this card`}
size="small" size="small"
variant="ghost" variant="ghost"
color="secondary" 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 {useMemo} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api' import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro' import {
import {useLingui} from '@lingui/react' type AppBskyUnspeccedDefs,
import {Trans} from '@lingui/react/macro' 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 {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useTrendingSettings} from '#/state/preferences/trending' import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config' import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 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 {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 {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import * as ModuleHeader from '../components/ModuleHeader'
const TOPIC_COUNT = 5 const TOPIC_COUNT = 5
const IMAGE_SIZE = 56
export function ExploreTrendingTopics() { export function ExploreTrendingTopics() {
const {enabled} = useTrendingConfig() const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings() const {trendingDisabled} = useTrendingSettings()
@@ -32,24 +39,36 @@ function Inner() {
const ax = useAnalytics() const ax = useAnalytics()
const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery() const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery()
const noTopics = !isLoading && !error && !trending?.trends?.length const noTopics = !isLoading && !error && !trending?.trends?.length
const showLoading = isLoading || isRefetching
return isLoading || isRefetching ? ( if (!showLoading && (error || !trending?.trends || noTopics)) return null
Array.from({length: TOPIC_COUNT}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} withPosts={i === 0} /> return (
)) <View style={[a.pb_md]}>
) : error || !trending?.trends || noTopics ? null : ( <ModuleHeader.Container bottomBorder>
<> <ModuleHeader.Icon icon={TrendingIcon} size="md" />
{trending.trends.map((trend, index) => ( <ModuleHeader.TitleText>
<TrendRow <Trans>Trending</Trans>
key={trend.link} </ModuleHeader.TitleText>
trend={trend} </ModuleHeader.Container>
rank={index + 1} {showLoading
onPress={() => { ? Array.from({length: TOPIC_COUNT}).map((__, i) => (
ax.metric('trendingTopic:click', {context: 'explore'}) <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 onPress?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l, i18n} = useLingui()
const gutters = useGutters([0, 'base']) 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 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 ( return (
<Link <Link
testID={trend.link} testID={trend.link}
label={_(msg`Browse topic ${trend.displayName}`)} label={l`Browse topic ${trend.displayName}`}
to={trend.link} to={trend.link}
onPress={onPress} onPress={onPress}
style={[a.border_b, t.atoms.border_contrast_low]} style={[a.border_b, t.atoms.border_contrast_low]}
@@ -88,52 +109,75 @@ export function TrendRow({
{({hovered, pressed}) => ( {({hovered, pressed}) => (
<> <>
<SubtleHover hover={hovered || pressed} native /> <SubtleHover hover={hovered || pressed} native />
<View style={[gutters, a.w_full, a.py_lg, a.flex_row, a.gap_2xs]}> <View style={[gutters, a.w_full, a.flex_row, a.py_md, a.gap_sm]}>
<View style={[a.flex_1, a.gap_xs]}> <Text
<View style={[a.flex_row]}> style={[
<Text a.text_sm,
style={[ a.font_medium,
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>
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} {children}
</> </>
)} )}
@@ -141,96 +185,30 @@ export function TrendRow({
) )
} }
type TrendingIndicatorType = 'hot' | 'new' | number // Unused atm, but leaving here so we don't lose localization. -dsb
export function useCategoryDisplayName(
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(
category: AppBskyUnspeccedDefs.TrendView['category'], category: AppBskyUnspeccedDefs.TrendView['category'],
) { ) {
const {_} = useLingui() const {t: l} = useLingui()
switch (category) { switch (category) {
case 'sports': case 'sports':
return _(msg`Sports`) return l`Sports`
case 'politics': case 'politics':
return _(msg`Politics`) return l`Politics`
case 'video-games': case 'video-games':
return _(msg`Video Games`) return l`Video Games`
case 'pop-culture': case 'pop-culture':
return _(msg`Entertainment`) return l`Entertainment`
case 'news': case 'news':
return _(msg`News`) return l`News`
case 'other': case 'other':
default: default:
return null return null
} }
} }
export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) { export function TrendingTopicRowSkeleton() {
const t = useTheme() const t = useTheme()
const gutters = useGutters([0, 'base']) const gutters = useGutters([0, 'base'])
@@ -239,32 +217,39 @@ export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
style={[ style={[
gutters, gutters,
a.w_full, a.w_full,
a.py_lg, a.py_md,
a.flex_row, a.flex_row,
a.gap_2xs, a.gap_sm,
a.border_b, a.border_b,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
]}> ]}>
<View style={[a.flex_1, a.gap_sm]}> <View style={[{width: 20}]}>
<View style={[a.flex_row, a.align_center]}> <LoadingPlaceholder width={17} height={17} style={[a.rounded_full]} />
<View style={[{width: 20}]}> </View>
<LoadingPlaceholder <View style={[a.flex_1, a.gap_2xs]}>
width={12} <LoadingPlaceholder width={90} height={17} />
height={12} <View style={[a.flex_row, a.gap_sm, a.align_center]}>
style={[a.rounded_full]}
/>
</View>
<LoadingPlaceholder width={90} height={17} />
</View>
<View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}>
<LoadingPlaceholder width={70} height={16} /> <LoadingPlaceholder width={70} height={16} />
<LoadingPlaceholder width={40} height={16} /> <LoadingPlaceholder width={40} height={16} />
<LoadingPlaceholder width={60} height={16} /> <LoadingPlaceholder width={60} height={16} />
</View> </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>
<View style={[a.flex_shrink_0]}> {/* TODO Image placeholder goes here when images are available. -dsb */}
<TrendingIndicator type="skeleton" />
</View>
</View> </View>
) )
} }
+6 -2
View File
@@ -31,6 +31,8 @@ export type SearchFilters = {
video?: string video?: string
/** 'true' */ /** 'true' */
following?: string following?: string
/** 'me' */
from?: string
} }
export const FILTER_PARAM_KEYS = [ export const FILTER_PARAM_KEYS = [
@@ -51,6 +53,7 @@ export const FILTER_PARAM_KEYS = [
'media', 'media',
'video', 'video',
'following', 'following',
'from',
] as const ] as const
/** /**
@@ -76,13 +79,14 @@ export function readSearchFilters(
} }
export function hasActiveFilters(filters: SearchFilters): boolean { 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 * 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 * 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 { export function countActiveFilters(filters: SearchFilters): number {
return FILTER_PARAM_KEYS.filter(key => filters[key]).length 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 &rarr; 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 /> <Divider />
<Text style={[a.font_semi_bold, a.text_md]}> <Text style={[a.font_semi_bold, a.text_md]}>
<Trans>From</Trans> <Trans comment="Filter who you receive notifications from">
From
</Trans>
</Text> </Text>
<Toggle.Group <Toggle.Group
type="radio" type="radio"

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