Merge remote-tracking branch 'origin/main' into app-2958-header-passthrough

* origin/main: (68 commits)
  Create Mod Inbox action details screen (#11657)
  Add carousel Slack alerts for nightly Maestro failures (#11645)
  Migrate Expo image manipulator API (#11661)
  APP-3031: Fix contact imports after Expo 57 (#11658)
  Nightly source-language update
  Use title case for Starter Pack (#11653)
  Create mod inbox report details screen (#11641)
  Create Mod Inbox settings screen (#11640)
  v1.132.0 release prep (#11644)
  Add starter pack reference-list opt-out UI (#11578)
  Nightly source-language update
  APP-2977: Handle AppView polling errors (#11590)
  APP-3015: Fix link metadata type detection (#11637)
  Create Mod Inbox account status banner (#11636)
  Remove gate for OP thread numbering (#11639)
  APP-2997: Handle age assurance data load failures (#11612)
  APP-2687: Fix threaded view replies breaking after rotating the lightbox (#11638)
  Create moderation inbox reports list (#11570)
  APP-2974: Fix composer not scrollable when Reduced Motion is on (#11628)
  Add video playback analytics events (#11629)
  ...
This commit is contained in:
Eric Bailey
2026-09-04 13:01:28 -05:00
372 changed files with 82583 additions and 71341 deletions
@@ -0,0 +1,55 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import sharp from 'sharp'
export async function frameSlackScreenshots({
inputPath,
outputPath,
outputDir,
}) {
const payload = JSON.parse(fs.readFileSync(inputPath, 'utf8'))
if (!Array.isArray(payload.file_uploads)) {
throw new Error('Slack upload payload must contain a file_uploads array')
}
fs.mkdirSync(outputDir, {recursive: true})
const framedUploads = []
for (const [index, upload] of payload.file_uploads.entries()) {
if (
typeof upload.file !== 'string' ||
typeof upload.filename !== 'string'
) {
throw new Error(`Invalid Slack file upload at index ${index}`)
}
const filename = `${path.parse(path.basename(upload.filename)).name}.png`
const framedFile = path.join(outputDir, filename)
await sharp(upload.file)
.resize(1600, 1200, {fit: 'contain', background: '#f8f8f8'})
.png()
.toFile(framedFile)
framedUploads.push({
...upload,
file: framedFile,
filename,
highlight_type: 'png',
})
}
payload.file_uploads = framedUploads
fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`)
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
) {
const [inputPath, outputPath, outputDir] = process.argv.slice(2)
if (!inputPath || !outputPath || !outputDir) {
throw new Error(
'Usage: frame-slack-screenshots.mjs <input.json> <output.json> <output-dir>',
)
}
await frameSlackScreenshots({inputPath, outputPath, outputDir})
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import sharp from 'sharp'
import {frameSlackScreenshots} from './frame-slack-screenshots.mjs'
test('frames Slack screenshots as 4:3 PNGs', async t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-slack-frame-'))
t.after(() => fs.rmSync(root, {recursive: true, force: true}))
const source = path.join(root, 'source.png')
const input = path.join(root, 'input.json')
const output = path.join(root, 'output.json')
const outputDir = path.join(root, 'images')
await sharp({
create: {
width: 2,
height: 4,
channels: 3,
background: '#ffffff',
},
})
.png()
.toFile(source)
fs.writeFileSync(
input,
JSON.stringify({
file_uploads: [
{
file: source,
filename: '1-android-login.png',
alt_text: 'Android failure screenshot for login',
},
],
}),
)
await frameSlackScreenshots({inputPath: input, outputPath: output, outputDir})
const payload = JSON.parse(fs.readFileSync(output, 'utf8'))
const {data, info} = await sharp(payload.file_uploads[0].file)
.raw()
.toBuffer({resolveWithObject: true})
const pixelAt = (x, y) => {
const offset = (y * info.width + x) * info.channels
return Array.from(data.subarray(offset, offset + 3))
}
assert.equal(info.width, 1600)
assert.equal(info.height, 1200)
assert.deepEqual(pixelAt(0, 0), [248, 248, 248])
assert.deepEqual(pixelAt(800, 600), [255, 255, 255])
assert.equal(payload.file_uploads[0].filename, '1-android-login.png')
assert.equal(payload.file_uploads[0].highlight_type, 'png')
})
+330
View File
@@ -0,0 +1,330 @@
import path from 'node:path'
const CAROUSEL_LIMIT = 10
function concise(value, limit) {
return value.length > limit ? `${value.slice(0, limit - 1)}` : value
}
function slackEscape(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
function mrkdwnText(value, limit) {
return concise(slackEscape(value), limit)
}
function pluralize(count, singular) {
return `${count} ${singular}${count === 1 ? '' : 's'}`
}
function stateFor(platforms) {
if (platforms.some(platform => platform.status === 'cancelled')) {
return 'cancelled'
}
if (platforms.some(platform => platform.failures.length > 0)) {
return 'failed'
}
if (platforms.some(platform => platform.failed)) {
return 'setup_failed'
}
return 'passed'
}
function statePresentation(state, failureCount) {
if (state === 'cancelled') {
return {
header: '⏹️ Nightly Maestro E2E cancelled',
summary: 'results may be incomplete',
fallback: 'Nightly Maestro E2E was cancelled. Results may be incomplete.',
}
}
if (state === 'setup_failed') {
return {
header: '⚠️ Nightly Maestro setup failed',
summary: 'no complete test results',
fallback: 'Nightly Maestro E2E setup failed before tests could complete.',
}
}
if (state === 'failed') {
return {
header: '🚨 Nightly Maestro E2E failed',
summary: `${pluralize(failureCount, 'failed flow')}`,
fallback: `Nightly Maestro E2E failed with ${pluralize(failureCount, 'failed flow')}.`,
}
}
return {
header: '✅ Nightly Maestro E2E passed',
summary: 'all platforms passed',
fallback: 'Nightly Maestro E2E passed on all platforms.',
}
}
function platformStatus(platform) {
if (platform.status === 'cancelled') return '⏹️ Cancelled'
if (platform.status === 'skipped') return '⏭️ Skipped'
if (platform.failures.length > 0) {
return `❌ Failed · ${pluralize(platform.failures.length, 'flow')}`
}
if (platform.failed && !platform.hasJUnit) return '⚠️ Setup failed'
if (platform.failed) return '❌ Failed'
return '✅ Passed'
}
function selectFailures(platforms, limit = CAROUSEL_LIMIT) {
const queues = platforms.map(platform =>
platform.failures.map(failure => ({platform, failure})),
)
const selected = []
while (selected.length < limit && queues.some(queue => queue.length > 0)) {
for (const queue of queues) {
const next = queue.shift()
if (next) selected.push(next)
if (selected.length === limit) break
}
}
return selected
}
function uploadFilename({platform, failure}, index) {
const extension = path.extname(failure.screenshot) || '.png'
const slug = failure.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
return `${index + 1}-${platform.name.toLowerCase()}-${slug || 'failed-flow'}${extension}`
}
function buildUploadPayload(selectedFailures) {
return {
file_uploads: selectedFailures
.filter(({failure}) => failure.screenshot)
.map((entry, index) => ({
file: entry.failure.screenshot,
filename: uploadFilename(entry, index),
highlight_type: 'png',
alt_text: `${entry.platform.name} failure screenshot for ${entry.failure.name}`,
})),
}
}
function buildThreadPayload(platforms) {
const lines = ['*All Maestro failure details*']
for (const platform of platforms) {
if (platform.failures.length === 0) continue
lines.push('', `*${slackEscape(platform.name)}*`)
for (const failure of platform.failures) {
lines.push(
`• *${mrkdwnText(failure.name, 140)}*\n ${mrkdwnText(failure.message, 300)}`,
)
}
if (platform.artifactUrl) {
lines.push(`<${platform.artifactUrl}|Open ${platform.name} artifacts>`)
}
}
return {text: lines.join('\n')}
}
function buildCarousel(selectedFailures, slackFileIds) {
let screenshotIndex = 0
const elements = selectedFailures.map(({platform, failure}, index) => {
const slackFileId = failure.screenshot
? slackFileIds[screenshotIndex++]
: undefined
return {
type: 'card',
block_id: `maestro_failure_${index + 1}`,
title: {
type: 'mrkdwn',
text: `*${mrkdwnText(failure.name, 140)}*`,
verbatim: true,
},
subtitle: {
type: 'mrkdwn',
text: `${platform.name} · failed flow`,
verbatim: true,
},
...(slackFileId
? {
hero_image: {
type: 'image',
slack_file: {id: slackFileId},
alt_text: `${platform.name} failure screenshot for ${failure.name}`,
},
}
: {}),
body: {
type: 'mrkdwn',
text: mrkdwnText(failure.message, 190),
verbatim: true,
},
...(platform.artifactUrl
? {
subtext: {
type: 'mrkdwn',
text: `<${platform.artifactUrl}|Open logs and artifacts>`,
verbatim: true,
},
}
: {}),
}
})
return {type: 'carousel', block_id: 'maestro_failures', elements}
}
function diagnosticBlock(platform) {
const phase = mrkdwnText(
platform.phase || 'No phase metadata was captured',
220,
)
if (platform.status === 'cancelled') {
return {
type: 'section',
text: {
type: 'mrkdwn',
text: `*${platform.name} cancelled*\nLatest phase: ${phase}\nResults may be incomplete.`,
},
}
}
if (!platform.hasJUnit) {
return {
type: 'section',
text: {
type: 'mrkdwn',
text: `*${platform.name} setup failed*\nLatest phase: ${phase}\nNo JUnit results were produced.`,
},
}
}
return {
type: 'section',
text: {
type: 'mrkdwn',
text: `*${platform.name} job failed*\nLatest phase: ${phase}`,
},
}
}
function footerBlock(platforms, runUrl) {
const links = [`<${runUrl}|Open workflow>`]
for (const platform of platforms) {
if (platform.artifactUrl) {
links.push(`<${platform.artifactUrl}|${platform.name} artifacts>`)
}
}
return {
type: 'section',
text: {type: 'mrkdwn', text: links.join(' • ')},
}
}
export function buildSlackMessage({
platforms,
sha,
runUrl,
commitUrl,
slackFileIds = [],
}) {
const state = stateFor(platforms)
const failureCount = platforms.reduce(
(total, platform) => total + platform.failures.length,
0,
)
const presentation = statePresentation(state, failureCount)
const allFailures = selectFailures(platforms, failureCount)
const selectedFailures = allFailures.slice(0, CAROUSEL_LIMIT)
const uploadPayload = buildUploadPayload(allFailures)
const detailBlocks = platforms
.filter(
platform =>
platform.status === 'cancelled' ||
(platform.failed && platform.failures.length === 0),
)
.map(diagnosticBlock)
if (selectedFailures.length > 0) {
detailBlocks.push(buildCarousel(selectedFailures, slackFileIds), {
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Showing ${selectedFailures.length} of ${pluralize(failureCount, 'failed flow')} • full details and screenshots are in the thread`,
},
],
})
}
const shortSha = sha.slice(0, 12)
const blocks = [
{
type: 'header',
text: {type: 'plain_text', text: presentation.header},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Commit <${commitUrl}|\`${shortSha}\`> • ${presentation.summary}`,
},
],
},
{
type: 'section',
fields: platforms.map(platform => ({
type: 'mrkdwn',
text: `*${platform.name}*\n${platformStatus(platform)}`,
})),
},
...(detailBlocks.length > 0 ? [{type: 'divider'}, ...detailBlocks] : []),
footerBlock(platforms, runUrl),
]
return {
state,
failureCount,
screenshotCount: uploadPayload.file_uploads.length,
uploadPayload,
threadPayload: buildThreadPayload(platforms),
payload: {text: presentation.fallback, blocks},
}
}
export function extractSlackFileIds(response) {
if (!response) return []
let parsed = response
if (typeof response === 'string') {
try {
parsed = JSON.parse(response)
} catch {
return []
}
}
const ids = []
const seen = new Set()
function visit(value) {
if (Array.isArray(value)) {
for (const item of value) visit(item)
return
}
if (!value || typeof value !== 'object') return
if (
typeof value.id === 'string' &&
/^F[A-Z0-9]+$/.test(value.id) &&
!seen.has(value.id)
) {
seen.add(value.id)
ids.push(value.id)
}
for (const child of Object.values(value)) visit(child)
}
visit(parsed)
return ids
}
+194
View File
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs'
import {screenshotsByFlow} from './summarize-maestro.mjs'
function platform({
name,
status = 'success',
failed = false,
failures = [],
phase = 'Completed',
hasJUnit = true,
}) {
return {
name,
status,
failed,
failures,
phase,
hasJUnit,
artifactUrl: `https://example.com/${name.toLowerCase()}`,
}
}
function build(platforms, slackFileIds = []) {
return buildSlackMessage({
platforms,
sha: '1234567890abcdef',
runUrl: 'https://example.com/run',
commitUrl: 'https://example.com/commit',
slackFileIds,
})
}
test('builds a screenshot carousel for failed flows', () => {
const platforms = [
platform({
name: 'iOS',
status: 'failure',
failed: true,
failures: [
{
name: 'composer',
message: 'Element not found',
screenshot: '/tmp/screenshot-(composer).png',
},
],
}),
platform({name: 'Android'}),
]
const summary = build(platforms, ['F123ABC'])
const carousel = summary.payload.blocks.find(
block => block.type === 'carousel',
)
assert.equal(summary.state, 'failed')
assert.equal(summary.screenshotCount, 1)
assert.equal(
summary.uploadPayload.file_uploads[0].file,
platforms[0].failures[0].screenshot,
)
assert.equal(summary.uploadPayload.file_uploads[0].highlight_type, 'png')
assert.deepEqual(carousel.elements[0].hero_image.slack_file, {id: 'F123ABC'})
})
test('selects failures across both platforms for the carousel', () => {
const failures = prefix =>
Array.from({length: 7}, (_, index) => ({
name: `${prefix}-${index}`,
message: 'Failed',
screenshot: `/tmp/${prefix}-${index}.png`,
}))
const summary = build([
platform({
name: 'iOS',
status: 'failure',
failed: true,
failures: failures('ios'),
}),
platform({
name: 'Android',
status: 'failure',
failed: true,
failures: failures('android'),
}),
])
const carousel = summary.payload.blocks.find(
block => block.type === 'carousel',
)
assert.equal(carousel.elements.length, 10)
assert.equal(summary.failureCount, 14)
assert.equal(summary.screenshotCount, 14)
assert.equal(summary.uploadPayload.file_uploads.length, 14)
assert.match(summary.threadPayload.text, /ios-6/)
assert.match(summary.threadPayload.text, /android-6/)
assert.equal(carousel.elements[0].subtitle.text, 'iOS · failed flow')
assert.equal(carousel.elements[1].subtitle.text, 'Android · failed flow')
})
test('uses a cancellation presentation for partial results', () => {
const summary = build([
platform({
name: 'iOS',
status: 'cancelled',
failed: true,
failures: [],
phase: 'Building iOS development client',
hasJUnit: false,
}),
platform({
name: 'Android',
status: 'cancelled',
failed: true,
failures: [],
phase: 'Building Android development client',
hasJUnit: false,
}),
])
assert.equal(summary.state, 'cancelled')
assert.equal(
summary.payload.blocks[0].text.text,
'⏹️ Nightly Maestro E2E cancelled',
)
assert.match(summary.payload.text, /cancelled/)
assert.equal(summary.screenshotCount, 0)
})
test('distinguishes setup failures from failed Maestro flows', () => {
const summary = build([
platform({
name: 'iOS',
status: 'failure',
failed: true,
failures: [],
phase: 'Starting Metro',
hasJUnit: false,
}),
platform({name: 'Android', status: 'skipped'}),
])
assert.equal(summary.state, 'setup_failed')
assert.equal(
summary.payload.blocks[0].text.text,
'⚠️ Nightly Maestro setup failed',
)
assert.equal(
summary.payload.blocks.some(block => block.type === 'carousel'),
false,
)
})
test('extracts file ids from single and multi-file upload responses', () => {
const response = {
ok: true,
files: [
{
ok: true,
files: [{id: 'FONE'}, {id: 'FTWO'}],
},
],
}
assert.deepEqual(extractSlackFileIds(response), ['FONE', 'FTWO'])
assert.deepEqual(
extractSlackFileIds(JSON.stringify({ok: true, files: [{id: 'FTHREE'}]})),
['FTHREE'],
)
assert.deepEqual(extractSlackFileIds('not json'), [])
})
test('selects the newest Maestro screenshot for each flow', () => {
const screenshots = screenshotsByFlow([
'/tmp/screenshot-❌-300-(composer).png',
'/tmp/screenshot-❌-100-(composer).png',
'/tmp/screenshot-❌-200-(login).png',
'/tmp/artifacts/maestro/composer-self-label/screenshots/step-020-tapOnElement-openMediaBtn.png',
'/tmp/artifacts/maestro/composer-self-label/screenshots/step-010-launchApp.png',
'/tmp/not-a-maestro-screenshot.png',
])
assert.equal(
screenshots.get('composer'),
'/tmp/screenshot-❌-300-(composer).png',
)
assert.equal(screenshots.get('login'), '/tmp/screenshot-❌-200-(login).png')
assert.equal(
screenshots.get('composer-self-label'),
'/tmp/artifacts/maestro/composer-self-label/screenshots/step-020-tapOnElement-openMediaBtn.png',
)
assert.equal(screenshots.size, 3)
})
+80 -104
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs'
const ENTITY_REPLACEMENTS = {
'&amp;': '&',
'&apos;': "'",
@@ -101,6 +103,54 @@ function readPhase(root) {
return phaseFile ? fs.readFileSync(phaseFile, 'utf8').trim() : ''
}
function screenshotMetadata(file) {
const legacyMatch = path
.basename(file)
.match(/^screenshot-.*?-(\d+)-\((.+)\)\.(?:gif|jpe?g|png)$/i)
if (legacyMatch) {
return {
file,
order: Number(legacyMatch[1]),
flowName: legacyMatch[2],
}
}
const parts = file.split(/[\\/]/)
const screenshotsIndex = parts.lastIndexOf('screenshots')
if (
screenshotsIndex < 2 ||
!parts.slice(0, screenshotsIndex - 1).includes('maestro')
) {
return undefined
}
const step = parts.at(-1)?.match(/^step-(\d+)-.*\.(?:gif|jpe?g|png)$/i)
return step
? {
file,
order: Number(step[1]),
flowName: parts[screenshotsIndex - 1],
}
: undefined
}
export function screenshotsByFlow(files) {
const screenshots = new Map()
for (const file of files) {
const screenshot = screenshotMetadata(file)
if (!screenshot) continue
const current = screenshots.get(screenshot.flowName)
if (!current || screenshot.order > current.order) {
screenshots.set(screenshot.flowName, screenshot)
}
}
return new Map(
[...screenshots].map(([flowName, screenshot]) => [
flowName,
screenshot.file,
]),
)
}
function platformResult({name, status, root, artifactUrl}) {
const files = walk(root)
const reports = files.filter(file => /(?:report|junit).*\.xml$/i.test(file))
@@ -115,7 +165,12 @@ function platformResult({name, status, root, artifactUrl}) {
)
// 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
const rawFailures = junitFailures.length > 0 ? junitFailures : cliFailures
const screenshots = screenshotsByFlow(files)
const failures = rawFailures.map(failure => ({
...failure,
screenshot: screenshots.get(failure.name),
}))
// A skipped platform (e.g. iOS while temporarily disabled) is not a failure
// as long as it produced no flow failures.
const failed =
@@ -131,52 +186,15 @@ function platformResult({name, status, root, 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) {
function githubSummary({state, platforms, shortSha, runUrl, commitUrl}) {
const outcome =
state === 'cancelled'
? 'cancelled'
: state === 'passed'
? 'passed'
: 'failed'
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'}`,
`# Nightly Maestro E2E ${outcome}`,
'',
`- Commit: [\`${shortSha}\`](${commitUrl})`,
`- Workflow run: [open run](${runUrl})`,
@@ -235,6 +253,7 @@ export function buildSummary({
sha,
runUrl,
commitUrl,
slackFileIds = [],
}) {
const platforms = [
platformResult({
@@ -252,73 +271,29 @@ export function buildSummary({
]
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'}] : []),
]),
]
const slack = buildSlackMessage({
platforms,
sha,
runUrl,
commitUrl,
slackFileIds,
})
return {
notify,
state: slack.state,
platforms,
githubSummary: githubSummary({
notify,
state: slack.state,
platforms,
shortSha,
runUrl,
commitUrl,
}),
payload: {text, blocks},
failureCount: slack.failureCount,
screenshotCount: slack.screenshotCount,
uploadPayload: slack.uploadPayload,
threadPayload: slack.threadPayload,
payload: slack.payload,
}
}
@@ -351,6 +326,7 @@ if (
sha: args.sha,
runUrl: args['run-url'],
commitUrl: args['commit-url'],
slackFileIds: extractSlackFileIds(args['slack-upload-response']),
})
process.stdout.write(`${JSON.stringify(summary)}\n`)
}
+2 -2
View File
@@ -88,7 +88,7 @@ jobs:
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "17"
@@ -195,7 +195,7 @@ jobs:
# 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@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "17"
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
fetch-depth: 1
- name: 🤖 Claude
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with:
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
fetch-depth: 1
- name: 🤖 Claude review
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with:
+157 -13
View File
@@ -15,7 +15,7 @@ concurrency:
env:
CI: "1"
MAESTRO_VERSION: "2.6.1"
MAESTRO_VERSION: "2.10.0"
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
MAESTRO_CLI_NO_ANALYTICS: "1"
MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true"
@@ -51,7 +51,7 @@ jobs:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: temurin
java-version: "17"
@@ -62,12 +62,12 @@ jobs:
- name: 🔤 Compile translations
uses: ./.github/actions/compile-i18n
- name: 📥 Install Maestro 2.6.1
- name: 📥 Install Maestro 2.10.0
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" \
echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \
| shasum -a 256 --check
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
@@ -176,7 +176,7 @@ jobs:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: temurin
java-version: "17"
@@ -187,12 +187,12 @@ jobs:
- name: 🔤 Compile translations
uses: ./.github/actions/compile-i18n
- name: 📥 Install Maestro 2.6.1
- name: 📥 Install Maestro 2.10.0
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" \
echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \
| shasum -a 256 --check
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
@@ -411,14 +411,158 @@ jobs:
--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"
{
echo "notify=$(jq -r .notify e2e-summary.json)"
echo "failure_count=$(jq -r .failureCount e2e-summary.json)"
echo "screenshot_count=$(jq -r .screenshotCount e2e-summary.json)"
} >> "$GITHUB_OUTPUT"
jq .uploadPayload e2e-summary.json > slack-screenshot-upload.json
jq -r .githubSummary e2e-summary.json >> "$GITHUB_STEP_SUMMARY"
- name: 🔔 Notify Slack of E2E failures
- name: 📦 Set up pnpm for Slack screenshot framing
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Set up Node for Slack screenshot framing
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 📦 Install Slack screenshot framing dependencies
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
run: pnpm install --frozen-lockfile
- name: 🖼️ Frame failure screenshots for Slack cards
id: frame_screenshots
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
run: |
node .github/scripts/frame-slack-screenshots.mjs \
slack-screenshot-upload.json \
slack-screenshot-upload-framed.json \
slack-screenshots
- name: 📝 Build Slack message
if: steps.summary.outputs.notify == 'true'
env:
ANDROID_STATUS: ${{ needs.android.result }}
IOS_STATUS: ${{ needs.ios.result }}
SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }}
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-for-slack.json
jq --arg channel "$SLACK_CHANNEL_ID" \
'.payload + {channel: $channel, unfurl_links: false, unfurl_media: false}' \
e2e-summary-for-slack.json > slack-message.json
- name: 🔔 Notify Slack of E2E result
id: notify_slack
if: steps.summary.outputs.notify == 'true'
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.summary.outputs.payload }}
method: chat.postMessage
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-message.json
errors: true
- name: 🧵 Prepare Slack thread payloads
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.failure_count != '0'
env:
FRAMING_OUTCOME: ${{ steps.frame_screenshots.outcome }}
SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }}
SLACK_THREAD_TS: ${{ steps.notify_slack.outputs.ts }}
run: |
upload_payload=slack-screenshot-upload.json
if [ "$FRAMING_OUTCOME" = "success" ]; then
upload_payload=slack-screenshot-upload-framed.json
fi
jq --arg channel "$SLACK_CHANNEL_ID" --arg thread_ts "$SLACK_THREAD_TS" \
'.threadPayload + {channel: $channel, thread_ts: $thread_ts, unfurl_links: false, unfurl_media: false}' \
e2e-summary-for-slack.json > slack-thread-details.json
jq --arg channel_id "$SLACK_CHANNEL_ID" --arg thread_ts "$SLACK_THREAD_TS" \
'. + {channel_id: $channel_id, thread_ts: $thread_ts}' \
"$upload_payload" > slack-screenshot-upload-thread.json
- name: 🧾 Post all failure details to Slack thread
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.failure_count != '0'
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.postMessage
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-thread-details.json
errors: true
- name: 🖼️ Upload failure screenshots to Slack thread
id: upload_screenshots
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: files.uploadV2
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-screenshot-upload-thread.json
errors: true
- name: ⏳ Wait for Slack to process failure screenshots
if: steps.upload_screenshots.outcome == 'success'
run: sleep 5
- name: 📝 Build Slack message with screenshots
if: steps.upload_screenshots.outcome == 'success'
env:
ANDROID_STATUS: ${{ needs.android.result }}
IOS_STATUS: ${{ needs.ios.result }}
SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }}
SLACK_THREAD_TS: ${{ steps.notify_slack.outputs.ts }}
SLACK_UPLOAD_RESPONSE: ${{ steps.upload_screenshots.outputs.response }}
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}" \
--slack-upload-response "$SLACK_UPLOAD_RESPONSE" \
> e2e-summary-with-slack-files.json
jq --arg channel "$SLACK_CHANNEL_ID" --arg ts "$SLACK_THREAD_TS" \
'.payload + {channel: $channel, ts: $ts, unfurl_links: false, unfurl_media: false}' \
e2e-summary-with-slack-files.json > slack-message-update.json
- name: 🔄 Add screenshots to Slack message
if: steps.upload_screenshots.outcome == 'success'
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.update
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-message-update.json
errors: true
@@ -35,7 +35,7 @@ jobs:
commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po
- name: 🚀 Push source lang to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
upload_sources: true
upload_sources_args: "-b main"
+4
View File
@@ -30,6 +30,10 @@ appId: xyz.blueskyweb.app
id: "confirmBtn"
- tapOn:
id: "composerPublishBtn"
- extendedWaitUntil:
notVisible:
id: "composePostView"
timeout: 30000
- tapOn:
id: "e2eRefreshHome"
- assertVisible: "Adult Content"
@@ -60,6 +60,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "onboardingContinue"
- assertVisible: "What are your interests?"
- tapOn: "Animals"
- tapOn:
id: "onboardingContinue"
- assertVisible: "Suggested for you"
+1
View File
@@ -41,6 +41,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "onboardingContinue"
- assertVisible: "What are your interests?"
- tapOn: "Animals"
- tapOn:
id: "onboardingContinue"
- assertVisible: "Suggested for you"
+15 -27
View File
@@ -3,34 +3,22 @@ appId: xyz.blueskyweb.app
- launchApp:
appId: "xyz.blueskyweb.app"
clearState: true
arguments:
"-EXDevMenuIsOnboardingFinished": true
- runFlow:
when:
platform: iOS
commands:
- extendedWaitUntil:
visible: "http://localhost:8081"
timeout: 60000
- tapOn: "http://localhost:8081"
- runFlow:
when:
platform: Android
commands:
- extendedWaitUntil:
visible: "http://10.0.2.2:8081"
timeout: 60000
- tapOn: "http://10.0.2.2:8081"
- extendedWaitUntil:
visible: "Continue"
timeout: 180000
- tapOn: "Continue"
- back
- extendedWaitUntil:
visible:
id: e2eProxyHeaderInput
timeout: 180000
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
- pressKey: Enter
- extendedWaitUntil:
visible: "Sign in"
timeout: 180000
- retry:
maxRetries: 3
commands:
- tapOn:
id: e2eProxyHeaderInput
- eraseText
- inputText: ${output.result}
- pressKey: Enter
- extendedWaitUntil:
visible:
id: e2eSignInAlice
timeout: 10000
+115 -15
View File
@@ -1,5 +1,9 @@
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {
createDownloadResumable,
deleteAsync,
getInfoAsync,
} from 'expo-file-system/legacy'
import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
import {
@@ -9,7 +13,6 @@ import {
import {getResizedDimensions} from '../../src/lib/media/util'
const mockResizedImage = {
path: 'file://resized-image.jpg',
size: 100,
width: 100,
height: 100,
@@ -20,10 +23,26 @@ describe('downloadAndResize', () => {
const errorSpy = jest.spyOn(global.console, 'error')
beforeEach(() => {
const mockedCreateResizedImage = manipulateAsync as jest.Mock
mockedCreateResizedImage.mockResolvedValue({
uri: 'file://resized-image.jpg',
...mockResizedImage,
let savedImageCount = 0
const mockedManipulate = ImageManipulator.manipulate as jest.Mock
mockedManipulate.mockImplementation(() => {
const image = {
...mockResizedImage,
release: jest.fn(),
uri: 'file://rendered-image.jpg',
saveAsync: jest.fn().mockImplementation(() => {
savedImageCount += 1
return Promise.resolve({
uri: `file://resized-image-${savedImageCount}.jpg`,
...mockResizedImage,
})
}),
}
return {
release: jest.fn(),
renderAsync: jest.fn().mockResolvedValue(image),
resize: jest.fn(),
}
})
})
@@ -48,7 +67,10 @@ describe('downloadAndResize', () => {
}
const result = await downloadAndResize(opts)
expect(result).toEqual(mockResizedImage)
expect(result).toEqual({
...mockResizedImage,
path: 'file://resized-image-7.jpg',
})
expect(createDownloadResumable).toHaveBeenCalledWith(
opts.uri,
expect.anything(),
@@ -57,20 +79,98 @@ describe('downloadAndResize', () => {
},
)
// First time it gets called is to get dimensions
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
// First time it gets called is to get dimensions.
expect(ImageManipulator.manipulate).toHaveBeenNthCalledWith(
1,
expect.any(String),
)
const firstContext = (ImageManipulator.manipulate as jest.Mock).mock
.results[0].value
expect(firstContext.resize).not.toHaveBeenCalled()
// The mocked source image is 100x100, below maxDimension, so it is not
// downsized.
expect(manipulateAsync).toHaveBeenCalledWith(
expect.any(String),
[{resize: {height: 100, width: 100}}],
{format: SaveFormat.JPEG, compress: 1.0},
const secondContext = (ImageManipulator.manipulate as jest.Mock).mock
.results[1].value
expect(secondContext.resize).toHaveBeenCalledWith({
height: 100,
width: 100,
})
const lastContext = (
ImageManipulator.manipulate as jest.Mock
).mock.results.at(-1)!.value
const resizedImage = await lastContext.renderAsync.mock.results[0].value
expect(resizedImage.saveAsync).toHaveBeenCalledWith(
expect.objectContaining({format: SaveFormat.JPEG, compress: 1.0}),
)
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
const deletedPaths = (deleteAsync as jest.Mock).mock.calls.map(
([path]) => path,
)
expect(deletedPaths).toEqual(
expect.arrayContaining([
'file://resized-image-1.jpg',
'file://resized-image-2.jpg',
'file://resized-image-3.jpg',
'file://resized-image-4.jpg',
'file://resized-image-5.jpg',
'file://resized-image-6.jpg',
]),
)
expect(deletedPaths).not.toContain('file://resized-image-7.jpg')
})
it('deletes a partial download when downloading fails', async () => {
const mockedFetch = createDownloadResumable as jest.Mock
mockedFetch.mockReturnValue({
cancelAsync: jest.fn(),
downloadAsync: jest.fn().mockRejectedValue(new Error('download failed')),
})
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
maxDimension: 2000,
maxSize: 500000,
timeout: 10000,
}
await expect(downloadAndResize(opts)).rejects.toThrow('download failed')
expect(deleteAsync).toHaveBeenCalledWith(expect.stringMatching(/\.bin$/), {
idempotent: true,
})
})
it('deletes every intermediate image when resizing fails', async () => {
const mockedFetch = createDownloadResumable as jest.Mock
mockedFetch.mockReturnValue({
cancelAsync: jest.fn(),
downloadAsync: jest
.fn()
.mockResolvedValue({uri: 'file://downloaded-image.jpg'}),
})
;(getInfoAsync as jest.Mock)
.mockResolvedValueOnce({exists: true, size: 100})
.mockRejectedValueOnce(new Error('stat failed'))
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
maxDimension: 2000,
maxSize: 500000,
timeout: 10000,
}
await expect(downloadAndResize(opts)).rejects.toThrow('stat failed')
const deletedPaths = (deleteAsync as jest.Mock).mock.calls.map(
([path]) => path,
)
expect(deletedPaths).toEqual(
expect.arrayContaining([
'file://resized-image-1.jpg',
'file://resized-image-2.jpg',
'file://resized-image-3.jpg',
]),
)
})
it('should return undefined for invalid URI', async () => {
const opts: DownloadAndResizeOpts = {
uri: 'invalid-uri',
+50 -5
View File
@@ -1,13 +1,58 @@
import {getLikelyType, LikelyType} from '../../src/lib/link-meta/link-meta'
import {
getLikelyType,
getLinkMeta,
LikelyType,
} from '../../src/lib/link-meta/link-meta'
describe('getLikelyType', () => {
it('correctly handles non-parsed url', async () => {
const output = await getLikelyType('https://example.com')
it('correctly handles non-parsed url', () => {
const output = getLikelyType('https://example.com')
expect(output).toEqual(LikelyType.HTML)
})
it('handles non-string urls without crashing', async () => {
const output = await getLikelyType('123')
it('handles non-string urls without crashing', () => {
const output = getLikelyType('123')
expect(output).toEqual(LikelyType.Other)
})
})
describe('getLinkMeta', () => {
const originalFetch = global.fetch
afterEach(() => {
global.fetch = originalFetch
})
it('fetches metadata for stream.place routes that look like files', async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: () =>
Promise.resolve({
error: '',
description: 'AT Protocol livestreams',
image: 'https://stream.place/thumbnail.jpg',
title: 'atproto.com on stream.place',
}),
})
global.fetch = fetchMock
const output = await getLinkMeta('https://stream.place/atproto.com')
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(output).toMatchObject({
description: 'AT Protocol livestreams',
image: 'https://stream.place/thumbnail.jpg',
likelyType: LikelyType.HTML,
title: 'atproto.com on stream.place',
})
})
it('skips metadata fetching for direct image URLs', async () => {
const fetchMock = jest.fn()
global.fetch = fetchMock
const output = await getLinkMeta('https://example.com/image.JPEG')
expect(fetchMock).not.toHaveBeenCalled()
expect(output).toMatchObject({likelyType: LikelyType.Image})
})
})
+22 -3
View File
@@ -20,6 +20,7 @@ module.exports = function (_config) {
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
const IS_E2E = process.env.EXPO_PUBLIC_ENV === 'e2e'
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
const ASSOCIATED_DOMAINS = [
@@ -234,6 +235,25 @@ module.exports = function (_config) {
checkAutomatically: 'NEVER',
},
plugins: [
[
'expo-dev-client',
{
toolsButton: false,
...(IS_E2E
? {
launchMode: 'most-recent',
skipOnboarding: true,
showMenuAtLaunch: false,
ios: {
defaultLaunchURL: 'http://localhost:8081',
},
android: {
defaultLaunchURL: 'http://10.0.2.2:8081',
},
}
: {}),
},
],
'expo-video',
'expo-localization',
'expo-web-browser',
@@ -271,8 +291,8 @@ module.exports = function (_config) {
},
android: {
compileSdkVersion: 36,
targetSdkVersion: 35,
buildToolsVersion: '35.0.0',
targetSdkVersion: 36,
buildToolsVersion: '36.0.0',
buildReactNativeFromSource: IS_PRODUCTION,
},
},
@@ -285,7 +305,6 @@ module.exports = function (_config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
'react-native-compressor',
[
'@bitdrift/react-native',
{
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 12a8 8 0 1 0-16 0 8 8 0 0 0 16 0m2 0c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-10.843.256-.47-3.768a1.324 1.324 0 1 1 2.627 0l-.47 3.768a.85.85 0 0 1-1.687 0M12 17a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4"/></svg>

After

Width:  |  Height:  |  Size: 334 B

+30 -9
View File
@@ -36,15 +36,36 @@ jest.mock('expo-file-system/legacy', () => ({
createDownloadResumable: jest.fn(),
}))
jest.mock('expo-image-manipulator', () => ({
manipulateAsync: jest.fn().mockResolvedValue({
uri: 'file://resized-image',
}),
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}))
jest.mock('expo-image-manipulator', () => {
const createContext = () => {
const image = {
height: 100,
release: jest.fn(),
saveAsync: jest.fn().mockResolvedValue({
height: 100,
uri: 'file://resized-image',
width: 100,
}),
width: 100,
}
return {
crop: jest.fn(),
release: jest.fn(),
renderAsync: jest.fn().mockResolvedValue(image),
resize: jest.fn(),
}
}
return {
ImageManipulator: {
manipulate: jest.fn(createContext),
},
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}
})
jest.mock('expo-camera', () => ({
Camera: {
+7 -2
View File
@@ -92,6 +92,7 @@
"app.bsky.graph.muteActor",
"app.bsky.graph.muteActorList",
"app.bsky.graph.muteThread",
"app.bsky.graph.referencelistoptout",
"app.bsky.graph.searchStarterPacks",
"app.bsky.graph.searchStarterPacksV2",
"app.bsky.graph.starterpack",
@@ -426,7 +427,7 @@
},
"app.bsky.embed.video": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.embed.video",
"cid": "bafyreiaqos23yv3t4ptrxily6s6qea5fcxfjzjlm42zq46xweby2mkgr4m"
"cid": "bafyreihoxb7lvczityqcv2s5od3rllmkee7tn3m4qg6wn34kd6m45p6v24"
},
"app.bsky.feed.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.defs",
@@ -538,7 +539,7 @@
},
"app.bsky.graph.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.defs",
"cid": "bafyreifcipomli7yggtl46xufgxlnrw7se6xmsdxmzgfcz2tiu76ljatxm"
"cid": "bafyreief2f7zpllyicjugbn7faohmnzwujeiytfzj76uckxmrqmtdvechy"
},
"app.bsky.graph.follow": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow",
@@ -632,6 +633,10 @@
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.muteThread",
"cid": "bafyreib6ppci3qzye6wktkm4byxtb5mnl2vg22fm7oawcdvof2tfogx4dy"
},
"app.bsky.graph.referencelistoptout": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.referencelistoptout",
"cid": "bafyreifode2cfu7x7yamiorzg66u46l4zdr2yxmtuoikrup7j2dhwzxf3q"
},
"app.bsky.graph.searchStarterPacks": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks",
"cid": "bafyreia446ip6mbnwpml6hlvxab7jtsud7zczde3u6zmnsa3op4bpxu7um"
+2 -6
View File
@@ -9,9 +9,7 @@
"properties": {
"alt": {
"type": "string",
"maxLength": 10000,
"description": "Alt text description of the video, for accessibility.",
"maxGraphemes": 1000
"description": "Alt text description of the video, for accessibility."
},
"video": {
"type": "blob",
@@ -51,9 +49,7 @@
],
"properties": {
"alt": {
"type": "string",
"maxLength": 10000,
"maxGraphemes": 1000
"type": "string"
},
"cid": {
"type": "string",
+27 -27
View File
@@ -169,6 +169,28 @@
}
}
},
"knownLikers": {
"type": "object",
"required": [
"count",
"actors"
],
"properties": {
"count": {
"type": "integer"
},
"actors": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileViewBasic",
"type": "ref"
},
"maxLength": 5,
"minLength": 0
}
},
"description": "The post's likers whom you also follow"
},
"requestLess": {
"type": "token",
"description": "Request that less content like the given feed item be shown in the feed"
@@ -194,6 +216,11 @@
"bookmarked": {
"type": "boolean"
},
"knownLikers": {
"ref": "#knownLikers",
"type": "ref",
"description": "This property is present only in selected cases, as an optimization."
},
"threadMuted": {
"type": "boolean"
},
@@ -202,37 +229,10 @@
},
"embeddingDisabled": {
"type": "boolean"
},
"knownLikers": {
"description": "This property is present only in selected cases, as an optimization.",
"type": "ref",
"ref": "#knownLikers"
}
},
"description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests."
},
"knownLikers": {
"type": "object",
"description": "The post's likers whom you also follow",
"required": [
"count",
"actors"
],
"properties": {
"count": {
"type": "integer"
},
"actors": {
"type": "array",
"minLength": 0,
"maxLength": 5,
"items": {
"type": "ref",
"ref": "app.bsky.actor.defs#profileViewBasic"
}
}
}
},
"feedViewPost": {
"type": "object",
"required": [
+10
View File
@@ -100,6 +100,11 @@
"subject": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref"
},
"subjectOptedOut": {
"type": "boolean",
"const": true,
"description": "Set to true when the subject has opted out of appearing in the reference list. Only set when the viewer owns the list."
}
}
},
@@ -228,6 +233,11 @@
"blocked": {
"type": "string",
"format": "at-uri"
},
"referenceListOptOut": {
"type": "string",
"format": "at-uri",
"description": "The authenticated viewer's app.bsky.graph.referencelistoptout record URI for this reference list. Only set for reference lists. A client can delete this record to undo the opt-out."
}
}
},
@@ -0,0 +1,30 @@
{
"id": "app.bsky.graph.referencelistoptout",
"defs": {
"main": {
"key": "tid",
"type": "record",
"record": {
"type": "object",
"required": [
"subject",
"createdAt"
],
"properties": {
"subject": {
"type": "string",
"format": "at-uri",
"description": "Canonical, DID-based AT URI of the app.bsky.graph.list record from which the author requests omission."
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
},
"description": "Record requesting that its author be omitted from the public presentation of a reference list. This record is only enforced when the subject list's current purpose is app.bsky.graph.defs#referencelist. AppView indexes at most one record per actor and list pair, and ignores duplicate records."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+24 -1
View File
@@ -61,7 +61,8 @@ The component uses a class-based approach to expose imperative methods (`present
- Preserves status/nav bar appearance from host activity
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
- Forwards touch events to React Native event system
- Updates shadow node size to match window dimensions
- Reports its measured width to `BottomSheetView` so the content canvas can follow it
- Also carries the legacy `UIManagerModule.updateNodeSize()` shadow node sizing, which only runs on the old architecture
- Based on React Native's ReactModalHostView pattern
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
@@ -74,6 +75,24 @@ Both platforms detect content height changes natively without JS bridge round-tr
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
### Content Canvas Sizing
The "canvas" is the size the sheet content is laid out on by Yoga. **On Android the native side owns it**; on iOS it is still sized from JS.
- **Android**: JS renders unsized `flex: 1` content and `BottomSheetView` pushes the canvas size into the Fabric shadow tree through `ExpoView`'s `setViewSize` state channel (`shadowNodeProxy.setViewSize()`). Only native knows the real sheet frame - Material caps the frame at 640dp on tablets and centers it, and it changes on rotation.
- **iOS**: `BottomSheetNativeComponent` sets `height: screenHeight - insets.top` and `width: '100%'` on the native view. Moving iOS onto the same state channel is deferred: it needs on-device iteration on iOS 26 sheet geometry (large-detent and floating-card metrics, where the visible sheet is shorter than the window minus the top inset).
How the Android path works:
- The JS style on the native view **must not set `width` or `height`** on Android. `ExpoViewComponentDescriptor::adopt()` only applies the state size on an axis where the style leaves that dimension undefined, so a style dimension would silently win.
- The two axes come from different places, and the distinction is load-bearing:
- **Width** is authoritatively the dialog container's measured width, reported through `DialogRootViewGroup`'s size-change listener - that is the real sheet width, with the horizontal window insets and Material's 640dp cap already applied. It is seeded from `min(window width, material_bottom_sheet_max_width)` on the first `onLayout` so content has something to lay out in before the dialog exists.
- **Height** is always computed natively as `screenHeight - statusBarHeight` (matching the behavior's `expandedOffset`) - the whole expanded frame, **never** the dialog's measured height. The canvas has to be room for the content to grow *into*, because the content's height is what drives the snap points. Sizing it from the dialog's own height is circular: `BottomSheetBehavior` measures the container against the sheet, so the canvas collapses onto the content height and the content is then pinned - extra `ScrollView` padding (the Android keyboard path) or a longer list becomes scroll extent instead of a height change, `OnLayoutChangeListener` never fires, and the sheet stops responding to its content.
- Seeding runs once per open cycle - re-seeding would fight the width the dialog reported and the two would push each other back and forth.
- Because the content measures 0x0 until that first state commit lands, `present()` bails out early when the content height is still zero. The commit resizes the native view, which re-fires `onLayout`, which re-enters `present()` - so presentation self-retries rather than needing an explicit callback. Full-height sheets skip the check, since they don't need a content measurement.
- Rotation is handled by the container push: the RN activity handles configuration changes itself, so the view is never recreated. `screenHeight` is read per access so the computed height follows the rotation, and the container reports the new width (plus a deferred `updateLayout()` to reposition the sheet).
- On the **old architecture** there is no state channel (`stateWrapper` is null, so `setViewSize` no-ops) and Android falls back to `DialogRootViewGroup`'s legacy `UIManagerModule.updateNodeSize()` path. The `present()` gate is skipped there for the same reason - nothing would ever resize the view.
## Props
```typescript
@@ -213,6 +232,10 @@ BottomSheetNativeComponent.dismissAll()
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
5. **Tablet Width**: Material caps the sheet frame at 640dp (`material_bottom_sheet_max_width`, the `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`) and centers it horizontally, so on tablets the sheet is narrower than the screen. `BottomSheetView` reads that cap from resources when seeding the canvas width, and the dialog container's measured width then corrects it - see [Content Canvas Sizing](#content-canvas-sizing).
6. **Rotation**: The RN activity handles configuration changes itself, so a rotation resizes the display without recreating `BottomSheetView`. Screen height is therefore read per access rather than cached, and `maxHeight` is stored unclamped and clamped against the current screen at use time.
### Platform Differences
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
@@ -21,6 +21,12 @@ import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
/**
* Fallback for Material's `material_bottom_sheet_max_width` dimen (in dp), used only
* if the resource lookup fails. 640dp is the value Material ships.
*/
private const val FALLBACK_MAX_SHEET_WIDTH_DP = 640f
class BottomSheetView(
context: Context,
appContext: AppContext,
@@ -38,26 +44,30 @@ class BottomSheetView(
private var lastObservedContentHeight: Float = 0f
private var pendingLayoutUpdate: Boolean = false
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
// Computed per read rather than cached at construction: the RN activity handles
// configuration changes itself, so a rotation resizes the display without
// recreating this view and a cached value would stay stale for the sheet's life.
private val screenHeight: Float
get() =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
@@ -73,6 +83,11 @@ class BottomSheetView(
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Last canvas size (in dp) pushed into the shadow tree, so repeated layout
// passes don't spam state updates
private var lastPushedCanvasWidth: Float = -1f
private var lastPushedCanvasHeight: Float = -1f
var disableDrag = false
set(value) {
field = value
@@ -99,10 +114,11 @@ class BottomSheetView(
field = if (value < 0) 0f else dpToPx(value)
}
var maxHeight = this.screenHeight
// Stored unclamped (in px) because screenHeight can change under us on rotation.
// The clamp against the screen happens at use time, in getTargetHeight().
var maxHeight = Float.MAX_VALUE
set(value) {
val px = dpToPx(value)
field = if (px > this.screenHeight) this.screenHeight else px
field = dpToPx(value)
}
private var isOpen: Boolean = false
@@ -140,6 +156,38 @@ class BottomSheetView(
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
// The dialog container's measured WIDTH is the authoritative canvas width: it
// already accounts for the window's horizontal insets, Material's max-width cap on
// tablets and the current rotation. DialogRootViewGroup's own updateNodeSize() path
// is a no-op on the new architecture (getNativeModule(UIManagerModule) returns null
// under Fabric), so this state channel is what actually gets the width across there.
//
// Its measured HEIGHT is deliberately ignored - see canvasHeight.
this.dialogRootViewGroup.setOnSizeChangeListener(
object : DialogRootViewGroup.OnSizeChangeListener {
override fun onSizeChange(
width: Int,
height: Int,
) {
val density = context.resources.displayMetrics.density
pushCanvasSize(width / density, canvasHeight / density)
// onSizeChanged fires from inside a layout pass, so defer the reposition:
// updateLayout() reads child heights that aren't final yet. This is what
// makes the sheet settle back into place after a rotation. It no-ops for
// fullHeight sheets, which is correct - those are pinned to the expanded
// offset either way.
if ((isOpen || isOpening) && !isClosing) {
post {
if ((isOpen || isOpening) && !isClosing) {
updateLayout()
}
}
}
}
},
)
}
SheetManager.add(this)
}
@@ -151,9 +199,84 @@ class BottomSheetView(
r: Int,
b: Int,
) {
this.seedCanvasSize()
this.present()
}
/**
* The height, in px, of the canvas the sheet content is laid out on. This is the whole
* expanded frame (the behavior's expandedOffset is the status bar height), NOT the
* sheet's current height.
*
* That distinction is the whole ballgame. The content's height is what drives the snap
* points, so the canvas has to be room to grow *into*. Sizing the canvas from the
* dialog's own measured height is circular - BottomSheetBehavior measures the dialog
* container against the sheet, so the canvas collapses onto the content height, and from
* then on the content is pinned: extra ScrollView padding (the Android keyboard path) or
* a longer list just becomes scroll extent instead of a height change, the
* OnLayoutChangeListener never fires, and the sheet stops responding to its content.
*/
private val canvasHeight: Float
get() = screenHeight - getStatusBarHeight()
/**
* JS renders the sheet content unsized, so before the first state commit it measures
* 0x0 and present() has no content height to derive snap points from. Seed the canvas
* here to kick that off - the dialog container reports the authoritative width later,
* via its OnSizeChangeListener.
*
* Runs at most once per open cycle. It has to: each state commit re-fires onLayout, so
* re-seeding would fight the width the dialog reported and the two would push each other
* back and forth forever.
*
* stateWrapper is assigned while Fabric mounts the view, before the first layout pass,
* so setViewSize() should already reach the shadow tree from here. On the old
* architecture it is null and this no-ops, which is fine: DialogRootViewGroup's legacy
* updateNodeSize() path still sizes the content there.
*/
private fun seedCanvasSize() {
if (lastPushedCanvasWidth > 0f) return
val density = context.resources.displayMetrics.density
val widthPx =
minOf(
context.resources.displayMetrics.widthPixels
.toFloat(),
getMaxSheetWidth(),
)
this.pushCanvasSize(widthPx / density, canvasHeight / density)
}
/**
* Sets the size of this view's shadow node, which is the canvas the sheet content is
* laid out on. Deduped because both onLayout and the dialog container's size changes
* can re-report an unchanged size.
*/
private fun pushCanvasSize(
widthDp: Float,
heightDp: Float,
) {
if (widthDp <= 0f || heightDp <= 0f) return
if (widthDp == lastPushedCanvasWidth && heightDp == lastPushedCanvasHeight) return
lastPushedCanvasWidth = widthDp
lastPushedCanvasHeight = heightDp
this.shadowNodeProxy.setViewSize(widthDp.toDouble(), heightDp.toDouble())
}
/**
* Material caps the sheet frame at `material_bottom_sheet_max_width` (the
* `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`, which our dialog theme
* inherits from) and centers it horizontally, so on tablets the sheet is narrower than
* the display. Returns the cap in px.
*/
private fun getMaxSheetWidth(): Float =
try {
resources
.getDimensionPixelSize(com.google.android.material.R.dimen.material_bottom_sheet_max_width)
.toFloat()
} catch (e: android.content.res.Resources.NotFoundException) {
FALLBACK_MAX_SHEET_WIDTH_DP * context.resources.displayMetrics.density
}
private fun destroy() {
this.stopObservingContentHeight()
this.isClosing = false
@@ -178,6 +301,15 @@ class BottomSheetView(
val contentHeight = this.getContentHeight()
// The content is unsized until the canvas size we pushed lands in the shadow tree,
// so bail and let this retry itself: the state commit resizes this view, that
// re-fires onLayout, and onLayout re-enters present(). Full-height sheets don't
// need a content measurement, so they can go ahead immediately.
//
// Only gate when there is a state channel to wait on. Without one (old architecture)
// nothing would ever resize this view, and the sheet would never present.
if (stateWrapper != null && !fullHeight && contentHeight <= 0f) return
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
@@ -425,8 +557,10 @@ class BottomSheetView(
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
// maxHeight is stored unclamped, so clamp it against the current screen here
val effectiveMaxHeight = minOf(this.maxHeight, this.screenHeight)
return when {
contentHeight > maxHeight -> maxHeight
contentHeight > effectiveMaxHeight -> effectiveMaxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
@@ -34,10 +34,6 @@ const IS_IOS15 =
Platform.OS === 'ios' &&
// semvar - can be 3 segments, so can't use Number(Platform.Version)
Number(Platform.Version.split('.').at(0)) < 16
// older android versions (15 and below) aren't naturally edge-to-edge
// and behave a little differently
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
@@ -148,24 +144,35 @@ function BottomSheetNativeComponentInner({
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
// for the sheet content
const sheetHeight = IS_NON_E2E_ANDROID
? screenHeight + insets.bottom
: screenHeight - insets.top
return (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
position: 'absolute',
height: sheetHeight,
width: '100%',
}}
/*
* On Android the native side owns this view's size - the canvas the sheet
* content is laid out on - and pushes it into the Fabric shadow tree through
* ExpoView's `setViewSize` state channel. It knows the real sheet frame
* (window insets, Material's max-width cap on tablets, rotation), which JS
* can only guess at. `width` and `height` must stay unset there:
* `ExpoViewComponentDescriptor::adopt()` only applies the state size on an
* axis where the style leaves that dimension undefined, so a style dimension
* would silently win and clip the content again.
*
* iOS still sizes the canvas from JS. Moving it onto the same state channel
* needs on-device iteration on iOS 26 sheet geometry (large-detent and
* floating-card metrics), so it is deferred.
*/
style={
Platform.OS === 'ios'
? {
position: 'absolute',
height: screenHeight - insets.top,
width: '100%',
}
: {position: 'absolute'}
}
containerBackgroundColor={backgroundColor}>
<View
style={[
@@ -1,15 +0,0 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.blueskyvideocompress'
version = '1.0.0'
android {
namespace "expo.modules.blueskyvideocompress"
defaultConfig {
versionCode 1
versionName "1.0.0"
}
}
@@ -1,55 +0,0 @@
package expo.modules.blueskyvideocompress
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
object CodecSelector {
// Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500
private val AVC_DENYLIST = setOf(
"c2.qti.avc.encoder"
)
private val SOFTWARE_PREFIXES = listOf(
"OMX.google.",
"c2.android.",
"c2.google."
)
data class EncoderInfo(
val name: String,
val mime: String,
val isHardware: Boolean
)
fun findEncoder(mime: String, requireHardware: Boolean): EncoderInfo? {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val candidates = codecList.codecInfos
.filter { it.isEncoder }
.filter { it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } }
.filter { !it.name.contains("secure", ignoreCase = true) }
.filter { !(mime == MediaFormat.MIMETYPE_VIDEO_AVC && AVC_DENYLIST.contains(it.name)) }
val hardware = candidates.filter { isHardware(it) }
val selected = if (requireHardware) {
hardware.firstOrNull()
} else {
hardware.firstOrNull() ?: candidates.firstOrNull()
}
selected ?: return null
return EncoderInfo(
name = selected.name,
mime = mime,
isHardware = isHardware(selected)
)
}
private fun isHardware(info: MediaCodecInfo): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
} else {
SOFTWARE_PREFIXES.none { info.name.startsWith(it, ignoreCase = true) }
}
}
}
@@ -1,62 +0,0 @@
package expo.modules.blueskyvideocompress
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.util.concurrent.ConcurrentHashMap
class ExpoBlueskyVideoCompressModule : Module() {
private val activeCompressors = ConcurrentHashMap<Int, VideoCompressor>()
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyVideoCompress")
Events("onProgress")
AsyncFunction("probe") { uri: String ->
val context = appContext.reactContext
?: throw Error("React context is null")
return@AsyncFunction VideoProber.probe(context, uri)
}
AsyncFunction("compress") { uri: String, options: Map<String, Any?> ->
val context = appContext.reactContext
?: throw Error("React context is null")
val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0
val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920
val codecPref = (options["codec"] as? String) ?: "auto"
val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1)
val jobId = (options["jobId"] as? Number)?.toInt() ?: 0
val compressor = VideoCompressor(
context = context,
uriString = uri,
targetBitrate = targetBitrate,
maxSize = maxSize,
codecPref = codecPref,
frameRateCap = frameRateCap,
jobId = jobId,
onProgress = { id, progress ->
sendEvent("onProgress", mapOf(
"id" to id,
"progress" to progress
))
}
)
activeCompressors[jobId] = compressor
try {
val result = compressor.compress()
activeCompressors.remove(jobId)
return@AsyncFunction result
} catch (e: Exception) {
activeCompressors.remove(jobId)
throw e
}
}
Function("cancel") { jobId: Int ->
activeCompressors.remove(jobId)?.cancel()
}
}
}
@@ -1,96 +0,0 @@
package expo.modules.blueskyvideocompress
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.view.Surface
class InputSurface(private val surface: Surface) {
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
init {
eglSetup()
}
private fun eglSetup() {
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
if (eglDisplay === EGL14.EGL_NO_DISPLAY) {
throw RuntimeException("unable to get EGL14 display")
}
val version = IntArray(2)
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
throw RuntimeException("unable to initialize EGL14")
}
val attribList = intArrayOf(
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
EGL14.EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0)
checkEglError("eglChooseConfig")
val contextAttribs = intArrayOf(
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
EGL14.EGL_NONE
)
eglContext = EGL14.eglCreateContext(
eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0
)
checkEglError("eglCreateContext")
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
eglSurface = EGL14.eglCreateWindowSurface(
eglDisplay, configs[0], surface, surfaceAttribs, 0
)
checkEglError("eglCreateWindowSurface")
}
fun makeCurrent() {
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
checkEglError("eglMakeCurrent")
}
fun swapBuffers(): Boolean {
return EGL14.eglSwapBuffers(eglDisplay, eglSurface)
}
fun setPresentationTime(nsecs: Long) {
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
}
fun release() {
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(
eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT
)
EGL14.eglDestroySurface(eglDisplay, eglSurface)
EGL14.eglDestroyContext(eglDisplay, eglContext)
EGL14.eglReleaseThread()
EGL14.eglTerminate(eglDisplay)
}
surface.release()
eglDisplay = EGL14.EGL_NO_DISPLAY
eglContext = EGL14.EGL_NO_CONTEXT
eglSurface = EGL14.EGL_NO_SURFACE
}
private fun checkEglError(msg: String) {
val error = EGL14.eglGetError()
if (error != EGL14.EGL_SUCCESS) {
throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}")
}
}
}
@@ -1,61 +0,0 @@
package expo.modules.blueskyvideocompress
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
class OutputSurface : SurfaceTexture.OnFrameAvailableListener {
private val renderer = TextureRenderer()
private var surfaceTexture: SurfaceTexture? = null
private val stMatrix = FloatArray(16)
private val callbackThread = HandlerThread("OutputSurfaceCallbacks")
val surface: Surface
@Volatile
private var frameAvailable = false
private val frameSyncObject = Object()
init {
renderer.surfaceCreated()
callbackThread.start()
val handler = Handler(callbackThread.looper)
surfaceTexture = SurfaceTexture(renderer.getTextureId()).also {
it.setOnFrameAvailableListener(this, handler)
}
surface = Surface(surfaceTexture)
}
fun release() {
surface.release()
surfaceTexture?.release()
surfaceTexture = null
callbackThread.quitSafely()
}
fun awaitNewImage() {
val timeoutMs = 2500L
synchronized(frameSyncObject) {
while (!frameAvailable) {
frameSyncObject.wait(timeoutMs)
if (!frameAvailable) {
throw RuntimeException("Surface frame wait timed out")
}
}
frameAvailable = false
}
surfaceTexture!!.updateTexImage()
}
fun drawImage() {
surfaceTexture!!.getTransformMatrix(stMatrix)
renderer.drawFrame(stMatrix)
}
override fun onFrameAvailable(st: SurfaceTexture) {
synchronized(frameSyncObject) {
frameAvailable = true
frameSyncObject.notifyAll()
}
}
}
@@ -1,156 +0,0 @@
package expo.modules.blueskyvideocompress
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.Matrix
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
class TextureRenderer {
companion object {
private const val FLOAT_SIZE_BYTES = 4
private const val STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
private const val POS_OFFSET = 0
private const val UV_OFFSET = 3
private val VERTICES = floatArrayOf(
-1.0f, -1.0f, 0f, 0f, 0f,
1.0f, -1.0f, 0f, 1f, 0f,
-1.0f, 1.0f, 0f, 0f, 1f,
1.0f, 1.0f, 0f, 1f, 1f,
)
private const val VERTEX_SHADER = """
uniform mat4 uMVPMatrix;
uniform mat4 uSTMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
}
"""
private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTextureCoord);
}
"""
}
private val vertices: FloatBuffer =
ByteBuffer.allocateDirect(VERTICES.size * FLOAT_SIZE_BYTES)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply { put(VERTICES); position(0) }
private val mvpMatrix = FloatArray(16)
private var program = 0
private var textureId = -1
private var uMVPMatrixHandle = 0
private var uSTMatrixHandle = 0
private var aPositionHandle = 0
private var aTextureCoordHandle = 0
init {
Matrix.setIdentityM(mvpMatrix, 0)
}
fun getTextureId(): Int = textureId
fun surfaceCreated() {
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
textureId = textures[0]
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
GLES20.glTexParameterf(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()
)
GLES20.glTexParameterf(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE
)
}
fun drawFrame(stMatrix: FloatArray) {
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
vertices.position(POS_OFFSET)
GLES20.glVertexAttribPointer(
aPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
)
GLES20.glEnableVertexAttribArray(aPositionHandle)
vertices.position(UV_OFFSET)
GLES20.glVertexAttribPointer(
aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
)
GLES20.glEnableVertexAttribArray(aTextureCoordHandle)
GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(aPositionHandle)
GLES20.glDisableVertexAttribArray(aTextureCoordHandle)
}
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
val program = GLES20.glCreateProgram()
GLES20.glAttachShader(program, vertexShader)
GLES20.glAttachShader(program, fragmentShader)
GLES20.glLinkProgram(program)
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] != GLES20.GL_TRUE) {
val log = GLES20.glGetProgramInfoLog(program)
GLES20.glDeleteProgram(program)
throw RuntimeException("Could not link program: $log")
}
return program
}
private fun loadShader(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
val compiled = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
if (compiled[0] == 0) {
val log = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
throw RuntimeException("Could not compile shader $type: $log")
}
return shader
}
}
@@ -1,582 +0,0 @@
package expo.modules.blueskyvideocompress
import android.content.Context
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import android.net.Uri
import android.os.Build
import android.util.Log
import java.io.File
import java.nio.ByteBuffer
import java.util.UUID
class VideoCompressor(
private val context: Context,
private val uriString: String,
private val targetBitrate: Int,
private val maxSize: Int,
private val codecPref: String,
private val frameRateCap: Int,
private val jobId: Int,
private val onProgress: (Int, Double) -> Unit
) {
companion object {
private const val TAG = "BskyVideoCompress"
private const val TIMEOUT_DEQUEUE = 100L
private const val I_FRAME_INTERVAL = 3
}
@Volatile
private var isCancelled = false
fun cancel() {
isCancelled = true
}
fun compress(): Map<String, Any> {
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
val tryHevc = codecPref == "hevc"
if (tryHevc) {
try {
return doCompress(useHevc = true, allowSoftwareFallback = false)
} catch (e: Exception) {
if (codecPref == "hevc" || isCancelled) throw e
Log.w(TAG, "HEVC encode failed, falling back to h264", e)
}
}
return doCompress(useHevc = false, allowSoftwareFallback = true)
}
private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map<String, Any> {
val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC
val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback)
?: throw RuntimeException("No encoder for $mime")
try {
return runPipeline(encoderInfo, useHevc)
} catch (e: Exception) {
if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e
Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e)
val sw = CodecSelector.findEncoder(mime, requireHardware = false)
?.takeIf { !it.isHardware }
?: throw e
return runPipeline(sw, useHevc)
}
}
private fun runPipeline(
encoderInfo: CodecSelector.EncoderInfo,
useHevc: Boolean
): Map<String, Any> {
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
var extractor: MediaExtractor? = null
var muxer: MediaMuxer? = null
var encoder: MediaCodec? = null
var decoder: MediaCodec? = null
var inputSurface: InputSurface? = null
var outputSurface: OutputSurface? = null
var muxerStarted = false
var outputDims = Pair(0, 0)
var durationUs = 0L
try {
val uri = Uri.parse(uriString)
extractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
extractor.setDataSource(context, uri, null)
} else {
extractor.setDataSource(uriString)
}
var videoTrackIndex = -1
var audioTrackIndex = -1
var videoFormat: MediaFormat? = null
var audioFormat: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (trackMime.startsWith("video/") && videoTrackIndex == -1) {
videoTrackIndex = i
videoFormat = format
} else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) {
audioTrackIndex = i
audioFormat = format
}
}
if (videoTrackIndex == -1 || videoFormat == null) {
throw RuntimeException("No video track found")
}
val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
videoFormat.getInteger(MediaFormat.KEY_ROTATION)
} else 0
durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) {
videoFormat.getLong(MediaFormat.KEY_DURATION)
} else 0L
val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
} else 30
outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize)
val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat)
val transcodedAudio: TranscodedAudio? = if (
audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio
) {
transcodeAudioToAAC(audioTrackIndex, audioFormat)
} else null
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val effectiveBitrate = if (targetBitrate > 0) {
targetBitrate
} else if (useHevc) 2_500_000 else 3_000_000
val encoderFormat = MediaFormat.createVideoFormat(
encoderInfo.mime, outputDims.first, outputDims.second
).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate)
setInteger(
MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
)
setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709)
setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setInteger(MediaFormat.KEY_PRIORITY, 0)
setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap)
if (useHevc) {
setInteger(
MediaFormat.KEY_PROFILE,
MediaCodecInfo.CodecProfileLevel.HEVCProfileMain
)
} else {
setInteger(
MediaFormat.KEY_PROFILE,
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
)
setInteger(
MediaFormat.KEY_LEVEL,
MediaCodecInfo.CodecProfileLevel.AVCLevel41
)
}
}
}
encoder = MediaCodec.createByCodecName(encoderInfo.name)
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val encoderInputSurface = encoder.createInputSurface()
inputSurface = InputSurface(encoderInputSurface)
inputSurface.makeCurrent()
outputSurface = OutputSurface()
encoder.start()
decoder = MediaCodec.createDecoderByType(
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
)
// Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may
// ignore the hint, but where supported it produces correct BT.709 pixels
// for the encoder rather than HDR pixels mislabeled as SDR.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
videoFormat.setInteger(
MediaFormat.KEY_COLOR_TRANSFER_REQUEST,
MediaFormat.COLOR_TRANSFER_SDR_VIDEO
)
}
decoder.configure(videoFormat, outputSurface.surface, null, 0)
decoder.start()
extractor.selectTrack(videoTrackIndex)
val frameDropEnabled = sourceFps > frameRateCap
val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L
var nextTargetPtsUs = 0L
var muxerVideoTrack = -1
var muxerAudioTrack = -1
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var decoderDone = false
var outputDone = false
var lastProgressMs = 0L
while (!outputDone && !isCancelled) {
if (!inputDone) {
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (idx >= 0) {
val buf = decoder.getInputBuffer(idx)
if (buf != null) {
val sz = extractor.readSampleData(buf, 0)
if (sz < 0) {
decoder.queueInputBuffer(
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
} else {
decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0)
extractor.advance()
}
}
}
}
if (!decoderDone) {
val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
if (status >= 0) {
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val shouldRender = if (isEos) {
false
} else if (frameDropEnabled) {
if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
true
} else false
} else true
decoder.releaseOutputBuffer(status, shouldRender)
if (shouldRender) {
outputSurface.awaitNewImage()
outputSurface.drawImage()
inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
inputSurface.swapBuffers()
}
if (isEos) {
encoder.signalEndOfInputStream()
decoderDone = true
}
}
}
var encoderDrained = false
while (!outputDone && !isCancelled && !encoderDrained) {
val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0)
when {
encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true
encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (!muxerStarted) {
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
if (audioTrackIndex >= 0 && audioFormat != null) {
if (shouldPassthroughAudio) {
muxerAudioTrack = muxer.addTrack(audioFormat)
} else if (transcodedAudio != null) {
muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat)
}
}
muxer.start()
muxerStarted = true
}
}
encIdx >= 0 -> {
val data = encoder.getOutputBuffer(encIdx)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
bufferInfo.size = 0
}
if (data != null && bufferInfo.size > 0 && muxerStarted) {
muxer.writeSampleData(muxerVideoTrack, data, bufferInfo)
}
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
encoder.releaseOutputBuffer(encIdx, false)
if (isEos) {
outputDone = true
} else if (durationUs > 0) {
val now = System.currentTimeMillis()
if (now - lastProgressMs >= 100) {
lastProgressMs = now
val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
.coerceIn(0.0, 1.0)
onProgress(jobId, p)
}
}
}
}
}
}
if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) {
if (shouldPassthroughAudio) {
passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack)
} else if (transcodedAudio != null) {
writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack)
}
}
} finally {
try { decoder?.stop() } catch (_: Exception) {}
try { decoder?.release() } catch (_: Exception) {}
try { encoder?.stop() } catch (_: Exception) {}
try { encoder?.release() } catch (_: Exception) {}
try { outputSurface?.release() } catch (_: Exception) {}
try { inputSurface?.release() } catch (_: Exception) {}
try { extractor?.release() } catch (_: Exception) {}
try {
if (muxerStarted) muxer?.stop()
muxer?.release()
} catch (_: Exception) {}
}
if (isCancelled) {
outputFile.delete()
throw RuntimeException("Compression cancelled")
}
onProgress(jobId, 1.0)
val durationSeconds = durationUs / 1_000_000.0
return mapOf(
"uri" to "file://${outputFile.absolutePath}",
"size" to outputFile.length(),
"mimeType" to "video/mp4",
"width" to outputDims.first,
"height" to outputDims.second,
"duration" to durationSeconds,
"codec" to if (useHevc) "hevc" else "h264"
)
}
private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) {
val audioExtractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
} else {
audioExtractor.setDataSource(uriString)
}
audioExtractor.selectTrack(audioTrackIndex)
audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
val buffer = ByteBuffer.allocate(256 * 1024)
val info = MediaCodec.BufferInfo()
try {
while (!isCancelled) {
val sz = audioExtractor.readSampleData(buffer, 0)
if (sz < 0) break
info.offset = 0
info.size = sz
info.presentationTimeUs = audioExtractor.sampleTime
info.flags = audioExtractor.sampleFlags
muxer.writeSampleData(muxerAudioTrack, buffer, info)
audioExtractor.advance()
}
} finally {
audioExtractor.release()
}
}
private fun canPassthroughAudio(format: MediaFormat): Boolean {
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
return mime == MediaFormat.MIMETYPE_AUDIO_AAC
}
private data class TranscodedAudio(
val outputFormat: MediaFormat,
val samples: List<Sample>
) {
data class Sample(
val bytes: ByteArray,
val presentationTimeUs: Long,
val flags: Int
)
}
// Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer
// can take it. iOS always re-encodes to AAC; without this Android would drop
// the audio track entirely.
private fun transcodeAudioToAAC(
audioTrackIndex: Int,
sourceFormat: MediaFormat
): TranscodedAudio? {
val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null
val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT))
sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2
val audioExtractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
} else {
audioExtractor.setDataSource(uriString)
}
audioExtractor.selectTrack(audioTrackIndex)
var decoder: MediaCodec? = null
var encoder: MediaCodec? = null
try {
decoder = MediaCodec.createDecoderByType(sourceMime)
decoder.configure(sourceFormat, null, null, 0)
decoder.start()
val encoderFormat = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount
).apply {
setInteger(
MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC
)
setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024)
}
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
encoder.start()
val samples = mutableListOf<TranscodedAudio.Sample>()
var outputFormat: MediaFormat? = null
var inputDone = false
var decoderDone = false
var encoderInputSignalled = false
var encoderDone = false
val info = MediaCodec.BufferInfo()
while (!encoderDone && !isCancelled) {
if (!inputDone) {
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (idx >= 0) {
val buf = decoder.getInputBuffer(idx)
if (buf != null) {
val sz = audioExtractor.readSampleData(buf, 0)
if (sz < 0) {
decoder.queueInputBuffer(
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
} else {
decoder.queueInputBuffer(
idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags
)
audioExtractor.advance()
}
}
}
}
if (!decoderDone) {
val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
when {
status == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {}
status >= 0 -> {
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val data = decoder.getOutputBuffer(status)
if (data != null && info.size > 0) {
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (encInIdx >= 0) {
val encInBuf = encoder.getInputBuffer(encInIdx)
if (encInBuf != null) {
encInBuf.clear()
data.position(info.offset)
data.limit(info.offset + info.size)
encInBuf.put(data)
encoder.queueInputBuffer(
encInIdx, 0, info.size, info.presentationTimeUs, 0
)
}
}
}
decoder.releaseOutputBuffer(status, false)
if (isEos) {
if (!encoderInputSignalled) {
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10)
if (encInIdx >= 0) {
encoder.queueInputBuffer(
encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
encoderInputSignalled = true
}
}
decoderDone = true
}
}
}
}
val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
when {
encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
outputFormat = encoder.outputFormat
}
encOutIdx >= 0 -> {
val data = encoder.getOutputBuffer(encOutIdx)
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
if (data != null && info.size > 0 && !isConfig) {
val bytes = ByteArray(info.size)
data.position(info.offset)
data.get(bytes, 0, info.size)
samples.add(
TranscodedAudio.Sample(
bytes = bytes,
presentationTimeUs = info.presentationTimeUs,
flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv()
)
)
}
encoder.releaseOutputBuffer(encOutIdx, false)
if (isEos) encoderDone = true
}
}
}
val fmt = outputFormat ?: return null
return TranscodedAudio(fmt, samples)
} catch (e: Exception) {
Log.w(TAG, "Audio transcode failed; dropping audio", e)
return null
} finally {
try { decoder?.stop() } catch (_: Exception) {}
try { decoder?.release() } catch (_: Exception) {}
try { encoder?.stop() } catch (_: Exception) {}
try { encoder?.release() } catch (_: Exception) {}
audioExtractor.release()
}
}
private fun writeTranscodedAudio(
samples: List<TranscodedAudio.Sample>,
muxer: MediaMuxer,
muxerAudioTrack: Int
) {
val info = MediaCodec.BufferInfo()
for (sample in samples) {
if (isCancelled) break
val buffer = ByteBuffer.wrap(sample.bytes)
info.offset = 0
info.size = sample.bytes.size
info.presentationTimeUs = sample.presentationTimeUs
info.flags = sample.flags
muxer.writeSampleData(muxerAudioTrack, buffer, info)
}
}
private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair<Int, Int> {
val isRotated = rotation == 90 || rotation == 270
val displayW = if (isRotated) srcH else srcW
val displayH = if (isRotated) srcW else srcH
val scale: Float = when {
displayW <= maxSize && displayH <= maxSize -> 1.0f
displayW > displayH -> maxSize.toFloat() / displayW
else -> maxSize.toFloat() / displayH
}
return Pair(
roundToEven((displayW * scale).toInt()),
roundToEven((displayH * scale).toInt())
)
}
private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1
}
@@ -1,125 +0,0 @@
package expo.modules.blueskyvideocompress
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
object VideoProber {
fun probe(context: Context, uriString: String): Map<String, Any> {
val uri = Uri.parse(uriString)
val retriever = MediaMetadataRetriever()
try {
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
retriever.setDataSource(context, uri)
} else {
retriever.setDataSource(uriString)
}
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toIntOrNull() ?: 0
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toIntOrNull() ?: 0
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull() ?: 0L
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull() ?: 0
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull() ?: 0
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
?.equals("yes") ?: false
val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
?.toFloatOrNull() ?: 0f
val fileSize = getFileSize(context, uriString)
val extractor = MediaExtractor()
var codec = "unknown"
var mimeType = "video/mp4"
var extractedFrameRate = frameRate
var isHDR = false
try {
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
extractor.setDataSource(context, uri, null)
} else {
extractor.setDataSource(uriString)
}
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME)
if (mime?.startsWith("video/") == true) {
mimeType = mime
codec = mime.removePrefix("video/")
if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) {
extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
format.containsKey(MediaFormat.KEY_COLOR_TRANSFER)
) {
val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER)
isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG ||
transfer == MediaFormat.COLOR_TRANSFER_ST2084
}
// Dolby Vision tracks use codec-specific mimes that aren't covered by
// KEY_COLOR_TRANSFER on every device.
if (mime.contains("dolby-vision", ignoreCase = true)) {
isHDR = true
}
break
}
}
} finally {
extractor.release()
}
val durationSeconds = durationMs / 1000.0
val effectiveBitrate = if (bitrate > 0) {
bitrate
} else if (durationSeconds > 0 && fileSize > 0) {
(fileSize * 8 / durationSeconds).toInt()
} else {
0
}
return mapOf(
"width" to width,
"height" to height,
"duration" to durationSeconds,
"bitrate" to effectiveBitrate,
"fileSize" to fileSize,
"mimeType" to mimeType,
"codec" to codec,
"hasAudio" to hasAudio,
"frameRate" to extractedFrameRate.toDouble(),
"rotation" to rotation,
"isHDR" to isHDR
)
} finally {
retriever.release()
}
}
private fun getFileSize(context: Context, uriString: String): Long {
return try {
if (uriString.startsWith("content://")) {
context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use {
it.statSize
} ?: 0L
} else {
val path = if (uriString.startsWith("file://")) {
uriString.removePrefix("file://")
} else {
uriString
}
java.io.File(path).length()
}
} catch (_: Exception) {
0L
}
}
}
@@ -1,9 +0,0 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["ExpoBlueskyVideoCompressModule"]
},
"android": {
"modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"]
}
}
@@ -1,87 +0,0 @@
import {type EventSubscription} from 'expo-modules-core'
import NativeModule from './src/ExpoBlueskyVideoCompressModule'
import {
type CodecPreference,
type CompressCallbacks,
type CompressOptions,
type CompressResult,
type VideoMetadata,
} from './src/types'
export type {
CodecPreference,
CompressCallbacks,
CompressOptions,
CompressResult,
VideoMetadata,
}
class AbortError extends Error {
name = 'AbortError'
constructor() {
super('Aborted')
}
}
let jobIdCounter = 0
export function probe(uri: string): Promise<VideoMetadata> {
return NativeModule.probe(uri)
}
export function compress(
uri: string,
options: CompressOptions = {},
callbacks?: CompressCallbacks,
): Promise<CompressResult> {
const jobId = ++jobIdCounter
let subscription: EventSubscription | undefined
if (callbacks?.signal?.aborted) {
return Promise.reject(new AbortError())
}
const nativeOptions = {
targetBitrate: options.targetBitrate ?? 0,
maxSize: options.maxSize ?? 1920,
codec: options.codec ?? 'auto',
frameRateCap: options.frameRateCap ?? 30,
jobId,
}
return new Promise<CompressResult>((resolve, reject) => {
if (callbacks?.onProgress) {
subscription = NativeModule.addListener(
'onProgress',
(event: {id: number; progress: number}) => {
if (event.id === jobId) {
callbacks.onProgress!(event.progress)
}
},
)
}
const abortHandler = () => {
NativeModule.cancel(jobId)
subscription?.remove()
reject(new AbortError())
}
if (callbacks?.signal) {
callbacks.signal.addEventListener('abort', abortHandler, {once: true})
}
NativeModule.compress(uri, nativeOptions)
.then(result => {
callbacks?.signal?.removeEventListener('abort', abortHandler)
subscription?.remove()
resolve(result)
})
.catch((error: unknown) => {
callbacks?.signal?.removeEventListener('abort', abortHandler)
subscription?.remove()
reject(error instanceof Error ? error : new Error(String(error)))
})
})
}
@@ -1,21 +0,0 @@
import VideoToolbox
enum CodecCapability {
static let isHardwareHEVCEncodeAvailable: Bool = {
var encoderListCF: CFArray?
let status = VTCopyVideoEncoderList(nil, &encoderListCF)
guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else {
return false
}
return encoderList.contains { encoder in
guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int,
codecTypeValue == Int(kCMVideoCodecType_HEVC) else {
return false
}
if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool {
return isHardware
}
return true
}
}()
}
@@ -1,20 +0,0 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyVideoCompress'
s.version = '1.0.0'
s.summary = 'Hardware-accelerated video compression for Bluesky'
s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '15.1' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -1,74 +0,0 @@
import AVFoundation
import ExpoModulesCore
public class ExpoBlueskyVideoCompressModule: Module {
private var activeCompressors: [Int: VideoCompressor] = [:]
private let activeCompressorsLock = NSLock()
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyVideoCompress")
Events("onProgress")
AsyncFunction("probe") { (uri: String) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
return try await VideoProber.probe(url: url)
}
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
let targetBitrate = options["targetBitrate"] as? Int ?? 0
let maxSize = options["maxSize"] as? Int ?? 1920
let codecPref = options["codec"] as? String ?? "auto"
let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30)
let jobId = options["jobId"] as? Int ?? 0
let compressor = VideoCompressor(
url: url,
targetBitrate: targetBitrate,
maxSize: maxSize,
codecPref: codecPref,
frameRateCap: frameRateCap,
jobId: jobId,
onProgress: { [weak self] id, progress in
self?.sendEvent("onProgress", [
"id": id,
"progress": progress,
])
}
)
self.setCompressor(jobId, compressor)
do {
let result = try await compressor.compress()
self.setCompressor(jobId, nil)
return result
} catch {
self.setCompressor(jobId, nil)
throw error
}
}
Function("cancel") { (jobId: Int) in
self.cancelCompressor(jobId)
}
}
private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) {
activeCompressorsLock.lock()
defer { activeCompressorsLock.unlock() }
if let compressor = compressor {
activeCompressors[jobId] = compressor
} else {
activeCompressors.removeValue(forKey: jobId)
}
}
private func cancelCompressor(_ jobId: Int) {
activeCompressorsLock.lock()
let compressor = activeCompressors.removeValue(forKey: jobId)
activeCompressorsLock.unlock()
compressor?.cancel()
}
}
@@ -1,410 +0,0 @@
import AVFoundation
import VideoToolbox
class VideoCompressor {
private let url: URL
private let targetBitrate: Int
private let maxSize: Int
private let codecPref: String
private let frameRateCap: Int
private let jobId: Int
private let onProgress: (Int, Double) -> Void
private var isCancelled = false
init(
url: URL,
targetBitrate: Int,
maxSize: Int,
codecPref: String,
frameRateCap: Int,
jobId: Int,
onProgress: @escaping (Int, Double) -> Void
) {
self.url = url
self.targetBitrate = targetBitrate
self.maxSize = maxSize
self.codecPref = codecPref
self.frameRateCap = frameRateCap
self.jobId = jobId
self.onProgress = onProgress
}
func cancel() {
isCancelled = true
}
func compress() async throws -> [String: Any] {
let asset = AVURLAsset(
url: url,
options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]
)
let duration = try await asset.load(.duration)
let totalSeconds = CMTimeGetSeconds(duration)
guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) }
let videoTracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = videoTracks.first else {
throw err("No video track found", code: 1)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
let displaySize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
// 'auto' targets h264 server pipeline is HLS, which favors h264 (HEVC needs
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
let useHEVC: Bool
switch codecPref {
case "hevc": useHEVC = true
default: useHEVC = false
}
let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264
let profileLevel: String = useHEVC
? kVTProfileLevel_HEVC_Main_AutoLevel as String
: kVTProfileLevel_H264_High_AutoLevel as String
let effectiveBitrate = targetBitrate > 0
? targetBitrate
: (useHEVC ? 2_500_000 : 3_000_000)
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mp4")
let videoComposition = makeRotatingComposition(
videoTrack: videoTrack,
preferredTransform: preferredTransform,
naturalSize: naturalSize,
outputSize: outputSize,
duration: duration
)
let reader = try AVAssetReader(asset: asset)
let videoReaderSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
let videoReaderOutput = AVAssetReaderVideoCompositionOutput(
videoTracks: [videoTrack],
videoSettings: videoReaderSettings
)
videoReaderOutput.videoComposition = videoComposition
videoReaderOutput.alwaysCopiesSampleData = false
guard reader.canAdd(videoReaderOutput) else {
throw err("Cannot read video track", code: 3)
}
reader.add(videoReaderOutput)
var compressionProps: [String: Any] = [
AVVideoAverageBitRateKey: effectiveBitrate,
AVVideoProfileLevelKey: profileLevel,
AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30),
AVVideoExpectedSourceFrameRateKey: frameRateCap,
AVVideoAllowFrameReorderingKey: false,
kVTCompressionPropertyKey_RealTime as String: true,
]
let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5)
compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [
peakBytesPerSecond, 1.0
] as CFArray
let videoColorProps: [String: Any] = [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
]
let videoWriterSettings: [String: Any] = [
AVVideoCodecKey: codecType,
AVVideoWidthKey: outputSize.width,
AVVideoHeightKey: outputSize.height,
AVVideoColorPropertiesKey: videoColorProps,
AVVideoCompressionPropertiesKey: compressionProps,
]
let videoWriterInput = AVAssetWriterInput(
mediaType: .video,
outputSettings: videoWriterSettings
)
videoWriterInput.expectsMediaDataInRealTime = false
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
writer.metadata = []
guard writer.canAdd(videoWriterInput) else {
throw err("Cannot write video track", code: 4)
}
writer.add(videoWriterInput)
var audioReaderOutput: AVAssetReaderTrackOutput?
var audioWriterInput: AVAssetWriterInput?
if let audioTrack = audioTracks.first {
let audioDecoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false,
]
let audioOutput = AVAssetReaderTrackOutput(
track: audioTrack,
outputSettings: audioDecoderSettings
)
audioOutput.alwaysCopiesSampleData = false
if reader.canAdd(audioOutput) {
reader.add(audioOutput)
audioReaderOutput = audioOutput
let audioEncoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: 128_000,
]
let audioInput = AVAssetWriterInput(
mediaType: .audio,
outputSettings: audioEncoderSettings
)
audioInput.expectsMediaDataInRealTime = false
if writer.canAdd(audioInput) {
writer.add(audioInput)
audioWriterInput = audioInput
}
}
}
guard reader.startReading() else {
throw reader.error ?? err("Reader failed to start", code: 8)
}
guard writer.startWriting() else {
throw writer.error ?? err("Writer failed to start", code: 9)
}
writer.startSession(atSourceTime: .zero)
let minFrameIntervalSeconds = 1.0 / Double(frameRateCap)
let minFrameInterval = CMTime(
seconds: minFrameIntervalSeconds,
preferredTimescale: 600
)
await withTaskGroup(of: Void.self) { group in
group.addTask { [self] in
await processVideoTrack(
readerOutput: videoReaderOutput,
writerInput: videoWriterInput,
totalDuration: totalSeconds,
minFrameInterval: minFrameInterval
)
}
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
group.addTask { [self] in
await processAudioTrack(
readerOutput: audioOutput,
writerInput: audioInput
)
}
}
await group.waitForAll()
}
if isCancelled {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw err("Compression cancelled", code: 5)
}
if reader.status == .failed {
let error = reader.error ?? err("Reader failed", code: 6)
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw error
}
await writer.finishWriting()
if writer.status == .failed {
let error = writer.error ?? err("Writer failed", code: 7)
try? FileManager.default.removeItem(at: outputURL)
throw error
}
onProgress(jobId, 1.0)
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
let fileSize = attributes[.size] as? Int ?? 0
return [
"uri": outputURL.absoluteString,
"size": fileSize,
"mimeType": "video/mp4",
"width": outputSize.width,
"height": outputSize.height,
"duration": totalSeconds,
"codec": useHEVC ? "hevc" : "h264",
]
}
private func processVideoTrack(
readerOutput: AVAssetReaderOutput,
writerInput: AVAssetWriterInput,
totalDuration: Double,
minFrameInterval: CMTime
) async {
var lastProgressTime: CFAbsoluteTime = 0
var lastAppendedPTS: CMTime?
var finished = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.video")
) {
let finish = {
if !finished {
finished = true
writerInput.markAsFinished()
continuation.resume()
}
}
while writerInput.isReadyForMoreMediaData {
if finished { return }
if self.isCancelled {
finish()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
finish()
return
}
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if let last = lastAppendedPTS {
let delta = CMTimeSubtract(pts, last)
if CMTimeCompare(delta, minFrameInterval) < 0 {
continue
}
}
lastAppendedPTS = pts
if !writerInput.append(sampleBuffer) {
finish()
return
}
let now = CFAbsoluteTimeGetCurrent()
if now - lastProgressTime >= 0.1 {
lastProgressTime = now
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
self.onProgress(self.jobId, progress)
}
}
}
}
}
private func processAudioTrack(
readerOutput: AVAssetReaderOutput,
writerInput: AVAssetWriterInput
) async {
var finished = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.audio")
) {
let finish = {
if !finished {
finished = true
writerInput.markAsFinished()
continuation.resume()
}
}
while writerInput.isReadyForMoreMediaData {
if finished { return }
if self.isCancelled {
finish()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
finish()
return
}
if !writerInput.append(sampleBuffer) {
finish()
return
}
}
}
}
}
private func makeRotatingComposition(
videoTrack: AVAssetTrack,
preferredTransform: CGAffineTransform,
naturalSize: CGSize,
outputSize: (width: Int, height: Int),
duration: CMTime
) -> AVMutableVideoComposition {
let composition = AVMutableVideoComposition()
composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height)
composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap))
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
let translate = CGAffineTransform(
translationX: -rotatedRect.minX,
y: -rotatedRect.minY
)
let displaySize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let scaleX = CGFloat(outputSize.width) / displaySize.width
let scaleY = CGFloat(outputSize.height) / displaySize.height
let scale = CGAffineTransform(scaleX: scaleX, y: scaleY)
let combined = preferredTransform.concatenating(translate).concatenating(scale)
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
layerInstruction.setTransform(combined, at: .zero)
let instruction = AVMutableVideoCompositionInstruction()
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
instruction.layerInstructions = [layerInstruction]
composition.instructions = [instruction]
return composition
}
private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) {
let cap = CGFloat(maxSize)
let scale: CGFloat
if displaySize.width <= cap && displaySize.height <= cap {
scale = 1.0
} else if displaySize.width > displaySize.height {
scale = cap / displaySize.width
} else {
scale = cap / displaySize.height
}
return (
roundToEven(Int(displaySize.width * scale)),
roundToEven(Int(displaySize.height * scale))
)
}
private func roundToEven(_ value: Int) -> Int {
return value % 2 == 0 ? value : value - 1
}
private func err(_ message: String, code: Int) -> NSError {
return NSError(
domain: "ExpoBlueskyVideoCompress",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
@@ -1,105 +0,0 @@
import AVFoundation
import UniformTypeIdentifiers
struct VideoProber {
static func probe(url: URL) async throws -> [String: Any] {
let asset = AVURLAsset(url: url)
let duration = try await asset.load(.duration)
let tracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = tracks.first else {
throw NSError(
domain: "ExpoBlueskyVideoCompress",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
var codec = "unknown"
var isHDR = false
if let formatDescription = formatDescriptions.first {
let subType = CMFormatDescriptionGetMediaSubType(formatDescription)
codec = fourCCToString(subType)
// Dolby Vision codecs are HDR by definition.
let dolbyVisionSubtypes: Set<String> = ["dvhe", "dvh1", "dvav", "dva1"]
if dolbyVisionSubtypes.contains(codec) {
isHDR = true
} else if let extensions = CMFormatDescriptionGetExtensions(formatDescription)
as? [String: Any]
{
let transferKey = kCMFormatDescriptionExtension_TransferFunction as String
if let transfer = extensions[transferKey] as? String {
let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String
let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String
isHDR = transfer == hlg || transfer == pq
}
}
}
let rotation = rotationFromTransform(preferredTransform)
let isRotated = rotation == 90 || rotation == 270
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
let hasAudio = !audioTracks.isEmpty
let fileSize: Int
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attributes[.size] as? Int {
fileSize = size
} else {
fileSize = 0
}
let mimeType: String
if let utType = UTType(filenameExtension: url.pathExtension) {
mimeType = utType.preferredMIMEType ?? "video/mp4"
} else {
mimeType = "video/mp4"
}
let durationSeconds = CMTimeGetSeconds(duration)
var bitrate = Int(estimatedDataRate)
if bitrate == 0 && durationSeconds > 0 {
bitrate = Int(Double(fileSize * 8) / durationSeconds)
}
return [
"width": width,
"height": height,
"duration": durationSeconds,
"bitrate": bitrate,
"fileSize": fileSize,
"mimeType": mimeType,
"codec": codec,
"hasAudio": hasAudio,
"frameRate": nominalFrameRate,
"rotation": rotation,
"isHDR": isHDR
]
}
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
let angle = atan2(transform.b, transform.a)
let degrees = Int(round(angle * 180.0 / .pi))
return ((degrees % 360) + 360) % 360
}
private static func fourCCToString(_ code: FourCharCode) -> String {
let chars: [Character] = [
Character(UnicodeScalar((code >> 24) & 0xFF)!),
Character(UnicodeScalar((code >> 16) & 0xFF)!),
Character(UnicodeScalar((code >> 8) & 0xFF)!),
Character(UnicodeScalar(code & 0xFF)!)
]
return String(chars).trimmingCharacters(in: .whitespaces)
}
}
@@ -1,23 +0,0 @@
import {type EventSubscription, requireNativeModule} from 'expo-modules-core'
import {
type CompressResult,
type NativeCompressOptions,
type VideoMetadata,
} from './types'
type ProgressEvent = {id: number; progress: number}
interface ExpoBlueskyVideoCompressModule {
probe(uri: string): Promise<VideoMetadata>
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
cancel(jobId: number): void
addListener(
eventName: 'onProgress',
listener: (event: ProgressEvent) => void,
): EventSubscription
}
export default requireNativeModule<ExpoBlueskyVideoCompressModule>(
'ExpoBlueskyVideoCompress',
)
@@ -1,45 +0,0 @@
export type CodecPreference = 'auto' | 'hevc' | 'h264'
export type VideoMetadata = {
width: number
height: number
duration: number
bitrate: number
fileSize: number
mimeType: string
codec: string
hasAudio: boolean
frameRate: number
rotation: number
isHDR: boolean
}
export type CompressOptions = {
targetBitrate?: number
maxSize?: number
codec?: CodecPreference
frameRateCap?: number
}
export type CompressCallbacks = {
onProgress?: (progress: number) => void
signal?: AbortSignal
}
export type CompressResult = {
uri: string
size: number
mimeType: string
width: number
height: number
duration: number
codec: 'h264' | 'hevc'
}
export type NativeCompressOptions = {
targetBitrate: number
maxSize: number
codec: CodecPreference
frameRateCap: number
jobId: number
}
+3 -78
View File
@@ -296,19 +296,6 @@
"count": 1
}
},
"src/components/StarterPack/Main/ProfilesList.tsx": {
"typescript/no-misused-promises": {
"count": 1
}
},
"src/components/StarterPack/ProfileStarterPacks.tsx": {
"typescript/no-explicit-any": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 2
}
},
"src/components/StarterPack/QrCodeDialog.tsx": {
"typescript/no-floating-promises": {
"count": 5
@@ -317,14 +304,6 @@
"count": 4
}
},
"src/components/StarterPack/ShareDialog.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/require-await": {
"count": 1
}
},
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
"typescript/no-explicit-any": {
"count": 1
@@ -406,21 +385,11 @@
"count": 1
}
},
"src/components/dialogs/StarterPackDialog.tsx": {
"typescript/no-misused-promises": {
"count": 1
}
},
"src/components/dialogs/SwitchAccount.tsx": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx": {
"typescript/no-floating-promises": {
"count": 2
}
},
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
"typescript/no-explicit-any": {
"count": 2
@@ -557,11 +526,6 @@
"count": 2
}
},
"src/lib/async/until.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"src/lib/broadcast/stub.ts": {
"typescript/no-explicit-any": {
"count": 1
@@ -674,7 +638,7 @@
"count": 1
},
"typescript/no-floating-promises": {
"count": 5
"count": 3
}
},
"src/lib/media/manip.web.ts": {
@@ -870,14 +834,6 @@
"count": 1
}
},
"src/screens/ProfileList/components/MoreOptionsMenu.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 5
}
},
"src/screens/ProfileList/components/SubscribeMenu.tsx": {
"typescript/no-misused-promises": {
"count": 2
@@ -1009,22 +965,6 @@
"count": 1
}
},
"src/screens/StarterPack/StarterPackScreen.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/require-await": {
"count": 1
}
},
"src/screens/StarterPack/Wizard/index.tsx": {
"typescript/no-floating-promises": {
"count": 2
},
"typescript/require-await": {
"count": 1
}
},
"src/state/a11y.tsx": {
"typescript/no-floating-promises": {
"count": 1
@@ -1191,7 +1131,7 @@
"count": 2
},
"typescript/no-floating-promises": {
"count": 2
"count": 1
},
"typescript/no-unsafe-member-access": {
"count": 2
@@ -1226,7 +1166,7 @@
"count": 3
},
"typescript/no-floating-promises": {
"count": 3
"count": 2
},
"typescript/no-unsafe-member-access": {
"count": 3
@@ -1246,11 +1186,6 @@
"count": 2
}
},
"src/state/queries/starter-packs.ts": {
"typescript/require-await": {
"count": 1
}
},
"src/state/queries/suggested-follows.ts": {
"no-unused-vars": {
"count": 1
@@ -1318,11 +1253,6 @@
"count": 1
}
},
"src/view/com/composer/drafts/DraftsButton.tsx": {
"typescript/no-misused-promises": {
"count": 1
}
},
"src/view/com/composer/drafts/state/queries.ts": {
"typescript/no-explicit-any": {
"count": 2
@@ -1533,11 +1463,6 @@
"count": 1
}
},
"src/view/screens/Home.tsx": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/view/screens/ModerationBlockedAccounts.tsx": {
"typescript/no-misused-promises": {
"count": 3
+5 -4
View File
@@ -112,7 +112,7 @@
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.3.2",
"@bsky.app/react-native-uitextview": "^2.7.0",
"@bsky.app/react-native-uitextview": "^2.7.1",
"@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6",
@@ -159,6 +159,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"bidi-js": "^1.0.3",
"date-fns": "^4.4.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.6.0",
@@ -229,7 +230,6 @@
"react-is": "19",
"react-keyed-flatten-children": "^5.0.0",
"react-native": "0.86.0",
"react-native-compressor": "1.13.0",
"react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.3",
@@ -240,7 +240,7 @@
"react-native-pager-view": "6.8.0",
"react-native-progress": "^5.0.1",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "~4.5.3",
"react-native-reanimated": "~4.6.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.26.2",
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
@@ -250,7 +250,7 @@
"react-native-web": "^0.21.0",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.16.1",
"react-native-worklets": "0.11.3",
"react-native-worklets": "0.12.1",
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^10.0.1",
"react-textarea-autosize": "^8.5.3",
@@ -305,6 +305,7 @@
"prettier": "3.9.6",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"sharp": "^0.35.4",
"svgo": "^4.0.2",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "npm:@typescript/typescript6@^6.0.2",
+30
View File
@@ -0,0 +1,30 @@
diff --git a/index.js b/index.js
index 12101e1b954e1a8c7bf7887bef4f4e08fb06a8be..51d7af352a8a222c5320426f376d51c5a173300b 100644
--- a/index.js
+++ b/index.js
@@ -170,6 +170,13 @@ var BigDecimal = class BigDecimal {
times(y) {
const other = BigDecimal._coerce(y);
if (this._special || other._special) return this._specialArith(other, "times");
+ // Fast path: multiplying by exactly 1 is an identity operation. ECMA-402's
+ // ToRawFixed multiplies by roundingIncrement, which defaults to 1, on every
+ // format call - without this check that is a full BigInt multiply followed
+ // by a trailing-zero strip. BigDecimal is immutable, so returning the
+ // existing instance is safe.
+ if (other._mantissa === 1n && other._exponent === 0) return this;
+ if (this._mantissa === 1n && this._exponent === 0) return other;
if (this._mantissa === 0n || other._mantissa === 0n) {
const negZero = this._isSignNegative() ? !other._isSignNegative() : other._isSignNegative();
return BigDecimal._create(0n, 0, SpecialValue.NONE, negZero);
@@ -189,6 +196,11 @@ var BigDecimal = class BigDecimal {
const negZero = this._isSignNegative() !== other._isSignNegative();
return BigDecimal._create(0n, 0, SpecialValue.NONE, negZero);
}
+ // Fast path: dividing by exactly 1 is an identity operation. ECMA-402's
+ // ToRawFixed divides by roundingIncrement, which defaults to 1, on every
+ // format call - without this check that is a 10^DIV_PRECISION BigInt
+ // scale-up, a division, and a trailing-zero strip that undoes the scaling.
+ if (other._mantissa === 1n && other._exponent === 0) return this;
const [nm, ne] = removeTrailingZeros(this._mantissa * bigintPow10(DIV_PRECISION) / other._mantissa, this._exponent - other._exponent - DIV_PRECISION);
return BigDecimal._create(nm, ne, SpecialValue.NONE, false);
}
+29
View File
@@ -1,3 +1,19 @@
diff --git a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
index a2d707ce76ceb35456eb075d06473ac98ad8b5eb..be2e3d66031d2bbd6208a7cdc1700a9daefb1267 100644
--- a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
+++ b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
@@ -24,10 +24,7 @@ namespace expo {
return;
}
- workletRuntime->executeSync([func = std::move(func)](jsi::Runtime &rt) -> jsi::Value {
- func(rt);
- return jsi::Value::undefined();
- });
+ workletRuntime->runSync(func);
}
} // namespace expo
diff --git a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8454eab96 100644
--- a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
@@ -13,3 +29,16 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
// Check for Content-Type
val skipContentTypes = listOf(
"text/event-stream", // Server Sent Events
diff --git a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
index 126545fae12dc3af71e0ea382b976ef430e62d17..d97163a2e9a9db09f43786bc919f2fa229f21901 100644
--- a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
+++ b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
@@ -233,7 +233,7 @@ - (void)executeWorkletWithRuntimeHandle:(id)runtimeHandle
return;
}
- workletRuntime->executeSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
+ workletRuntime->runSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
return callWorklet(rt, worklet, arguments);
});
}
+12
View File
@@ -3,3 +3,15 @@
### Android: bitdrift interceptor
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
### iOS + Android: worklets `runSync` migration
Backport of https://github.com/expo/expo/pull/49366 ("[sdk-57] Backport
WorkletRuntime runSync migration"). react-native-worklets 0.12 removed the
deprecated `WorkletRuntime::executeSync`, so the worklets adapters in
`ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm` and
`android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp` fail to compile
against it. The patch swaps both call sites to `runSync` (available since
worklets 0.7.0). Required for the react-native-reanimated 4.6.0 /
react-native-worklets 0.12.1 upgrade; drop once expo-modules-core ships a
version containing that PR.
@@ -1,59 +0,0 @@
diff --git a/android/build.gradle b/android/build.gradle
index 5071139f8ee5fbba085d2afe3b2093de8eda915c..84bee34a238c6510169f6b6bdb0fda0594c77136 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -115,7 +115,6 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation 'org.mp4parser:isoparser:1.9.56'
- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1'
implementation 'javazoom:jlayer:1.0.1'
}
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
deleted file mode 100644
index 9292d3ee50776bd9d7760b8dcf6d123d44b4e31b..0000000000000000000000000000000000000000
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
deleted file mode 100644
index c6551828014437a14dc8f2f19488b647dba1bbe1..0000000000000000000000000000000000000000
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
deleted file mode 100644
index 42040b4916573463415ef2f57789b3c4fa25d135..0000000000000000000000000000000000000000
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
index 446d4fb8b69e7cfdb51b29603aa2d52aac1ab8c8..f02190992dac823b6bbf2d77880a25adc48f16c7 100644
--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) {
promise: Promise) {
try {
- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise)
+ // Skip compression on Android to avoid libandroidlame dependency
+ // Return the original file URL without compression
+ promise.resolve(fileUrl)
} catch (ex: Exception) {
promise.reject(ex)
}
diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
index c14b727e930f4114765bfbe15b742ddcdeaa392f..1198908fcc66eeeea5e537085d7632a0d4b04545 100644
--- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
+++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
@@ -7,7 +7,6 @@ import android.provider.OpenableColumns
import android.util.Log
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
-import com.reactnativecompressor.Audio.AudioCompressor
import com.reactnativecompressor.Video.VideoCompressor.CompressionListener
import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass
import java.io.FileNotFoundException
@@ -152,10 +151,6 @@ object Utils {
}
}
- fun addLog(log: String) {
- Log.d(AudioCompressor.TAG, log)
- }
-
val exifAttributes = arrayOf(
"FNumber",
"ApertureValue",
@@ -1,5 +0,0 @@
# react-native-compressor
Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738
This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025.
-283
View File
@@ -1,283 +0,0 @@
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
index 8603591..20d042b 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
@@ -62,11 +62,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<facebook::react::UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<facebook::react::UIManager> &uiManager,
const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker
#endif
)
@@ -74,11 +74,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
contextContainer_(contextContainer),
componentDescriptorRegistry_(componentDescriptorRegistry),
uiRuntime_(uiRuntime),
- uiScheduler_(uiScheduler)
+ uiScheduler_(uiScheduler),
+ uiManager_(uiManager)
#ifdef ANDROID
,
preserveMountedTags_(filterUnmountedTagsFunction),
- uiManager_(uiManager),
jsInvoker_(jsInvoker)
#endif
{
@@ -98,10 +98,10 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
SharedComponentDescriptorRegistry componentDescriptorRegistry_;
jsi::Runtime &uiRuntime_;
const std::shared_ptr<UIScheduler> uiScheduler_;
+ std::shared_ptr<facebook::react::UIManager> uiManager_;
PreserveMountedTagsFunction preserveMountedTags_;
#ifdef ANDROID
- std::shared_ptr<facebook::react::UIManager> uiManager_;
std::shared_ptr<facebook::react::CallInvoker> jsInvoker_;
void restoreOpacityInCaseOfFlakyEnteringAnimation(SurfaceId surfaceId) const;
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
index fcc677f..115971a 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
@@ -67,11 +67,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<UIManager> &uiManager,
const std::shared_ptr<CallInvoker> &jsInvoker
#endif
)
@@ -80,11 +80,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
componentDescriptorRegistry,
contextContainer,
uiRuntime,
- uiScheduler
+ uiScheduler,
+ uiManager
#ifdef ANDROID
,
filterUnmountedTagsFunction,
- uiManager,
jsInvoker
#endif
),
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
index df53d8d..735f138 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
@@ -1,6 +1,7 @@
#include <reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h>
#include <react/debug/react_native_assert.h>
+#include <react/renderer/mounting/ShadowTree.h>
#include <react/renderer/mounting/ShadowViewMutation.h>
#include <memory>
@@ -60,14 +61,37 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
parseRemoveMutations(movedViews, mutations, roots);
- auto shouldAnimate = !surfacesToRemove_.contains(surfaceId);
- surfacesToRemove_.erase(surfaceId);
+ // Consume the teardown mark only on the transaction that actually clears
+ // the root — pulls emitted for animation frames must not eat it early.
+ auto shouldAnimate = true;
+ const auto removesRootChildren = std::ranges::any_of(mutations, [surfaceId](const auto &mutation) {
+ return mutation.type == ShadowViewMutation::Remove && mutation.parentTag == surfaceId;
+ });
+ if (removesRootChildren) {
+ shouldAnimate = surfacesToRemove_.erase(surfaceId) == 0;
+ }
handleRemovals(filteredMutations, roots, deadNodes, shouldAnimate);
handleUpdatesAndEnterings(filteredMutations, movedViews, mutations, propsParserContext, surfaceId);
addOngoingAnimations(surfaceId, filteredMutations);
+ // The LayoutAnimationDriver can emit a final keyframe update in the same
+ // transaction as the deferred Remove/Delete it withheld for a delete
+ // animation. We emit removals before updates, so such an update would
+ // otherwise reach the mounting layer after its view was deleted.
+ std::unordered_set<Tag> deletedTags;
+ for (const auto &mutation : filteredMutations) {
+ if (mutation.type == ShadowViewMutation::Delete) {
+ deletedTags.insert(mutation.oldChildShadowView.tag);
+ }
+ }
+ if (!deletedTags.empty()) {
+ std::erase_if(filteredMutations, [&deletedTags](const auto &mutation) {
+ return mutation.type == ShadowViewMutation::Update && deletedTags.contains(mutation.newChildShadowView.tag);
+ });
+ }
+
return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
}
@@ -998,23 +1022,22 @@ inline bool MutationNode::isMutationNode() {
return true;
}
-// UIManagerAnimationDelegate
-
-void LayoutAnimationsProxy_Legacy::uiManagerDidConfigureNextLayoutAnimation(
- jsi::Runtime &runtime,
- const RawValue &config,
- const jsi::Value &successCallbackValue,
- const jsi::Value &failureCallbackValue) const {}
+// UIManagerCommitHook
-void LayoutAnimationsProxy_Legacy::setComponentDescriptorRegistry(
- const SharedComponentDescriptorRegistry &componentDescriptorRegistry) {}
-
-bool LayoutAnimationsProxy_Legacy::shouldAnimateFrame() const {
- return false;
-}
-
-void LayoutAnimationsProxy_Legacy::stopSurface(SurfaceId surfaceId) {
- surfacesToRemove_.insert(surfaceId);
+// Surface teardown commits an empty root (SurfaceHandler::stop) before the
+// teardown transaction is pulled — mark it so pullTransaction skips exit
+// animations. Reading the ShadowTreeRegistry here instead would deadlock (#8579).
+RootShadowNode::Unshared LayoutAnimationsProxy_Legacy::shadowTreeWillCommit(
+ const ShadowTree &shadowTree,
+ const RootShadowNode::Shared & /*oldRootShadowNode*/,
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept {
+ auto lock = std::unique_lock<std::recursive_mutex>(mutex);
+ if (newRootShadowNode->getChildren().empty()) {
+ surfacesToRemove_.insert(shadowTree.getSurfaceId());
+ } else {
+ surfacesToRemove_.erase(shadowTree.getSurfaceId());
+ }
+ return newRootShadowNode;
}
} // namespace reanimated
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
index 57cc134..1a2966c 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
@@ -3,8 +3,8 @@
#include <react/renderer/componentregistry/ComponentDescriptorFactory.h>
#include <react/renderer/mounting/MountingOverrideDelegate.h>
#include <react/renderer/scheduler/Scheduler.h>
-#include <react/renderer/uimanager/UIManagerAnimationDelegate.h>
#include <react/renderer/uimanager/UIManagerBinding.h>
+#include <react/renderer/uimanager/UIManagerCommitHook.h>
#include <reanimated/Compat/WorkletsApi.h>
#include <reanimated/LayoutAnimations/LayoutAnimationsManager.h>
#include <reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h>
@@ -102,7 +102,7 @@ struct SurfaceContext {
};
struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
- public UIManagerAnimationDelegate,
+ public UIManagerCommitHook,
public std::enable_shared_from_this<LayoutAnimationsProxy_Legacy> {
mutable std::unordered_map<Tag, std::shared_ptr<Node>> nodeForTag_;
mutable std::recursive_mutex mutex;
@@ -116,11 +116,11 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<UIManager> &uiManager,
const std::shared_ptr<CallInvoker> &jsInvoker
#endif
)
@@ -129,14 +129,19 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
componentDescriptorRegistry,
contextContainer,
uiRuntime,
- uiScheduler
+ uiScheduler,
+ uiManager
#ifdef ANDROID
,
filterUnmountedTagsFunction,
- uiManager,
jsInvoker
#endif
) {
+ uiManager->registerCommitHook(*this);
+ }
+
+ ~LayoutAnimationsProxy_Legacy() override {
+ uiManager_->unregisterCommitHook(*this);
}
void startEnteringAnimation(const int tag, ShadowViewMutation &mutation) const;
@@ -206,19 +211,15 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
const TransactionTelemetry &telemetry,
ShadowViewMutationList mutations) const override;
- // UIManagerAnimationDelegate
-
- void uiManagerDidConfigureNextLayoutAnimation(
- jsi::Runtime &runtime,
- const RawValue &config,
- const jsi::Value &successCallbackValue,
- const jsi::Value &failureCallbackValue) const override;
-
- void setComponentDescriptorRegistry(const SharedComponentDescriptorRegistry &componentDescriptorRegistry) override;
+ // UIManagerCommitHook
- bool shouldAnimateFrame() const override;
+ void commitHookWasRegistered(const UIManager &uiManager) noexcept override {}
+ void commitHookWasUnregistered(const UIManager &uiManager) noexcept override {}
- void stopSurface(SurfaceId surfaceId) override;
+ RootShadowNode::Unshared shadowTreeWillCommit(
+ const ShadowTree &shadowTree,
+ const RootShadowNode::Shared &oldRootShadowNode,
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept override;
};
} // namespace reanimated
diff --git a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
index 2b68ff7..d08b1ae 100644
--- a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
+++ b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
@@ -1235,22 +1235,19 @@ void ReanimatedModuleProxy::initializeLayoutAnimationsProxy() {
#endif
layoutAnimationsProxy_ = std::move(layoutAnimationsProxyExperimental);
} else {
- auto layoutAnimationsProxyLegacy = std::make_shared<LayoutAnimationsProxy_Legacy>(
+ layoutAnimationsProxy_ = std::make_shared<LayoutAnimationsProxy_Legacy>(
layoutAnimationsManager_,
componentDescriptorRegistry,
scheduler->getContextContainer(),
getJSIRuntimeFromWorkletRuntime(uiRuntime_),
- uiScheduler_
+ uiScheduler_,
+ uiManager_
#ifdef ANDROID
,
filterUnmountedTagsFunction_,
- uiManager_,
jsInvoker_
#endif
);
- // TODO (future): support in experimental
- uiManager_->setAnimationDelegate(layoutAnimationsProxyLegacy.get());
- layoutAnimationsProxy_ = std::move(layoutAnimationsProxyLegacy);
}
}
}
@@ -1,27 +0,0 @@
# react-native-reanimated@4.5.3.patch
Backport of https://github.com/software-mansion/react-native-reanimated/pull/9901
("refactor(LayoutAnimations): stop taking over UIManagerAnimationDelegate").
Reanimated's legacy `LayoutAnimationsProxy_Legacy` registered itself as the
`UIManagerAnimationDelegate` only to receive `stopSurface`. Occupying that slot
overwrites the `LayoutAnimationDriver` that React Native installs there, which
silently breaks `LayoutAnimation.configureNext` for the whole app.
The patch makes the proxy detect surface teardown itself via a
`UIManagerCommitHook` (a commit with an empty root marks the surface in
`surfacesToRemove_`), frees the animation-delegate slot, and drops final
keyframe `Update` mutations for views deleted in the same transaction (a
deterministic `configureNext` delete-animation crash found in this app).
`uiManager` moves from Android-only to shared constructor args since the hook
registration needs it on both platforms.
Only the `packages/react-native-reanimated` part of the PR is included (the
`apps/fabric-example` hunk is not part of the published package), and the hunks
were rebased onto the 4.5.3 release sources.
Note that upstream's own `pullTransaction` rework in 4.5.3 (the new
`reconcileContradictedRemovals`) covers a different case - a `Create`/`Insert`
contradicting a *withheld* exit removal - and does not subsume the deleted-tag
`Update` filter here, which guards against the `LayoutAnimationDriver` final
keyframe. That driver only runs at all once this patch frees the delegate slot.
@@ -1,88 +0,0 @@
diff --git a/lib/module/threads.js b/lib/module/threads.js
index c17e314..71f3cf7 100644
--- a/lib/module/threads.js
+++ b/lib/module/threads.js
@@ -1,7 +1,6 @@
'use strict';
import { IS_JEST } from './platformChecker';
-import { mockedRequestAnimationFrame } from "./runLoop/uiRuntime/mockedRequestAnimationFrame.js";
export function scheduleOnUI(worklet, ...args) {
enqueueUI(worklet, args);
}
@@ -23,38 +22,50 @@ export function scheduleOnRN(fun, ...args) {
queueMicrotask(args.length ? () => fun(...args) : fun);
}
export function runOnUIAsync(worklet, ...args) {
- return new Promise(resolve => {
- enqueueUI(worklet, args, resolve);
+ return new Promise((resolve, reject) => {
+ enqueueUI(worklet, args, resolve, reject);
});
}
let runOnUIQueue = [];
-function enqueueUI(worklet, args, resolve) {
- if (IS_JEST) {
- mockedRequestAnimationFrame(() => {
- const result = worklet(...args);
- resolve?.(result);
- });
- } else {
- const job = [worklet, args, resolve];
- runOnUIQueue.push(job);
- if (runOnUIQueue.length === 1) {
+function enqueueUI(worklet, args, resolve, reject) {
+ const job = [worklet, args, resolve, reject];
+ runOnUIQueue.push(job);
+ if (runOnUIQueue.length === 1) {
+ if (IS_JEST) {
flushUIQueue();
+ } else {
+ queueMicrotask(flushUIQueue);
}
}
}
+let offset = 0;
function flushUIQueue() {
- queueMicrotask(() => {
- const queue = runOnUIQueue;
- runOnUIQueue = [];
- requestAnimationFrameImpl(() => {
- queue.forEach(([workletFunction, workletArgs, jobResolve]) => {
- const result = workletFunction(...workletArgs);
- if (jobResolve) {
- jobResolve(result);
+ const queue = runOnUIQueue;
+ runOnUIQueue = [];
+ requestAnimationFrame(() => {
+ offset = 0;
+ while (queue.length > offset) {
+ try {
+ drainUIQueue(queue);
+ } catch (e) {
+ const [, , , jobReject] = queue[offset - 1];
+ if (jobReject) {
+ jobReject(e);
+ } else {
+ console.error(e);
}
- });
- });
+ }
+ }
});
}
-const requestAnimationFrameImpl = !globalThis.requestAnimationFrame ? mockedRequestAnimationFrame : globalThis.requestAnimationFrame;
-//# sourceMappingURL=threads.js.map
\ No newline at end of file
+function drainUIQueue(queue) {
+ while (queue.length > offset) {
+ const [workletFunction, workletArgs, jobResolve] = queue[offset];
+ offset++;
+ const result = workletFunction(...workletArgs);
+ if (jobResolve) {
+ jobResolve(result);
+ }
+ }
+}
+//# sourceMappingURL=threads.js.map
@@ -1,37 +0,0 @@
# react-native-worklets@0.11.3.patch
Backport of https://github.com/software-mansion/react-native-reanimated/pull/10167
("fix(Worklets): web scheduleOnUI implementation on errors").
## The bug
On web, `scheduleOnUI`/`runOnUI` batch their callbacks per animation frame and
run them with `queue.forEach(...)`. If any callback in the batch throws,
`forEach` aborts immediately and every callback still queued after it is
silently dropped - it never runs, and any `runOnUIAsync` promise for it never
resolves or rejects.
Reanimated's own internals rely on those callbacks running in order (e.g. to
populate `frameCallbackRegistry`), so a single throwing worklet in a batch can
leave later, unrelated frame callbacks referencing state that was never set
up, surfacing as:
```
TypeError: can't access property "startTime", this.frameCallbackRegistry.get(...) is undefined
```
## The fix
Replace the `forEach` batch runner with a `while` loop (`drainUIQueue`) that
tracks its position via an `offset`, wrapped in a `try`/`catch`. A throw now
only aborts the *current* callback: the loop resumes at the next queued job
instead of abandoning the rest of the batch. Errors are routed to the
matching `runOnUIAsync` promise's `reject` (a new second argument threaded
through `enqueueUI`) if there is one, or `console.error`-ed otherwise, rather
than crashing the whole frame.
Only `lib/module/threads.js` (the compiled web entry point actually loaded by
the app's webpack build) is patched - `src/threads.ts` is unused here since
this repo's web build resolves the package's `module` field, and native
platforms use the separate `threads.native.ts` implementation untouched by
this PR.
+249 -174
View File
@@ -23,180 +23,6 @@ index 1b02e8b2d39672063551411d5c403a69b671a869..b3481c1b98b45dea769035140dc2fd8d
- (void)setFrame:(CGRect)frame
{
[super setFrame:frame];
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index a087536f3af0d33b13fe38d8abd1bc6d7935def2..01f5c884ea4772350c0ebe6263723d97632f2b74 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -396,7 +396,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
MAP_SCROLL_VIEW_PROP(zoomScale);
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
+ // When disabling centerContent, reset inset to prop value
+ // (enabling is handled automatically by the setCenterContent: setter)
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
+ }
+
+ // Only apply contentInset from props if centerContent is disabled
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
}
@@ -523,7 +531,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
}
}
- return isPointInside ? self : nil;
+ return isPointInside ? _scrollView : nil;
}
/*
@@ -1133,6 +1141,11 @@ - (RCTVirtualViewContainerState *)virtualViewContainerState
return _virtualViewContainerState;
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
index ed306d7cadbf36a2fed79be8bd9d68b5dca135bd..d447dad534fefa9fcbdbbde6dcbbdcddadd5a824 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
@@ -18,6 +18,7 @@ __attribute__((deprecated("This API will be removed along with the legacy archit
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+@property (nonatomic, copy) UIColor *customTintColor;
@end
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
index 2dc86e464264c9450eef18d7b153d35bf6a5cc55..6661dc69a04766afa0284d6e83839b219e98cf57 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
@@ -25,6 +25,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
+ UIColor *_customTintColor;
}
- (instancetype)init
@@ -60,6 +61,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ [self setTintColor:_customTintColor];
+}
+
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -225,6 +232,18 @@ - (void)refreshControlValueChanged
}
}
+// Fix for https://github.com/facebook/react-native/issues/43388
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
+// is set before the refresh control gets added to the scrollview. We'll call this
+// function whenever the superview changes. We'll also call it if the value of customTintColor
+// changes.
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
@end
#endif // RCT_REMOVE_LEGACY_ARCH
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
index 1e9ff527f4e6691d716da624031113a397876981..44329c5422c6f24d8a437fa35c6f2bad6bf8622b 100644
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -24,11 +24,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
index 59775241c80bec99ad3ec080f2425aacc8900c24..426de3aa77cda2032d7b0991e2ca3f8482a438d3 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
@@ -459,6 +459,13 @@ public open class ReactViewGroup public constructor(context: Context?) :
inSubviewClippingLoop = true
var clippedSoFar = 0
for (i in 0..<allChildrenCount) {
+ // Reentrant child removal during this loop can compact allChildren and leave a null at
+ // an index below allChildrenCount. A null entry means the view is already detached, so
+ // treat it as clipped instead of crashing.
+ if (childArray[i] == null) {
+ clippedSoFar++
+ continue
+ }
try {
updateSubviewClipStatus(clippingRect, i, clippedSoFar, excludedViewsSet)
} catch (ex: IndexOutOfBoundsException) {
@@ -496,7 +503,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
) {
assertOnUiThread()
- val child = checkNotNull(allChildren?.get(idx))
+ // allChildren can be mutated reentrantly while a clipping pass is running, so a stale
+ // index can point at a null slot. Skip it instead of crashing.
+ val child = allChildren?.get(idx) ?: return
val intersects = clippingRect.intersects(child.left, child.top, child.right, child.bottom)
var needUpdateClippingRecursive = false
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
index 9b04cadc22f5ae7b105f9f9875a242b53188cf03..b2b27626edc46625ac2372a13977d700948835b6 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
@@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f
font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];
if (font != nullptr) {
fontNames = [UIFont fontNamesForFamilyName:font.familyName];
- fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font);
+ fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font);
} else {
// Failback to system font.
font = RCTDefaultFontWithFontProperties(fontProperties);
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
index ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce9c63137b 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
@@ -389,8 +389,9 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
size.height = enumeratedLinesHeight;
}
- size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
+ CGFloat epsilon = 0.001;
+ size = (CGSize){ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
@@ -371,3 +197,252 @@ index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90
}
}
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index a087536f3af0d33b13fe38d8abd1bc6d7935def2..01f5c884ea4772350c0ebe6263723d97632f2b74 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -396,7 +396,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
MAP_SCROLL_VIEW_PROP(zoomScale);
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
+ // When disabling centerContent, reset inset to prop value
+ // (enabling is handled automatically by the setCenterContent: setter)
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
+ }
+
+ // Only apply contentInset from props if centerContent is disabled
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
}
@@ -523,7 +531,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
}
}
- return isPointInside ? self : nil;
+ return isPointInside ? _scrollView : nil;
}
/*
@@ -1133,6 +1141,11 @@ - (RCTVirtualViewContainerState *)virtualViewContainerState
return _virtualViewContainerState;
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
index b033b7c71914d287470b7b86bd6bf39d311294ba..7e10dc929147fa4474ca9f955f5cd85d27aea935 100644
--- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
@@ -827,9 +827,17 @@ static void RCTAddContourEffectToLayer(
} else {
CGSize imageSize = image.size;
UIEdgeInsets imageCapInsets = image.capInsets;
+ // The stretchable middle is whatever lies between the cap insets. The image
+ // may be larger than capInsets + 1 (its size is ceil'd to whole points), so
+ // deriving the middle from the caps rather than assuming a 1pt band keeps
+ // the bottom/right caps at their true size. A phantom cap here makes the
+ // caps overflow sub-pixel-sized layers (e.g. hairline borders), and the
+ // squeezed mesh + nearest-neighbor filtering drops the stroke entirely.
CGRect contentsCenter = CGRect{
CGPoint{imageCapInsets.left / imageSize.width, imageCapInsets.top / imageSize.height},
- CGSize{(CGFloat)1.0 / imageSize.width, (CGFloat)1.0 / imageSize.height}};
+ CGSize{
+ (imageSize.width - imageCapInsets.left - imageCapInsets.right) / imageSize.width,
+ (imageSize.height - imageCapInsets.top - imageCapInsets.bottom) / imageSize.height}};
layer.contents = (id)image.CGImage;
layer.contentsScale = image.scale;
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
index ed306d7cadbf36a2fed79be8bd9d68b5dca135bd..d447dad534fefa9fcbdbbde6dcbbdcddadd5a824 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
@@ -18,6 +18,7 @@ __attribute__((deprecated("This API will be removed along with the legacy archit
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+@property (nonatomic, copy) UIColor *customTintColor;
@end
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
index 2dc86e464264c9450eef18d7b153d35bf6a5cc55..6661dc69a04766afa0284d6e83839b219e98cf57 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
@@ -25,6 +25,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
+ UIColor *_customTintColor;
}
- (instancetype)init
@@ -60,6 +61,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ [self setTintColor:_customTintColor];
+}
+
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -225,6 +232,18 @@ - (void)refreshControlValueChanged
}
}
+// Fix for https://github.com/facebook/react-native/issues/43388
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
+// is set before the refresh control gets added to the scrollview. We'll call this
+// function whenever the superview changes. We'll also call it if the value of customTintColor
+// changes.
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
@end
#endif // RCT_REMOVE_LEGACY_ARCH
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
index 1e9ff527f4e6691d716da624031113a397876981..44329c5422c6f24d8a437fa35c6f2bad6bf8622b 100644
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -24,11 +24,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
index 59775241c80bec99ad3ec080f2425aacc8900c24..426de3aa77cda2032d7b0991e2ca3f8482a438d3 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
@@ -459,6 +459,13 @@ public open class ReactViewGroup public constructor(context: Context?) :
inSubviewClippingLoop = true
var clippedSoFar = 0
for (i in 0..<allChildrenCount) {
+ // Reentrant child removal during this loop can compact allChildren and leave a null at
+ // an index below allChildrenCount. A null entry means the view is already detached, so
+ // treat it as clipped instead of crashing.
+ if (childArray[i] == null) {
+ clippedSoFar++
+ continue
+ }
try {
updateSubviewClipStatus(clippingRect, i, clippedSoFar, excludedViewsSet)
} catch (ex: IndexOutOfBoundsException) {
@@ -496,7 +503,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
) {
assertOnUiThread()
- val child = checkNotNull(allChildren?.get(idx))
+ // allChildren can be mutated reentrantly while a clipping pass is running, so a stale
+ // index can point at a null slot. Skip it instead of crashing.
+ val child = allChildren?.get(idx) ?: return
val intersects = clippingRect.intersects(child.left, child.top, child.right, child.bottom)
var needUpdateClippingRecursive = false
diff --git a/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h b/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
index fdabd7bab1f03966dc04ba9a462465daeccf8ae3..ef70011ee5c270fb2cac52f3a7408d1e87334145 100644
--- a/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
+++ b/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
@@ -21,6 +21,10 @@ class ReactNativeFeatureFlagsOverridesOSSStable : public ReactNativeFeatureFlags
{
return true;
}
+ bool enableSchedulerDelegateInvalidation() override
+ {
+ return true;
+ }
bool useTurboModules() override
{
return true;
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
index 9b04cadc22f5ae7b105f9f9875a242b53188cf03..b2b27626edc46625ac2372a13977d700948835b6 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
@@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f
font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];
if (font != nullptr) {
fontNames = [UIFont fontNamesForFamilyName:font.familyName];
- fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font);
+ fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font);
} else {
// Failback to system font.
font = RCTDefaultFontWithFontProperties(fontProperties);
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
index ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce9c63137b 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
@@ -389,8 +389,9 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
size.height = enumeratedLinesHeight;
}
- size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
+ CGFloat epsilon = 0.001;
+ size = (CGSize){ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
diff --git a/ReactCommon/react/renderer/uimanager/UIManager.cpp b/ReactCommon/react/renderer/uimanager/UIManager.cpp
index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492facf4ead221 100644
--- a/ReactCommon/react/renderer/uimanager/UIManager.cpp
+++ b/ReactCommon/react/renderer/uimanager/UIManager.cpp
@@ -530,30 +530,8 @@ std::shared_ptr<const ShadowNode> UIManager::findShadowNodeByTag_DEPRECATED(
auto shadowNode = std::shared_ptr<const ShadowNode>{};
shadowTreeRegistry_.enumerate([&](const ShadowTree& shadowTree, bool& stop) {
- // Obtain a pointer to the root node. The flag-gated path uses
- // getCurrentRevision() which keeps the root alive via shared_ptr for
- // the entire traversal, fixing a use-after-free race condition.
- RootShadowNode::Shared rootShadowNodeHolder;
- const RootShadowNode* rootShadowNode = nullptr;
- if (ReactNativeFeatureFlags::fixFindShadowNodeByTagRaceCondition()) {
- rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
- rootShadowNode = rootShadowNodeHolder.get();
- } else {
- // TODO(T257154369): Remove after flag rollout.
- // The public interface of `ShadowTree` discourages accessing a stored
- // pointer to a root node because of the possible data race.
- // To work around this, we ask for a commit and immediately cancel it
- // returning `nullptr` instead of a new shadow tree.
- // We don't want to add a way to access a stored pointer to a root
- // node because this `findShadowNodeByTag` is deprecated. It is only
- // added to make migration to the new architecture easier.
- shadowTree.tryCommit(
- [&](const RootShadowNode& oldRootShadowNode) {
- rootShadowNode = &oldRootShadowNode;
- return nullptr;
- },
- {/* default commit options */});
- }
+ auto rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
+ const auto* rootShadowNode = rootShadowNodeHolder.get();
if (rootShadowNode != nullptr) {
const auto& children = rootShadowNode->getChildren();
+50
View File
@@ -1,5 +1,36 @@
# ***This second part of this patch is load bearing, do not remove.***
## Scheduler delegate invalidation - iOS use-after-free
Fixes Sentry issue APP-T28X: an `EXC_BAD_ACCESS` in
`Scheduler::uiManagerDidFinishTransaction` or
`Scheduler::uiManagerDidDispatchCommand` after a queued rendering update
outlives its captured raw `SchedulerDelegate` pointer.
React Native 0.86 contains the invalidation-token guard from
facebook/react-native#56680, but `enableSchedulerDelegateInvalidation` is false
for the stable release level used by Expo. Override only this flag in
`ReactNativeFeatureFlagsOverridesOSSStable` instead of opting the app into all
experimental React Native flags.
**TODO: Remove after upgrading to a React Native release that closes the
queued Scheduler delegate lifetime race by default.**
## UIManager.cpp Patch - Fabric focus navigation use-after-free
Fixes Sentry issue APP-T4H9: a SIGSEGV in
`FabricUIManagerBinding::findNextFocusableElement` during focus navigation.
React Native 0.86 contains the safe implementation behind
`fixFindShadowNodeByTagRaceCondition`, but the public default is false. The
fallback captures a raw root shadow-node pointer in `tryCommit` and dereferences
it after the lock is released, allowing a concurrent commit or surface stop to
free the node first. This backports the final upstream implementation, which
holds the current revision's `shared_ptr` for the entire traversal.
**TODO: Remove after bumping React Native to a release containing
facebook/react-native#56850.**
## RefreshControl Patch - iOS 17.4 Haptic Regression
Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
@@ -144,3 +175,22 @@ PR: https://github.com/facebook/react-native/pull/57483
Issue: https://github.com/react/react-native/issues/53450#issuecomment-3298157830
Bandaid fix taken from: https://github.com/react/react-native/commit/581d643a9e59fd88f93757f80194e1efd11bd0e5
## RCTViewComponentView.mm Patch - Hairline border strokes dropped at certain subpixel Y offsets on New Arch
Symptom: dividers built as `borderTopWidth: hairlineWidth` vanish on some screens and not
others, deterministically by the view's absolute subpixel Y. A background fill of the same
geometry always renders.
Cause: Fabric draws borders as a stretched 9-slice image. The consumer
(`RCTAddContourEffectToLayer`) hardcodes the stretchable middle as a 1pt band, which matched
the image the producer built until RN 0.81. facebook/react-native#54237 changed the image
size to `ceil(insets) + 1 + ceil(insets)` without updating that formula, so for fractional
(hairline) insets the labels no longer match the image: transparent filler is treated as a
rigid cap, and when squeezed into a one-pixel-tall layer the sampling can land on it instead
of the stroke - no line.
Fix: compute the middle from the cap insets (`size - caps`) instead of assuming 1pt.
Upstream issue: https://github.com/react/react-native/issues/58054 (repro:
https://github.com/abulenok/HairlineBorderRepro, fails identically on 0.86.0 and 0.87.0).
+689 -369
View File
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -17,9 +17,8 @@ overrides:
'@react-native/normalize-colors': '0.86.0'
'@expo/image-utils': '0.8.12'
'@types/estree': '1.0.6'
'react-native-compressor': '1.13.0'
'react-native-reanimated': '4.5.3'
'react-native-worklets': '0.11.3'
'react-native-reanimated': '4.6.0'
'react-native-worklets': '0.12.1'
'psl': '1.9.0'
'@types/psl': '1.1.1'
'react-native-screens': '4.26.2'
@@ -30,6 +29,7 @@ allowBuilds:
'esbuild': false
'unrs-resolver': true
patchedDependencies:
'@formatjs/bigdecimal@0.2.5': patches/@formatjs__bigdecimal@0.2.5.patch
'@sentry/expo-upload-sourcemaps@8.18.0': patches/@sentry__expo-upload-sourcemaps@8.18.0.patch
'expo-age-range@57.0.2': patches/expo-age-range@57.0.2.patch
'expo-haptics@57.0.1': patches/expo-haptics@57.0.1.patch
@@ -38,16 +38,13 @@ patchedDependencies:
'expo-notifications@57.0.7': patches/expo-notifications@57.0.7.patch
'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch
expo@57.0.8: patches/expo@57.0.8.patch
'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch
'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch
'react-native-dotenv@3.4.11': patches/react-native-dotenv@3.4.11.patch
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
'react-native-keyboard-controller@1.21.9': patches/react-native-keyboard-controller@1.21.9.patch
'react-native-pager-view@6.8.0': patches/react-native-pager-view@6.8.0.patch
'react-native-reanimated@4.5.3': patches/react-native-reanimated@4.5.3.patch
'react-native-screens@4.26.2': patches/react-native-screens@4.26.2.patch
'react-native-svg@15.15.4': patches/react-native-svg@15.15.4.patch
react-native-worklets@0.11.3: patches/react-native-worklets@0.11.3.patch
'react-native@0.86.0': patches/react-native@0.86.0.patch
minimumReleaseAgeExclude:
- '@atproto/*'
+1 -2
View File
@@ -126,9 +126,8 @@ function InnerApp() {
}
} catch (e) {
logger.warn(`session: resume failed`, {message: e})
} finally {
setIsReady(true)
}
setIsReady(true)
}
const account = readLastActiveAccount()
void onLaunch(account)
+1 -2
View File
@@ -105,9 +105,8 @@ function InnerApp() {
}
} catch (e) {
logger.warn('session: resumeSession failed', {message: e})
} finally {
setIsReady(true)
}
setIsReady(true)
}
const account = readLastActiveAccount()
void onLaunch(account)
+21 -3
View File
@@ -80,6 +80,7 @@ import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {renderMessagesSplitViewLayout} from '#/screens/Messages/components/splitView/MessagesSplitViewLayout'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
@@ -88,6 +89,9 @@ import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
import {ModerationInboxReportDetailsScreen} from '#/screens/ModerationInbox/Report'
import {ModerationInboxSettingsScreen} from '#/screens/ModerationInbox/Settings'
import {ModerationInboxSubjectDetailsScreen} from '#/screens/ModerationInbox/Subject'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
@@ -140,7 +144,6 @@ import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {InviteScannerScreen} from '#/features/inviteFriends'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -184,6 +187,21 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => ModerationInboxScreen}
options={{title: title(msg`Moderation inbox`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInboxSettings"
getComponent={() => ModerationInboxSettingsScreen}
options={{title: title(msg`Mod inbox settings`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInboxReportDetails"
getComponent={() => ModerationInboxReportDetailsScreen}
options={{title: title(msg`Your report`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInboxSubjectDetails"
getComponent={() => ModerationInboxSubjectDetailsScreen}
options={{title: title(msg`Notice`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
@@ -563,12 +581,12 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
<Stack.Screen
name="StarterPackWizard"
getComponent={() => Wizard}
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
options={{title: title(msg`Create a Starter Pack`), requireAuth: true}}
/>
<Stack.Screen
name="StarterPackEdit"
getComponent={() => Wizard}
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
options={{title: title(msg`Edit your Starter Pack`), requireAuth: true}}
/>
<Stack.Screen
name="VideoFeed"
@@ -0,0 +1,30 @@
import {useLingui} from '@lingui/react/macro'
import {useSessionApi} from '#/state/session'
import {Error} from '#/components/Error'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji'
import {useOtherRequiredDataQuery} from '#/ageAssurance/data'
import {IS_WEB} from '#/env'
export function DataUnavailableScreen() {
const {t: l} = useLingui()
const {logoutCurrentAccount} = useSessionApi()
const {isFetching, refetch} = useOtherRequiredDataQuery()
return (
<Error
icon={EmojiSadIcon}
title={l`Unable to load your account`}
message={l`We couldn't load your account settings. Check your internet connection and try again.`}
onRetry={() => void refetch()}
isRetrying={isFetching}
secondaryAction={{
label: l`Sign out`,
onPress: () => {
if (IS_WEB) history.pushState(null, '', '/')
logoutCurrentAccount('AgeAssuranceDataUnavailableScreen')
},
}}
/>
)
}
@@ -1,4 +1,4 @@
import {useCallback, useEffect} from 'react'
import {useCallback, useEffect, useEffectEvent} from 'react'
import {ScrollView, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Trans, useLingui} from '@lingui/react/macro'
@@ -76,7 +76,7 @@ export function NoAccessScreen() {
const geolocationString = createGeolocationString(geolocation, i18n.locale)
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
useEffect(() => {
const onShown = useEffectEvent(() => {
// just counting overall hits here
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
@@ -85,8 +85,10 @@ export function NoAccessScreen() {
hasDeclaredAge,
canUpdateBirthday,
})
// TODO This can be cleaned up with useEffectEvent once we're on 19.2
// eslint-disable-next-line react-hooks/exhaustive-deps
})
useEffect(() => {
onShown()
}, [])
const onPressLogout = useCallback(() => {
+34 -7
View File
@@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry'
import {isRetryableRequestError, networkRetry} from '#/lib/async/retry'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time'
import {
@@ -347,9 +347,15 @@ export type OtherRequiredData = {
birthdate: string | undefined
actorDeclaration?: chat.bsky.actor.declaration.Main
}
export type OtherRequiredDataStatus = 'pending' | 'error' | 'success'
const otherRequiredDataRetryOptions = {
retry: (failureCount: number, error: unknown) =>
failureCount < 2 && isRetryableRequestError(error),
}
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
return ['otherRequiredData', did]
}
async function getOtherRequiredData({
accountClient,
}: {
@@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({
try {
logger.debug(`prefetchOtherRequiredData: resolving...`)
const res = await networkRetry(3, () =>
getOtherRequiredData({accountClient}),
)
qc.setQueryData<OtherRequiredData>(qk, res)
await qc.fetchQuery({
...otherRequiredDataRetryOptions,
queryKey: qk,
queryFn: () => getOtherRequiredData({accountClient}),
})
} catch (err) {
const e = err as Error
logger.warn(`prefetchOtherRequiredData: failed`, {
@@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() {
const did = accountClient.did
return useQuery(
{
...otherRequiredDataRetryOptions,
enabled: !!did,
initialData: () => {
if (!did) return
return getOtherRequiredDataFromCache({did})
},
queryKey: createOtherRequiredDataQueryKey({did: did!}),
retryOnMount: false,
async queryFn() {
return getOtherRequiredData({accountClient})
},
@@ -722,6 +731,11 @@ export type AgeAssuranceServerData = {
*/
state: app.bsky.ageassurance.defs.State | undefined
metadata: AgeAssuranceMetadata | undefined
/**
* Whether the account data needed to compute age assurance is available.
* A successful response without a birthdate is still `success`.
*/
otherRequiredDataStatus: OtherRequiredDataStatus
/**
* The native on-device age signals for the region the user is currently in,
* if they've granted access there. Already resolved from the region-keyed
@@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
declaredAge: undefined,
birthdate: undefined,
},
otherRequiredDataStatus: 'pending',
deviceSignals: undefined,
})
export function useAgeAssuranceServerDataContext() {
@@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
const {data: config} = useConfigQuery()
const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery()
const {data, errorUpdatedAt, status} = useOtherRequiredDataQuery()
/*
* A data-less query returns to `pending` and clears `error` while refetching,
* but retains `errorUpdatedAt`. Keep the error screen mounted until data
* loads successfully.
*/
const otherRequiredDataStatus: OtherRequiredDataStatus =
data !== undefined
? 'success'
: status === 'error' || errorUpdatedAt > 0
? 'error'
: 'pending'
// `select` resolves the cached region-keyed map to the current region.
const {data: deviceSignals} = useDeviceSignalsQuery()
const ctx = useMemo(
@@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
: undefined,
birthdate: data?.birthdate,
},
otherRequiredDataStatus,
deviceSignals,
}),
[config, state, data, metadata, deviceSignals],
[config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
)
return (
<AgeAssuranceServerDataContext.Provider value={ctx}>
+85
View File
@@ -0,0 +1,85 @@
import {computeAgeAssuranceState} from '#/ageAssurance/state'
import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types'
jest.mock('#/ageAssurance/data', () => ({}))
jest.mock('#/ageAssurance/logger', () => ({
logger: {
debug: jest.fn(),
warn: jest.fn(),
},
}))
jest.mock('#/state/session', () => ({}))
const geolocation = {
countryCode: undefined,
regionCode: undefined,
}
describe('computeAgeAssuranceState', () => {
it('computes access while required account data is pending', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
otherRequiredDataStatus: 'pending',
}),
).toMatchObject({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
})
})
it('denies access when required account data fails', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
otherRequiredDataStatus: 'error',
}),
).toEqual({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
error: 'account-data',
})
})
it('computes access after a successful response without a birthdate', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
metadata: {birthdate: undefined},
otherRequiredDataStatus: 'success',
}),
).toMatchObject({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
})
})
it('preserves authoritative terminal server state without account data', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation: {countryCode: 'AA', regionCode: undefined},
config: {
regions: [
{
countryCode: 'AA',
minAccessAge: 13,
rules: [],
},
],
},
state: {status: 'blocked', access: 'none'},
otherRequiredDataStatus: 'error',
}),
).toMatchObject({
status: AgeAssuranceStatus.Blocked,
access: AgeAssuranceAccess.None,
})
})
})
+24 -3
View File
@@ -9,6 +9,7 @@ import {
getDeviceSignalsFromCacheForRegion,
getOtherRequiredDataFromCache,
getServerStateFromCache,
type OtherRequiredDataStatus,
useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
@@ -35,12 +36,13 @@ import {device} from '#/storage'
* server state before computing access based on AA config from the server +
* geolocation and other data.
*/
function computeAgeAssuranceState({
export function computeAgeAssuranceState({
hasSession,
geolocation,
config,
state,
metadata,
otherRequiredDataStatus,
deviceSignals,
}: {
hasSession: boolean
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
config?: app.bsky.ageassurance.defs.Config
state?: app.bsky.ageassurance.defs.State
metadata?: AgeAssuranceMetadata
otherRequiredDataStatus: OtherRequiredDataStatus
deviceSignals?: AgeRange.AgeRangeResponse
}) {
/**
@@ -93,6 +96,14 @@ function computeAgeAssuranceState({
}
}
if (otherRequiredDataStatus === 'error') {
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
error: 'account-data' as const,
}
}
/*
* Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
geolocation,
state: state.state,
metadata,
otherRequiredDataStatus: 'success',
deviceSignals,
})
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, metadata, deviceSignals} =
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
useAgeAssuranceServerDataContext()
return useMemo(
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
geolocation,
state,
metadata,
otherRequiredDataStatus,
deviceSignals,
}),
[hasSession, geolocation, config, state, metadata, deviceSignals],
[
hasSession,
geolocation,
config,
state,
metadata,
otherRequiredDataStatus,
deviceSignals,
],
)
}
+2 -1
View File
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
error?: 'config' // maybe other specific cases in the future
isLoading?: boolean
error?: 'config' | 'account-data'
}
export type AgeAssuranceFlags = {
+15 -14
View File
@@ -1,5 +1,3 @@
import {useMemo} from 'react'
import {type Breakpoint, useBreakpoints} from '#/alf/breakpoints'
import * as tokens from '#/alf/tokens'
@@ -44,23 +42,26 @@ export function useGutters([top, right, bottom, left]: [
Gutter,
Gutter,
]): Gutters
export function useGutters([top, right, bottom, left]: Gutter[]) {
export function useGutters(gutter: Gutter[]) {
const {activeBreakpoint} = useBreakpoints()
/*
* Destructured in the body rather than the parameter list: these are
* reassigned below to fill in the CSS-shorthand forms, and React Compiler
* cannot lower a reassigned destructured parameter.
*/
let [top, right, bottom, left] = gutter
if (right === undefined) {
right = bottom = left = top
} else if (bottom === undefined) {
bottom = top
left = right
}
return useMemo(() => {
return {
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
paddingRight:
right === 0 ? 0 : gutters[right][activeBreakpoint || 'default'],
paddingBottom:
bottom === 0 ? 0 : gutters[bottom][activeBreakpoint || 'default'],
paddingLeft:
left === 0 ? 0 : gutters[left][activeBreakpoint || 'default'],
}
}, [activeBreakpoint, top, right, bottom, left])
return {
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
paddingRight:
right === 0 ? 0 : gutters[right][activeBreakpoint || 'default'],
paddingBottom:
bottom === 0 ? 0 : gutters[bottom][activeBreakpoint || 'default'],
paddingLeft: left === 0 ? 0 : gutters[left][activeBreakpoint || 'default'],
}
}
+5 -7
View File
@@ -77,21 +77,19 @@ export function getFeatures() {
export function getFeatureDescription(feature: Features, i18n: I18n) {
switch (feature) {
case Features.CanonicalPostNumberingEnable:
case Features.PostFeedKnownLikersEnable:
return {
key: feature,
name: i18n._(
msg({
message: 'Thread numbering',
comment:
'Name for a feature flag (See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.)',
message: 'Social proofing on posts',
comment: 'Name for a feature flag',
}),
),
description: i18n._(
msg({
message:
'See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.',
comment: 'Description of a feature flag (Thread numbering)',
message: 'Spot posts your friends and follows have liked.',
comment: 'Description of a feature flag (Social proofing on posts)',
}),
),
}
+1 -1
View File
@@ -16,12 +16,12 @@ export enum Features {
GroupChatsDisable = 'group_chats:disable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
PostFeedKnownLikersEnable = 'post_feed:known_likers:enable',
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
CustomLogoJapanEnable = 'custom_logo:japan:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable',
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
ModerationInboxEnable = 'moderation_inbox:enable',
+2
View File
@@ -117,6 +117,7 @@ const Context = createContext<AnalyticsBaseContextType>({
geolocation: device.get(['geolocationServiceResponse']) || {
countryCode: '',
regionCode: '',
city: '',
},
},
})
@@ -181,6 +182,7 @@ export function AnalyticsContext({
}
const deviceId = useDeviceId() ?? 'unknown'
const sessionId = useSessionId()
// only IP based, never GPS
const geolocation = useGeolocationServiceResponse()
const parentContext = useContext(Context)
/*
+42
View File
@@ -44,6 +44,7 @@ export type Events = {
| 'SignupQueued'
| 'Deactivated'
| 'Takendown'
| 'AgeAssuranceDataUnavailableScreen'
| 'AgeAssuranceNoAccessScreen'
scope: 'current' | 'every'
}
@@ -450,6 +451,7 @@ export type Events = {
'post:view': {
uri: string
authorDid: string
isReply: boolean
logContext:
| 'FeedItem'
| 'PostThreadItem'
@@ -703,6 +705,7 @@ export type Events = {
}
'starterPack:removeUser': {
starterPack?: string
context?: 'opt-out'
}
'starterPack:share': {
starterPack: string
@@ -715,6 +718,10 @@ export type Events = {
count: number
}
'starterPack:delete': {}
'starterPack:optOut': {
starterPack: string
action: 'optOut' | 'undo'
}
'starterPack:create': {
setName: boolean
setDescription: boolean
@@ -1396,6 +1403,41 @@ export type Events = {
playlist: string
}
/**
* The playable video was meaningfully visible. This is an exposure event,
* not proof that playback started. Fires once per mounted video item.
*/
'video:impression': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
}
/**
* Playback advanced far enough to render the first frame. Preloading and
* merely becoming active do not count. Fires once per mounted video item;
* automatic loops do not produce another event.
*/
'video:playback:start': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
autoplay: boolean
}
/**
* The user activated a third-party media player. Cross-origin players do
* not expose confirmed playback consistently, so this must not be treated
* as equivalent to video:playback:start without an explicit methodology.
*/
'externalEmbed:playerActivated': {
postUri?: string
postAuthorDid?: string
source: string
playerType: string
mediaType: 'video' | 'audio' | 'gif' | 'other'
}
// === 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.
+7 -3
View File
@@ -11,14 +11,18 @@ import {
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
* type guard.
*/
export function useMeta(metadata?: MergeableMetadata) {
const m = useMemo(() => metadata, [metadata])
if (!m) return
function markMemoized<T extends MergeableMetadata>(m: T): T {
// @ts-expect-error
m.__meta = true
return m
}
export function useMeta(metadata?: MergeableMetadata) {
const m = useMemo(() => metadata, [metadata])
if (!m) return
return markMemoized(m)
}
export function accountToSessionMetadata(
account: SessionAccount | undefined,
): SessionMetadata | undefined {
+7 -3
View File
@@ -13,13 +13,17 @@ export function AvatarStack({
size = 26,
numPending,
backgroundColor,
borderWidth = 1,
overlap,
}: {
profiles: bsky.profile.AnyProfileView[]
size?: number
numPending?: number
backgroundColor?: string
borderWidth?: number
overlap?: number
}) {
const translation = size / 3 // overlap by 1/3
const translation = overlap ?? size / 3
const t = useTheme()
const moderationOpts = useModerationOpts()
@@ -55,7 +59,7 @@ export function AvatarStack({
width: size,
height: size,
left: i * -translation,
borderWidth: 1,
borderWidth,
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
borderRadius: 999,
zIndex: 3 - i,
@@ -63,7 +67,7 @@ export function AvatarStack({
]}>
{item.profile && (
<UserAvatar
size={size - 2}
size={size - borderWidth * 2}
avatar={item.profile.avatar}
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
moderation={item.moderation.ui('avatar')}
+2 -2
View File
@@ -362,9 +362,9 @@ export function Composer({
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
onScroll={e => {
if (IS_WEB) {
inputScrollSharedValue.value = (e.target as any).scrollTop
inputScrollSharedValue.set((e.target as any).scrollTop)
} else {
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
inputScrollSharedValue.set(e.nativeEvent.contentOffset.y)
}
}}
// @ts-expect-error web only
+4 -8
View File
@@ -530,7 +530,7 @@ export function AuxiliaryView({
transform: [
{
translateY:
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
Math.max(ensureOnScreenTranslationSV.get(), translationSV.get()) *
animationSV.get(),
},
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
@@ -544,21 +544,17 @@ export function AuxiliaryView({
const onLayout = useCallback(() => {
if (!measurement) return
let translation = 0
// vibes based, just assuming it'll fit within this space. revisit if we use
// AuxiliaryView for something tall
const TOP_INSET = topInset + 80
const distanceMessageFromTop = measurement.y - TOP_INSET
if (distanceMessageFromTop < 0) {
translation = -distanceMessageFromTop
}
const minimumTranslation = -distanceMessageFromTop
// normally, the context menu is responsible for measuring itself and moving everything into the right place
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
if (mode === 'auxiliary-only') {
translationSV.set(translation)
translationSV.set(Math.max(minimumTranslation, 0))
ensureOnScreenTranslationSV.set(0)
}
// however, we also need to make sure that for super tall triggers, we don't go off the screen
@@ -567,7 +563,7 @@ export function AuxiliaryView({
// we'll just have to live with it for now, fixing it would be possible but be a large complexity
// increase for an edge case
else {
ensureOnScreenTranslationSV.set(translation)
ensureOnScreenTranslationSV.set(minimumTranslation)
}
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
-1
View File
@@ -42,7 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
useEffect(() => {
activeDialogs.current.set(id, control)
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
activeDialogs.current.delete(id)
}
}, [id, activeDialogs])
+12 -6
View File
@@ -73,12 +73,18 @@ export function Outer({
setIsOpen(false)
try {
if (cb && typeof cb === 'function') {
// This timeout ensures that the callback runs at the same time as it would on native. I.e.
// console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
// 'Step 1', 'Step 3', 'Step 2'.
setTimeout(cb)
/*
* Nested rather than `&&`: React Compiler cannot lower a logical
* expression in a test position inside a `try`.
*/
if (cb) {
if (typeof cb === 'function') {
// This timeout ensures that the callback runs at the same time as it would on native. I.e.
// console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
// 'Step 1', 'Step 3', 'Step 2'.
setTimeout(cb)
}
}
} catch (e: any) {
logger.error(`Dialog closeCallback failed`, {
+12 -12
View File
@@ -1,4 +1,3 @@
import {useCallback} from 'react'
import {init} from 'emoji-mart'
/**
@@ -16,15 +15,16 @@ let loadRequested = false
*
* @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs}
*/
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
const preload = useCallback(async () => {
if (loadRequested) return
loadRequested = true
try {
const data = (await import('@emoji-mart/data')).default
init({data})
} catch (e) {}
}, [])
if (immediate) preload()
return preload
async function loadEmojiData() {
if (loadRequested) return
loadRequested = true
try {
const data = (await import('@emoji-mart/data')).default
init({data})
} catch (e) {}
}
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
if (immediate) loadEmojiData()
return loadEmojiData
}
+59 -13
View File
@@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type Props as SVGIconProps} from '#/components/icons/common'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Error({
icon: Icon,
title,
message,
onRetry,
onGoBack,
hideBackButton,
secondaryAction,
isRetrying,
}: {
icon?: React.ComponentType<SVGIconProps>
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
isRetrying?: boolean
secondaryAction?: {
label: string
accessibilityLabel?: string
onPress: () => unknown
}
}) {
const {t: l} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
return (
<Layout.Center
@@ -35,8 +46,11 @@ export function Error({
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
<View style={[a.w_full, a.align_center, a.gap_lg, a.px_md]}>
{Icon && <Icon size="4xl" fill={t.atoms.text_contrast_medium.color} />}
<Text style={[a.font_semi_bold, a.text_3xl, a.text_center]}>
{title}
</Text>
<Text
style={[
a.text_md,
@@ -51,29 +65,61 @@ export function Error({
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
{onRetry && (
<Button
variant="solid"
color="primary"
label={l`Press to retry`}
onPress={onRetry}
disabled={isRetrying}
size="large">
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
{isRetrying && <ButtonIcon icon={Loader} />}
</Button>
)}
{!hideBackButton && (
{!hideBackButton && secondaryAction ? (
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`}
onPress={goBack}
label={secondaryAction.accessibilityLabel ?? secondaryAction.label}
onPress={secondaryAction.onPress}
disabled={isRetrying}
size="large">
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
<ButtonText>{secondaryAction.label}</ButtonText>
</Button>
)}
) : !hideBackButton ? (
<GoBackButton
hasRetry={Boolean(onRetry)}
isRetrying={isRetrying}
onGoBack={onGoBack}
/>
) : null}
</View>
</Layout.Center>
)
}
function GoBackButton({
hasRetry,
isRetrying,
onGoBack,
}: {
hasRetry: boolean
isRetrying?: boolean
onGoBack?: () => unknown
}) {
const {t: l} = useLingui()
const goBack = useGoBack(onGoBack)
return (
<Button
variant="solid"
color={hasRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`}
onPress={goBack}
disabled={isRetrying}
size="large">
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
</Button>
)
}
+3 -1
View File
@@ -295,6 +295,8 @@ function SaveButtonInner({
e.preventDefault()
e.stopPropagation()
const pinned = pin || false
try {
if (savedFeedConfig) {
await removeFeed(savedFeedConfig)
@@ -303,7 +305,7 @@ function SaveButtonInner({
{
type,
value: uri,
pinned: pin || false,
pinned,
},
])
}
+10 -10
View File
@@ -54,6 +54,16 @@ export function InterestTabs({
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
const onInitialLayout = useNonReactiveCallback(() => {
const index = interests.indexOf(selectedInterest)
scrollIntoViewIfNeeded(index)
@@ -65,16 +75,6 @@ export function InterestTabs({
}
}, [tabOffsets, onInitialLayout])
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
function handleSelectTab(index: number) {
const tab = interests[index]
onSelectTab(tab)
+6 -4
View File
@@ -5,13 +5,15 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import * as Select from '#/components/Select'
const DEFAULT_ITEMS = APP_LANGUAGES.map(l => ({
label: l.name,
value: l.code2,
}))
export function LanguageSelect({
value,
onChange,
items = APP_LANGUAGES.map(l => ({
label: l.name,
value: l.code2,
})),
items = DEFAULT_ITEMS,
label,
disabledBlueskySupportedLanguageSanitization = false,
}: {
+1 -1
View File
@@ -219,7 +219,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
numberOfLines={1}>
{children}
</Text>
)
@@ -79,6 +79,11 @@ const ImageItem = ({
// Keep track of when we're entering or leaving scaled rendering.
// Note: DO NOT move any logic reading animated values outside this function.
function handleZoom(nextIsScaled: boolean) {
setIsScaled(nextIsScaled)
onZoom(nextIsScaled)
}
useAnimatedReaction(
() => {
if (pinchScale.get() !== 1) {
@@ -100,11 +105,6 @@ const ImageItem = ({
},
)
function handleZoom(nextIsScaled: boolean) {
setIsScaled(nextIsScaled)
onZoom(nextIsScaled)
}
// On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges.
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
function getExtraTranslationToStayInBounds(
+2 -2
View File
@@ -102,7 +102,7 @@ function DialogInner({
if (isMe) {
if (profile.joinedViaStarterPack) {
return _(
msg`You joined Bluesky using a starter pack ${timeAgoString} ago`,
msg`You joined Bluesky using a Starter Pack ${timeAgoString} ago`,
)
} else {
return _(msg`You joined Bluesky ${timeAgoString} ago`)
@@ -110,7 +110,7 @@ function DialogInner({
} else {
if (profile.joinedViaStarterPack) {
return _(
msg`${profileName} joined Bluesky using a starter pack ${timeAgoString} ago`,
msg`${profileName} joined Bluesky using a Starter Pack ${timeAgoString} ago`,
)
} else {
return _(msg`${profileName} joined Bluesky ${timeAgoString} ago`)
@@ -22,6 +22,7 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {
type EmbedPlayerParams,
getEmbedPlayerMediaType,
getPlayerAspect,
} from '#/lib/strings/embed-player'
import {useExternalEmbedsPrefs} from '#/state/preferences'
@@ -32,6 +33,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {KeepAwake} from '#/components/KeepAwake'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {type app} from '#/lexicons'
@@ -121,9 +123,11 @@ function Player({
export function ExternalPlayer({
link,
params,
post,
}: {
link: app.bsky.embed.external.ViewExternal
params: EmbedPlayerParams
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
@@ -131,10 +135,31 @@ export function ExternalPlayer({
const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl()
const ax = useAnalytics()
const [isPlayerActive, setIsPlayerActive] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const activatePlayer = useCallback(() => {
if (!isPlayerActive) {
ax.metric('externalEmbed:playerActivated', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
source: params.source,
playerType: params.type,
mediaType: getEmbedPlayerMediaType(params.type),
})
}
setIsPlayerActive(true)
}, [
ax,
isPlayerActive,
params.source,
params.type,
post?.author.did,
post?.uri,
])
const aspect = useMemo(() => {
return getPlayerAspect({
type: params.type,
@@ -202,14 +227,14 @@ export function ExternalPlayer({
return
}
setIsPlayerActive(true)
activatePlayer()
},
[externalEmbedsPrefs, consentDialogControl, params.source],
[externalEmbedsPrefs, consentDialogControl, params.source, activatePlayer],
)
const onAcceptConsent = useCallback(() => {
setIsPlayerActive(true)
}, [])
activatePlayer()
}, [activatePlayer])
return (
<>
@@ -27,11 +27,13 @@ import {GifEmbed} from './Gif'
export const ExternalEmbed = ({
link,
onOpen,
post,
style,
hideAlt,
}: {
link: app.bsky.embed.external.ViewExternal
onOpen?: () => void
post?: app.bsky.feed.defs.PostView
style?: StyleProp<ViewStyle>
hideAlt?: boolean
}) => {
@@ -120,7 +122,11 @@ export const ExternalEmbed = ({
{embedPlayerParams?.isGif ? (
<ExternalGif link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
<ExternalPlayer
link={link}
params={embedPlayerParams}
post={post}
/>
) : undefined}
<View
@@ -4,6 +4,7 @@ import {BlueskyVideoView} from '@bsky.app/video'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
@@ -26,6 +27,7 @@ export function VideoEmbedInnerNative({
setStatus,
setIsLoading,
setIsActive,
onPlaybackStart,
onError,
}: {
ref: React.Ref<{togglePlayback: () => void}>
@@ -33,6 +35,7 @@ export function VideoEmbedInnerNative({
setStatus: (status: 'playing' | 'paused') => void
setIsLoading: (isLoading: boolean) => void
setIsActive: (isActive: boolean) => void
onPlaybackStart: (autoplay: boolean) => void
/**
* Called with the native error message before the component throws to the
* surrounding error boundary.
@@ -46,6 +49,7 @@ export function VideoEmbedInnerNative({
const [muted, setMuted] = useVideoMuteState()
const reportDialogMetadata = useReportDialogMetadataContext()
const maxTimeRemainingSeconds = useRef(0)
const playbackStartTrackedRef = useRef(false)
const [isPlaying, setIsPlaying] = useState(false)
const [timeRemaining, setTimeRemaining] = useState(0)
@@ -62,12 +66,13 @@ export function VideoEmbedInnerNative({
}
const isGif = embed.presentation === 'gif'
const autoplay = !autoplayDisabled && !isWithinMessage
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
autoplay={autoplay}
beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]}
onActiveChange={e => {
@@ -88,20 +93,26 @@ export function VideoEmbedInnerNative({
onTimeRemainingChange={e => {
const {timeRemaining} = e.nativeEvent
setTimeRemaining(timeRemaining)
if (
!isGif &&
reportDialogMetadata &&
Number.isFinite(timeRemaining) &&
timeRemaining >= 0
) {
if (Number.isFinite(timeRemaining) && timeRemaining >= 0) {
maxTimeRemainingSeconds.current = Math.max(
maxTimeRemainingSeconds.current,
timeRemaining,
)
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(
maxTimeRemainingSeconds.current - timeRemaining,
)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(autoplay)
}
if (!isGif && reportDialogMetadata) {
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
}
}
}}
onError={e => {
@@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = {
setActive: () => void
onScreen: boolean
lastKnownTime: React.RefObject<number | undefined>
onPlaybackStart: (autoplay: boolean) => void
}
export class HLSUnsupportedError extends Error {
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react/macro'
import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
@@ -29,6 +30,7 @@ export function VideoEmbedInnerWeb({
setActive,
onScreen,
lastKnownTime,
onPlaybackStart,
}: VideoEmbedInnerWebProps) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
@@ -40,6 +42,7 @@ export function VideoEmbedInnerWeb({
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
const reportDialogMetadata = useReportDialogMetadataContext()
const playbackStartTrackedRef = useRef(false)
// send error up to error boundary
const [error, setError] = useState<Error | null>(null)
@@ -79,6 +82,13 @@ export function VideoEmbedInnerWeb({
onTimeUpdate={e => {
const currentTime = e.currentTarget.currentTime
lastKnownTime.current = currentTime
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(currentTime)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(!focused)
}
if (
!isGif &&
reportDialogMetadata &&
+32 -7
View File
@@ -23,9 +23,10 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}
export function VideoEmbed({embed}: Props) {
export function VideoEmbed({embed, post}: Props) {
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -52,7 +53,7 @@ export function VideoEmbed({embed}: Props) {
const contents = (
<ErrorBoundary renderError={renderError} key={key}>
<InnerWrapper embed={embed} />
<InnerWrapper embed={embed} post={post} />
</ErrorBoundary>
)
@@ -69,7 +70,7 @@ export function VideoEmbed({embed}: Props) {
)
}
function InnerWrapper({embed}: Props) {
function InnerWrapper({embed, post}: Props) {
const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null)
@@ -86,6 +87,8 @@ function InnerWrapper({embed}: Props) {
* the active position cost nothing.
*/
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
useEffect(() => {
return () => {
telemetryRef.current?.deactivated()
@@ -121,15 +124,37 @@ function InnerWrapper({embed}: Props) {
setIsActive={active => {
setIsActive(active)
if (active) {
telemetryRef.current ??= createPlaybackTelemetry({
surface: 'feed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
if (!impressionTrackedRef.current) {
impressionTrackedRef.current = true
ax.metric('video:impression', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
}
if (telemetryRef.current == null) {
telemetryRef.current = createPlaybackTelemetry({
surface: 'feed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
}
telemetryRef.current.activated()
} else {
telemetryRef.current?.deactivated()
}
}}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
autoplay,
})
}}
onError={error => {
telemetryRef.current?.error(error)
ax.metric('video:playback:failed', {
@@ -37,7 +37,13 @@ const noop = () => {}
*/
const MIN_CARD_WIDTH = 280
export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
export function VideoEmbed({
embed,
post,
}: {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {
@@ -47,13 +53,28 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
currentActiveView,
} = useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
const [meaningfullyVisible, setMeaningfullyVisible] = useState(false)
const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(undefined)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
const ax = useAnalytics()
const isGif = embed.presentation === 'gif'
// GIFs don't participate in the "one video at a time" system
const active = isGif || activeFromContext
useEffect(() => {
if (!meaningfullyVisible || impressionTrackedRef.current) return
impressionTrackedRef.current = true
ax.metric('video:impression', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
})
}, [ax, isGif, meaningfullyVisible, post?.author.did, post?.uri])
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !IS_WEB_FIREFOX) return
@@ -62,6 +83,9 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
setMeaningfullyVisible(
entry.isIntersecting && entry.intersectionRatio >= 0.5,
)
// GIFs don't send position - they don't compete to be the active video
if (!isGif) {
sendPosition(
@@ -179,6 +203,17 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
setActive={setActive}
onScreen={onScreen}
lastKnownTime={lastKnownTime}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
autoplay,
})
}}
/>
</OnlyNearScreen>
</ErrorBoundary>
+2 -1
View File
@@ -134,6 +134,7 @@ function MediaEmbed({
<ExternalEmbed
link={embed.view.external}
onOpen={rest.onOpen}
post={rest.post}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>
@@ -144,7 +145,7 @@ function MediaEmbed({
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<VideoEmbed embed={embed.view} />
<VideoEmbed embed={embed.view} post={rest.post} />
</ContentHider>
)
}
+113
View File
@@ -0,0 +1,113 @@
import {View} from 'react-native'
import {AtUri} from '@atproto/syntax'
import {moderateProfile} from '@bsky/sdk/moderation'
import {Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {atoms as a, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {InlineLinkText, Link} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Text} from '#/components/Typography'
import {type Features, useAnalytics} from '#/analytics'
import {type app} from '#/lexicons'
/**
* Social proof for a post. When the viewer follows some of the post's recent
* likers, renders a face pile plus "Liked by A and B". Renders nothing when
* the feature is disabled or no visible known likers are available.
*/
export function KnownLikers({
post,
feature,
variant = 'thread',
}: {
post: app.bsky.feed.defs.PostView
feature: Features
variant?: 'feed' | 'thread'
}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const ax = useAnalytics()
const knownLikersAndModeration = moderationOpts
? (post.viewer?.knownLikers?.actors ?? [])
.map(actor => ({
actor,
moderation: moderateProfile(actor, moderationOpts),
}))
.filter(({moderation}) => !moderation.ui('profileList').filter)
: []
if (knownLikersAndModeration.length === 0 || !ax.features.enabled(feature)) {
return null
}
const urip = new AtUri(post.uri)
const likesHref = makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
const aviStackProfiles = knownLikersAndModeration
.slice(0, 3)
.map(({actor}) => actor)
const names = knownLikersAndModeration
.slice(0, 2)
.map(({actor, moderation}) => ({
did: actor.did,
href: makeProfileLink(actor),
displayName: sanitizeDisplayName(
actor.displayName || actor.handle,
moderation.ui('displayName'),
),
}))
const isFeed = variant === 'feed'
const rowLabel =
names.length >= 2
? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
: l`Liked by ${names[0].displayName}`
const textStyle = [a.text_sm, t.atoms.text_contrast_medium]
const nameStyle = [a.text_sm, a.font_medium, t.atoms.text_contrast_medium]
const nameLink = (name: (typeof names)[number]) => (
<ProfileHoverCard key={name.did} did={name.did} inline>
<InlineLinkText
to={name.href}
label={l`Go to ${name.displayName}'s profile`}
disableMismatchWarning
emoji
style={nameStyle}>
{name.displayName}
</InlineLinkText>
</ProfileHoverCard>
)
return (
<View style={[a.w_full, a.flex_row, isFeed && a.mt_sm]}>
<Link
to={likesHref}
label={rowLabel}
style={[a.flex_row, a.align_center, a.gap_xs, a.flex_shrink]}
onPress={() => ax.metric('post:likedBy:click', {})}>
<AvatarStack
profiles={aviStackProfiles}
size={16}
overlap={4}
borderWidth={0.5}
backgroundColor={t.atoms.bg_contrast_25.backgroundColor}
/>
<Text testID="knownLikersStat" style={[a.flex_shrink, textStyle]}>
{names.length >= 2 ? (
<Trans comment="Social proof below a post; 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 below a post; the bolded name is a person the viewer follows who liked the post">
Liked by {nameLink(names[0])}
</Trans>
)}
</Text>
</Link>
</View>
)
}
@@ -429,14 +429,13 @@ let PostMenuItems = ({
type: 'error',
})
}
} finally {
ax.metric('postMenu:blockAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
ax.metric('postMenu:blockAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
const onMuteAuthor = async () => {
@@ -452,14 +451,13 @@ let PostMenuItems = ({
type: 'error',
})
}
} finally {
ax.metric('postMenu:unmuteAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
ax.metric('postMenu:unmuteAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
} else {
try {
await queueMute()
@@ -472,14 +470,13 @@ let PostMenuItems = ({
type: 'error',
})
}
} finally {
ax.metric('postMenu:muteAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
ax.metric('postMenu:muteAccount', {
uri: postUri,
authorDid: postAuthor.did,
logContext,
feedDescriptor: feedFeedback.feedDescriptor,
})
}
}
+4 -2
View File
@@ -107,9 +107,10 @@ let PostControls = ({
return
}
const existingLike = post.viewer?.like
try {
setHasLikeIconBeenToggled(true)
if (!post.viewer?.like) {
if (!existingLike) {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
@@ -137,8 +138,9 @@ let PostControls = ({
return
}
const existingRepost = post.viewer?.repost
try {
if (!post.viewer?.repost) {
if (!existingRepost) {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionRepost',
+9 -6
View File
@@ -86,17 +86,20 @@ export function ProfileBadges({
const gap = isOnTheSmallSide ? a.gap_2xs : a.gap_xs
const padding = gap.gap / 2
const hitSlops = []
let visibleBadgeIndex = 0
const hitSlops = badgeVisibility.map(isVisible => {
if (!isVisible) return HITSLOP_20
for (const isVisible of badgeVisibility) {
if (!isVisible) {
hitSlops.push(HITSLOP_20)
continue
}
const index = visibleBadgeIndex++
return {
hitSlops.push({
...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, gap, style]}>
+13 -5
View File
@@ -491,16 +491,21 @@ export function FollowButtonInner({
const onPressFollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
const displayNameOrHandle = profile.displayName || profile.handle
try {
await queueFollow()
Toast.show(
l`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
displayNameOrHandle,
moderation.ui('displayName'),
)}`,
)
onPressProp?.(e)
onFollow?.()
if (onPressProp) {
onPressProp(e)
}
if (onFollow) {
onFollow()
}
} catch (e) {
const err = e as Error
if (err?.name !== 'AbortError') {
@@ -514,15 +519,18 @@ export function FollowButtonInner({
const onPressUnfollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
const displayNameOrHandle = profile.displayName || profile.handle
try {
await queueUnfollow()
Toast.show(
l`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
displayNameOrHandle,
moderation.ui('displayName'),
)}`,
)
onPressProp?.(e)
if (onPressProp) {
onPressProp(e)
}
} catch (e) {
const err = e as Error
if (err?.name !== 'AbortError') {

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