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:
- testflight-android
- production
submit:
type: boolean
description: Submit the build to Google Play (disable to only produce the APK artifact)
default: true
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
submit:
type: boolean
description: Submit the build to Google Play (disable to only produce the APK artifact)
default: true
runner:
type: string
description: Runner for the build job (defaults to Linux-x64-32core)
required: false
default: ''
outputs:
package-version:
description: Version from package.json
@@ -56,93 +69,92 @@ permissions:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit Android
runs-on: Linux-x64-32core
name: Build Android
runs-on: ${{ inputs.runner || 'Linux-x64-32core' }}
concurrency:
group: android-build
cancel-in-progress: false
outputs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
version-code: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
node-version-file: package.json
cache: pnpm
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: "temurin"
java-version: "17"
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
uses: ./.github/actions/compile-i18n
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
- name: ✏️ Write environment variables
id: env
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
uses: ./.github/actions/write-env
with:
env-token: ${{ secrets.ENV_TOKEN }}
sentry-dsn: ${{ secrets.SENTRY_DSN }}
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile $PROFILE
--local --output build.aab --non-interactive
uses: ./.github/actions/eas-local-build
with:
platform: android
profile: ${{ inputs.profile || 'testflight-android' }}
output: build.aab
bump-build-number: "true"
sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry-release: ${{ steps.env.outputs.release-version }}
sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
# Hands the built bundle off to the submit / universalApk jobs. Retention is
# deliberately short (1 day) since it's only an intra-run handoff artifact.
- name: 🚀 Upload AAB artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: android-aab-${{ github.run_id }}
retention-days: 1
if-no-files-found: error
path: build.aab
submit:
name: Submit to Google Play
runs-on: ubuntu-latest
needs: [build]
# Submit unless explicitly disabled; on events where inputs is empty this still submits.
if: ${{ inputs.submit != false }}
steps:
# eas submit reads app config from the repo, so we need a checkout.
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ⬇️ Download AAB artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-aab-${{ github.run_id }}
- name: 🚀 Submit to Google Play
env:
PROFILE: ${{ inputs.profile || 'testflight-android' }}
@@ -150,13 +162,49 @@ jobs:
- name: 🔔 Notify Slack of Play Store Submission
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
{"text": "Android ${{ inputs.profile || 'testflight-android' }} build submitted to Google Play!\n```Version Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"}
# Record the commit only after a successful submit, so a failed submit doesn't
# advance the "most recent testflight" marker.
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.profile == 'testflight-android' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight-android' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
# Runs in parallel with submit: the QA APK shouldn't be blocked by a Play submission failure.
universalApk:
name: Build universal APK
runs-on: ubuntu-latest
needs: [build]
outputs:
apk-artifact-name: build-${{ steps.timestamp.outputs.time }}.apk
steps:
- name: ⬇️ Download AAB artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: android-aab-${{ github.run_id }}
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
# like the build job so the toolchain is deterministic.
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: "temurin"
java-version: "17"
- name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
@@ -164,19 +212,24 @@ jobs:
version: "1.18.3"
- name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
keystore.jks
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > keystore.jks
- name: 📦 Build signed universal APK
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
bundletool build-apks \
--bundle=build.aab \
--output=universal.apks \
--mode=universal \
--ks=keystore.jks \
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
--ks-pass=pass:"$ANDROID_KEYSTORE_PASSWORD" \
--ks-key-alias="$ANDROID_KEY_ALIAS" \
--key-pass=pass:"$ANDROID_KEY_PASSWORD"
- name: 📋 Rename to .zip for extraction
run: mv universal.apks universal.zip
@@ -198,27 +251,13 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack of APK Artifact
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.get-build-info.outputs.BSKY_ANDROID_VERSION_CODE }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.profile == 'testflight-android' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.profile == 'testflight-android' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
{"text": "Android ${{ inputs.profile || 'testflight-android' }} APK is ready for testing!\n```Artifact: ${{ steps.upload-artifact.outputs.artifact-url }}\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.version-code }}```"}
# Releases are cut from tags named after the version (e.g. "1.124.0"), so when a production
# build is dispatched against such a tag we attach the APK to the matching release. This runs
@@ -226,7 +265,7 @@ jobs:
attachToRelease:
name: Attach APK to GitHub Release
runs-on: ubuntu-latest
needs: [build]
needs: [build, universalApk]
if: ${{ inputs.profile == 'production' && github.ref_type == 'tag' && github.repository == 'bluesky-social/social-app' }}
permissions:
contents: write
@@ -254,7 +293,7 @@ jobs:
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.build.outputs.apk-artifact-name }}
name: ${{ needs.universalApk.outputs.apk-artifact-name }}
- name: 🏷️ Rename APK for release
if: ${{ steps.release-check.outputs.exists == 'true' }}
@@ -265,6 +304,7 @@ jobs:
if: ${{ steps.release-check.outputs.exists == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
APK: Bluesky-${{ needs.build.outputs.package-version }}.apk
run: |
@@ -274,7 +314,7 @@ jobs:
- name: 🔔 Notify Slack of Release Attachment
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
+111 -91
View File
@@ -28,6 +28,11 @@ on:
type: string
description: TestFlight group to assign the build to after submitting ("none" to skip)
default: none
runner:
type: string
description: Runner for the build job (defaults to macos-26-xlarge)
required: false
default: ''
outputs:
package-version:
description: Version from package.json
@@ -66,8 +71,8 @@ permissions:
jobs:
build:
if: github.repository == 'bluesky-social/social-app'
name: Build and Submit iOS
runs-on: macos-26-xlarge
name: Build iOS
runs-on: ${{ inputs.runner || 'macos-26-xlarge' }}
concurrency:
group: ios-build
cancel-in-progress: false
@@ -75,38 +80,15 @@ jobs:
package-version: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
build-number: ${{ steps.ipa-build-number.outputs.build-number }}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
node-version-file: package.json
cache: pnpm
- name: 🪛 Setup jq
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
expo-token: ${{ secrets.EXPO_TOKEN }}
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
@@ -133,38 +115,29 @@ jobs:
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
uses: ./.github/actions/compile-i18n
# EXPO_PUBLIC_ENV is handled in eas.json
- name: ✏️ Write environment variables
id: env
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
uses: ./.github/actions/write-env
with:
env-token: ${{ secrets.ENV_TOKEN }}
sentry-dsn: ${{ secrets.SENTRY_DSN }}
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
- name: 🏗️ EAS Build
env:
PROFILE: ${{ inputs.profile || 'testflight' }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p ios
--profile $PROFILE
--local --output build.tar.gz --non-interactive
uses: ./.github/actions/eas-local-build
with:
platform: ios
profile: ${{ inputs.profile || 'testflight' }}
output: build.tar.gz
bump-build-number: "true"
sentry-auth-token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry-release: ${{ steps.env.outputs.release-version }}
sentry-dist: ${{ steps.env.outputs.bundle-identifier }}
- name: 📂 Extract build artifact
run: |
@@ -201,16 +174,6 @@ jobs:
exit 1
fi
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
@@ -220,6 +183,7 @@ jobs:
# number that actually lands in App Store Connect. `eas build:version:get` reads the
# remote counter, which a --local build does not advance, so it can be off by one —
# using it here would make distribute_only poll for a nonexistent build.
# PlistBuddy is macOS-only, which is why this stays in the build job.
- name: 🔢 Read build number from IPA
id: ipa-build-number
run: |
@@ -235,18 +199,98 @@ jobs:
echo "IPA build number: $build_number"
echo "build-number=$build_number" >> "$GITHUB_OUTPUT"
# Hand the IPA and dSYM off to the submit job. Retention is deliberately short since
# this artifact only exists to bridge the two jobs within a single run.
- name: 🚀 Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-build-${{ github.run_id }}
retention-days: 1
if-no-files-found: error
path: |
${{ env.BUILD_DIR }}/Bluesky.ipa
${{ env.BUILD_DIR }}/Bluesky.app.dSYM.zip
submit:
name: Submit iOS
# Submission and dSYM upload are I/O bound and don't need the xlarge builder.
runs-on: macos-26
needs: [build]
steps:
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# eas submit reads the app config from the repo
fetch-depth: 5
- name: 🔧 Setup Expo project
uses: ./.github/actions/setup-expo-project
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ⬇️ Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ios-build-${{ github.run_id }}
path: ios-build
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path ios-build/Bluesky.ipa
- name: 🪲 Upload dSYM to Sentry
env:
SENTRY_ORG: blueskyweb
SENTRY_PROJECT: app
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm sentry-cli debug-files upload ios-build/Bluesky.app.dSYM.zip --include-sources
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ needs.build.outputs.package-version }}\nBuild Number: ${{ needs.build.outputs.build-number }}```"}
# Record the commit only after a successful submit, so a failed submit doesn't advance
# the baseline used for the next testflight build's changelog.
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.profile == 'testflight' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
distribute:
name: Assign build to TestFlight group
# fastlane and jq ship preinstalled on the macOS runner image, and this step mostly idles
# polling Apple processing, so it runs on a normal-size runner.
runs-on: macos-26
needs: [build, submit]
# testFlightGroup defaults to 'none' on both workflow_call and dispatch; guard against the
# empty string too, since `!= 'none'` alone would be true for ''.
if: ${{ inputs.testFlightGroup && inputs.testFlightGroup != 'none' }}
steps:
# eas submit only uploads to App Store Connect; it can't assign a build to a
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
# already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group
if: ${{ inputs.testFlightGroup != 'none' }}
env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
APP_VERSION: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}
BUILD_NUMBER: ${{ steps.ipa-build-number.outputs.build-number }}
APP_VERSION: ${{ needs.build.outputs.package-version }}
BUILD_NUMBER: ${{ needs.build.outputs.build-number }}
run: |
# Ensure the API key material is removed even if fastlane exits non-zero
# (the step runs under `bash -e`, which would otherwise abort before cleanup).
@@ -271,27 +315,3 @@ jobs:
build_number:"$BUILD_NUMBER" \
groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "iOS production build for App Store submission is ready!\n```Artifact: Check TestFlight to know when it is available\nVersion Number: ${{ steps.get-build-info.outputs.PACKAGE_VERSION }}\nBuild Number: ${{ steps.ipa-build-number.outputs.build-number }}```"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.profile == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.profile == 'testflight' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
+60 -314
View File
@@ -86,7 +86,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
@@ -99,11 +99,7 @@ jobs:
previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation
errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
uses: ./.github/actions/compile-i18n
- name: Lint check
run: pnpm lint
@@ -128,35 +124,26 @@ jobs:
!steps.version.outputs.version-changed }}
uses: dcarbone/install-jq-action@4fcb5062d7ce9bc4382d1a352d19ba3ba2c317c1 # v4.0.1
# eas.json not used here, set EXPO_PUBLIC_ENV
- name: Env
env:
CHANNEL: ${{ inputs.channel || 'testflight' }}
GITHUB_SHA: ${{ github.sha }}
# eas.json not used here, so EXPO_PUBLIC_ENV must be written explicitly
- name: ✏️ Write environment variables
id: env
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
if: ${{ !steps.fingerprint.outputs.includes-changes && !steps.version.outputs.version-changed }}
uses: ./.github/actions/write-env
with:
env-token: ${{ secrets.ENV_TOKEN }}
sentry-dsn: ${{ secrets.SENTRY_DSN }}
bitdrift-api-key: ${{ secrets.BITDRIFT_API_KEY }}
gcp-project-id: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
google-services-token: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
expo-public-env: ${{ inputs.channel || 'testflight' }}
- name: 🏗️ Create Bundle
if: ${{ !steps.fingerprint.outputs.includes-changes &&
!steps.version.outputs.version-changed }}
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
SENTRY_RELEASE=${{ steps.env.outputs.release-version }}
SENTRY_DIST=${{ steps.env.outputs.bundle-identifier }}
pnpm export
- name: 📦 Package Bundle and 🚀 Deploy
@@ -182,305 +169,64 @@ jobs:
!steps.version.outputs.version-changed }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-26
concurrency:
group: ios-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app' }}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: "26.4"
- name: ☕️ Assert Cocoapods version
run: |
EXPECTED=1.17.0
ACTUAL=$(pod --version)
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "Expected Cocoapods $EXPECTED but runner has $ACTUAL."
echo "The version ships preinstalled with the macOS runner image: https://github.com/actions/runner-images/blob/main/images/macos/macos-26-Readme.md"
echo "If the runner image changed, update EXPECTED here or reinstall the pinned version."
exit 1
fi
- name: 💾 Cache Pods
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pods-cache
with:
path: ./ios/Pods
# We'll use the pnpm-lock.yaml for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the pnpm version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }}
- name: 🔤 Compile translations
run: pnpm intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
id: env
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p ios
--profile testflight
--local --output build.tar.gz --non-interactive
- name: 📂 Extract build artifact
run: |
if [ -f "build.tar.gz" ]; then
echo "Extracting build.tar.gz..."
rm -rf ios-build
mkdir -p ios-build
tar -xzf build.tar.gz -C ios-build
echo "Extraction completed successfully"
echo ""
echo "Top-level extracted files:"
find ios-build -maxdepth 3 -print
echo ""
echo "Searching for IPA..."
IPA_PATH="$(find ios-build -type f -name '*.ipa' -print -quit)"
if [ -z "$IPA_PATH" ]; then
echo "ERROR: No .ipa found anywhere under ios-build."
echo "Archive contents:"
tar -tzf build.tar.gz | sed -n '1,200p'
exit 1
fi
BUILD_DIR="$(dirname "$IPA_PATH")"
echo "Found IPA at: $IPA_PATH"
echo "Build dir: $BUILD_DIR"
echo ""
echo "Build dir contents:"
ls -la "$BUILD_DIR"
echo "BUILD_DIR=$BUILD_DIR" >> $GITHUB_ENV
else
echo "Archive file not found!"
exit 1
fi
- name: 🚀 Deploy
run: pnpm eas submit -p ios --non-interactive --path "$BUILD_DIR/Bluesky.ipa"
- name: 🪲 Upload dSYM to Sentry
run: >
SENTRY_ORG=blueskyweb
SENTRY_PROJECT=app
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
pnpm sentry-cli debug-files upload "$BUILD_DIR/Bluesky.app.dSYM.zip" --include-sources
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.channel == 'testflight' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
if: ${{ inputs.channel == 'testflight' }}
env:
GITHUB_SHA: ${{ github.sha }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
testFlightGroup: none
# OTA rebuilds don't need the xlarge builder used for releases
runner: macos-26
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
# so this workflow never hands the reusable workflow the entire repo secret store.
secrets:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
buildIfNecessaryAndroid:
name: Build and Submit Android
runs-on: ubuntu-latest
concurrency:
group: android-build
cancel-in-progress: false
needs: [bundleDeploy]
# Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be
# available here
if: ${{ inputs.channel != 'production' &&
needs.bundleDeploy.outputs.changes-detected && github.repository ==
'bluesky-social/social-app'}}
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 5
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 9.0.0
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: "temurin"
java-version: "17"
- name: ⚙️ Install dependencies
run: pnpm install --frozen-lockfile
- name: 🔤 Compile translations
run: pnpm intl:build
# EXPO_PUBLIC_ENV is handled in eas.json
- name: Env
id: env
run: |
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "EXPO_PUBLIC_BUNDLE_DATE=$(date -u +"%y%m%d%H")" >> .env
echo "EXPO_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env
echo "EXPO_PUBLIC_BITDRIFT_API_KEY=${{ secrets.BITDRIFT_API_KEY }}" >> .env
echo "EXPO_PUBLIC_GCP_PROJECT_ID=${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}" >> .env
echo "$json" > google-services.json
- name: 🏗️ EAS Build
run: >
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
pnpm use-build-number-with-bump
pnpm eas build -p android
--profile testflight-android
--local --output build.aab --non-interactive
- name: 📚 Get version from package.json
id: get-build-info
run: bash scripts/setGitHubOutput.sh
- name: 🚀 Submit to Google Play
run: pnpm eas submit -p android --profile testflight-android --non-interactive --path
build.aab
- name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
with:
version: "1.18.3"
- name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
keystore.jks
- name: 📦 Build signed universal APK
run: |
bundletool build-apks \
--bundle=build.aab \
--output=universal.apks \
--mode=universal \
--ks=keystore.jks \
--ks-pass=pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
--ks-key-alias=${{ secrets.ANDROID_KEY_ALIAS }} \
--key-pass=pass:${{ secrets.ANDROID_KEY_PASSWORD }}
- name: 📋 Rename to .zip for extraction
run: mv universal.apks universal.zip
- name: 📦 Extract universal APK
run: unzip -p universal.zip universal.apk > build.apk
- name: ⏰ Get a timestamp
id: timestamp
run: echo "time=$(date -u +'%m-%d-%H-%M-%S')" >> "$GITHUB_OUTPUT"
- name: 🚀 Upload Artifact
id: upload-artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
retention-days: 30
compression-level: 0
name: build-${{ steps.timestamp.outputs.time }}.apk
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
payload-templated: true
payload: |
{"text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}"}
- name: ⬇️ Restore Cache
id: get-base-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
with:
path: most-recent-testflight-commit.txt
key: most-recent-testflight-commit
- name: ✏️ Write commit hash to cache
env:
GITHUB_SHA: ${{ github.sha }}
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
'bluesky-social/social-app' }}
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
# That job is skipped here (it needs a production tag build), but GitHub statically
# validates the reusable-workflow permission ceiling, so the caller must grant it.
permissions:
contents: write
uses: ./.github/workflows/build-submit-android.yml
with:
profile: testflight-android
runner: ubuntu-latest
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
# so this workflow never hands the reusable workflow the entire repo secret store.
secrets:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+2 -2
View File
@@ -59,13 +59,13 @@ jobs:
fetch-depth: 1
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: Claude
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
with:
use_bedrock: 'true'
additional_permissions: |
+2 -2
View File
@@ -45,13 +45,13 @@ jobs:
fetch-depth: 1
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
with:
role-to-assume: ${{ secrets.AWS_BEDROCK_REVIEW_ROLE_ARN }}
aws-region: us-east-2
- name: Claude review
uses: anthropics/claude-code-action@f87768c6d25f92ae6efa7175e223ef77d4cbf97f # v1.0.166
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
with:
use_bedrock: 'true'
additional_permissions: |
+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
exit $rc
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
@@ -91,7 +91,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
+2 -2
View File
@@ -144,7 +144,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
@@ -174,7 +174,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
+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}}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
+1 -1
View File
@@ -132,7 +132,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
@@ -73,7 +73,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
@@ -168,7 +168,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
cache: pnpm
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: package.json
+16
View File
@@ -44,6 +44,12 @@ appId: xyz.blueskyweb.app
id: "e2eRefreshHome"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing. Tapping replyBtn right
# after the previous publish can race the closing composer on Android.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply text only"
- tapOn:
id: "composerPublishBtn"
@@ -51,6 +57,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply with an image"
- tapOn:
id: "openMediaBtn"
@@ -63,6 +74,11 @@ appId: xyz.blueskyweb.app
id: "composeFAB"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply with a https://example.com link card"
- tapOn:
id: "composerPublishBtn"
+81 -44
View File
@@ -29,51 +29,88 @@ appId: xyz.blueskyweb.app
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# Set alice-favs first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "Following"
# Reordering feeds is driven by a drag on the feed-drag-handle. Maestro cannot
# activate the RNGH Pan gesture from a synthetic swipe on Android (proven
# twice - coordinate swipes never register the pan), so the reorder
# verification below runs on iOS only. If Android drag coverage is needed,
# revisit with the SavedFeedsA11y move buttons rather than a swipe.
- runFlow:
when:
platform: iOS
commands:
# Set alice-favs first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "alice-favs"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "Following"
# Set following first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# Set following first
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- swipe:
label: "Drag feed down"
from:
id: "feed-drag-handle"
direction: "DOWN"
duration: 1000
- assertVisible:
id: "saveChangesBtn"
enabled: true
- tapOn:
label: "Save button"
id: "saveChangesBtn"
- tapOn: "Go back"
- assertVisible:
id: "homeScreenFeedTabs-selector-0"
text: "Following"
- assertVisible:
id: "homeScreenFeedTabs-selector-1"
text: "alice-favs"
# On Android, the reorder path above is skipped. Smoke-test that the feeds
# edit screen opens and the pinned feeds render, then return to a valid state.
- runFlow:
when:
platform: Android
commands:
- tapOn: "Open drawer menu"
- tapOn:
id: "menuItemButton-Feeds"
- tapOn:
id: "editFeedsBtn"
- assertVisible: "Following"
- assertVisible: "alice-favs"
# Two back presses to reach Home: the first pops the saved-feeds editor
# back to the Feeds screen, the second pops Feeds back to Home. On iOS the
# equivalent path saves changes first (saveChangesBtn calls
# navigation.goBack), so a single "Go back" there already lands on Home.
# This Android smoke branch never saves, so it needs the extra pop to
# leave the screen state on Home, which the shared steps below expect.
- tapOn: "Go back"
- tapOn: "Go back"
# Remove following
- tapOn: "Open drawer menu"
@@ -15,6 +15,24 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eStartOnboarding"
- tapOn: "Open avatar creator"
# The avatar-creator bottom sheet (Dialog.Inner, non-scrollable) opens only
# half-expanded on the short E2E emulator (720x1600), a ~220px sliver with the
# emoji grid below the fold. It is NOT a scroll view, so scrollUntilVisible's
# swipe grabs the sheet's own drag gesture and flings it closed. Instead, drag
# the sheet upward to expand it to full height, which brings the picker into
# view. iOS opens the sheet fully already, so this is Android-only.
- runFlow:
when:
platform: Android
commands:
- swipe:
label: "Drag the bottom sheet up to expand it"
start: "50%, 90%"
end: "50%, 20%"
duration: 600
- extendedWaitUntil:
visible: "Select an emoji"
timeout: 10000
- tapOn: "Select the zap emoji as your avatar"
- tapOn:
label: "Tap on yellow"
@@ -22,6 +40,20 @@ appId: xyz.blueskyweb.app
- tapOn: "Done"
- waitForAnimationToEnd
- tapOn: "Select an avatar"
# Reopening the creator sheet lands on the same half-expanded sliver on
# Android, so expand it again before reaching for the emoji grid. No-op on iOS.
- runFlow:
when:
platform: Android
commands:
- swipe:
label: "Drag the bottom sheet up to expand it"
start: "50%, 90%"
end: "50%, 20%"
duration: 600
- extendedWaitUntil:
visible: "Select an emoji"
timeout: 10000
- tapOn: "Select the atom emoji as your avatar"
- tapOn: "Done"
- waitForAnimationToEnd
+22 -7
View File
@@ -16,13 +16,28 @@ appId: xyz.blueskyweb.app
id: "e2eStartOnboarding"
- tapOn: "Select an avatar"
- waitForAnimationToEnd
- assertVisible: "Photos"
- assertVisible: "Collections"
- tapOn:
point: "50%,22%"
- waitForAnimationToEnd
- tapOn: "Done"
- waitForAnimationToEnd
- runFlow:
when:
platform: iOS
commands:
- assertVisible: "Photos"
- assertVisible: "Collections"
- tapOn:
point: "50%,22%"
- waitForAnimationToEnd
- tapOn: "Done"
- waitForAnimationToEnd
- runFlow:
when:
platform: Android
commands:
# The system photo picker opened here shows MediaStore photos, which
# the e2e run doesn't seed (media is seeded into app-scoped storage for
# the composer's mocked picker instead). With no photo to pick, dismiss
# the picker and continue - onContinue falls back to the generated
# placeholder avatar, and nothing later in the flow depends on the image.
- back
- waitForAnimationToEnd
- tapOn:
id: "onboardingContinue"
- assertVisible: "What are your interests?"
+5 -2
View File
@@ -45,7 +45,7 @@ appId: xyz.blueskyweb.app
id: "editProfileSaveBtn"
- assertNotVisible:
id: "editProfileModal"
- assertVisible: "Alicia"
- assertVisible: ".*Alicia.*"
- assertVisible: "One cool hacker"
# Remove display name and description via the edit profile modal
@@ -64,7 +64,10 @@ appId: xyz.blueskyweb.app
id: "editProfileSaveBtn"
- assertNotVisible:
id: "editProfileModal"
- assertVisible: "alice.test"
# The display-name node renders the handle as a Text with a nested badge View
# once the display name is cleared, so the a11y text is not the bare handle
# string on Android. Match it as a substring instead.
- assertVisible: ".*alice\\.test.*"
- assertNotVisible: "One cool hacker"
# Set avi and banner via the edit profile modal
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
text: "Send report to Dev-env Moderation"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
+4 -2
View File
@@ -22,5 +22,7 @@ appId: xyz.blueskyweb.app
text: "Send report to Dev-env Moderation"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
@@ -39,5 +39,7 @@ appId: xyz.blueskyweb.app
text: Your report will be sent to Dev-env Moderation.*
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
@@ -29,5 +29,7 @@ appId: xyz.blueskyweb.app
- hideKeyboard
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
- extendedWaitUntil:
notVisible:
id: "report:dialog"
timeout: 20000
+85 -12
View File
@@ -20,6 +20,12 @@ appId: xyz.blueskyweb.app
- inputText: "Test thread"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close and the home feed to settle before signing
# out. Without a settle guard the next action can race the closing composer.
- extendedWaitUntil:
visible:
id: "composeFAB"
timeout: 10000
# Login, reply to the thread, and log out
- tapOn:
@@ -31,9 +37,19 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn"
- tapOn:
id: "replyBtn"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 1"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to close before signing out.
- extendedWaitUntil:
visible:
id: "composeFAB"
timeout: 10000
# Login, confirm notification exists, mute thread, and log out
- tapOn:
@@ -45,10 +61,8 @@ appId: xyz.blueskyweb.app
id: "viewHeaderHomeFeedPrefsBtn"
- tapOn:
id: "bottomBarNotificationsBtn"
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
id: "feedItem-by-bob.test"
- assertVisible: ".*Reply 1.*"
- tapOn: ".*Reply 1.*"
- tapOn:
id: "postDropdownBtn"
childOf:
@@ -67,16 +81,78 @@ appId: xyz.blueskyweb.app
id: "bottomBarProfileBtn"
- tapOn:
id: "profilePager-selector-1"
- tapOn:
id: "replyBtn"
# Both replies target the thread root ("Test thread" by alice), which sits at
# the top of bob's Replies tab. That tab renders each post in the thread with
# its own replyBtn, so scope the tap to the root post's card
# (feedItem-by-alice.test) rather than relying on which replyBtn Maestro picks
# first. This keeps both reply taps deterministic regardless of list order or
# how many posts have rendered.
#
# Even with the close-gating below, the replyBtn tap can land on a recycled list
# row while the author feed re-renders after a publish, and be swallowed so the
# composer never opens. Wrapping the tap + open-wait in retry makes opening the
# composer idempotent: a swallowed tap just re-taps until the publish button
# appears. A first-try success does not retry.
- retry:
maxRetries: 3
commands:
- tapOn:
id: "replyBtn"
childOf:
id: "feedItem-by-alice.test"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 2"
- tapOn:
id: "composerPublishBtn"
- tapOn:
id: "replyBtn"
# Wait for the composer to actually close before opening it again. replyBtn
# stays in the accessibility tree behind the open composer sheet, so waiting on
# its visibility returns immediately and does not gate on the close animation or
# the author-feed re-render that follows a post - the next replyBtn tap then
# fires mid-transition and is swallowed, so the composer never opens. Gate on
# the publish button disappearing (the composer is gone), then confirm the
# reply button underneath is back and let animations settle.
- extendedWaitUntil:
notVisible:
id: "composerPublishBtn"
timeout: 15000
- extendedWaitUntil:
visible:
id: "replyBtn"
timeout: 10000
- waitForAnimationToEnd
# As with Reply 2: even after gating on the composer close, this tap can hit a
# recycled row during the post-publish feed re-render and be swallowed, so wrap
# the open in retry to make it idempotent.
- retry:
maxRetries: 3
commands:
- tapOn:
id: "replyBtn"
childOf:
id: "feedItem-by-alice.test"
# Wait for the composer to fully open before typing.
- extendedWaitUntil:
visible:
id: "composerPublishBtn"
timeout: 10000
- inputText: "Reply 3"
- tapOn:
id: "composerPublishBtn"
# Wait for the composer to actually close before signing out. As above,
# replyBtn stays visible behind the sheet, so gate on the publish button
# disappearing first, then confirm the reply button underneath has returned.
- extendedWaitUntil:
notVisible:
id: "composerPublishBtn"
timeout: 15000
- extendedWaitUntil:
visible:
id: "replyBtn"
timeout: 10000
# Login, confirm notifications dont exist, unmute the thread, ~~confirm notifications exist~~
# Mute thread behaviour no longer change old notifications after muting/unmuting a thread -sfn
@@ -92,10 +168,7 @@ appId: xyz.blueskyweb.app
- assertVisible: ".*Reply 1.*"
- assertNotVisible: ".*Reply 2.*"
- assertNotVisible: ".*Reply 3.*"
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
id: "feedItem-by-bob.test"
- tapOn: ".*Reply 1.*"
- tapOn:
id: "postDropdownBtn"
childOf:
+17 -13
View File
@@ -9,23 +9,27 @@ appId: xyz.blueskyweb.app
when:
platform: iOS
commands:
- openLink: "exp+bluesky://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
- runFlow:
when:
visible: 'Open in "Bluesky"'
commands:
- tapOn: Open
- extendedWaitUntil:
visible: "http://localhost:8081"
timeout: 60000
- tapOn: "http://localhost:8081"
- runFlow:
when:
platform: Android
commands:
- tapOn: 'http://localhost:8081'
- runFlow:
label: "Dismiss Expo dev menu"
when:
visible: "Continue"
commands:
- back
- extendedWaitUntil:
visible: "http://10.0.2.2:8081"
timeout: 60000
- tapOn: "http://10.0.2.2:8081"
- extendedWaitUntil:
visible: "Continue"
timeout: 180000
- tapOn: "Continue"
- back
- extendedWaitUntil:
visible:
id: e2eProxyHeaderInput
timeout: 180000
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
+1
View File
@@ -328,6 +328,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/interests", server.WebGenericNoindex)
e.GET("/settings/about", server.WebGenericNoindex)
e.GET("/settings/notifications", server.WebGenericNoindex)
e.GET("/settings/notifications/activity", server.WebGenericNoindex)
e.GET("/sys/debug", server.WebGenericNoindex)
e.GET("/sys/debug-mod", server.WebGenericNoindex)
e.GET("/sys/log", server.WebGenericNoindex)
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts",
"start:external": "NODE_ENV=development PGPORT=5433 PGHOST=localhost PGUSER=pg PGPASSWORD=password PGDATABASE=postgres DB_POSTGRES_URL=postgresql://pg:password@127.0.0.1:5433/postgres REDIS_HOST=127.0.0.1:6380 node ./mock-server.ts"
},
"dependencies": {
"@atproto/api": "^0.20.22",
+43
View File
@@ -29,6 +29,49 @@ adb reverse tcp:3000 tcp:3000
- In a second tab, run `pnpm e2e:build`
- In a third tab, run `pnpm e2e:run __e2e__`
## Nightly Maestro CI
The `Nightly Maestro E2E` GitHub Actions workflow runs every day at 04:00 UTC
and can also be started from the Actions tab with **Run workflow**. It runs iOS
and Android concurrently, but each platform runs all of `__e2e__/config.yml`
sequentially on one explicitly selected simulator or emulator. The flows share a
stateful mock-server manager, so the suite must not be sharded.
The jobs run Maestro CLI 2.6.1 locally on GitHub Actions; Maestro Cloud is not
used. iOS runs on `macos-26-xlarge` with Xcode 26.4. Android runs on
`Linux-x64-32core`. Both use Java 17 and the Node and pnpm versions declared in
`package.json`. The iOS job selects an iPhone 17 simulator running iOS 26.5;
Android directly provisions and boots a Pixel 6 AVD with the API 35 Google APIs
x86_64 image using the Android SDK command-line tools.
Both development clients use the `e2e` EAS profile and the same reusable local
EAS build action as the release build workflows; the resulting simulator app
and APK are installed directly on the selected devices.
The mock-server manager listens on host port 1986 and creates test services on
port 3000. Metro listens on 8081. Android reverses ports 3000 and 8081 into the
emulator; port 1986 remains host-side because Maestro JavaScript calls it from
the runner. Android uses the existing Docker Compose PostgreSQL 14 and Redis 7
services on ports 5433 and 6380. GitHub-hosted macOS cannot run nested Docker
virtualization, so iOS provisions ephemeral native PostgreSQL 14.x and Redis
7.4.7 on those same ports and starts `pnpm --dir dev-env start:external`.
Each platform uploads a `nightly-e2e-<platform>-<run-id>` artifact for 14 days.
It contains JUnit at `report.xml`, Maestro screenshots, videos, command metadata
and `maestro.log` under `maestro/`, plus Metro, native build, mock-server, service,
dependency, and translation logs. The workflow always uploads what was captured,
including when setup or the native build fails before Maestro starts.
Add the repository secret `E2E_FAILURES_SLACK_WEBHOOK` before enabling the
schedule. The aggregation job runs even when either platform fails and posts one
detailed Slack notification containing both job statuses, failed flow details or
the failed setup phase, the commit and workflow links, and links to both artifact
sets. Successful runs do not post to Slack.
Before relying on the schedule, manually dispatch the workflow and verify both
platforms against live Metro and `dev-env`, Android localhost routing, artifact
uploads on success and failure, one Slack message for a forced failure, and no
Slack message for an all-green run.
## Using Flashlight for Performance Testing
1. Make sure Maestro is installed (optional: only for automated testing) by following the instructions above
2. Install Flashlight by following [these instructions](https://docs.flashlight.dev/)
+11
View File
@@ -21,6 +21,17 @@
"EXPO_PUBLIC_ENV": "production"
}
},
"e2e": {
"extends": "development",
"android": {
"buildType": "apk"
},
"env": {
"EXPO_PUBLIC_ENV": "e2e",
"NODE_ENV": "test",
"RN_SRC_EXT": "e2e.ts,e2e.tsx"
}
},
"preview": {
"extends": "base",
"distribution": "internal",
@@ -146,6 +146,7 @@ function BottomSheetNativeComponentInner({
const insets = useSafeAreaInsets()
const cornerRadius = rest.cornerRadius ?? 0
const {height: screenHeight} = useWindowDimensions()
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
// sigh... on older Android versions, screenHeight does not include safe area insets
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
@@ -182,7 +183,7 @@ function BottomSheetNativeComponentInner({
]}>
<View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
style={isHeightConstrained ? {flex: 1} : undefined}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
-44
View File
@@ -244,14 +244,6 @@
"count": 3
}
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"typescript/no-explicit-any": {
"count": 2
},
"typescript/no-floating-promises": {
"count": 1
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"typescript/no-floating-promises": {
"count": 2
@@ -290,19 +282,6 @@
"count": 1
}
},
"src/components/PostControls/ShareMenu/ShareMenuItems.tsx": {
"typescript/no-floating-promises": {
"count": 3
},
"typescript/no-misused-promises": {
"count": 1
}
},
"src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx": {
"typescript/no-floating-promises": {
"count": 3
}
},
"src/components/PostControls/ShareMenu/index.tsx": {
"typescript/no-floating-promises": {
"count": 1
@@ -1055,11 +1034,6 @@
"count": 1
}
},
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
"typescript/no-misused-promises": {
"count": 5
}
},
"src/screens/ProfileList/FeedSection.tsx": {
"typescript/no-floating-promises": {
"count": 1
@@ -1406,11 +1380,6 @@
"count": 2
}
},
"src/state/queries/feed.ts": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/state/queries/handle.ts": {
"typescript/no-floating-promises": {
"count": 1
@@ -1617,14 +1586,6 @@
"count": 1
}
},
"src/view/com/composer/text-input/TextInput.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 1
}
},
"src/view/com/composer/text-input/TextInput.web.tsx": {
"typescript/no-misused-promises": {
"count": 1
@@ -1764,11 +1725,6 @@
"count": 2
}
},
"src/view/com/posts/PostFeed.tsx": {
"typescript/no-explicit-any": {
"count": 1
}
},
"src/view/com/posts/PostFeedErrorMessage.tsx": {
"typescript/no-explicit-any": {
"count": 1
+9 -8
View File
@@ -63,9 +63,9 @@
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web",
"typecheck:ios": "tsgo --project ./tsconfig.check.ios.json",
"typecheck:android": "tsgo --project ./tsconfig.check.android.json",
"typecheck:web": "tsgo --project ./tsconfig.check.web.json",
"typecheck:ios": "tsc --project ./tsconfig.check.ios.json",
"typecheck:android": "tsc --project ./tsconfig.check.android.json",
"typecheck:web": "tsc --project ./tsconfig.check.web.json",
"e2e:mock-server": "cd dev-env && pnpm start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
@@ -96,12 +96,12 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.28",
"@atproto/api": "0.20.31",
"@atproto/common-web": "0.5.6",
"@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
"@bsky.app/alf": "^0.1.15",
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
"@bsky.app/expo-guess-language": "^0.2.8",
"@bsky.app/expo-image-crop-tool": "^0.5.1",
@@ -161,6 +161,7 @@
"expo": "54.0.34",
"expo-age-range": "0.2.18",
"expo-application": "~7.0.8",
"expo-asset": "~12.0.13",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
@@ -273,7 +274,7 @@
"@types/psl": "1.1.1",
"@types/react": "^19.1.17",
"@types/react-dom": "^19.1.11",
"@typescript/native-preview": "^7.0.0-dev.20260428.1",
"@typescript/native": "npm:typescript@^7.0.2",
"babel-jest": "^29.7.0",
"babel-plugin-module-resolver": "^5.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
@@ -292,13 +293,13 @@
"jest-junit": "^16.0.0",
"lint-staged": "^17.0.8",
"oxlint": "^1.73.0",
"oxlint-tsgolint": "^0.24.0",
"oxlint-tsgolint": "^7.0.2001",
"prettier": "^3.8.3",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"svgo": "^4.0.2",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^6.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"webpack-bundle-analyzer": "^4.10.1"
},
"jest": {
+244 -103
View File
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.28
version: 0.20.28
specifier: 0.20.31
version: 0.20.31
'@atproto/common-web':
specifier: 0.5.6
version: 0.5.6
@@ -257,8 +257,8 @@ importers:
specifier: ^6.0.2
version: 6.0.4
'@bsky.app/alf':
specifier: ^0.1.14
version: 0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
specifier: ^0.1.15
version: 0.1.15(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/expo-dynamic-app-icon':
specifier: ^1.8.5
version: 1.8.5(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -330,10 +330,10 @@ importers:
version: 9.2.7
'@lingui/core':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
'@lingui/react':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))(react@19.1.0)
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))
@@ -436,6 +436,9 @@ importers:
expo-application:
specifier: ~7.0.8
version: 7.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
expo-asset:
specifier: ~12.0.13
version: 12.0.13(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-blur:
specifier: ~15.0.8
version: 15.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -727,10 +730,10 @@ importers:
version: 4.14.2
'@lingui/babel-plugin-lingui-macro':
specifier: ^5.9.2
version: 5.9.5(typescript@6.0.3)
version: 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/cli':
specifier: ^5.9.2
version: 5.9.5(typescript@6.0.3)
version: 5.9.5(@typescript/typescript6@6.0.2)
'@pmmmwh/react-refresh-webpack-plugin':
specifier: ^0.5.15
version: 0.5.17(react-refresh@0.14.2)(type-fest@1.4.0)(webpack-dev-server@4.15.2(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14))
@@ -767,9 +770,9 @@ importers:
'@types/react-dom':
specifier: ^19.1.11
version: 19.1.11(@types/react@19.1.17)
'@typescript/native-preview':
specifier: ^7.0.0-dev.20260428.1
version: 7.0.0-dev.20260512.1
'@typescript/native':
specifier: npm:typescript@^7.0.2
version: typescript@7.0.2
babel-jest:
specifier: ^29.7.0
version: 29.7.0(@babel/core@7.29.0)
@@ -826,10 +829,10 @@ importers:
version: runtime:24.18.0
oxlint:
specifier: ^1.73.0
version: 1.73.0(oxlint-tsgolint@0.24.0)
version: 1.73.0(oxlint-tsgolint@7.0.2001)
oxlint-tsgolint:
specifier: ^0.24.0
version: 0.24.0
specifier: ^7.0.2001
version: 7.0.2001
prettier:
specifier: ^3.8.3
version: 3.8.3
@@ -846,8 +849,8 @@ importers:
specifier: ^1.0.4
version: 1.0.4
typescript:
specifier: ^6.0.2
version: 6.0.3
specifier: npm:@typescript/typescript6@^6.0.2
version: '@typescript/typescript6@6.0.2'
webpack-bundle-analyzer:
specifier: ^4.10.1
version: 4.10.2
@@ -862,8 +865,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.28':
resolution: {integrity: sha512-/Rvk8zt9mtRi9tlMD2Qg+NG2lMj3B0HDjmfswR5724pH7GsOEMDDHwleVlmOBSrgACIyVSa5tdCDJ+R+SEhwww==}
'@atproto/api@0.20.31':
resolution: {integrity: sha512-TovCQLQv5ti1jqh8UH6jJ0EFuWRjGdUtFyFR5xYC/IkIulwHDuyrVaXdCv7VLWiHftp92DevtZnFRm+BZsZZdw==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.6':
@@ -1603,8 +1606,8 @@ packages:
'@braintree/sanitize-url@6.0.4':
resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==}
'@bsky.app/alf@0.1.14':
resolution: {integrity: sha512-c/KK6avyjEnYzhXDsN0rQaTFSbi8BPg4sJCW8aUk8AtKsO34GTaY6FU1adQ2i8xVfe5UIRu9FJjnBoGpuXj21A==}
'@bsky.app/alf@0.1.15':
resolution: {integrity: sha512-e6blt+oZ2klv+Cp7u11FeZckoILdXOtUZ11ATERgjZgb4zX3y7rt4e2fI03JkJO31wTvXHG8A1LnM96Az+ndJg==}
peerDependencies:
react: '*'
react-native: '*'
@@ -2242,33 +2245,33 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@oxlint-tsgolint/darwin-arm64@0.24.0':
resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==}
'@oxlint-tsgolint/darwin-arm64@7.0.2001':
resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.24.0':
resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==}
'@oxlint-tsgolint/darwin-x64@7.0.2001':
resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.24.0':
resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==}
'@oxlint-tsgolint/linux-arm64@7.0.2001':
resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.24.0':
resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==}
'@oxlint-tsgolint/linux-x64@7.0.2001':
resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.24.0':
resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==}
'@oxlint-tsgolint/win32-arm64@7.0.2001':
resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.24.0':
resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==}
'@oxlint-tsgolint/win32-x64@7.0.2001':
resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==}
cpu: [x64]
os: [win32]
@@ -3715,51 +3718,128 @@ packages:
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-l9AJi/TIVMPx5R1c7fxZCSA7eUaHeA0C9Mxdxx/oQJo1K/GtbI3mzYe/SiKNltko1KSdKUmWVhPwxTOS289REg==}
'@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'}
cpu: [ppc64]
os: [aix]
'@typescript/typescript-darwin-arm64@7.0.2':
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-oABZLQrfB8JN2Ct2CiLK5PyE28Em3sIJlZsAMD45/A2ymtIaa5826dwv8vapE5Wjp54ao0LXxCSuKFm1A8zzCQ==}
'@typescript/typescript-darwin-x64@7.0.2':
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-xvbwzpTe+5N6bnBI/t9n4zsGzXxz3V6rVbvDUoJmRLfav5fz+ck0QDkGQGUPrQEEIp0KEzQvx7c+AEZnzdvTQA==}
'@typescript/typescript-freebsd-arm64@7.0.2':
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [freebsd]
'@typescript/typescript-freebsd-x64@7.0.2':
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [freebsd]
'@typescript/typescript-linux-arm64@7.0.2':
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-0Hs1Gqa/t9cthoPdqHud1pFGUr9DgJivBTjwquTUh8jt/6PI2bQxoMNZLiN/bhqeDFDTzdxoMBfCaytsTMcXqw==}
'@typescript/typescript-linux-arm@7.0.2':
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-qr5h6FPo74bN/U+EwRuayBhUbxaji8xzFbIbhMOA2oYSc/qozp5ia2g1+9xGw67MXxPPw/IPT+UGvrNK7K1NeQ==}
'@typescript/typescript-linux-loong64@7.0.2':
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
engines: {node: '>=16.20.0'}
cpu: [loong64]
os: [linux]
'@typescript/typescript-linux-mips64el@7.0.2':
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
engines: {node: '>=16.20.0'}
cpu: [mips64el]
os: [linux]
'@typescript/typescript-linux-ppc64@7.0.2':
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
engines: {node: '>=16.20.0'}
cpu: [ppc64]
os: [linux]
'@typescript/typescript-linux-riscv64@7.0.2':
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
engines: {node: '>=16.20.0'}
cpu: [riscv64]
os: [linux]
'@typescript/typescript-linux-s390x@7.0.2':
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
engines: {node: '>=16.20.0'}
cpu: [s390x]
os: [linux]
'@typescript/typescript-linux-x64@7.0.2':
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-meNWxhNEfaqos2U0JXvfxWvy4JWrKE9fZepCndDZi+t04X+AIiLYp5s6crWnKP67nzVAzMNgTAc8mu8CnGM+/A==}
'@typescript/typescript-netbsd-arm64@7.0.2':
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [netbsd]
'@typescript/typescript-netbsd-x64@7.0.2':
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [netbsd]
'@typescript/typescript-openbsd-arm64@7.0.2':
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [openbsd]
'@typescript/typescript-openbsd-x64@7.0.2':
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [openbsd]
'@typescript/typescript-sunos-x64@7.0.2':
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [sunos]
'@typescript/typescript-win32-arm64@7.0.2':
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-Hp6vBnxJSKEEAVWgIoWMmfqkZXCdkhm6XTivrwgRzBwWfiTVe2ZyZ7byWegIKeNnBbffg/K2KvoM8JAHl059GQ==}
'@typescript/typescript-win32-x64@7.0.2':
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260512.1':
resolution: {integrity: sha512-KIzYPGuxZnyiiYkYrozDT94Af2nwbdLXoY1cgGY66RRa9HSEw13RH9WHg8wA8fZhT4wYzF5uF7WY3hz0QhaxGg==}
engines: {node: '>=16.20.0'}
'@typescript/typescript6@6.0.2':
resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==}
hasBin: true
'@ungap/structured-clone@1.3.1':
@@ -7060,8 +7140,8 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
oxlint-tsgolint@0.24.0:
resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==}
oxlint-tsgolint@7.0.2001:
resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==}
hasBin: true
oxlint@1.73.0:
@@ -8655,6 +8735,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
typescript@7.0.2:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'}
hasBin: true
ua-parser-js@0.7.41:
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
hasBin: true
@@ -9123,7 +9208,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.28':
'@atproto/api@0.20.31':
dependencies:
'@atproto/common-web': 0.5.6
'@atproto/lexicon': 0.7.7
@@ -10069,7 +10154,7 @@ snapshots:
'@braintree/sanitize-url@6.0.4': {}
'@bsky.app/alf@0.1.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
'@bsky.app/alf@0.1.15(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
@@ -10902,19 +10987,19 @@ snapshots:
'@lingui/babel-plugin-extract-messages@5.9.5': {}
'@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3)':
'@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2)':
dependencies:
'@babel/core': 7.29.0
'@babel/runtime': 7.29.2
'@babel/types': 7.29.0
'@lingui/conf': 5.9.5(typescript@6.0.3)
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
'@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
'@lingui/message-utils': 5.9.5
transitivePeerDependencies:
- supports-color
- typescript
'@lingui/cli@5.9.5(typescript@6.0.3)':
'@lingui/cli@5.9.5(@typescript/typescript6@6.0.2)':
dependencies:
'@babel/core': 7.29.0
'@babel/generator': 7.29.1
@@ -10922,10 +11007,10 @@ snapshots:
'@babel/runtime': 7.29.2
'@babel/types': 7.29.0
'@lingui/babel-plugin-extract-messages': 5.9.5
'@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3)
'@lingui/conf': 5.9.5(typescript@6.0.3)
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
'@lingui/format-po': 5.9.5(typescript@6.0.3)
'@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
'@lingui/format-po': 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/message-utils': 5.9.5
chokidar: 3.5.1
cli-table: 0.3.11
@@ -10948,26 +11033,26 @@ snapshots:
- supports-color
- typescript
'@lingui/conf@5.9.5(typescript@6.0.3)':
'@lingui/conf@5.9.5(@typescript/typescript6@6.0.2)':
dependencies:
'@babel/runtime': 7.29.2
cosmiconfig: 8.3.6(typescript@6.0.3)
cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2)
jest-validate: 29.7.0
jiti: 2.7.0
picocolors: 1.1.1
transitivePeerDependencies:
- typescript
'@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))':
'@lingui/core@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))':
dependencies:
'@babel/runtime': 7.29.2
'@lingui/message-utils': 5.9.5
optionalDependencies:
'@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3)
'@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/format-po@5.9.5(typescript@6.0.3)':
'@lingui/format-po@5.9.5(@typescript/typescript6@6.0.2)':
dependencies:
'@lingui/conf': 5.9.5(typescript@6.0.3)
'@lingui/conf': 5.9.5(@typescript/typescript6@6.0.2)
'@lingui/message-utils': 5.9.5
date-fns: 3.6.0
pofile: 1.1.4
@@ -10979,13 +11064,13 @@ snapshots:
'@messageformat/parser': 5.1.1
js-sha256: 0.10.1
'@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)':
'@lingui/react@5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.29.2
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))
'@lingui/core': 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(@typescript/typescript6@6.0.2))
react: 19.1.0
optionalDependencies:
'@lingui/babel-plugin-lingui-macro': 5.9.5(typescript@6.0.3)
'@lingui/babel-plugin-lingui-macro': 5.9.5(@typescript/typescript6@6.0.2)
'@messageformat/parser@5.1.1':
dependencies:
@@ -11003,22 +11088,22 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@oxlint-tsgolint/darwin-arm64@0.24.0':
'@oxlint-tsgolint/darwin-arm64@7.0.2001':
optional: true
'@oxlint-tsgolint/darwin-x64@0.24.0':
'@oxlint-tsgolint/darwin-x64@7.0.2001':
optional: true
'@oxlint-tsgolint/linux-arm64@0.24.0':
'@oxlint-tsgolint/linux-arm64@7.0.2001':
optional: true
'@oxlint-tsgolint/linux-x64@0.24.0':
'@oxlint-tsgolint/linux-x64@7.0.2001':
optional: true
'@oxlint-tsgolint/win32-arm64@0.24.0':
'@oxlint-tsgolint/win32-arm64@7.0.2001':
optional: true
'@oxlint-tsgolint/win32-x64@0.24.0':
'@oxlint-tsgolint/win32-x64@7.0.2001':
optional: true
'@oxlint/binding-android-arm-eabi@1.73.0':
@@ -12563,36 +12648,69 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1':
'@typescript/typescript-aix-ppc64@7.0.2':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1':
'@typescript/typescript-darwin-arm64@7.0.2':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1':
'@typescript/typescript-darwin-x64@7.0.2':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1':
'@typescript/typescript-freebsd-arm64@7.0.2':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1':
'@typescript/typescript-freebsd-x64@7.0.2':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1':
'@typescript/typescript-linux-arm64@7.0.2':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1':
'@typescript/typescript-linux-arm@7.0.2':
optional: true
'@typescript/native-preview@7.0.0-dev.20260512.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260512.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260512.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260512.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260512.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260512.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260512.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260512.1
'@typescript/typescript-linux-loong64@7.0.2':
optional: true
'@typescript/typescript-linux-mips64el@7.0.2':
optional: true
'@typescript/typescript-linux-ppc64@7.0.2':
optional: true
'@typescript/typescript-linux-riscv64@7.0.2':
optional: true
'@typescript/typescript-linux-s390x@7.0.2':
optional: true
'@typescript/typescript-linux-x64@7.0.2':
optional: true
'@typescript/typescript-netbsd-arm64@7.0.2':
optional: true
'@typescript/typescript-netbsd-x64@7.0.2':
optional: true
'@typescript/typescript-openbsd-arm64@7.0.2':
optional: true
'@typescript/typescript-openbsd-x64@7.0.2':
optional: true
'@typescript/typescript-sunos-x64@7.0.2':
optional: true
'@typescript/typescript-win32-arm64@7.0.2':
optional: true
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@typescript/typescript6@6.0.2':
dependencies:
'@typescript/old': typescript@6.0.3
'@ungap/structured-clone@1.3.1': {}
@@ -13434,14 +13552,14 @@ snapshots:
core-util-is@1.0.3: {}
cosmiconfig@8.3.6(typescript@6.0.3):
cosmiconfig@8.3.6(@typescript/typescript6@6.0.2):
dependencies:
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
path-type: 4.0.0
optionalDependencies:
typescript: 6.0.3
typescript: '@typescript/typescript6@6.0.2'
create-jest@29.7.0(@types/node@24.12.4):
dependencies:
@@ -16340,16 +16458,16 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
oxlint-tsgolint@0.24.0:
oxlint-tsgolint@7.0.2001:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.24.0
'@oxlint-tsgolint/darwin-x64': 0.24.0
'@oxlint-tsgolint/linux-arm64': 0.24.0
'@oxlint-tsgolint/linux-x64': 0.24.0
'@oxlint-tsgolint/win32-arm64': 0.24.0
'@oxlint-tsgolint/win32-x64': 0.24.0
'@oxlint-tsgolint/darwin-arm64': 7.0.2001
'@oxlint-tsgolint/darwin-x64': 7.0.2001
'@oxlint-tsgolint/linux-arm64': 7.0.2001
'@oxlint-tsgolint/linux-x64': 7.0.2001
'@oxlint-tsgolint/win32-arm64': 7.0.2001
'@oxlint-tsgolint/win32-x64': 7.0.2001
oxlint@1.73.0(oxlint-tsgolint@0.24.0):
oxlint@1.73.0(oxlint-tsgolint@7.0.2001):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.73.0
'@oxlint/binding-android-arm64': 1.73.0
@@ -16370,7 +16488,7 @@ snapshots:
'@oxlint/binding-win32-arm64-msvc': 1.73.0
'@oxlint/binding-win32-ia32-msvc': 1.73.0
'@oxlint/binding-win32-x64-msvc': 1.73.0
oxlint-tsgolint: 0.24.0
oxlint-tsgolint: 7.0.2001
p-limit@2.3.0:
dependencies:
@@ -18110,6 +18228,29 @@ snapshots:
typescript@6.0.3: {}
typescript@7.0.2:
optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2
'@typescript/typescript-darwin-arm64': 7.0.2
'@typescript/typescript-darwin-x64': 7.0.2
'@typescript/typescript-freebsd-arm64': 7.0.2
'@typescript/typescript-freebsd-x64': 7.0.2
'@typescript/typescript-linux-arm': 7.0.2
'@typescript/typescript-linux-arm64': 7.0.2
'@typescript/typescript-linux-loong64': 7.0.2
'@typescript/typescript-linux-mips64el': 7.0.2
'@typescript/typescript-linux-ppc64': 7.0.2
'@typescript/typescript-linux-riscv64': 7.0.2
'@typescript/typescript-linux-s390x': 7.0.2
'@typescript/typescript-linux-x64': 7.0.2
'@typescript/typescript-netbsd-arm64': 7.0.2
'@typescript/typescript-netbsd-x64': 7.0.2
'@typescript/typescript-openbsd-arm64': 7.0.2
'@typescript/typescript-openbsd-x64': 7.0.2
'@typescript/typescript-sunos-x64': 7.0.2
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
ua-parser-js@0.7.41: {}
ua-parser-js@1.0.41: {}
+8
View File
@@ -43,3 +43,11 @@ patchedDependencies:
minimumReleaseAgeExclude:
- '@atproto/*'
- '@bsky.app/*'
# todo: remove when old enough
- '@oxlint-tsgolint/darwin-arm64@7.0.2001'
- '@oxlint-tsgolint/darwin-x64@7.0.2001'
- '@oxlint-tsgolint/linux-arm64@7.0.2001'
- '@oxlint-tsgolint/linux-x64@7.0.2001'
- '@oxlint-tsgolint/win32-arm64@7.0.2001'
- '@oxlint-tsgolint/win32-x64@7.0.2001'
- oxlint-tsgolint@7.0.2001
+9
View File
@@ -117,6 +117,7 @@ import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from '#/screens/Settings/Settings'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
@@ -451,6 +452,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => NotificationSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
name="ActivityNotificationSettings"
getComponent={() => ActivityNotificationSettingsScreen}
options={{
title: title(msg`Activity notifications`),
requireAuth: true,
}}
/>
<Stack.Screen
name="ContentAndMediaSettings"
getComponent={() => ContentAndMediaSettingsScreen}
+1
View File
@@ -20,6 +20,7 @@ export enum Features {
PostThreadKnownLikersFetchEnable = 'post_thread:known_likers:fetch:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable',
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
AATest = 'aa-test',
}
+30 -12
View File
@@ -513,7 +513,7 @@ export type Events = {
| 'ProgressGuide'
location: 'Card' | 'Profile' | 'FollowAll'
recSource?: 'Search'
recId?: number | string
recId?: string
position: number
suggestedDid: string
category: string | null
@@ -526,7 +526,7 @@ export type Events = {
| 'ProfileHeader'
| 'Onboarding'
| 'SeeMoreSuggestedUsers'
recId?: number | string
recId?: string
position: number
suggestedDid: string
category: string | null
@@ -541,7 +541,7 @@ export type Events = {
| 'SeeMoreSuggestedUsers'
| 'ProgressGuide'
recSource?: 'Search'
recId?: number | string
recId?: string
position: number
suggestedDid: string
category: string | null
@@ -553,11 +553,11 @@ export type Events = {
| 'ProfileInterstitial'
| 'ProfileHeader'
| 'Onboarding'
recId?: number | string
recId?: string
}
'suggestedUser:dismiss': {
logContext: 'DiscoverInterstitial' | 'ProfileInterstitial' | 'ProfileHeader'
recId?: number | string
recId?: string
position: number
suggestedDid: string
}
@@ -608,7 +608,7 @@ export type Events = {
// Group chat adoption
'groupchat:create': {
logContext: 'NewChatDialog'
logContext: 'NewChatDialog' | 'SendViaChatDialog'
}
'groupchat:landingPage:view': {
hasSession: boolean
@@ -746,9 +746,7 @@ export type Events = {
}
'trendingTopic:click': {
context: 'sidebar' | 'interstitial' | 'explore'
}
'recommendedTopic:click': {
context: 'explore'
recId?: string
}
'trendingVideos:show': {
context: 'settings'
@@ -782,13 +780,13 @@ export type Events = {
}
'search:results:loaded': {
tab: 'top' | 'latest' | 'people' | 'feeds'
tab: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
initialCount: number
}
'search:result:press': {
tab?: 'top' | 'latest' | 'people' | 'feeds'
resultType: 'post' | 'profile' | 'feed'
tab?: 'top' | 'latest' | 'people' | 'feeds' | 'starterPacks'
resultType: 'post' | 'profile' | 'feed' | 'starterPack'
position: number
uri: string
}
@@ -1349,6 +1347,26 @@ export type Events = {
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
/**
* Fired when a video fails terminally during playback: unreachable (404),
* undecodable, or the client lacks the required codecs. Complements the
* Sentry-only video.playback spans with a countable, unsampled event.
*/
'video:playback:failed': {
surface: 'feed' | 'immersiveFeed'
presentation: 'video' | 'gif'
/**
* Coarse failure bucket: VideoNotFoundError, HLSUnsupportedError, an
* hls.js error details code (e.g. bufferAppendError), or PlayerError on
* native.
*/
errorClass: string
/** Truncated to 256 chars */
errorMessage: string
/** HLS playlist URL, identifies the exact video for server-side lookup */
playlist: string
}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
+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 {useLingui} from '@lingui/react/macro'
@@ -44,9 +44,11 @@ export function BotBadge({
export function BotBadgeButton({
profile,
width,
hitSlop,
}: {
profile: bsky.profile.AnyProfileView
width: number
hitSlop: Insets
}) {
const t = useTheme()
const ax = useAnalytics()
@@ -61,7 +63,7 @@ export function BotBadgeButton({
<>
<Button
label={l`Automated account`}
hitSlop={20}
hitSlop={hitSlop}
onPress={evt => {
evt.preventDefault()
ax.metric('bot:badge:click', {})
+1 -1
View File
@@ -286,7 +286,7 @@ export function Composer({
ref={IS_WEB ? sift.refs.setAnchor : undefined}
style={
node.type === 'facet' && {
color: t.palette.primary_500,
color: t.atoms.text_link.color,
}
}>
{node.raw}
+2 -1
View File
@@ -157,7 +157,8 @@ export function Outer({
[open, close],
)
const isHeightConstrained = nativeOptions?.maxHeight != null
const isHeightConstrained =
nativeOptions?.maxHeight != null || nativeOptions?.fullHeight === true
const context = useMemo(
() => ({
+2 -2
View File
@@ -481,11 +481,11 @@ export function ProfileGrid({
<Text
style={[
a.text_sm,
{color: t.palette.primary_500},
t.atoms.text_link,
hovered &&
web({
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
textDecorationColor: t.atoms.text_link.color,
}),
]}>
<Trans>See more</Trans>
@@ -78,6 +78,7 @@ export function ImageMenu({onPressShare, onPressSave}: Props) {
visible={isMounted}
animationType="none"
onRequestClose={close}
supportedOrientations={['portrait', 'landscape']}
statusBarTranslucent>
<Pressable
accessibilityRole="button"
+5 -5
View File
@@ -415,7 +415,7 @@ function LinkPeek({
// dialog can show.
useInAppBrowser: useInAppBrowserPref === true,
browserToolbarColor: t.atoms.bg.backgroundColor,
browserControlsColor: t.palette.primary_500,
browserControlsColor: t.atoms.text_link.color,
}}
borderRadius={borderRadius}
// Fires only when not morphing natively (in-app browser off/unset).
@@ -487,14 +487,14 @@ export function InlineLinkText({
accessibilityLabel={label}
{...rest}
style={[
{color: t.palette.primary_500},
t.atoms.text_link,
interacted &&
!disableUnderline && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500,
flattenedStyle.color ?? t.atoms.text_link.color,
}),
},
flattenedStyle,
@@ -591,14 +591,14 @@ export function SimpleInlineLinkText({
accessibilityLabel={label}
{...rest}
style={[
{color: t.palette.primary_500},
t.atoms.text_link,
interacted &&
!disableUnderline && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor:
flattenedStyle.color ?? t.palette.primary_500,
flattenedStyle.color ?? t.atoms.text_link.color,
}),
},
flattenedStyle,
+5 -6
View File
@@ -59,7 +59,7 @@ export function ImageEmbed({
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
const singleContainerRef = useRef<AnimatedRef<React.Component> | null>(null)
const singleDimsRef = useRef<Dimensions | null>(null)
if (images.length > 0) {
@@ -71,7 +71,7 @@ export function ImageEmbed({
}))
const onPress = (
index: number,
refs: AnimatedRef<any>[],
refs: AnimatedRef<React.Component>[],
fetchedDims: (Dimensions | null)[],
) => {
if (postContext) {
@@ -97,7 +97,7 @@ export function ImageEmbed({
}
const onPressIn = (_: number) => {
InteractionManager.runAfterInteractions(() => {
Image.prefetch(
void Image.prefetch(
items.map(i => i.uri),
'memory',
)
@@ -115,6 +115,7 @@ export function ImageEmbed({
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
}
}
return (
<View style={[a.mt_sm, rest.style]}>
<ImageContextMenu
@@ -127,9 +128,7 @@ export function ImageEmbed({
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.isWithinQuote
? 'square'
: 'constrained'
: 'constrained'
}
image={image}
onContainerRef={ref => {
@@ -1,6 +1,7 @@
import {type VideoEmbedInnerWebProps} from './VideoEmbedInnerWeb.shared'
export {
HLSFatalError,
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
@@ -19,3 +19,16 @@ export class VideoNotFoundError extends Error {
super('Video not found')
}
}
/**
* Fatal hls.js playback error. `detail` is the hls.js error details code
* (e.g. bufferAppendError), which buckets failures more usefully than the
* error message.
*/
export class HLSFatalError extends Error {
detail: string
constructor(detail: string, cause: Error) {
super(cause.message, {cause})
this.detail = detail
}
}
@@ -10,6 +10,7 @@ import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate'
import {
HLSFatalError,
HLSUnsupportedError,
type VideoEmbedInnerWebProps,
VideoNotFoundError,
@@ -17,6 +18,7 @@ import {
import {Controls} from './web-controls/VideoControls'
export {
HLSFatalError,
HLSUnsupportedError,
VideoNotFoundError,
} from './VideoEmbedInnerWeb.shared'
@@ -306,7 +308,7 @@ function useHLS({
) {
setError(new VideoNotFoundError())
} else {
setError(data.error)
setError(new HLSFatalError(data.details, data.error))
}
} else {
console.error(data.error)
@@ -16,6 +16,7 @@ import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {GifPresentationControls} from './GifPresentationControls'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -70,6 +71,7 @@ export function VideoEmbed({embed}: Props) {
function InnerWrapper({embed}: Props) {
const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null)
const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>(
@@ -130,6 +132,13 @@ function InnerWrapper({embed}: Props) {
}}
onError={error => {
telemetryRef.current?.error(error)
ax.metric('video:playback:failed', {
surface: 'feed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
errorClass: 'PlayerError',
errorMessage: error.slice(0, 256),
playlist: embed.playlist,
})
}}
ref={ref}
/>
@@ -18,16 +18,25 @@ import {useFullscreen} from '#/components/hooks/useFullscreen'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {
HLSFatalError,
HLSUnsupportedError,
VideoEmbedInnerWeb,
VideoNotFoundError,
} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
import {useAnalytics} from '#/analytics'
import {IS_WEB_FIREFOX} from '#/env'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
const noop = () => {}
/**
* Minimum card width for the overlay controls (play, time, CC, volume,
* fullscreen) to fit without crowding. Narrower cards fall back to the
* full-width pillarbox.
*/
const MIN_CARD_WIDTH = 280
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
@@ -69,9 +78,9 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const [key, setKey] = useState(0)
const renderError = useCallback(
(error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} />
<VideoError embed={embed} error={error} retry={() => setKey(key + 1)} />
),
[key],
[key, embed],
)
let aspectRatio: number | undefined
@@ -89,6 +98,21 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
constrained = Math.max(aspectRatio, ratio)
}
const [containerWidth, setContainerWidth] = useState(0)
/*
* Portrait videos render at their own ratio instead of pillarboxed, but
* only when the resulting card fits the overlay controls. Videos taller
* than 1:2 would still show bars inside a ratio-fit card, and an unknown
* ratio can't be fit, so both keep the full-width pillarbox - a narrow
* card with black slices down the sides looks broken (see #9371).
*/
const cardWidth = containerWidth * Math.min(aspectRatio ?? 1, 1)
const fullBleed =
aspectRatio === undefined ||
aspectRatio < 1 / 2 ||
(containerWidth > 0 && cardWidth < MIN_CARD_WIDTH)
const contents = (
<div
ref={ref}
@@ -96,6 +120,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
display: 'flex',
flex: 1,
cursor: 'default',
position: 'relative',
backgroundColor: t.palette.black,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain',
@@ -103,6 +128,36 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
backgroundRepeat: 'no-repeat',
}}
onClick={evt => evt.stopPropagation()}>
{fullBleed && embed.thumbnail && (
<>
{/* blurred backdrop fills the bars when the video is boxed */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: 'blur(32px)',
// hide the transparent fade the blur creates at the edges
transform: 'scale(1.2)',
}}
/>
{/* redraw the sharp thumbnail above the blur */}
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
backgroundImage: `url(${embed.thumbnail})`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
</>
)}
<ErrorBoundary renderError={renderError} key={key}>
<OnlyNearScreen>
<VideoEmbedInnerWeb
@@ -118,12 +173,14 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
)
return (
<View style={[a.pt_xs]}>
<View
style={[a.pt_xs]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
<ViewportObserver
sendPosition={isGif ? noop : sendPosition}
isAnyViewActive={currentActiveView !== null}>
<ConstrainedImage
fullBleed
fullBleed={fullBleed}
aspectRatio={constrained || 1}
// slightly smaller max height than images
// images use 16 / 9, for reference
@@ -222,23 +279,63 @@ export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
return nearScreen ? children : null
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
function VideoError({
embed,
error,
retry,
}: {
embed: AppBskyEmbedVideo.View
error: unknown
retry: () => void
}) {
const {_} = useLingui()
const ax = useAnalytics()
let showRetryButton = true
let text = null
let errorClass: string
if (error instanceof VideoNotFoundError) {
text = _(msg`Video not found.`)
errorClass = 'VideoNotFoundError'
} else if (error instanceof HLSUnsupportedError) {
showRetryButton = false
text = _(
msg`This video cant be played on your device. Your browser or system may be missing the required video codecs (H.264/AAC).`,
)
errorClass = 'HLSUnsupportedError'
} else {
text = _(msg`An error occurred while loading the video. Please try again.`)
if (error instanceof HLSFatalError) {
errorClass = error.detail
} else if (error instanceof Error) {
errorClass = error.name || 'Error'
} else {
errorClass = 'Unknown'
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
const presentation = embed.presentation === 'gif' ? 'gif' : 'video'
const playlist = embed.playlist
/*
* Fire exactly once per failure - the analytics context identity can change
* (session/geolocation updates) while this fallback stays mounted, which
* would otherwise re-run the effect and double-count.
*/
const fired = useRef(false)
useEffect(() => {
if (fired.current) return
fired.current = true
ax.metric('video:playback:failed', {
surface: 'feed',
presentation,
errorClass,
errorMessage: errorMessage.slice(0, 256),
playlist,
})
}, [ax, presentation, playlist, errorClass, errorMessage])
return (
<VideoFallback.Container>
<VideoFallback.Text>{text}</VideoFallback.Text>
-1
View File
@@ -4,7 +4,6 @@ import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
export enum PostEmbedViewContext {
ThreadHighlighted = 'ThreadHighlighted',
Feed = 'Feed',
FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
ChatMessage = 'ChatMessage',
}
+5 -7
View File
@@ -1,8 +1,6 @@
import {useCallback, useMemo} from 'react'
import {LayoutAnimation, type TextStyle} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
import {atoms as a, flatten, type TextStyleProp, useTheme} from '#/alf'
@@ -14,7 +12,7 @@ export function ShowMoreTextButton({
style,
}: TextStyleProp & {onPress: () => void}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const onPress = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
@@ -30,7 +28,7 @@ export function ShowMoreTextButton({
return (
<Button
label={_(msg`Expand post text`)}
label={l`Expand post text`}
onPress={onPress}
style={[
a.self_start,
@@ -43,13 +41,13 @@ export function ShowMoreTextButton({
<Text
style={[
textStyle,
t.atoms.text_link,
{
color: t.palette.primary_500,
opacity: pressed ? 0.6 : 1,
textDecorationLine: hovered ? 'underline' : undefined,
},
]}>
<Trans>Show More</Trans>
<Trans>Show more</Trans>
</Text>
)}
</Button>
+4 -4
View File
@@ -150,10 +150,10 @@ function TranslationLink({
label={l`Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
]}
hitSlop={HITSLOP_30}>
<Text style={[a.text_sm, {color: t.palette.primary_500}]}>
<Text style={[a.text_sm, t.atoms.text_link]}>
<Trans>Translate</Trans>
</Text>
</Link>
@@ -229,7 +229,7 @@ function TranslationError({
label={l`Try Google Translate`}
hoverStyle={[
native({opacity: 0.5}),
web([a.underline, {textDecorationColor: t.palette.primary_500}]),
web([a.underline, {textDecorationColor: t.atoms.text_link.color}]),
]}
hitSlop={HITSLOP_30}>
<Text
@@ -237,7 +237,7 @@ function TranslationError({
a.text_xs,
a.font_medium,
a.leading_snug,
{color: t.palette.primary_500},
t.atoms.text_link,
]}>
<Trans>Try Google Translate</Trans>
</Text>
@@ -1,9 +1,7 @@
import {memo, useMemo} from 'react'
import * as ExpoClipboard from 'expo-clipboard'
import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -37,7 +35,7 @@ let ShareMenuItems = ({
}: ShareMenuItemsProps): React.ReactNode => {
const ax = useAnalytics()
const {hasSession} = useSession()
const {_} = useLingui()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const sendViaChatControl = useDialogControl()
const [devModeEnabled] = useDevMode()
@@ -61,7 +59,7 @@ let ShareMenuItems = ({
const onSharePost = () => {
ax.metric('share:press:nativeShare', {})
const url = toShareUrl(href)
shareUrl(url)
void shareUrl(url)
onShareProp()
}
@@ -74,7 +72,7 @@ let ShareMenuItems = ({
} else {
await ExpoClipboard.setStringAsync(url)
}
Toast.show(_(msg`Copied to clipboard`), {
Toast.show(l`Copied to clipboard`, {
type: 'success',
})
onShareProp()
@@ -93,11 +91,11 @@ let ShareMenuItems = ({
}
const onShareATURI = () => {
shareText(postUri)
void shareText(postUri)
}
const onShareAuthorDID = () => {
shareText(postAuthor.did)
void shareText(postAuthor.did)
}
return (
@@ -113,13 +111,13 @@ let ShareMenuItems = ({
</Menu.ContainerItem>
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
label={l`Send via chat`}
onPress={() => {
ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open()
}}>
<Menu.ItemText>
<Trans>Send via direct message</Trans>
<Trans>Send via chat</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PaperPlaneIcon} position="right" />
</Menu.Item>
@@ -129,7 +127,7 @@ let ShareMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postDropdownShareBtn"
label={_(msg`Share via...`)}
label={l`Share via...`}
onPress={onSharePost}>
<Menu.ItemText>
<Trans>Share via...</Trans>
@@ -139,8 +137,8 @@ let ShareMenuItems = ({
<Menu.Item
testID="postDropdownShareBtn"
label={_(msg`Copy link to post`)}
onPress={onCopyLink}>
label={l`Copy link to post`}
onPress={() => void onCopyLink()}>
<Menu.ItemText>
<Trans>Copy link to post</Trans>
</Menu.ItemText>
@@ -164,7 +162,7 @@ let ShareMenuItems = ({
<Menu.Group>
<Menu.Item
testID="postAtUriShareBtn"
label={_(msg`Share post at:// URI`)}
label={l`Share post at:// URI`}
onPress={onShareATURI}>
<Menu.ItemText>
<Trans>Share post at:// URI</Trans>
@@ -173,7 +171,7 @@ let ShareMenuItems = ({
</Menu.Item>
<Menu.Item
testID="postAuthorDIDShareBtn"
label={_(msg`Share author DID`)}
label={l`Share author DID`}
onPress={onShareAuthorDID}>
<Menu.ItemText>
<Trans>Share author DID</Trans>
@@ -183,7 +181,6 @@ let ShareMenuItems = ({
</Menu.Group>
)}
</Menu.Outer>
<SendViaChatDialog
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
@@ -1,8 +1,6 @@
import {memo, useMemo} from 'react'
import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {makeProfileLink} from '#/lib/routes/links'
@@ -35,7 +33,7 @@ let ShareMenuItems = ({
const ax = useAnalytics()
const {hasSession} = useSession()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
@@ -60,7 +58,7 @@ let ShareMenuItems = ({
const onCopyLink = () => {
ax.metric('share:press:copyLink', {})
const url = toShareUrl(href)
shareUrl(url)
void shareUrl(url)
onShareProp()
}
@@ -75,17 +73,17 @@ let ShareMenuItems = ({
const canEmbed = IS_WEB && gtMobile && !hideInPWI
const onShareATURI = () => {
shareText(postUri)
void shareText(postUri)
}
const onShareAuthorDID = () => {
shareText(postAuthor.did)
void shareText(postAuthor.did)
}
const copyLinkItem = (
<Menu.Item
testID="postDropdownShareBtn"
label={_(msg`Copy link to post`)}
label={l`Copy link to post`}
onPress={onCopyLink}>
<Menu.ItemText>
<Trans>Copy link to post</Trans>
@@ -102,13 +100,13 @@ let ShareMenuItems = ({
{hasSession && aa.state.access === aa.Access.Full && (
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
label={l`Send via chat`}
onPress={() => {
ax.metric('share:press:openDmSearch', {})
sendViaChatControl.open()
}}>
<Menu.ItemText>
<Trans>Send via direct message</Trans>
<Trans>Send via chat</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Send} position="right" />
</Menu.Item>
@@ -117,12 +115,12 @@ let ShareMenuItems = ({
{canEmbed && (
<Menu.Item
testID="postDropdownEmbedBtn"
label={_(msg`Embed post`)}
label={l`Embed post`}
onPress={() => {
ax.metric('share:press:embed', {})
embedPostControl.open()
}}>
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
<Menu.ItemText>{l`Embed post`}</Menu.ItemText>
<Menu.ItemIcon icon={CodeBracketsIcon} position="right" />
</Menu.Item>
)}
@@ -142,7 +140,7 @@ let ShareMenuItems = ({
<Menu.Divider />
<Menu.Item
testID="postAtUriShareBtn"
label={_(msg`Copy post at:// URI`)}
label={l`Copy post at:// URI`}
onPress={onShareATURI}>
<Menu.ItemText>
<Trans>Copy post at:// URI</Trans>
@@ -151,7 +149,7 @@ let ShareMenuItems = ({
</Menu.Item>
<Menu.Item
testID="postAuthorDIDShareBtn"
label={_(msg`Copy author DID`)}
label={l`Copy author DID`}
onPress={onShareAuthorDID}>
<Menu.ItemText>
<Trans>Copy author DID</Trans>
@@ -161,7 +159,6 @@ let ShareMenuItems = ({
</>
)}
</Menu.Outer>
{canEmbed && (
<EmbedDialog
control={embedPostControl}
@@ -172,7 +169,6 @@ let ShareMenuItems = ({
timestamp={timestamp}
/>
)}
<SendViaChatDialog
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
+61 -11
View File
@@ -1,5 +1,6 @@
import {View} from 'react-native'
import {HITSLOP_20} from '#/lib/constants'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {useNativeFontScale} from '#/alf/util/dimensions'
@@ -8,6 +9,7 @@ import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import type * as bsky from '#/types/bsky'
import {BetaBadge, BetaBadgeButton, useIsBetaBadgeVisible} from './BetaBadge'
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
@@ -27,6 +29,22 @@ const botIconSizes: Record<Size, number> = {
xl: 23,
} as const
const betaIconSizes: Record<Size, number> = {
xs: 8,
sm: 8,
md: 8,
lg: 10,
xl: 12,
} as const
const betaBadgePadding: Record<Size, number> = {
xs: 1,
sm: 2,
md: 3,
lg: 4,
xl: 5,
} as const
export function ProfileBadges({
profile,
interactive = false,
@@ -41,13 +59,19 @@ export function ProfileBadges({
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
const badgeVisibility = [
verification.showBadge,
useIsBetaBadgeVisible(profile),
isBotAccount(shadowed),
]
const badgeCount = badgeVisibility.filter(Boolean).length
const nativeScaleMultiplier = useNativeFontScale()
const {
fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf()
// if nothing to show, don't render the container at all
if (!verification.showBadge && !isBotAccount(shadowed)) return null
if (badgeCount < 1) return null
const isOnTheSmallSide = size === 'xs' || size === 'sm'
@@ -57,31 +81,57 @@ export function ProfileBadges({
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[size] * scaleMultiplier
const betaIconWidth = betaIconSizes[size] * scaleMultiplier
const betaBadgeScaledPadding = betaBadgePadding[size] * scaleMultiplier
const gap = isOnTheSmallSide ? a.gap_2xs : a.gap_xs
const padding = gap.gap / 2
let visibleBadgeIndex = 0
const hitSlops = badgeVisibility.map(isVisible => {
if (!isVisible) return HITSLOP_20
const index = visibleBadgeIndex++
return {
...HITSLOP_20,
left: index === 0 ? HITSLOP_20.left : padding,
right: index === badgeCount - 1 ? HITSLOP_20.right : padding,
}
})
return (
<View
style={[
a.flex_row,
a.align_center,
isOnTheSmallSide ? a.gap_2xs : a.gap_xs,
style,
]}>
<View style={[a.flex_row, a.align_center, gap, style]}>
{interactive ? (
<>
<VerificationCheckButton
profile={shadowed}
width={verificationIconWidth}
hitSlop={hitSlops[0]}
/>
<BetaBadgeButton
profile={shadowed}
width={betaIconWidth}
padding={betaBadgeScaledPadding}
hitSlop={hitSlops[1]}
/>
<BotBadgeButton
profile={shadowed}
width={botIconWidth}
hitSlop={hitSlops[2]}
/>
<BotBadgeButton profile={shadowed} width={botIconWidth} />
</>
) : (
<>
{verification.showBadge && (
{verification.showBadge ? (
<VerificationCheck
verifier={verification.role === 'verifier'}
width={verificationIconWidth}
/>
)}
) : null}
<BetaBadge
profile={shadowed}
width={betaIconWidth}
padding={betaBadgeScaledPadding}
/>
<BotBadge profile={shadowed} width={botIconWidth} />
</>
)}
+6 -127
View File
@@ -1,143 +1,20 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {type AtUri} from '@atproto/api'
import {type AppBskyUnspeccedDefs, type AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
// import {makeProfileLink} from '#/lib/routes/links'
// import {feedUriToHref} from '#/lib/strings/url-helpers'
// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote'
// import {UserAvatar} from '#/view/com/util/UserAvatar'
import {type TrendingTopic} from '#/state/queries/trending/useTrendingTopics'
import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf'
import {StarterPack as StarterPackIcon} from '#/components/icons/StarterPack'
import {native} from '#/alf'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
export function TrendingTopic({
topic: raw,
size,
style,
hovered,
}: {
topic: TrendingTopic
size?: 'large' | 'small'
hovered?: boolean
} & ViewStyleProp) {
const topic = useTopic(raw)
const isSmall = size === 'small'
const hasIcon = topic.type === 'starter-pack' && !isSmall
const iconSize = 20
return (
<View
style={[
a.flex_row,
a.align_center,
isSmall
? [
{
paddingVertical: 2,
paddingHorizontal: 4,
},
]
: [a.py_xs, a.px_sm],
hasIcon && {gap: 6},
style,
]}>
{hasIcon && topic.type === 'starter-pack' && (
<StarterPackIcon
gradient="sky"
width={iconSize}
style={{marginLeft: -3, marginVertical: -1}}
/>
)}
{/*
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
a.overflow_hidden,
{
width: iconSize,
height: iconSize,
},
]}>
{topic.type === 'tag' ? (
<Hashtag width={iconSize} />
) : topic.type === 'topic' ? (
<Quote width={iconSize - 2} />
) : topic.type === 'feed' ? (
<UserAvatar
type="user"
size={aviSize}
avatar=""
/>
) : (
<UserAvatar
type="user"
size={aviSize}
avatar=""
/>
)}
</View>
*/}
<Text
style={[
a.font_semi_bold,
a.leading_tight,
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
hovered && {textDecorationLine: 'underline'},
]}
numberOfLines={1}>
{topic.displayName}
</Text>
</View>
)
}
export function TrendingTopicSkeleton({
size = 'large',
index = 0,
}: {
size?: 'large' | 'small'
index?: number
}) {
const t = useTheme()
const isSmall = size === 'small'
return (
<View
style={[
a.rounded_full,
a.border,
t.atoms.border_contrast_medium,
t.atoms.bg_contrast_25,
isSmall
? {
width: index % 2 === 0 ? 75 : 90,
height: 27,
}
: {
width: index % 2 === 0 ? 90 : 110,
height: 36,
},
]}
/>
)
}
export function TrendingTopicLink({
topic: raw,
children,
...rest
}: {
topic: TrendingTopic
topic: AppBskyUnspeccedDefs.TrendView
} & Omit<LinkProps, 'to' | 'label'>) {
const topic = useTopic(raw)
@@ -168,7 +45,9 @@ type ParsedTrendingTopic =
uri: AtUri
}
export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
export function useTopic(
raw: AppBskyUnspeccedDefs.TrendView,
): ParsedTrendingTopic {
const {_} = useLingui()
return useMemo(() => {
const {topic: displayName, link} = raw
+225 -12
View File
@@ -11,19 +11,33 @@ import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {type ListMethods} from '#/view/com/util/List'
import {android, atoms as a, native, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {canBeAddedToGroup, canBeMessaged} from '#/components/dms/util'
import {ChatProfileTabs} from '#/components/dms/ChatProfileTabs'
import {EmptyMemberList} from '#/components/dms/components/EmptyMemberList'
import {GroupChatProfileCard} from '#/components/dms/components/GroupChatProfileCard'
import {ProfileCardSkeleton} from '#/components/dms/components/ProfileCardSkeleton'
import {UserLabel} from '#/components/dms/components/UserLabel'
import {UserSearchInput} from '#/components/dms/components/UserSearchInput'
import {
canBeAddedToGroup,
canBeMessaged,
type ConvoWithDetails,
parseConvoView,
} from '#/components/dms/util'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
import {
@@ -33,18 +47,13 @@ import {
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {ProfileBadges} from '#/components/ProfileBadges'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {ChatProfileTabs} from './ChatProfileTabs'
import {EmptyMemberList} from './components/EmptyMemberList'
import {GroupChatProfileCard} from './components/GroupChatProfileCard'
import {ProfileCardSkeleton} from './components/ProfileCardSkeleton'
import {UserLabel} from './components/UserLabel'
import {UserSearchInput} from './components/UserSearchInput'
type NewGroupChatItem = {
type: 'newGroupChat'
@@ -63,6 +72,12 @@ type ProfileItem = {
profile: bsky.profile.AnyProfileView
}
type ExistingChatItem = {
type: 'existingChat'
key: string
convo: ConvoWithDetails
}
type EmptyItem = {
type: 'empty'
key: string
@@ -83,6 +98,7 @@ type Item =
| NewGroupChatItem
| LabelItem
| ProfileItem
| ExistingChatItem
| EmptyItem
| PlaceholderItem
| ErrorItem
@@ -212,11 +228,17 @@ export function InitiateChatFlow({
onSelectChat,
onSelectGroupChat,
startInGroupChat = false,
showRecentConvos = false,
onSelectExistingChat,
sortByMessageDeclaration = false,
}: {
title: string
onSelectChat: (did: string) => void
onSelectGroupChat: (dids: string[], groupName: string) => void
startInGroupChat?: boolean
showRecentConvos?: boolean
onSelectExistingChat?: (convoId: string) => void
sortByMessageDeclaration?: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -230,6 +252,12 @@ export function InitiateChatFlow({
const inputRef = useRef<TextInput>(null)
const accountTooNewPromptControl = Dialog.useDialogControl()
const {data: convos} = useListConvosQuery({
enabled: showRecentConvos,
status: 'accepted',
lockStatus: 'unlocked',
})
const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit
@@ -281,6 +309,10 @@ export function InitiateChatFlow({
let _items: Item[] = []
const checker =
chatState === ChatState.NEW_GROUP_CHAT ? canBeAddedToGroup : canBeMessaged
const messageDeclarationRank = (item: Item) =>
item.type === 'profile' && checker(item.profile) ? 0 : 1
const compareByMessageDeclaration = (a: Item, b: Item) =>
messageDeclarationRank(a) - messageDeclarationRank(b)
if (isError) {
_items.push({
@@ -310,9 +342,9 @@ export function InitiateChatFlow({
})
}
_items = _items.sort(item => {
return item.type === 'profile' && checker(item.profile) ? -1 : 1
})
if (sortByMessageDeclaration) {
_items = _items.sort(compareByMessageDeclaration)
}
}
} else {
const placeholders: Item[] = Array(10)
@@ -322,7 +354,57 @@ export function InitiateChatFlow({
key: i + '',
}))
if (follows) {
if (
chatState === ChatState.NEW_CHAT &&
showRecentConvos &&
convos &&
follows
) {
const usedDids = new Set()
for (const page of convos.pages) {
for (const convoView of page.convos) {
const convo = parseConvoView(convoView, currentAccount?.did)
if (!convo) continue
if (convo.kind === 'group') {
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
} else {
if (convo.primaryMember.handle === 'missing.invalid') continue
if (usedDids.has(convo.primaryMember.did)) continue
usedDids.add(convo.primaryMember.did)
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
}
}
}
let followsItems: ProfileItem[] = []
for (const page of follows.pages) {
for (const profile of page.follows) {
if (usedDids.has(profile.did)) continue
if (!checker(profile)) continue
followsItems.push({
type: 'profile',
key: profile.did,
profile,
})
}
}
_items.push(...followsItems)
} else if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
if (!checker(profile)) continue
@@ -359,10 +441,19 @@ export function InitiateChatFlow({
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
}
return _items
const profileDids = new Set<string>()
return _items.filter(item => {
if (item.type !== 'profile') return true
if (profileDids.has(item.profile.did)) return false
profileDids.add(item.profile.did)
return true
})
}, [
isError,
chatState,
convos,
searchText,
l,
groupChatProfiles,
@@ -370,6 +461,8 @@ export function InitiateChatFlow({
currentAccount?.did,
follows,
aa.flags.groupChatDisabled,
showRecentConvos,
sortByMessageDeclaration,
])
if (searchText && !isFetching && !items.length && !isError) {
@@ -429,6 +522,16 @@ export function InitiateChatFlow({
case 'label': {
return <UserLabel key={item.key} message={item.message} />
}
case 'existingChat': {
return showRecentConvos && onSelectExistingChat ? (
<ExistingChatCard
key={item.key}
convo={item.convo}
moderationOpts={moderationOpts!}
onPress={onSelectExistingChat}
/>
) : null
}
case 'profile': {
switch (chatState) {
case ChatState.NEW_CHAT:
@@ -474,6 +577,8 @@ export function InitiateChatFlow({
handlePressNewGroupChat,
moderationOpts,
onSelectChat,
onSelectExistingChat,
showRecentConvos,
],
)
@@ -845,6 +950,114 @@ function NewGroupChatButton({
)
}
function ExistingChatCard({
convo,
moderationOpts,
onPress,
}: {
convo: ConvoWithDetails
moderationOpts: ModerationOpts
onPress: (convoId: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const enabled =
convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true
const name =
convo.kind === 'group'
? convo.details.name
: createSanitizedDisplayName(
convo.primaryMember,
true,
moderateProfile(convo.primaryMember, moderationOpts).ui(
'displayName',
),
)
const handleOnPress = useCallback(() => {
onPress(convo.view.id)
}, [onPress, convo.view.id])
return (
<Button
disabled={!enabled}
label={l`Select chat "${name}"`}
onPress={handleOnPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_1,
a.py_sm,
a.px_lg,
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size={40} />
) : (
<ProfileCard.Avatar
profile={convo.primaryMember}
moderationOpts={moderationOpts}
disabledPreview
/>
)}
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
numberOfLines={1}>
{name}
</Text>
{convo.kind === 'direct' && (
<ProfileBadges
profile={convo.primaryMember}
size="md"
style={[a.pl_xs]}
/>
)}
</View>
{convo.kind === 'direct' ? (
<ProfileCard.Handle profile={convo.primaryMember} />
) : (
<>
{enabled ? (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={2}>
<Plural
value={convo.details.memberCount}
one="# member"
other="# members"
/>
</Text>
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>Group is locked</Trans>
</Text>
)}
</>
)}
</View>
</ProfileCard.Header>
</View>
)}
</Button>
)
}
function DefaultProfileCard({
profile,
moderationOpts,
+2 -1
View File
@@ -89,7 +89,7 @@ export function NewChat({
},
onError: error => {
logger.error('Failed to create groupchat', {safeMessage: error})
let errorMessage = l`An issue occurred creating the group chat, please try again.`
let errorMessage = l`An issue occurred starting the group chat, please try again.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
@@ -184,6 +184,7 @@ export function NewChat({
title={l`New chat`}
onSelectChat={onCreateChat}
onSelectGroupChat={onCreateGroupChat}
sortByMessageDeclaration
startInGroupChat={startInGroupChat}
/>
) : (
+114 -10
View File
@@ -1,11 +1,17 @@
import {useCallback} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useCallback, useState} from 'react'
import {
ChatBskyConvoGetConvoForMembers,
ChatBskyGroupCreateGroup,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
@@ -16,26 +22,39 @@ export function SendViaChatDialog({
control: Dialog.DialogControlProps
onSelectChat: (chatId: string) => void
}) {
const [flowKey, setFlowKey] = useState(0)
const onClose = useCallback(() => setFlowKey(key => key + 1), [])
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{fullHeight: true}}>
nativeOptions={{fullHeight: true}}
onClose={onClose}>
<Dialog.Handle />
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
<SendViaChatDialogInner
control={control}
flowKey={flowKey}
onSelectChat={onSelectChat}
/>
</Dialog.Outer>
)
}
function SendViaChatDialogInner({
control,
flowKey,
onSelectChat,
}: {
control: Dialog.DialogControlProps
flowKey: number
onSelectChat: (chatId: string) => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onSelectChat(data.convo.id)
@@ -46,8 +65,74 @@ function SendViaChatDialogInner({
ax.metric('chat:open', {logContext: 'SendViaChatDialog'})
},
onError: error => {
logger.error('Failed to share post to chat', {message: error})
Toast.show(_(msg`An issue occurred while trying to open the chat`), {
logger.error('Failed to share post to chat', {safeMessage: error})
let errorMessage = l`An issue occurred starting the chat, please try again.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.AccountSuspendedError
) {
errorMessage = l`Suspended accounts cannot participate in chat.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.BlockedActorError
) {
errorMessage = l`This user has blocked you and cannot be messaged.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.MessagesDisabledError
) {
errorMessage = l`This user has disabled chat and cannot be messaged.`
} else if (
error instanceof
ChatBskyConvoGetConvoForMembers.NotFollowedBySenderError
) {
errorMessage = l`Chat recipient is not followed by the sender.`
} else if (
error instanceof ChatBskyConvoGetConvoForMembers.RecipientNotFoundError
) {
errorMessage = l`Unable to find the selected recipient.`
}
Toast.show(errorMessage, {
type: 'error',
})
},
})
const {mutate: createGroupChat} = useCreateGroupChat({
onSuccess: data => {
onSelectChat(data.convo.id)
ax.metric('groupchat:create', {logContext: 'SendViaChatDialog'})
},
onError: error => {
logger.error('Failed to share post to group chat', {safeMessage: error})
let errorMessage = l`An issue occurred starting the group chat, please try again.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupCreateGroup.AccountSuspendedError
) {
errorMessage = l`Suspended accounts cannot participate in a group chat.`
} else if (error instanceof ChatBskyGroupCreateGroup.BlockedActorError) {
errorMessage = l`One of the selected recipients has blocked you and cannot be messaged.`
} else if (
error instanceof
ChatBskyGroupCreateGroup.NewAccountCannotCreateGroupError
) {
errorMessage = l`You cannot create a group chat yet.`
} else if (
error instanceof ChatBskyGroupCreateGroup.NotFollowedBySenderError
) {
errorMessage = l`A selected recipient is not followed by the sender.`
} else if (
error instanceof ChatBskyGroupCreateGroup.RecipientNotFoundError
) {
errorMessage = l`Unable to find a selected recipient.`
} else if (
error instanceof ChatBskyGroupCreateGroup.UserForbidsGroupsError
) {
errorMessage = l`One of the selected recipients does not allow group chats.`
}
Toast.show(errorMessage, {
type: 'error',
})
},
@@ -67,9 +152,28 @@ function SendViaChatDialogInner({
[control, createChat],
)
return (
const onCreateGroupChat = useCallback(
(members: string[], name: string) => {
control.close(() => {
createGroupChat({members, name})
})
},
[control, createGroupChat],
)
return isGroupChatEnabled ? (
<InitiateChatFlow
key={flowKey}
title={l`Send post to...`}
onSelectChat={onCreateChat}
onSelectExistingChat={onSelectExistingChat}
onSelectGroupChat={onCreateGroupChat}
showRecentConvos
sortByMessageDeclaration
/>
) : (
<SearchablePeopleList
title={_(msg`Send post to...`)}
title={l`Send post to...`}
onSelectChat={chat => {
if (chat.kind === 'user') {
onCreateChat(chat.did)
+2 -4
View File
@@ -5,7 +5,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, useBreakpoints} from '#/alf'
import {type Dimensions} from '#/components/Lightbox/types'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './ImageLayoutGridItem'
interface ImageLayoutGridProps {
@@ -28,9 +28,7 @@ export function ImageLayoutGrid({
...props
}: ImageLayoutGridProps) {
const {gtMobile} = useBreakpoints()
const isWithinQuote =
isWithinQuoteProp ??
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const isWithinQuote = isWithinQuoteProp
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
return (
@@ -0,0 +1,280 @@
import {useMemo} from 'react'
import {Pressable, View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {formatCount} from '#/view/com/util/numeric/format'
import {
atoms as a,
useGutters,
useLayoutBreakpoints,
useTheme,
type ViewStyleProp,
} from '#/alf'
import {alpha} from '#/alf/utils'
import {AvatarStack} from '#/components/AvatarStack'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const TOPIC_COUNT = 3
export function FeedTrendingTopicsInterstitial() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
const {rightNavVisible} = useLayoutBreakpoints()
return enabled && !trendingDisabled && !rightNavVisible ? <Inner /> : null
}
function Inner() {
const t = useTheme()
const {t: l} = useLingui()
const gutters = useGutters([0, 'base'])
const ax = useAnalytics()
const {
data: trending,
error,
isLoading,
isRefetching,
} = useGetTrendsQuery({limit: TOPIC_COUNT})
const noTopics = !isLoading && !error && !trending?.trends?.length
const shadowColor = alpha(t.palette.primary_100, 0.5)
const gradient = {
values: [
[0, t.atoms.bg.backgroundColor],
[0.1, t.palette.primary_25],
[0.9, t.palette.primary_25],
[1, t.atoms.bg.backgroundColor],
],
hover_value: t.palette.white,
}
if (error || noTopics) {
return null
}
return (
<View
style={[
gutters,
a.pt_lg,
a.pb_xl,
a.gap_sm,
a.border_t,
t.atoms.border_contrast_low,
]}>
<LinearGradient
colors={gradient.values.map(c => c[1]) as [string, string, ...string[]]}
locations={
gradient.values.map(c => c[0]) as [number, number, ...number[]]
}
style={[a.absolute, a.inset_0]}
/>
<View
style={[
a.relative,
a.z_20,
a.px_xs,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_sm,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_xs]}>
<TrendingIcon width={18} />
<Text
style={[a.text_md, a.font_medium, a.leading_snug]}
numberOfLines={1}>
<Trans>Trending</Trans>
</Text>
</View>
<Link label={l`See more trending topics`} to="/search">
<Text
style={[
a.text_sm,
a.font_medium,
a.leading_snug,
t.atoms.text_contrast_high,
]}
numberOfLines={1}>
<Trans>See more</Trans>
</Text>
</Link>
</View>
<View
style={[
a.relative,
a.z_10,
a.border,
a.rounded_xl,
t.atoms.bg,
{
borderColor: t.palette.primary_100,
boxShadow: `0 0 16px 0 ${shadowColor}`,
elevation: 8,
shadowColor: shadowColor,
shadowOffset: {width: 0, height: 0},
shadowOpacity: 1,
shadowRadius: 16,
},
]}>
{isLoading || isRefetching
? Array.from({length: TOPIC_COUNT}).map((_, i) => (
<TrendingTopicRowSkeleton key={i} rank={i + 1} />
))
: trending?.trends?.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
onPress={() => {
ax.metric('trendingTopic:click', {context: 'interstitial'})
}}
/>
))}
</View>
</View>
)
}
function TrendRow({
trend,
rank,
onPress,
}: ViewStyleProp & {
trend: AppBskyUnspeccedDefs.TrendView
rank: number
children?: React.ReactNode
onPress?: () => void
}) {
const t = useTheme()
const {t: l, i18n} = useLingui()
const actors = useModerateTrendingActors(trend.actors)
return (
<Link
testID={trend.link}
label={l`Browse topic ${trend.displayName}`}
to={trend.link}
onPress={onPress}
style={[
rank < TOPIC_COUNT && a.border_b,
{
borderColor: t.palette.primary_100,
},
]}
PressableComponent={Pressable}>
{({hovered, pressed}) => (
<>
<SubtleHover hover={hovered || pressed} native />
<View
style={[
a.w_full,
a.flex_row,
a.flex_row,
{
gap: 6,
padding: 14,
paddingLeft: 16,
},
]}>
<Text
style={[
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_low,
{
fontVariant: ['tabular-nums'],
},
]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.text_md, a.font_medium]} numberOfLines={1}>
{trend.displayName}
</Text>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
{actors.length > 0 ? (
<AvatarStack size={24} profiles={actors} />
) : null}
<Text
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{trend.postCount >= 1000 ? (
<Trans comment="Over 1,000 posts">1K+ posts</Trans>
) : (
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
{formatCount(i18n, trend.postCount)}{' '}
{plural(trend.postCount, {one: 'post', other: 'posts'})}
</Trans>
)}
</Text>
</View>
</View>
</View>
</>
)}
</Link>
)
}
function TrendingTopicRowSkeleton({rank}: {rank: number}) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.flex_row,
a.px_lg,
a.py_lg,
a.flex_row,
rank < TOPIC_COUNT && a.border_b,
t.atoms.border_contrast_low,
{
gap: 6,
},
]}>
<LoadingPlaceholder width={17} height={17} style={[a.rounded_full]} />
<View style={[a.flex_1, a.gap_xs]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder width={70} height={17} />
<LoadingPlaceholder width={40} height={17} />
<LoadingPlaceholder width={60} height={17} />
</View>
<LoadingPlaceholder width={24} height={24} style={[a.rounded_full]} />
</View>
</View>
)
}
function useModerateTrendingActors(
actors: AppBskyUnspeccedDefs.TrendView['actors'],
) {
const moderationOpts = useModerationOpts()
return useMemo(() => {
if (!moderationOpts) return []
return actors
.filter(actor => {
const decision = moderateProfile(actor, moderationOpts)
return !decision.ui('avatar').filter && !decision.ui('avatar').blur
})
.slice(0, 3)
}, [actors, moderationOpts])
}
+15 -5
View File
@@ -7,7 +7,7 @@ import {
useTrendingSettings,
useTrendingSettingsApi,
} from '#/state/preferences/trending'
import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
@@ -20,6 +20,8 @@ import {TrendingTopicLink} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const TRENDING_LIMIT = 14
export function TrendingInterstitial() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
@@ -33,8 +35,15 @@ export function Inner() {
const gutters = useGutters([0, 'base', 0, 'base'])
const trendingPrompt = Prompt.usePromptControl()
const {setTrendingDisabled} = useTrendingSettingsApi()
const {data: trending, error, isLoading} = useTrendingTopics()
const noTopics = !isLoading && !error && !trending?.topics?.length
const {
data: trending,
error,
isLoading,
} = useGetTrendsQuery({
limit: TRENDING_LIMIT,
refetchOnWindowFocus: true,
})
const noTopics = !isLoading && !error && !trending?.trends?.length
const onConfirmHide = useCallback(() => {
ax.metric('trendingTopics:hide', {context: 'interstitial'})
@@ -88,15 +97,16 @@ export function Inner() {
{' '}
</Text>
</View>
) : !trending?.topics ? null : (
) : !trending?.trends ? null : (
<>
{trending.topics.map(topic => (
{trending.trends.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
recId: trending.recId,
})
}}>
<View style={[a.py_lg]}>
+2 -5
View File
@@ -181,6 +181,7 @@ function BlockDialogInner({
const footer = (
<View style={[a.w_full, a.gap_sm, a.justify_end]}>
<Button
disabled={isLoading}
color={profile.viewer?.blocking ? undefined : 'negative'}
size="large"
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
@@ -192,6 +193,7 @@ function BlockDialogInner({
<Trans>Block</Trans>
)}
</ButtonText>
{isLoading ? <ButtonIcon icon={Loader} /> : null}
</Button>
<Button
color="secondary"
@@ -211,11 +213,6 @@ function BlockDialogInner({
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
style={[web([{maxWidth: 420}])]}>
{listHeader}
{isLoading ? (
<View style={[a.pb_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : null}
{footer}
</Dialog.ScrollableInner>
)
+10 -17
View File
@@ -6,9 +6,7 @@ import {
type ViewStyle,
} from 'react-native'
import {type ModerationUI} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {
ADULT_CONTENT_LABELS,
@@ -78,7 +76,7 @@ function ContentHiderActive({
children?: React.ReactNode
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {gtMobile} = useBreakpoints()
const [override, setOverride] = useState(false)
const control = useModerationDetailsDialogControl()
@@ -97,7 +95,7 @@ function ContentHiderActive({
(blur.type === 'label' && blur.source.type !== 'user')
) {
if (desc.isSubjectAccount) {
return _(msg`${desc.name} (Account)`)
return l`${desc.name} (Account)`
} else {
return desc.name
}
@@ -128,7 +126,7 @@ function ContentHiderActive({
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
if (def.identifier === 'porn' || def.identifier === 'sexual') {
return _(msg`Adult Content`)
return l`Adult Content`
}
return getLabelStrings(i18n.locale, globalLabelStrings, def).name
})
@@ -138,7 +136,7 @@ function ContentHiderActive({
}
return [...new Set(selfBlurNames)].join(', ')
}, [
_,
l,
modui.blurs,
blur,
desc.name,
@@ -151,7 +149,6 @@ function ContentHiderActive({
return (
<View testID={testID} style={[a.overflow_hidden, style]}>
<ModerationDetailsDialog control={control} modcause={blur} />
<Button
onPress={e => {
e.preventDefault()
@@ -166,10 +163,10 @@ function ContentHiderActive({
label={desc.name}
accessibilityHint={
modui.noOverride
? _(msg`Learn more about the moderation applied to this content`)
? l`Learn more about the moderation applied to this content`
: override
? _(msg`Hides the content`)
: _(msg`Shows the content`)
? l`Hides the content`
: l`Shows the content`
}>
{state => (
<View
@@ -223,7 +220,6 @@ function ContentHiderActive({
</View>
)}
</Button>
{desc.source && blur.type === 'label' && !override && (
<Button
onPress={e => {
@@ -231,9 +227,7 @@ function ContentHiderActive({
e.stopPropagation()
control.open()
}}
label={_(
msg`Learn more about the moderation applied to this content`,
)}
label={l`Learn more about the moderation applied to this content`}
style={[a.pt_sm]}>
{state => (
<Text
@@ -252,7 +246,7 @@ function ContentHiderActive({
)}{' '}
<Text
style={[
{color: t.palette.primary_500},
t.atoms.text_link,
a.text_sm,
state.hovered && [web({textDecoration: 'underline'})],
]}>
@@ -262,7 +256,6 @@ function ContentHiderActive({
)}
</Button>
)}
{override && <View style={childContainerStyle}>{children}</View>}
</View>
)
+9 -1
View File
@@ -54,7 +54,15 @@ export function PostAlerts({
const isOwnPost = !!post && post.author.did === currentAccount?.did
const allLabels: ComAtprotoLabelDefs.Label[] =
isOwnPost && view === 'expanded'
? [...(post.labels ?? []), ...(post.author.labels ?? [])]
? [
...(post.labels ?? []),
/*
* Account labels appear on Profile. We don't show them here unless the
* user's mod settings are configured such that the labels land in the
* modui handling.
*/
// ...(post.author.labels ?? [])
]
: []
/*
* Labels that the moderation system already surfaces in this context -
+6 -10
View File
@@ -6,9 +6,7 @@ import {
type ViewStyle,
} from 'react-native'
import {type ModerationUI} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
@@ -38,7 +36,7 @@ export function ScreenHider({
containerStyle?: StyleProp<ViewStyle>
}>) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const [override, setOverride] = useState(false)
const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries()
@@ -131,15 +129,13 @@ export function ScreenHider({
control.open()
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Learn more about this warning`)}
accessibilityLabel={l`Learn more about this warning`}
accessibilityHint="">
<Text
style={[
a.text_lg,
a.leading_snug,
{
color: t.palette.primary_500,
},
t.atoms.text_link,
web({
cursor: 'pointer',
}),
@@ -158,7 +154,7 @@ export function ScreenHider({
color="primary"
size="large"
style={[a.rounded_full]}
label={_(msg`Go back`)}
label={l`Go back`}
onPress={() => {
if (navigation.canGoBack()) {
navigation.goBack()
@@ -176,7 +172,7 @@ export function ScreenHider({
color="secondary"
size="large"
style={[a.rounded_full]}
label={_(msg`Show anyway`)}
label={l`Show anyway`}
onPress={() => setOverride(v => !v)}>
<ButtonText>
<Trans>Show anyway</Trans>
@@ -1,6 +1,5 @@
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {type Insets, View} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {type Shadow} from '#/state/cache/types'
import {atoms as a, useTheme} from '#/alf'
@@ -52,16 +51,25 @@ export function shouldShowVerificationCheckButton(
export function VerificationCheckButton({
profile,
width,
hitSlop,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
width: number
hitSlop: Insets
}) {
const state = useFullVerificationState({
profile,
})
if (shouldShowVerificationCheckButton(state)) {
return <Badge profile={profile} verificationState={state} width={width} />
return (
<Badge
profile={profile}
verificationState={state}
width={width}
hitSlop={hitSlop}
/>
)
}
return null
@@ -71,14 +79,16 @@ function Badge({
profile,
verificationState: state,
width,
hitSlop,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
verificationState: FullVerificationState
width: number
hitSlop: Insets
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const verificationsDialogControl = useDialogControl()
const verifierDialogControl = useDialogControl()
@@ -89,10 +99,10 @@ function Badge({
<Button
label={
state.profile.isViewer
? _(msg`View your verifications`)
: _(msg`View this user's verifications`)
? l`View your verifications`
: l`View this user's verifications`
}
hitSlop={20}
hitSlop={hitSlop}
onPress={evt => {
evt.preventDefault()
ax.metric('verification:badge:click', {})
@@ -132,13 +142,11 @@ function Badge({
</View>
)}
</Button>
<VerificationsDialog
control={verificationsDialogControl}
profile={profile}
verificationState={state}
/>
<VerifierDialog
control={verifierDialogControl}
profile={profile}
@@ -3,11 +3,9 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useTrendingSettings} from '#/state/preferences/trending'
import {atoms as a, useLayoutBreakpoints} from '#/alf'
import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as CloseIcon} from '#/components/icons/Times'
import {TrendingInterstitial} from '#/components/interstitials/Trending'
import * as Toast from '#/components/Toast'
import {LiveEventFeedCardWide} from '#/features/liveEvents/components/LiveEventFeedCardWide'
import {useUserPreferencedLiveEvents} from '#/features/liveEvents/context'
@@ -17,16 +15,10 @@ import {type LiveEventFeed} from '#/features/liveEvents/types'
export function DiscoverFeedLiveEventFeedsAndTrendingBanner() {
const events = useUserPreferencedLiveEvents()
const {rightNavVisible} = useLayoutBreakpoints()
const {trendingDisabled} = useTrendingSettings()
if (!events.feeds.length) {
if (!rightNavVisible && !trendingDisabled) {
// only show trending on mobile when live event banner is not shown
return <TrendingInterstitial />
} else {
// no feed, no trending
return null
}
// no feed
return null
}
// On desktop, we show in the sidebar
+3
View File
@@ -146,6 +146,9 @@ export const BSKY_FEED_OWNER_DIDS = [
'did:plc:q6gjnaw2blty4crticxkmujt',
]
export const TRENDING_DID = 'did:plc:qrz3lhbyuxbeilrc6nekdqme'
export const TRENDING_HANDLE = 'trending.bsky.app'
export const DISCOVER_FEED_URI =
'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot'
export const VIDEO_FEED_URI =
+44
View File
@@ -1,3 +1,4 @@
import {Asset} from 'expo-asset'
import {
documentDirectory,
getInfoAsync,
@@ -9,10 +10,15 @@ import ExpoImageCropTool, {
} from '@bsky.app/expo-image-crop-tool'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {IS_ANDROID} from '#/env'
import {compressIfNeeded} from './manip'
import {type PickerImage} from './picker.shared'
async function getFile() {
if (IS_ANDROID) {
return await getAndroidFile()
}
const imagesDir = documentDirectory!
.split('/')
.slice(0, -6)
@@ -41,6 +47,44 @@ async function getFile() {
)
}
/*
* The Android emulator can't reach the iOS simulator's sample photo library,
* so we load a jpg bundled with the app instead. It is bundled via require()
* (resolved by Metro), so it survives `pm clear`, which Maestro's clearState
* runs at the start of every flow. An adb-seeded file in app-scoped external
* storage does not survive: pm clear wipes that directory each flow, so the
* seeded file is gone before the picker mock ever reads it.
*/
async function getAndroidFile() {
const asset = Asset.fromModule(
require('../../../assets/images/welcome-modal-bg.jpg'),
)
await asset.downloadAsync()
const path = asset.localUri!
const fileInfo = await getInfoAsync(path)
if (!fileInfo.exists) {
throw new Error('Failed to get file info')
}
/*
* Dimensions of the bundled asset (assets/images/welcome-modal-bg.jpg). Only
* used for downstream aspect-ratio display; the actual bytes are read from
* disk by compressIfNeeded.
*/
return await compressIfNeeded(
{
path,
mime: 'image/jpeg',
size: fileInfo.size,
width: 1432,
height: 1025,
},
IMAGE_SIZE_CONFIG_2K_1MB,
)
}
export async function openPicker(): Promise<PickerImage[]> {
return [await getFile()]
}
+1
View File
@@ -70,6 +70,7 @@ export type CommonNavigatorParams = {
ActivityPrivacySettings: undefined
ContentAndMediaSettings: undefined
NotificationSettings: undefined
ActivityNotificationSettings: undefined
InterestsSettings: undefined
AboutSettings: 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',
AppIconSettings: '/settings/app-icon',
NotificationSettings: '/settings/notifications',
ActivityNotificationSettings: '/settings/notifications/activity',
FindContactsSettings: '/settings/find-contacts',
// support
Support: '/support',
@@ -370,7 +370,7 @@ function SuggestedProfileCard({
category: string | null
onSeen: (did: string, position: number) => void
recSource?: 'Search'
recId?: number | string
recId?: string
}) {
const t = useTheme()
const ax = useAnalytics()
+59 -92
View File
@@ -1,15 +1,13 @@
import {View} from 'react-native'
import {type AppBskyFeedDefs, AtUri, moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {enforceLen} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLikedBySampleQuery} from '#/state/queries/post-liked-by'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {InlineLinkText, Link} from '#/components/Link'
import {useFormatPostStatCount} from '#/components/PostControls/util'
@@ -18,27 +16,56 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
const AVI_SIZE = 20
const MAX_NAME_LENGTH = 16
/**
* The likes stat for the expanded anchor post. When the viewer follows some
* of the post's recent likers, renders social proof - a face pile plus
* "Liked by A, B, and N others" - in place of the plain "N likes" text,
* which it falls back to otherwise.
*
* Known likers are sourced client-side from a single `getLikes` request (100
* likes, the API max per page), so they are a sample of the most recent
* likers, not an exhaustive list. Only the faces and names are affected by
* sampling - the "N others" count is derived from the post's total like
* count.
* The plain "N likes" stat for the expanded anchor post, linking to the likes
* list. Renders nothing when the post has no likes.
*/
export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {t: l} = useLingui()
const formatPostStatCount = useFormatPostStatCount()
const ax = useAnalytics()
const likeCount = post.likeCount ?? 0
if (likeCount === 0) return null
const urip = new AtUri(post.uri)
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
return (
<Link
to={likesHref}
label={l`Likes on this post`}
onPress={() => ax.metric('post:likedBy:click', {})}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(likeCount)}
</Text>{' '}
<Plural value={likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
)
}
/**
* Social proof for the expanded anchor post. When the viewer follows some of
* the post's recent likers, renders a face pile plus "Liked by A and B" on
* its own row below the interaction stats line. Renders nothing otherwise.
*
* Known likers are sourced client-side from a single `getLikes` request (100
* likes, the API max per page), so they are a sample of the most recent
* likers, not an exhaustive list.
*/
export function KnownLikers({post}: {post: AppBskyFeedDefs.PostView}) {
const t = useTheme()
const {t: l} = useLingui()
const {hasSession, currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const formatPostStatCount = useFormatPostStatCount()
const ax = useAnalytics()
const likeCount = post.likeCount ?? 0
@@ -78,67 +105,34 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
knownLikersAndModeration.length > 0 &&
ax.features.enabled(ax.features.PostThreadKnownLikersEnable)
if (!showKnownLikers) {
return (
<Link
to={likesHref}
label={l`Likes on this post`}
onPress={onPressLikedBy}>
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(likeCount)}
</Text>{' '}
<Plural value={likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
)
}
if (!showKnownLikers) return null
const aviStackProfiles = knownLikersAndModeration
.slice(0, 3)
.map(({actor}) => actor)
const maxNames = gtMobile ? 2 : 1
const names = knownLikersAndModeration
.slice(0, maxNames)
.slice(0, 2)
.map(({actor, moderation}) => {
return {
did: actor.did,
href: makeProfileLink(actor),
displayName: enforceLen(
sanitizeDisplayName(
actor.displayName || actor.handle,
moderation.ui('displayName'),
),
MAX_NAME_LENGTH,
true,
displayName: sanitizeDisplayName(
actor.displayName || actor.handle,
moderation.ui('displayName'),
),
}
})
const others = likeCount - names.length
/*
* The row link's a11y label mirrors the visible sentence so screen readers
* announce the social proof.
*/
const othersLabel = plural(others, {
one: `${formatPostStatCount(others)} other`,
other: `${formatPostStatCount(others)} others`,
})
const rowLabel =
names.length >= 2
? others > 0
? l`${names[0].displayName}, ${names[1].displayName}, and ${othersLabel} like this`
: l`${names[0].displayName} and ${names[1].displayName} like this`
: others > 0
? l`${names[0].displayName} and ${othersLabel} like this`
: l`${names[0].displayName} likes this`
? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
: l`Liked by ${names[0].displayName}`
const textStyle = [a.text_md, t.atoms.text_contrast_medium]
const nameStyle = [a.text_md, a.font_semi_bold, t.atoms.text]
const textStyle = [a.text_sm, t.atoms.text_contrast_medium]
const nameStyle = [a.text_sm, a.font_semi_bold, t.atoms.text]
/*
* Nested inside the row link, but the deepest link claims the press, so
@@ -160,10 +154,8 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
return (
/*
* The full-width wrapper keeps the social proof on its own line within
* the wrapping stats row, rather than wrapping mid-row and orphaning
* whichever count stat comes last. The link itself hugs its content so
* the empty space to the right of the text is not pressable.
* The full-width wrapper forces the social proof onto its own line below
* the count stats within the wrapping stats row.
*/
<View style={[a.w_full, a.flex_row]}>
<Link
@@ -172,39 +164,14 @@ export function LikesStat({post}: {post: AppBskyFeedDefs.PostView}) {
style={[a.flex_row, a.align_center, a.gap_sm, a.flex_shrink]}
onPress={onPressLikedBy}>
<AvatarStack profiles={aviStackProfiles} size={AVI_SIZE} />
<Text
testID="knownLikersStat"
numberOfLines={1}
style={[a.flex_shrink, textStyle]}>
<Text testID="knownLikersStat" style={[a.flex_shrink, textStyle]}>
{names.length >= 2 ? (
others > 0 ? (
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post, and the count is the remaining number of likes">
{nameLink(names[0])}, {nameLink(names[1])}, and{' '}
<Plural
value={others}
one={`${formatPostStatCount(others)} other`}
other={`${formatPostStatCount(others)} others`}
/>{' '}
like this
</Trans>
) : (
<Trans comment="Social proof on the likes stat; the bolded names are people the viewer follows who liked the post and are its only likes">
{nameLink(names[0])} and {nameLink(names[1])} like this
</Trans>
)
) : others > 0 ? (
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post, and the count is the remaining number of likes">
{nameLink(names[0])} and{' '}
<Plural
value={others}
one={`${formatPostStatCount(others)} other`}
other={`${formatPostStatCount(others)} others`}
/>{' '}
like this
<Trans comment="Social proof below the post stats; the bolded names are people the viewer follows who liked the post">
Liked by {nameLink(names[0])} and {nameLink(names[1])}
</Trans>
) : (
<Trans comment="Social proof on the likes stat; the bolded name is a person the viewer follows who liked the post and is its only like">
{nameLink(names[0])} likes this
<Trans comment="Social proof below the post stats; the bolded name is a person the viewer follows who liked the post">
Liked by {nameLink(names[0])}
</Trans>
)}
</Text>
@@ -28,7 +28,7 @@ import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {type PostSource} from '#/state/unstable-post-source'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {LikesStat} from '#/screens/PostThread/components/LikesStat'
import {KnownLikers, LikesStat} from '#/screens/PostThread/components/LikesStat'
import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
import {
LINEAR_AVI_WIDTH,
@@ -440,7 +440,6 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
a.py_md,
t.atoms.border_contrast_low,
]}>
<LikesStat post={post} />
{post.repostCount != null && post.repostCount !== 0 ? (
<Link to={repostsHref} label={l`Reposts of this post`}>
<Text
@@ -481,6 +480,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Text>
</Link>
) : null}
<LikesStat post={post} />
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
<Text
testID="bookmarkCount-expanded"
@@ -497,6 +497,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
</Trans>
</Text>
) : null}
<KnownLikers post={post} />
</View>
) : null}
<View
+15 -14
View File
@@ -1,13 +1,12 @@
import {useCallback, useEffect, useMemo, useState} from 'react'
import {useAnimatedRef} from 'react-native-reanimated'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URIS} from '#/lib/constants'
import {TRENDING_DID, TRENDING_HANDLE, VIDEO_FEED_URIS} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {type CommonNavigatorParams} from '#/lib/routes/types'
@@ -52,7 +51,7 @@ export function ProfileFeedScreen(props: Props) {
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
? {feedCacheKey: props.route.params.feedCacheKey}
: undefined
const {_} = useLingui()
const {t: l} = useLingui()
const uri = useMemo(
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
@@ -70,7 +69,7 @@ export function ProfileFeedScreen(props: Props) {
<Layout.Screen testID="profileFeedScreenError">
<ErrorScreen
showHeader
title={_(msg`Could not load feed`)}
title={l`Could not load feed`}
message={cleanError(error)}
onPressTryAgain={() => void refetch()}
/>
@@ -131,7 +130,7 @@ export function ProfileFeedScreenInner({
feedInfo: FeedSourceFeedInfo
feedParams: FeedParams | undefined
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const {hasSession} = useSession()
const {openComposer} = useOpenComposer()
const isScreenFocused = useIsFocused()
@@ -168,10 +167,10 @@ export function ProfileFeedScreenInner({
<EmptyState
icon={HashtagWideIcon}
iconSize="2xl"
message={_(msg`This feed is empty.`)}
message={l`This feed is empty.`}
/>
)
}, [_])
}, [l])
const isVideoFeed = useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
@@ -181,13 +180,17 @@ export function ProfileFeedScreenInner({
return IS_NATIVE && _isVideoFeed
}, [feedInfo])
const isTrending =
feedInfo.creatorDid.toLowerCase() === TRENDING_DID ||
feedInfo.creatorHandle.toLowerCase() === TRENDING_HANDLE
return (
<>
<ProfileFeedHeader info={feedInfo} />
<ProfileFeedHeader info={feedInfo} isTrending={isTrending} />
<FeedFeedbackProvider value={feedFeedback}>
<PostFeed
enabled
description={isTrending ? feedInfo.description : undefined}
feed={feed}
feedParams={feedParams}
pollInterval={60e3}
@@ -199,22 +202,20 @@ export function ProfileFeedScreenInner({
isVideoFeed={isVideoFeed}
/>
</FeedFeedbackProvider>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
label={_(msg`Load new posts`)}
label={l`Load new posts`}
showIndicator={hasNew}
/>
)}
{hasSession && (
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityLabel={l`New post`}
accessibilityHint=""
/>
)}
@@ -3,6 +3,7 @@ import {View} from 'react-native'
import {AtUri} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {TRENDING_HANDLE} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {makeCustomFeedLink, makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
@@ -24,20 +25,20 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
Heart2_Stroke2_Corner0_Rounded as Heart,
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilledIcon,
Heart2_Stroke2_Corner0_Rounded as HeartIcon,
} from '#/components/icons/Heart2'
import {
Pin_Filled_Corner0_Rounded as PinFilled,
Pin_Stroke2_Corner0_Rounded as Pin,
Pin_Filled_Corner0_Rounded as PinFilledIcon,
Pin_Stroke2_Corner0_Rounded as PinIcon,
} from '#/components/icons/Pin'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import * as Menu from '#/components/Menu'
@@ -74,14 +75,20 @@ export function ProfileFeedHeaderSkeleton() {
width: 34,
},
]}>
<Pin size="lg" fill={t.atoms.text_contrast_low.color} />
<PinIcon size="lg" fill={t.atoms.text_contrast_low.color} />
</View>
</Layout.Header.Slot>
</Layout.Header.Outer>
)
}
export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
export function ProfileFeedHeader({
info,
isTrending,
}: {
info: FeedSourceFeedInfo
isTrending: boolean
}) {
const t = useTheme()
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
@@ -188,105 +195,143 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content align="left">
<Button
label={l`Open feed info screen`}
style={[
a.justify_start,
{
paddingVertical: IS_WEB ? 2 : 4,
paddingRight: 8,
},
]}
onPress={() => {
playHaptic()
infoControl.open()
}}>
{({hovered, pressed}) => (
<>
<View
{isTrending ? (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<View style={[a.flex_1]}>
<Text
style={[
a.absolute,
a.inset_0,
a.rounded_sm,
a.transition_all,
t.atoms.bg_contrast_25,
{
opacity: 0,
left: IS_WEB ? -2 : -4,
right: 0,
},
pressed && {
opacity: 1,
},
hovered && {
opacity: 1,
transform: [{scaleX: 1.01}, {scaleY: 1.1}],
},
a.text_md,
a.font_bold,
a.leading_snug,
gtMobile && a.text_lg,
]}
/>
numberOfLines={2}
emoji>
{info.displayName}
</Text>
</View>
<Button
label={l`Open feed info screen`}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onPress={() => {
playHaptic()
infoControl.open()
}}>
<ButtonIcon icon={EllipsisIcon} />
</Button>
</View>
) : (
<Button
label={l`Open feed info screen`}
style={[
a.justify_start,
{
paddingVertical: IS_WEB ? 2 : 4,
paddingRight: 8,
},
]}
onPress={() => {
playHaptic()
infoControl.open()
}}>
{({hovered, pressed}) => (
<>
<View
style={[
a.absolute,
a.inset_0,
a.rounded_sm,
a.transition_all,
t.atoms.bg_contrast_25,
{
opacity: 0,
left: IS_WEB ? -2 : -4,
right: 0,
},
pressed && {
opacity: 1,
},
hovered && {
opacity: 1,
transform: [{scaleX: 1.01}, {scaleY: 1.1}],
},
]}
/>
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{info.avatar && (
<UserAvatar size={36} type="algo" avatar={info.avatar} />
)}
<View
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{info.avatar && (
<UserAvatar
size={36}
type="algo"
avatar={info.avatar}
/>
)}
<View style={[a.flex_1]}>
<Text
style={[
a.text_md,
a.font_bold,
a.leading_snug,
gtMobile && a.text_lg,
]}
numberOfLines={2}
emoji>
{info.displayName}
</Text>
<View style={[a.flex_row, {gap: 6}]}>
<View style={[a.flex_1]}>
<Text
style={[
a.flex_shrink,
a.text_sm,
a.text_md,
a.font_bold,
a.leading_snug,
t.atoms.text_contrast_medium,
gtMobile && a.text_lg,
]}
numberOfLines={1}>
{sanitizeHandle(info.creatorHandle, '@')}
numberOfLines={2}
emoji>
{info.displayName}
</Text>
<View style={[a.flex_row, a.align_center, {gap: 2}]}>
<HeartFilled
size="xs"
fill={
likeUri
? t.palette.pink
: t.atoms.text_contrast_low.color
}
/>
<View style={[a.flex_row, a.gap_2xs]}>
<Text
style={[
a.flex_shrink,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
t.atoms.text_contrast_high,
]}
numberOfLines={1}>
{formatCount(i18n, likeCount)}
{sanitizeHandle(info.creatorHandle, '@')}
</Text>
{likeCount > 0 ? (
<View
style={[a.flex_row, a.align_center, {gap: 2}]}>
<HeartFilledIcon
size="xs"
fill={
likeUri
? t.palette.pink
: t.atoms.text_contrast_low.color
}
style={[{width: 14, height: 14}]}
/>
<Text
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_high,
]}
numberOfLines={1}>
{formatCount(i18n, likeCount)}
</Text>
</View>
) : null}
</View>
</View>
</View>
<Ellipsis
size="md"
fill={t.atoms.text_contrast_low.color}
/>
</View>
</>
)}
</Button>
<EllipsisIcon
size="md"
fill={t.atoms.text_contrast_high.color}
/>
</View>
</>
)}
</Button>
)}
</Layout.Header.Content>
{hasSession && (
{!isTrending && hasSession ? (
<Layout.Header.Slot>
{isPinned ? (
<Menu.Root>
@@ -300,7 +345,10 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
variant="ghost"
shape="square"
color="secondary">
<PinFilled size="lg" fill={t.palette.primary_500} />
<PinFilledIcon
size="lg"
fill={t.palette.primary_500}
/>
</Button>
)
}}
@@ -310,23 +358,23 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
<Menu.Item
disabled={isFeedStateChangePending}
label={l`Unpin from home`}
onPress={onTogglePinned}>
onPress={() => void onTogglePinned()}>
<Menu.ItemText>{l`Unpin from home`}</Menu.ItemText>
<Menu.ItemIcon icon={X} position="right" />
<Menu.ItemIcon icon={XIcon} position="right" />
</Menu.Item>
<Menu.Item
disabled={isFeedStateChangePending}
label={
isSaved ? l`Remove from my feeds` : l`Save to my feeds`
}
onPress={onToggleSaved}>
onPress={() => void onToggleSaved()}>
<Menu.ItemText>
{isSaved
? l`Remove from my feeds`
: l`Save to my feeds`}
</Menu.ItemText>
<Menu.ItemIcon
icon={isSaved ? Trash : Plus}
icon={isSaved ? TrashIcon : PlusIcon}
position="right"
/>
</Menu.Item>
@@ -339,12 +387,12 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
variant="ghost"
shape="square"
color="secondary"
onPress={onTogglePinned}>
<ButtonIcon icon={Pin} size="lg" />
onPress={() => void onTogglePinned()}>
<ButtonIcon icon={PinIcon} size="lg" />
</Button>
)}
</Layout.Header.Slot>
)}
) : null}
</Layout.Header.Outer>
</Layout.Center>
<Dialog.Outer control={infoControl}>
@@ -358,7 +406,8 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
setLikeUri={setLikeUri}
likeCount={likeCount}
isPinned={isPinned}
onTogglePinned={onTogglePinned}
isTrending={isTrending}
onTogglePinned={() => void onTogglePinned()}
isFeedStateChangePending={isFeedStateChangePending}
/>
</Dialog.ScrollableInner>
@@ -373,6 +422,7 @@ function DialogInner({
setLikeUri,
likeCount,
isPinned,
isTrending,
onTogglePinned,
isFeedStateChangePending,
}: {
@@ -381,6 +431,7 @@ function DialogInner({
setLikeUri: (uri: string) => void
likeCount: number
isPinned: boolean
isTrending: boolean
onTogglePinned: () => void
isFeedStateChangePending: boolean
}) {
@@ -459,7 +510,9 @@ function DialogInner({
style={[a.text_sm, a.underline, t.atoms.text_contrast_medium]}
numberOfLines={1}
onPress={() => control.close()}>
{sanitizeHandle(info.creatorHandle, '@')}
{info.creatorHandle === TRENDING_HANDLE
? l`Bluesky`
: sanitizeHandle(info.creatorHandle, '@')}
</InlineLinkText>
</Trans>
</Text>
@@ -472,12 +525,13 @@ function DialogInner({
color="secondary"
shape="round"
onPress={onPressShare}>
<ButtonIcon icon={Share} size="lg" />
<ButtonIcon icon={ShareIcon} size="lg" />
</Button>
</View>
<RichText value={info.description} style={[a.text_md]} />
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
{typeof likeCount === 'number' && (
{typeof likeCount === 'number' && likeCount > 0 ? (
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<InlineLinkText
label={l`View users who like this feed`}
to={makeCustomFeedLink(info.creatorDid, feedRkey, 'liked-by')}
@@ -487,41 +541,47 @@ function DialogInner({
Liked by <Plural value={likeCount} one="# user" other="# users" />
</Trans>
</InlineLinkText>
)}
</View>
{hasSession && (
</View>
) : null}
{hasSession ? (
<>
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
<Button
disabled={isLikePending || isUnlikePending}
label={l`Like this feed`}
size="small"
color="secondary"
onPress={onToggleLiked}
style={[a.flex_1]}>
{isLiked ? (
<HeartFilled size="sm" fill={t.palette.pink} />
) : (
<ButtonIcon icon={Heart} />
)}
{!isTrending ? (
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pt_sm]}>
<Button
disabled={isLikePending || isUnlikePending}
label={l`Like this feed`}
size="small"
color="secondary"
onPress={() => void onToggleLiked()}
style={[a.flex_1]}>
{isLiked ? (
<HeartFilledIcon size="sm" fill={t.palette.pink} />
) : (
<ButtonIcon icon={HeartIcon} />
)}
<ButtonText>
{isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
</ButtonText>
</Button>
<Button
disabled={isFeedStateChangePending}
label={isPinned ? l`Unpin feed` : l`Pin feed`}
size="small"
color={isPinned ? 'secondary' : 'primary'}
onPress={onTogglePinned}
style={[a.flex_1]}>
<ButtonText>
{isPinned ? <Trans>Unpin feed</Trans> : <Trans>Pin feed</Trans>}
</ButtonText>
<ButtonIcon icon={Pin} position="right" />
</Button>
</View>
<ButtonText>
{isLiked ? <Trans>Unlike</Trans> : <Trans>Like</Trans>}
</ButtonText>
</Button>
<Button
disabled={isFeedStateChangePending}
label={isPinned ? l`Unpin feed` : l`Pin feed`}
size="small"
color={isPinned ? 'secondary' : 'primary'}
onPress={onTogglePinned}
style={[a.flex_1]}>
<ButtonText>
{isPinned ? (
<Trans>Unpin feed</Trans>
) : (
<Trans>Pin feed</Trans>
)}
</ButtonText>
<ButtonIcon icon={PinIcon} position="right" />
</Button>
</View>
) : null}
<View style={[a.pt_xs, a.gap_lg]}>
<Divider />
@@ -541,7 +601,7 @@ function DialogInner({
<ButtonText>
<Trans>Report feed</Trans>
</ButtonText>
<ButtonIcon icon={CircleInfo} position="right" />
<ButtonIcon icon={CircleInfoIcon} position="right" />
</Button>
</View>
@@ -556,7 +616,7 @@ function DialogInner({
)}
</View>
</>
)}
) : null}
</View>
)
}
+34 -47
View File
@@ -5,9 +5,7 @@ import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match'
@@ -48,7 +46,6 @@ import {
StarterPackCardSkeleton,
} from '#/screens/Search/components/StarterPackCard'
import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCard'
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {atoms as a, native, platform, useTheme} from '#/alf'
@@ -79,7 +76,7 @@ import {
function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const handleOnPress = () => {
void item.onLoadMore()
@@ -87,7 +84,7 @@ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
return (
<Button
label={_(msg`Load more`)}
label={l`Load more`}
onPress={handleOnPress}
style={[a.relative, a.w_full]}>
{({hovered, pressed}) => (
@@ -139,6 +136,7 @@ type ExploreScreenItems =
key: string
title: string
icon: React.ComponentType<SVGIconProps>
iconSize?: IcoProps['size']
searchButton?: {
label: string
metricsTag: Metrics['explore:module:searchButtonPress']['module']
@@ -154,10 +152,6 @@ type ExploreScreenItems =
type: 'trendingVideos'
key: string
}
| {
type: 'recommendations'
key: string
}
| {
type: 'profile'
key: string
@@ -220,7 +214,7 @@ export function Explore({
headerHeight: number
}) {
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {data: preferences, error: preferencesError} = usePreferencesQuery()
const moderationOpts = useModerationOpts()
@@ -372,10 +366,11 @@ export function Explore({
i.push({
type: 'tabbedHeader',
key: 'suggested-accounts-header',
title: _(msg`Suggested accounts`),
title: l`Suggested accounts`,
icon: Person,
iconSize: 'md',
searchButton: {
label: _(msg`Search for more accounts`),
label: l`Search for more accounts`,
metricsTag: 'suggestedAccounts',
tab: 'user',
},
@@ -388,7 +383,7 @@ export function Explore({
i.push({
type: 'error',
key: 'suggestedUsersError',
message: _(msg`Failed to load suggested follows`),
message: l`Failed to load suggested follows`,
error: cleanError(suggestedUsersError),
})
} else {
@@ -436,7 +431,7 @@ export function Explore({
}
return i
}, [
_,
l,
moderationOpts,
suggestedUsers,
suggestedUsersIsLoading,
@@ -450,10 +445,11 @@ export function Explore({
i.push({
type: 'header',
key: 'suggested-feeds-header',
title: _(msg`Discover new feeds`),
title: l`Discover feeds`,
icon: ListSparkle,
iconSize: 'md',
searchButton: {
label: _(msg`Search for more feeds`),
label: l`Search for more feeds`,
metricsTag: 'suggestedFeeds',
tab: 'feed',
},
@@ -479,14 +475,14 @@ export function Explore({
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -516,7 +512,7 @@ export function Explore({
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`),
message: l`Load more suggested feeds`,
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
})
@@ -527,21 +523,21 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load feeds`),
message: l`Failed to load feeds`,
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -572,21 +568,21 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load feeds`),
message: l`Failed to load feeds`,
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -607,7 +603,7 @@ export function Explore({
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
message: _(msg`Load more suggested feeds`),
message: l`Load more suggested feeds`,
isLoadingMore: isLoadingMoreFeeds,
onLoadMore: onLoadMoreFeeds,
})
@@ -618,21 +614,21 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load feeds`),
message: l`Failed to load feeds`,
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
message: l`Failed to load suggested feeds`,
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
key: 'preferencesError',
message: _(msg`Failed to load feeds preferences`),
message: l`Failed to load feeds preferences`,
error: cleanError(preferencesError),
})
} else {
@@ -642,7 +638,7 @@ export function Explore({
}
return i
}, [
_,
l,
ax,
useFullExperience,
suggestedFeeds,
@@ -662,9 +658,9 @@ export function Explore({
i.push({
type: 'header',
key: 'suggested-starterPacks-header',
title: _(msg`Starter Packs`),
title: l`Starter Packs`,
icon: StarterPack,
iconSize: 'xl',
iconSize: 'md',
})
if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) {
@@ -689,7 +685,7 @@ export function Explore({
return i
}, [
suggestedSPs,
_,
l,
isLoadingSuggestedSPs,
suggestedSPsError,
isRefetchingSuggestedSPs,
@@ -778,7 +774,7 @@ export function Explore({
return (
<View style={[a.pb_md]}>
<ModuleHeader.Container style={[a.pb_xs]}>
<ModuleHeader.Icon icon={item.icon} />
<ModuleHeader.Icon icon={item.icon} size={item.iconSize} />
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
{item.searchButton && (
<ModuleHeader.SearchButton
@@ -798,18 +794,11 @@ export function Explore({
)
}
case 'trendingTopics': {
return (
<View style={[a.pb_md]}>
<ExploreTrendingTopics />
</View>
)
return <ExploreTrendingTopics />
}
case 'trendingVideos': {
return <ExploreTrendingVideos />
}
case 'recommendations': {
return <ExploreRecommendations />
}
case 'profile': {
return (
<SuggestedProfileCard
@@ -1023,9 +1012,7 @@ export function Explore({
case 'preview:loadMoreError': {
return (
<LoadMoreRetryBtn
label={_(
msg`There was an issue fetching posts. Tap here to try again.`,
)}
label={l`There was an issue fetching posts. Tap here to try again.`}
onPress={handleOnPressRetry}
/>
)
@@ -1050,7 +1037,7 @@ export function Explore({
moderationOpts,
interestsDisplayNames,
useFullExperience,
_,
l,
fetchNextPageFeedPreviews,
],
)
+150 -3
View File
@@ -1,6 +1,6 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api'
import {type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
@@ -15,6 +15,7 @@ import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useActorSearch} from '#/state/queries/actor-search'
import {usePopularFeedsSearch} from '#/state/queries/feed'
import {useSearchPostsV2Query} from '#/state/queries/search-posts-v2'
import {useStarterPackSearch} from '#/state/queries/starter-pack-search'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
@@ -23,6 +24,7 @@ import {TabBar} from '#/view/com/pager/TabBar'
import {Post} from '#/view/com/post/Post'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List'
import {StarterPackCard} from '#/screens/Search/components/StarterPackCard'
import {
hasPostOnlyFilters,
type SearchFilters,
@@ -41,6 +43,7 @@ let SearchResults = ({
query,
filters,
hasFilters,
fromMe,
activeTab,
onPageSelected,
headerHeight,
@@ -48,20 +51,26 @@ let SearchResults = ({
query: string
filters: SearchFilters
hasFilters: boolean
fromMe: boolean
activeTab: number
onPageSelected: (page: number) => void
headerHeight: number
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
/*
* People/Feeds visibility keys off post-only filters: a `lang` filter applies
* to people and feeds too, so it must not hide those tabs (which would also
* regress the non-v2 legacy language dropdown). Other filters are post-only.
*/
const hasPostFilters = hasPostOnlyFilters(filters)
const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain'
const isStarterPacksEnabled = ax.features.enabled(
ax.features.SearchStarterPacksV2Enable,
)
const sections = useMemo(() => {
if (!query && !hasFilters) return []
/*
@@ -106,11 +115,29 @@ let SearchResults = ({
<SearchScreenFeedsResults query={query} active={activePage === 3} />
),
},
noFilters &&
isStarterPacksEnabled && {
title: l`Starter packs`,
component: (
<SearchScreenStarterPackResults
query={query}
active={activePage === 4}
/>
),
},
].filter(Boolean) as {
title: string
component: React.ReactNode
}[]
}, [l, query, filters, hasFilters, hasPostFilters, activePage])
}, [
l,
query,
filters,
hasFilters,
hasPostFilters,
activePage,
isStarterPacksEnabled,
])
// There may be fewer tabs after changing the search options.
const selectedPage = activePage > sections.length - 1 ? 0 : activePage
@@ -692,3 +719,123 @@ function SearchFeedCard({
return <FeedCard.Default view={view} onPress={handleOnPress} />
}
let SearchScreenStarterPackResults = ({
query,
active,
}: {
query: string
active: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const {t: l} = useLingui()
const [isPTR, setIsPTR] = useState(false)
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
} = useStarterPackSearch({
query,
enabled: active,
})
const onPullToRefresh = useCallback(async () => {
setIsPTR(true)
await refetch()
setIsPTR(false)
}, [setIsPTR, refetch])
const onEndReached = useCallback(() => {
if (isFetching || !hasNextPage || error) return
void fetchNextPage()
}, [isFetching, error, hasNextPage, fetchNextPage])
const starterPacks = useMemo(() => {
return results?.pages.flatMap(page => page.starterPacks) || []
}, [results])
const fireTracking = useCallOnce(() => {
ax.metric('search:results:loaded', {
tab: 'starterPacks',
initialCount: starterPacks.length,
})
})
if (isFetched) {
fireTracking()
}
if (error) {
return (
<EmptyState
messageText={
shouldRetryError(error) || isNetworkError(error)
? l`Were sorry, but your search could not be completed. Please try again in a few minutes.`
: l`Were 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,
useProfilesQuery,
} from '#/state/queries/profile'
import {extractFromMe} from '#/state/queries/search-posts-params'
import {useSession} from '#/state/session'
import {
countActiveFilters,
@@ -123,8 +124,18 @@ export function SearchScreenShell({
const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab
const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam))
/*
* A raw `from:me` operator stays visible in the search input. Submitting the
* advanced dialog promotes it to a structured `from=me` filter and removes
* it from `q`; the API layer reconstructs the operator for post search.
*/
const {query, fromMe, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
// Query terms
const [searchText, setSearchText] = useState<string>(queryParam)
const [searchText, setSearchText] = useState<string>(query)
const searchTextRef = useRef(searchText)
const updateSearchText = useCallback((text: string) => {
searchTextRef.current = text
@@ -200,10 +211,6 @@ export function SearchScreenShell({
[accountHistory, setAccountHistory],
)
const {query, filters, setFilters, hasFilters} = useQueryManager({
initialQuery: queryParam,
fixedParams,
})
const showFilters = Boolean((query || hasFilters) && !showAutocomplete)
const onChangeLang = useCallback(
@@ -233,13 +240,13 @@ export function SearchScreenShell({
useEffect(() => {
if (IS_NATIVE) {
// eslint-disable-next-line react-hooks/set-state-in-effect
updateSearchText(queryParam)
updateSearchText(query)
}
}, [queryParam, updateSearchText])
}, [query, updateSearchText])
useFocusEffect(
useNonReactiveCallback(() => {
if (IS_WEB) {
updateSearchText(queryParam)
updateSearchText(query)
}
}),
)
@@ -329,11 +336,12 @@ export function SearchScreenShell({
])
const onSubmit = (source: 'typed' | 'autocomplete') => () => {
const nextQuery = searchTextRef.current
ax.metric('search:query', {
source,
filterCount: countActiveFilters(filters),
})
navigateToItem(searchTextRef.current)
navigateToItem(nextQuery)
}
const onSubmitAdvanced = useCallback(
@@ -474,17 +482,6 @@ export function SearchScreenShell({
}
}, [setShowAutocomplete])
const onSearchInputBlur = useCallback(() => {
/*
* Bind autocomplete visibility to focus state on native. On web this
* doesn't work because of focus management, which would render the
* autocomplete results uninteractable.
*/
if (IS_NATIVE) {
setShowAutocomplete(false)
}
}, [])
const focusSearchInput = useCallback(
(tab?: TabParam) => {
textInput.current?.focus()
@@ -609,7 +606,6 @@ export function SearchScreenShell({
ref={textInput}
value={searchText}
onFocus={onSearchInputFocus}
onBlur={onSearchInputBlur}
onChangeText={onChangeText}
onClearText={onPressClearQuery}
onSubmitEditing={onSubmit('typed')}
@@ -656,6 +652,7 @@ export function SearchScreenShell({
query={query}
filters={filters}
hasFilters={hasFilters}
fromMe={fromMe}
headerHeight={headerHeight}
focusSearchInput={focusSearchInput}
/>
@@ -707,6 +704,7 @@ let SearchScreenInner = ({
query,
filters,
hasFilters,
fromMe,
headerHeight,
focusSearchInput,
}: {
@@ -715,6 +713,7 @@ let SearchScreenInner = ({
query: string
filters: SearchFilters
hasFilters: boolean
fromMe: boolean
headerHeight: number
focusSearchInput: (tab?: TabParam) => void
}): React.ReactNode => {
@@ -731,6 +730,7 @@ let SearchScreenInner = ({
query={query}
filters={filters}
hasFilters={hasFilters}
fromMe={fromMe}
activeTab={activeTab}
headerHeight={headerHeight}
onPageSelected={onPageSelected}
@@ -781,8 +781,13 @@ function useQueryManager({
const navigation = useNavigation<NavigationProp>()
const route = useRoute()
// Free text only - structured filters live in sibling route params now.
// A raw Me operator remains part of the query until the advanced dialog
// promotes it to the structured `from` filter.
const query = initialQuery
const fromMe = useMemo(
() => extractFromMe(initialQuery).fromMe,
[initialQuery],
)
const filters = useMemo(() => {
const fromRoute = readSearchFilters(route.params as Record<string, unknown>)
@@ -814,11 +819,12 @@ function useQueryManager({
return useMemo(
() => ({
query,
fromMe,
filters,
setFilters,
hasFilters: hasActiveFilters(filters),
}),
[query, filters, setFilters],
[query, fromMe, filters, setFilters],
)
}
@@ -4,6 +4,7 @@ import {
countActiveFilters,
definedFilterParams,
filtersToApiParams,
hasActiveFilters,
hasPostOnlyFilters,
parseHistoryEntry,
readSearchFilters,
@@ -55,6 +56,14 @@ describe(`searchParams`, () => {
})
})
describe(`hasActiveFilters`, () => {
it(`includes the structured Me author filter`, () => {
expect(hasActiveFilters({})).toBe(false)
expect(hasActiveFilters({from: 'me'})).toBe(true)
expect(hasActiveFilters({author: 'alice'})).toBe(true)
})
})
describe(`definedFilterParams`, () => {
it(`omits absent keys entirely`, () => {
expect(definedFilterParams({author: 'alice'})).toEqual({author: 'alice'})
@@ -130,8 +139,9 @@ describe(`searchParams`, () => {
})
describe(`countActiveFilters`, () => {
it(`counts each set filter key once`, () => {
it(`counts each structured filter key once`, () => {
expect(countActiveFilters({})).toBe(0)
expect(countActiveFilters({from: 'me'})).toBe(1)
expect(
countActiveFilters({author: 'alice bob', domain: 'bsky.app'}),
).toBe(2)
@@ -152,6 +162,14 @@ describe(`searchParams`, () => {
})
})
it(`round-trips a promoted Me-only search`, () => {
const stored = serializeHistoryEntry('', {from: 'me'})
expect(parseHistoryEntry(stored)).toEqual({
q: '',
filters: {from: 'me'},
})
})
it(`round-trips query + filters`, () => {
const filters = {
author: 'alice',
@@ -7,20 +7,21 @@ import {
ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
} from '#/components/icons/Chevron'
import * as Menu from '#/components/Menu'
import {type FollowingFilter} from './utils'
import {type FromFilter} from './utils'
export function FollowingDropdown({
export function FromDropdown({
value,
onChange,
}: {
value: FollowingFilter
onChange: (value: FollowingFilter) => void
value: FromFilter
onChange: (value: FromFilter) => void
}) {
const {t: l} = useLingui()
const options: {value: FollowingFilter; label: string}[] = [
const options: {value: FromFilter; label: string}[] = [
{value: 'anyone', label: l`Anyone`},
{value: 'following', label: l`People you follow`},
{value: 'following', label: l`People I follow`},
{value: 'me', label: l`Me`},
]
const currentLabel = options.find(o => o.value === value)?.label ?? l`Anyone`
@@ -73,12 +73,68 @@ describe(`AdvancedSearchDialog serialize/parse`, () => {
expect(state.until).toBe('2024-02-01')
})
it(`keeps from:me in the query box rather than lifting it into a row`, () => {
const state = parseAdvancedSearch('from:me', {})
expect(state.query).toBe('from:me')
it(`lifts from:me typed into the query box into the "me" following filter`, () => {
const state = parseAdvancedSearch('cats from:me', {})
expect(state.query).toBe('cats')
expect(state.following).toBe('me')
expect(state.filters.find(f => f.field === 'authors')).toBeUndefined()
})
it(`promotes the "me" filter from q to a structured filter`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats',
following: 'me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
expect(out.filters.following).toBeUndefined()
})
it(`strips from:me typed after selecting Me`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats from:me',
following: 'me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
})
it(`promotes from:me typed after the dialog opens`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats from:me',
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
})
it(`keeps a quoted from:me as query text`, () => {
const out = serializeAdvancedSearch({
...emptySerializeState,
query: 'cats "from:me"',
})
expect(out.q).toBe('cats "from:me"')
expect(out.filters.from).toBeUndefined()
})
it(`round-trips cats from:me through the "me" following filter`, () => {
const state = parseAdvancedSearch('cats from:me', {})
const out = serializeAdvancedSearch({
...emptySerializeState,
query: state.query,
following: state.following,
})
expect(out.q).toBe('cats')
expect(out.filters.from).toBe('me')
expect(out.filters.following).toBeUndefined()
})
it(`parses the structured Me filter into the From dropdown`, () => {
expect(parseAdvancedSearch('cats', {from: 'me'}).following).toBe('me')
})
it(`merges a query-box operator with the matching filter param`, () => {
const state = parseAdvancedSearch('hi from:bob', {author: 'alice'})
expect(state.query).toBe('hi')
@@ -22,12 +22,12 @@ import {SearchLanguageDropdown} from '../SearchLanguageDropdown'
import {ClearableDateField, DEFAULT_DATE} from './ClearableDateField'
import {ClearableInput} from './ClearableInput'
import {FilterBlock} from './FilterBlock'
import {FollowingDropdown} from './FollowingDropdown'
import {FromDropdown} from './FromDropdown'
import {MediaDropdown} from './MediaDropdown'
import {RepliesDropdown} from './RepliesDropdown'
import {
type AdvancedFilter,
type FollowingFilter,
type FromFilter,
makeFilter,
type MediaFilter,
parseAdvancedSearch,
@@ -124,7 +124,7 @@ function DialogInner({
const [media, setMedia] = useState<MediaFilter>(parsed.media)
const [replies, setReplies] = useState<RepliesFilter>(parsed.replies)
const [following, setFollowing] = useState<FollowingFilter>(parsed.following)
const [following, setFollowing] = useState<FromFilter>(parsed.following)
/*
* The date picker requires a valid date, so these always hold one. The
@@ -389,7 +389,9 @@ function DialogInner({
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>Include</Trans>
<Trans comment="Include search results with or without replies">
Include
</Trans>
</Text>
<View style={[a.flex_row]}>
<RepliesDropdown value={replies} onChange={setReplies} />
@@ -403,10 +405,12 @@ function DialogInner({
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
<Trans>From</Trans>
<Trans comment="Filter search results by a specific post author">
From
</Trans>
</Text>
<View style={[a.flex_row]}>
<FollowingDropdown value={following} onChange={setFollowing} />
<FromDropdown value={following} onChange={setFollowing} />
</View>
</View>
</View>
@@ -1,4 +1,5 @@
import {
extractFromMe,
extractSearchPostsParams,
tokenizeQuery,
} from '#/state/queries/search-posts-params'
@@ -14,10 +15,11 @@ export type RepliesFilter = 'all' | 'none' | 'only'
export type MediaFilter = 'all' | 'media' | 'video'
/**
* Whether to limit results to authors the user follows. Serializes into the
* `following` sibling param ('following' -> following:true, anyone -> unset).
* Which authors to limit results to. 'following' serializes into the
* `following` sibling param (following=true); 'me' serializes into the `from`
* sibling param (from=me); 'anyone' leaves both unset.
*/
export type FollowingFilter = 'anyone' | 'following'
export type FromFilter = 'anyone' | 'following' | 'me'
export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
@@ -93,7 +95,7 @@ export type DialogState = {
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
following: FromFilter
since: string
until: string
filters: AdvancedFilter[]
@@ -141,18 +143,27 @@ function isSimpleWord(word: string): boolean {
* populate "none of these words" - but only when their contents are simple
* words. Anything that wouldn't round-trip (embedded quotes, a negated phrase
* like -"a b", etc.) is left verbatim in the main "all of these words" query
* text instead of parsed.
* text instead of parsed. A bare `from:me` is pulled out into the `fromMe`
* flag, which drives the "Me" author filter (the backend resolves `me` to the
* viewer, so it never becomes a structured `author` value).
*/
function parseFreeText(raw: string): {
query: string
exactPhrase: string
negatedWords: string
fromMe: boolean
} {
const queryParts: string[] = []
const negatedWords: string[] = []
let exactPhrase = ''
let fromMe = false
for (const token of tokenizeQuery(raw)) {
// from:me -> "Me" author filter rather than free text.
if (token === 'from:me') {
fromMe = true
continue
}
// "phrase" -> "exact phrase", only if it has no inner quote.
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
const inner = token.slice(1, -1)
@@ -176,6 +187,7 @@ function parseFreeText(raw: string): {
query: queryParts.join(' '),
exactPhrase,
negatedWords: negatedWords.join(' '),
fromMe,
}
}
@@ -208,7 +220,7 @@ export function parseAdvancedSearch(
* can be expressed as operators; exclude rows come solely from filter params.
*/
const lifted = extractSearchPostsParams(q)
const freeText = parseFreeText(lifted.q)
const {fromMe, ...freeText} = parseFreeText(lifted.q)
const includeValues: Record<FilterField, string> = {
authors: mergeValues(filters.author, lifted.author),
@@ -255,12 +267,24 @@ export function parseAdvancedSearch(
const since = filters.since ?? lifted.since
const until = filters.until ?? lifted.until
/*
* A raw `from:me` operator and the structured `from=me` filter both map to
* the "Me" author filter. The raw operator is promoted to the structured
* filter when the dialog is submitted.
*/
let following: FromFilter = 'anyone'
if (fromMe || filters.from === 'me') {
following = 'me'
} else if (filters.following === 'true') {
following = 'following'
}
return {
...freeText,
language: lang,
replies,
media,
following: filters.following === 'true' ? 'following' : 'anyone',
following,
since: since && isValidDate(since) ? since : '',
until: until && isValidDate(until) ? until : '',
filters: filterRows,
@@ -279,7 +303,7 @@ export function serializeAdvancedSearch(state: {
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
following: FromFilter
dateSince: string
dateSinceActive: boolean
dateUntil: string
@@ -352,7 +376,21 @@ export function serializeAdvancedSearch(state: {
if (state.replies === 'only') filters.replies = 'only'
if (state.media === 'media') filters.media = 'true'
else if (state.media === 'video') filters.video = 'true'
if (state.following === 'following') filters.following = 'true'
return {q: parts.join(' '), filters}
/*
* Re-parse the final text because a user can type `from:me` after the dialog
* has opened. Advanced submission removes every bare token from the search
* input and promotes it to the structured From filter. Quoted `"from:me"`
* remains ordinary query text.
*/
const {q, fromMe} = extractFromMe(parts.join(' '))
if (state.following === 'following') filters.following = 'true'
else if (state.following === 'me' || fromMe) filters.from = 'me'
/*
* Submitting the dialog promotes a raw `from:me` operator to `from=me`, so it
* leaves the search text and is represented by the From dropdown. The query
* hook reconstructs the backend operator at the API boundary.
*/
return {q, filters}
}
@@ -31,7 +31,7 @@ export function Container({
a.px_lg,
a.pt_2xl,
a.pb_md,
a.gap_sm,
a.gap_xs,
t.atoms.bg,
bottomBorder && [a.border_b, t.atoms.border_contrast_low],
style,
@@ -82,11 +82,13 @@ export function Icon({
icon: Comp,
size = 'lg',
}: Pick<React.ComponentProps<typeof ButtonIcon>, 'icon' | 'size'>) {
const t = useTheme()
const iconSize = iconSizes[size]
return (
<View style={[a.z_20, {width: iconSize, height: iconSize, marginLeft: -2}]}>
<Comp width={iconSize} />
<Comp width={iconSize} fill={t.atoms.text.color} />
</View>
)
}
@@ -94,7 +96,7 @@ export function Icon({
export function TitleText({style, ...props}: TextProps) {
return (
<Text
style={[a.font_semi_bold, a.flex_1, a.text_xl, style]}
style={[a.font_semi_bold, a.flex_1, a.text_lg, style]}
emoji
{...props}
/>
@@ -26,8 +26,10 @@ import * as bsky from '#/types/bsky'
export function StarterPackCard({
view,
onPress,
}: {
view: AppBskyGraphDefs.StarterPackView
onPress?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
@@ -55,7 +57,10 @@ export function StarterPackCard({
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}>
onPress={() => {
link.precache()
onPress?.()
}}>
{s => (
<>
<SubtleHover hover={s.hovered || s.pressed} />
@@ -111,7 +116,10 @@ export function StarterPackCard({
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}
onPress={() => {
link.precache()
onPress?.()
}}
variant="solid"
color="secondary"
size="small"
@@ -1,15 +1,13 @@
import {useState} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useInterestsDisplayNames} from '#/lib/interests'
import {Nux, useSaveNux} from '#/state/queries/nuxs'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Shapes_Stroke2_Corner0_Rounded as Shapes} from '#/components/icons/Shapes'
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
@@ -17,7 +15,7 @@ import {Text} from '#/components/Typography'
export function ExploreInterestsCard() {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {data: preferences} = usePreferencesQuery()
const interestsDisplayNames = useInterestsDisplayNames()
const {mutateAsync: saveNux} = useSaveNux()
@@ -41,16 +39,12 @@ export function ExploreInterestsCard() {
<>
<Prompt.Basic
control={trendingPrompt}
title={_(msg`Dismiss interests`)}
description={_(
msg`You can adjust your interests at any time from "Content and media" settings.`,
)}
confirmButtonCta={_(
msg({
message: `OK`,
comment: `Confirm button text.`,
}),
)}
title={l`Dismiss interests`}
description={l`You can adjust your interests at any time from "Content and media" settings.`}
confirmButtonCta={l({
message: `OK`,
comment: `Confirm button text.`,
})}
onConfirm={onConfirmClose}
/>
@@ -63,8 +57,8 @@ export function ExploreInterestsCard() {
t.atoms.border_contrast_medium,
]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<Shapes />
<Text style={[a.text_xl, a.font_semi_bold, a.leading_tight]}>
<ShapesIcon fill={t.atoms.text.color} />
<Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Your interests</Trans>
</Text>
</View>
@@ -96,7 +90,7 @@ export function ExploreInterestsCard() {
</Text>
<Link
label={_(msg`Edit interests`)}
label={l`Edit interests`}
to="/settings/interests"
size="small"
variant="solid"
@@ -108,7 +102,7 @@ export function ExploreInterestsCard() {
</Link>
<Button
label={_(msg`Hide this card`)}
label={l`Hide this card`}
size="small"
variant="ghost"
color="secondary"
@@ -1,120 +0,0 @@
import {View} from 'react-native'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {
DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
useTrendingTopics,
} from '#/state/queries/trending/useTrendingTopics'
import {useTrendingConfig} from '#/state/service-config'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
import {
TrendingTopic,
TrendingTopicLink,
TrendingTopicSkeleton,
} from '#/components/TrendingTopics'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
// Note: This module is not currently used and may be removed in the future.
export function ExploreRecommendations() {
const {enabled} = useTrendingConfig()
return enabled ? <Inner /> : null
}
function Inner() {
const t = useTheme()
const ax = useAnalytics()
const gutters = useGutters([0, 'compact'])
const {data: trending, error, isLoading} = useTrendingTopics()
const noRecs = !isLoading && !error && !trending?.suggested?.length
const allFeeds = trending?.suggested && isAllFeeds(trending.suggested)
return error || noRecs ? null : (
<>
<View
style={[
a.flex_row,
IS_WEB
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [a.p_lg, a.pt_2xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Hashtag_Stroke2_Corner0_Rounded
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Recommended</Trans>
</Text>
</View>
{!allFeeds ? (
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>
Content from across the network we think you might like.
</Trans>
</Text>
) : (
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>Feeds we think you might like.</Trans>
</Text>
)}
</View>
</View>
<View style={[a.pt_md, a.pb_lg]}>
<View
style={[
a.flex_row,
a.justify_start,
a.flex_wrap,
{rowGap: 8, columnGap: 6},
gutters,
]}>
{isLoading ? (
Array(RECOMMENDATIONS_COUNT)
.fill(0)
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
) : !trending?.suggested ? null : (
<>
{trending.suggested.map(topic => (
<TrendingTopicLink
key={topic.link}
topic={topic}
onPress={() => {
ax.metric('recommendedTopic:click', {context: 'explore'})
}}>
{({hovered}) => (
<TrendingTopic
topic={topic}
style={[
hovered && [
t.atoms.border_contrast_high,
t.atoms.bg_contrast_25,
],
]}
/>
)}
</TrendingTopicLink>
))}
</>
)}
</View>
</View>
</>
)
}
function isAllFeeds(topics: AppBskyUnspeccedDefs.TrendingTopic[]) {
return topics.every(topic => {
const segments = topic.link.split('/').slice(1)
return segments[0] === 'profile' && segments[2] === 'feed'
})
}
@@ -1,27 +1,34 @@
import {useMemo} from 'react'
import {Pressable, View} from 'react-native'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Image} from 'expo-image'
import {
type AppBskyUnspeccedDefs,
moderateProfile,
RichText as RichTextApi,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
import {formatCount} from '#/view/com/util/numeric/format'
import {atoms as a, useGutters, useTheme, type ViewStyleProp} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import * as ModuleHeader from '../components/ModuleHeader'
const TOPIC_COUNT = 5
const IMAGE_SIZE = 56
export function ExploreTrendingTopics() {
const {enabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
@@ -32,24 +39,36 @@ function Inner() {
const ax = useAnalytics()
const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery()
const noTopics = !isLoading && !error && !trending?.trends?.length
const showLoading = isLoading || isRefetching
return isLoading || isRefetching ? (
Array.from({length: TOPIC_COUNT}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} withPosts={i === 0} />
))
) : error || !trending?.trends || noTopics ? null : (
<>
{trending.trends.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
onPress={() => {
ax.metric('trendingTopic:click', {context: 'explore'})
}}
/>
))}
</>
if (!showLoading && (error || !trending?.trends || noTopics)) return null
return (
<View style={[a.pb_md]}>
<ModuleHeader.Container bottomBorder>
<ModuleHeader.Icon icon={TrendingIcon} size="md" />
<ModuleHeader.TitleText>
<Trans>Trending</Trans>
</ModuleHeader.TitleText>
</ModuleHeader.Container>
{showLoading
? Array.from({length: TOPIC_COUNT}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} />
))
: trending?.trends.map((trend, index) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'explore',
recId: trending.recId,
})
}}
/>
))}
</View>
)
}
@@ -65,22 +84,24 @@ export function TrendRow({
onPress?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l, i18n} = useLingui()
const gutters = useGutters([0, 'base'])
const category = useCategoryDisplayName(trend?.category || 'other')
const age = Math.floor(
(Date.now() - new Date(trend.startedAt || Date.now()).getTime()) /
(1000 * 60 * 60),
)
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
const actors = useModerateTrendingActors(trend.actors)
const description = useMemo(() => {
if (!trend.description) return
const rt = new RichTextApi({text: trend.description})
rt.detectFacetsWithoutResolution()
return rt
}, [trend.description])
let imageUrl = null // TODO Image URL goes here when available. -dsb
return (
<Link
testID={trend.link}
label={_(msg`Browse topic ${trend.displayName}`)}
label={l`Browse topic ${trend.displayName}`}
to={trend.link}
onPress={onPress}
style={[a.border_b, t.atoms.border_contrast_low]}
@@ -88,52 +109,75 @@ export function TrendRow({
{({hovered, pressed}) => (
<>
<SubtleHover hover={hovered || pressed} native />
<View style={[gutters, a.w_full, a.py_lg, a.flex_row, a.gap_2xs]}>
<View style={[a.flex_1, a.gap_xs]}>
<View style={[a.flex_row]}>
<Text
style={[
a.text_md,
a.font_semi_bold,
a.leading_tight,
{width: 20},
]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_tight]}
numberOfLines={1}>
{trend.displayName}
</Text>
</View>
<View
style={[
a.flex_row,
a.gap_sm,
a.align_center,
{paddingLeft: 20},
]}>
{actors.length > 0 && (
<AvatarStack size={20} profiles={actors} />
)}
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
web(a.leading_snug),
]}
numberOfLines={1}>
{category}
</Text>
</View>
</View>
<View style={[a.flex_shrink_0]}>
<TrendingIndicator type={badgeType} />
</View>
</View>
<View style={[gutters, a.w_full, a.flex_row, a.py_md, a.gap_sm]}>
<Text
style={[
a.text_sm,
a.font_medium,
t.atoms.text_contrast_low,
{
fontVariant: ['tabular-nums'],
},
]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[a.text_sm, a.font_semi_bold, a.leading_snug]}
numberOfLines={1}>
{trend.displayName}
</Text>
{description ? (
<RichText
value={description}
disableLinks
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={2}
/>
) : null}
<View style={[a.mt_xs, a.flex_row, a.gap_sm, a.align_center]}>
{actors.length > 0 ? (
<AvatarStack size={24} profiles={actors} />
) : null}
<Text
style={[a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{trend.postCount >= 1000 ? (
<Trans comment="Over 1,000 posts">1K+ posts</Trans>
) : (
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
{formatCount(i18n, trend.postCount)}{' '}
{plural(trend.postCount, {one: 'post', other: 'posts'})}
</Trans>
)}
</Text>
</View>
</View>
{imageUrl ? (
<Image
source={{
uri: imageUrl,
}}
alt={trend.topic}
style={[
a.flex_0,
a.rounded_md,
t.atoms.bg_contrast_25,
{
width: IMAGE_SIZE,
height: IMAGE_SIZE,
},
]}
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
useAppleWebpCodec
/>
) : null}
</View>
{children}
</>
)}
@@ -141,96 +185,30 @@ export function TrendRow({
)
}
type TrendingIndicatorType = 'hot' | 'new' | number
function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) {
const t = useTheme()
const {_} = useLingui()
const pillStyles = [
a.flex_row,
a.align_center,
a.gap_xs,
a.rounded_full,
{height: 28, paddingHorizontal: 10},
]
let Icon: React.ComponentType<SVGIconProps> | null = null
let text: string | null = null
let color: string | null = null
let backgroundColor: string | null = null
switch (type) {
case 'skeleton': {
return (
<View
style={[
pillStyles,
{backgroundColor: t.palette.contrast_25, width: 65, height: 28},
]}
/>
)
}
case 'hot': {
Icon = FlameIcon
color =
t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950
backgroundColor =
t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200
text = _(msg`Hot`)
break
}
case 'new': {
Icon = TrendingIcon
text = _(msg`New`)
color = t.palette.positive_600
backgroundColor = t.palette.positive_50
break
}
default: {
text = _(
msg({
message: `${type}h ago`,
comment:
'trending topic time spent trending. should be as short as possible to fit in a pill',
}),
)
color = t.atoms.text_contrast_medium.color
backgroundColor = t.atoms.bg_contrast_25.backgroundColor
break
}
}
return (
<View style={[pillStyles, {backgroundColor}]}>
{Icon && <Icon size="sm" style={{color}} />}
<Text style={[a.text_sm, a.font_medium, {color}]}>{text}</Text>
</View>
)
}
function useCategoryDisplayName(
// Unused atm, but leaving here so we don't lose localization. -dsb
export function useCategoryDisplayName(
category: AppBskyUnspeccedDefs.TrendView['category'],
) {
const {_} = useLingui()
const {t: l} = useLingui()
switch (category) {
case 'sports':
return _(msg`Sports`)
return l`Sports`
case 'politics':
return _(msg`Politics`)
return l`Politics`
case 'video-games':
return _(msg`Video Games`)
return l`Video Games`
case 'pop-culture':
return _(msg`Entertainment`)
return l`Entertainment`
case 'news':
return _(msg`News`)
return l`News`
case 'other':
default:
return null
}
}
export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
export function TrendingTopicRowSkeleton() {
const t = useTheme()
const gutters = useGutters([0, 'base'])
@@ -239,32 +217,39 @@ export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) {
style={[
gutters,
a.w_full,
a.py_lg,
a.py_md,
a.flex_row,
a.gap_2xs,
a.gap_sm,
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center]}>
<View style={[{width: 20}]}>
<LoadingPlaceholder
width={12}
height={12}
style={[a.rounded_full]}
/>
</View>
<LoadingPlaceholder width={90} height={17} />
</View>
<View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}>
<View style={[{width: 20}]}>
<LoadingPlaceholder width={17} height={17} style={[a.rounded_full]} />
</View>
<View style={[a.flex_1, a.gap_2xs]}>
<LoadingPlaceholder width={90} height={17} />
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder width={70} height={16} />
<LoadingPlaceholder width={40} height={16} />
<LoadingPlaceholder width={60} height={16} />
</View>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder width={50} height={16} />
<LoadingPlaceholder width={70} height={16} />
<LoadingPlaceholder width={30} height={16} />
</View>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.mt_xs, a.flex_row, a.gap_sm, a.align_center]}>
<LoadingPlaceholder
width={24}
height={24}
style={[a.rounded_full]}
/>
<LoadingPlaceholder width={60} height={16} />
</View>
</View>
</View>
<View style={[a.flex_shrink_0]}>
<TrendingIndicator type="skeleton" />
</View>
{/* TODO Image placeholder goes here when images are available. -dsb */}
</View>
)
}
+6 -2
View File
@@ -31,6 +31,8 @@ export type SearchFilters = {
video?: string
/** 'true' */
following?: string
/** 'me' */
from?: string
}
export const FILTER_PARAM_KEYS = [
@@ -51,6 +53,7 @@ export const FILTER_PARAM_KEYS = [
'media',
'video',
'following',
'from',
] as const
/**
@@ -76,13 +79,14 @@ export function readSearchFilters(
}
export function hasActiveFilters(filters: SearchFilters): boolean {
return FILTER_PARAM_KEYS.some(key => filters[key])
return countActiveFilters(filters) > 0
}
/**
* Number of active filter params, used for the "[+N filters]" pill in search
* history. Each set key counts once (a multi-value field like author counts as
* one filter regardless of how many handles it holds).
* one filter regardless of how many handles it holds). Raw query operators do
* not count until the advanced dialog promotes them to structured params.
*/
export function countActiveFilters(filters: SearchFilters): number {
return FILTER_PARAM_KEYS.filter(key => filters[key]).length
@@ -0,0 +1,263 @@
import {useCallback, useMemo} from 'react'
import {type ListRenderItemInfo, Text as RNText, View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActivitySubscriptionsQuery} from '#/state/queries/activity-subscriptions'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {SubscribeProfileDialog} from '#/components/activity-notifications/SubscribeProfileDialog'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {
BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon,
BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon,
} from '#/components/icons/BellRinging'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as SettingsList from '../components/SettingsList'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {PreferenceControls} from './components/PreferenceControls'
type Props = NativeStackScreenProps<
AllNavigatorParams,
'ActivityNotificationSettings'
>
export function ActivityNotificationSettingsScreen({}: Props) {
const t = useTheme()
const {t: l} = useLingui()
const {data: preferences, isError: isPreferencesError} =
useNotificationSettingsQuery()
const moderationOpts = useModerationOpts()
const {
data: subscriptions,
isPending,
isError: isSubscriptionsError,
error,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useActivitySubscriptionsQuery()
const items = useMemo(() => {
if (!subscriptions) return []
return subscriptions.pages.flatMap(page => page.subscriptions)
}, [subscriptions])
const renderItem = useCallback(
({item}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null
return (
<ActivitySubscriptionCard
profile={item}
moderationOpts={moderationOpts}
/>
)
},
[moderationOpts],
)
const onEndReached = useCallback(() => {
if (isFetchingNextPage || !hasNextPage || isSubscriptionsError) return
void fetchNextPage().catch(err => {
logger.error('Failed to load more activity subscriptions', {
message: err,
})
})
}, [isFetchingNextPage, hasNextPage, isSubscriptionsError, fetchNextPage])
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Notifications</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<List
ListHeaderComponent={
<SettingsList.Container>
<SettingsList.Item style={[a.align_start]}>
<SettingsList.ItemIcon icon={BellRingingIcon} />
<ItemTextWithSubtitle
bold
titleText={l`Activity from others`}
subtitleText={l`Get notified about posts and replies from accounts you choose.`}
/>
</SettingsList.Item>
{isPreferencesError ? (
<View style={[a.px_xl, a.pt_md]}>
<Admonition.Admonition type="error">
<Trans>Failed to load notification settings.</Trans>
</Admonition.Admonition>
</View>
) : (
<View style={[a.px_xl]}>
<PreferenceControls
name="subscribedPost"
preference={preferences?.subscribedPost}
/>
</View>
)}
</SettingsList.Container>
}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
onEndReached={onEndReached}
onEndReachedThreshold={4}
ListEmptyComponent={
error ? null : (
<View style={[a.px_xl, a.py_md]}>
{!isPending ? (
<Admonition.Outer type="tip">
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
Enable notifications for an account by visiting their
profile and pressing the{' '}
<RNText
style={[
a.font_semi_bold,
t.atoms.text_contrast_high,
]}>
bell icon
</RNText>{' '}
<BellRingingFilledIcon
size="xs"
style={t.atoms.text_contrast_high}
/>
.
</Trans>
</Admonition.Text>
<Admonition.Text>
<Trans>
If you want to restrict who can receive notifications
for your account's activity, you can change this in{' '}
<InlineLinkText
label={l`Privacy and Security settings`}
to={{screen: 'ActivityPrivacySettings'}}
style={[a.font_semi_bold]}>
Settings &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 />
<Text style={[a.font_semi_bold, a.text_md]}>
<Trans>From</Trans>
<Trans comment="Filter who you receive notifications from">
From
</Trans>
</Text>
<Toggle.Group
type="radio"

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