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:
@@ -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')
|
||||||
|
})
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -2,6 +2,8 @@ import fs from 'node:fs'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
|
|
||||||
|
import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs'
|
||||||
|
|
||||||
const ENTITY_REPLACEMENTS = {
|
const ENTITY_REPLACEMENTS = {
|
||||||
'&': '&',
|
'&': '&',
|
||||||
''': "'",
|
''': "'",
|
||||||
@@ -101,6 +103,54 @@ function readPhase(root) {
|
|||||||
return phaseFile ? fs.readFileSync(phaseFile, 'utf8').trim() : ''
|
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}) {
|
function platformResult({name, status, root, artifactUrl}) {
|
||||||
const files = walk(root)
|
const files = walk(root)
|
||||||
const reports = files.filter(file => /(?:report|junit).*\.xml$/i.test(file))
|
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
|
// 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.
|
// 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
|
// A skipped platform (e.g. iOS while temporarily disabled) is not a failure
|
||||||
// as long as it produced no flow failures.
|
// as long as it produced no flow failures.
|
||||||
const failed =
|
const failed =
|
||||||
@@ -131,52 +186,15 @@ function platformResult({name, status, root, artifactUrl}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusEmoji(status) {
|
function githubSummary({state, platforms, shortSha, runUrl, commitUrl}) {
|
||||||
if (status === 'success') return ':white_check_mark:'
|
const outcome =
|
||||||
if (status === 'skipped') return ':fast_forward:'
|
state === 'cancelled'
|
||||||
return ':x:'
|
? 'cancelled'
|
||||||
}
|
: state === 'passed'
|
||||||
|
? 'passed'
|
||||||
function slackEscape(value) {
|
: 'failed'
|
||||||
return value
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
function platformBlock(platform) {
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``,
|
`# Nightly Maestro E2E ${outcome}`,
|
||||||
]
|
|
||||||
if (platform.failures.length > 0) {
|
|
||||||
for (const failure of platform.failures.slice(0, 8)) {
|
|
||||||
lines.push(
|
|
||||||
`• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (platform.failures.length > 8) {
|
|
||||||
lines.push(`• …and ${platform.failures.length - 8} more failed flows`)
|
|
||||||
}
|
|
||||||
} else if (platform.failed && !platform.hasJUnit) {
|
|
||||||
lines.push(
|
|
||||||
`• *Setup phase:* ${slackEscape(platform.phase || 'No phase metadata was captured')}`,
|
|
||||||
)
|
|
||||||
} else if (platform.failed) {
|
|
||||||
lines.push(
|
|
||||||
`• Job failed after JUnit was written; latest phase: ${slackEscape(platform.phase || 'unknown')}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (platform.artifactUrl) {
|
|
||||||
lines.push(
|
|
||||||
`• <${platform.artifactUrl}|Open ${platform.name} logs and artifacts>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return lines.join('\n').slice(0, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function githubSummary({notify, platforms, shortSha, runUrl, commitUrl}) {
|
|
||||||
const lines = [
|
|
||||||
`# Nightly Maestro E2E ${notify ? 'failed' : 'passed'}`,
|
|
||||||
'',
|
'',
|
||||||
`- Commit: [\`${shortSha}\`](${commitUrl})`,
|
`- Commit: [\`${shortSha}\`](${commitUrl})`,
|
||||||
`- Workflow run: [open run](${runUrl})`,
|
`- Workflow run: [open run](${runUrl})`,
|
||||||
@@ -235,6 +253,7 @@ export function buildSummary({
|
|||||||
sha,
|
sha,
|
||||||
runUrl,
|
runUrl,
|
||||||
commitUrl,
|
commitUrl,
|
||||||
|
slackFileIds = [],
|
||||||
}) {
|
}) {
|
||||||
const platforms = [
|
const platforms = [
|
||||||
platformResult({
|
platformResult({
|
||||||
@@ -252,73 +271,29 @@ export function buildSummary({
|
|||||||
]
|
]
|
||||||
const notify = platforms.some(platform => platform.failed)
|
const notify = platforms.some(platform => platform.failed)
|
||||||
const shortSha = sha.slice(0, 12)
|
const shortSha = sha.slice(0, 12)
|
||||||
const lines = [
|
const slack = buildSlackMessage({
|
||||||
':rotating_light: *Nightly Maestro E2E failed*',
|
platforms,
|
||||||
`*Commit:* <${commitUrl}|\`${shortSha}\`>`,
|
sha,
|
||||||
`*Workflow run:* <${runUrl}|open run>`,
|
runUrl,
|
||||||
'',
|
commitUrl,
|
||||||
]
|
slackFileIds,
|
||||||
|
})
|
||||||
for (const platform of platforms) {
|
|
||||||
lines.push(
|
|
||||||
`${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``,
|
|
||||||
)
|
|
||||||
if (platform.failures.length > 0) {
|
|
||||||
for (const failure of platform.failures.slice(0, 10)) {
|
|
||||||
lines.push(
|
|
||||||
`• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (platform.failures.length > 10) {
|
|
||||||
lines.push(`• …and ${platform.failures.length - 10} more failed flows`)
|
|
||||||
}
|
|
||||||
} else if (platform.failed && !platform.hasJUnit) {
|
|
||||||
lines.push(
|
|
||||||
`• Setup phase: ${platform.phase || 'No phase metadata was captured'}`,
|
|
||||||
)
|
|
||||||
} else if (platform.failed) {
|
|
||||||
lines.push(
|
|
||||||
`• The job failed after JUnit was written (latest phase: ${platform.phase || 'unknown'})`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (platform.artifactUrl) {
|
|
||||||
lines.push(
|
|
||||||
`• <${platform.artifactUrl}|${platform.name} logs and artifacts>`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
lines.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = lines.join('\n').trim()
|
|
||||||
const blocks = [
|
|
||||||
{
|
|
||||||
type: 'header',
|
|
||||||
text: {type: 'plain_text', text: 'Nightly Maestro E2E failed'},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'section',
|
|
||||||
text: {
|
|
||||||
type: 'mrkdwn',
|
|
||||||
text: `*Commit:* <${commitUrl}|\`${shortSha}\`>\n*Workflow run:* <${runUrl}|open run>`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{type: 'divider'},
|
|
||||||
...platforms.flatMap((platform, index) => [
|
|
||||||
{type: 'section', text: {type: 'mrkdwn', text: platformBlock(platform)}},
|
|
||||||
...(index < platforms.length - 1 ? [{type: 'divider'}] : []),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
return {
|
return {
|
||||||
notify,
|
notify,
|
||||||
|
state: slack.state,
|
||||||
platforms,
|
platforms,
|
||||||
githubSummary: githubSummary({
|
githubSummary: githubSummary({
|
||||||
notify,
|
state: slack.state,
|
||||||
platforms,
|
platforms,
|
||||||
shortSha,
|
shortSha,
|
||||||
runUrl,
|
runUrl,
|
||||||
commitUrl,
|
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,
|
sha: args.sha,
|
||||||
runUrl: args['run-url'],
|
runUrl: args['run-url'],
|
||||||
commitUrl: args['commit-url'],
|
commitUrl: args['commit-url'],
|
||||||
|
slackFileIds: extractSlackFileIds(args['slack-upload-response']),
|
||||||
})
|
})
|
||||||
process.stdout.write(`${JSON.stringify(summary)}\n`)
|
process.stdout.write(`${JSON.stringify(summary)}\n`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
@@ -195,7 +195,7 @@ jobs:
|
|||||||
|
|
||||||
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
|
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
|
||||||
# like the build job so the toolchain is deterministic.
|
# 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:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: 🤖 Claude
|
- name: 🤖 Claude
|
||||||
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
|
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: 🤖 Claude review
|
- name: 🤖 Claude review
|
||||||
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
|
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CI: "1"
|
CI: "1"
|
||||||
MAESTRO_VERSION: "2.6.1"
|
MAESTRO_VERSION: "2.10.0"
|
||||||
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
|
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
|
||||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||||
MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true"
|
MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true"
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- name: ☕️ Set up Java 17
|
- name: ☕️ Set up Java 17
|
||||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
@@ -62,12 +62,12 @@ jobs:
|
|||||||
- name: 🔤 Compile translations
|
- name: 🔤 Compile translations
|
||||||
uses: ./.github/actions/compile-i18n
|
uses: ./.github/actions/compile-i18n
|
||||||
|
|
||||||
- name: 📥 Install Maestro 2.6.1
|
- name: 📥 Install Maestro 2.10.0
|
||||||
run: |
|
run: |
|
||||||
echo "Installing Maestro" > artifacts/ios/phase.txt
|
echo "Installing Maestro" > artifacts/ios/phase.txt
|
||||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||||
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/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
|
| shasum -a 256 --check
|
||||||
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
||||||
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
||||||
@@ -176,7 +176,7 @@ jobs:
|
|||||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- name: ☕️ Set up Java 17
|
- name: ☕️ Set up Java 17
|
||||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
@@ -187,12 +187,12 @@ jobs:
|
|||||||
- name: 🔤 Compile translations
|
- name: 🔤 Compile translations
|
||||||
uses: ./.github/actions/compile-i18n
|
uses: ./.github/actions/compile-i18n
|
||||||
|
|
||||||
- name: 📥 Install Maestro 2.6.1
|
- name: 📥 Install Maestro 2.10.0
|
||||||
run: |
|
run: |
|
||||||
echo "Installing Maestro" > artifacts/android/phase.txt
|
echo "Installing Maestro" > artifacts/android/phase.txt
|
||||||
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
|
||||||
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/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
|
| shasum -a 256 --check
|
||||||
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
|
||||||
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
|
||||||
@@ -411,14 +411,158 @@ jobs:
|
|||||||
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||||
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
|
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
|
||||||
> e2e-summary.json
|
> 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"
|
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'
|
if: steps.summary.outputs.notify == 'true'
|
||||||
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
|
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
|
||||||
with:
|
with:
|
||||||
webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }}
|
method: chat.postMessage
|
||||||
webhook-type: incoming-webhook
|
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
|
||||||
payload: ${{ steps.summary.outputs.payload }}
|
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
|
commit_message: Nightly source-language update
|
||||||
file_pattern: ./src/locale/locales/en/messages.po
|
file_pattern: ./src/locale/locales/en/messages.po
|
||||||
- name: 🚀 Push source lang to Crowdin
|
- name: 🚀 Push source lang to Crowdin
|
||||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_sources_args: "-b main"
|
upload_sources_args: "-b main"
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "confirmBtn"
|
id: "confirmBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "composerPublishBtn"
|
id: "composerPublishBtn"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
notVisible:
|
||||||
|
id: "composePostView"
|
||||||
|
timeout: 30000
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eRefreshHome"
|
id: "e2eRefreshHome"
|
||||||
- assertVisible: "Adult Content"
|
- assertVisible: "Adult Content"
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "onboardingContinue"
|
id: "onboardingContinue"
|
||||||
- assertVisible: "What are your interests?"
|
- assertVisible: "What are your interests?"
|
||||||
|
- tapOn: "Animals"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "onboardingContinue"
|
id: "onboardingContinue"
|
||||||
- assertVisible: "Suggested for you"
|
- assertVisible: "Suggested for you"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "onboardingContinue"
|
id: "onboardingContinue"
|
||||||
- assertVisible: "What are your interests?"
|
- assertVisible: "What are your interests?"
|
||||||
|
- tapOn: "Animals"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "onboardingContinue"
|
id: "onboardingContinue"
|
||||||
- assertVisible: "Suggested for you"
|
- assertVisible: "Suggested for you"
|
||||||
|
|||||||
+14
-26
@@ -3,34 +3,22 @@ appId: xyz.blueskyweb.app
|
|||||||
- launchApp:
|
- launchApp:
|
||||||
appId: "xyz.blueskyweb.app"
|
appId: "xyz.blueskyweb.app"
|
||||||
clearState: true
|
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:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
id: e2eProxyHeaderInput
|
id: e2eProxyHeaderInput
|
||||||
timeout: 180000
|
timeout: 180000
|
||||||
- tapOn:
|
- extendedWaitUntil:
|
||||||
|
visible: "Sign in"
|
||||||
|
timeout: 180000
|
||||||
|
- retry:
|
||||||
|
maxRetries: 3
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
id: e2eProxyHeaderInput
|
id: e2eProxyHeaderInput
|
||||||
- inputText: ${output.result}
|
- eraseText
|
||||||
- pressKey: Enter
|
- inputText: ${output.result}
|
||||||
|
- pressKey: Enter
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: e2eSignInAlice
|
||||||
|
timeout: 10000
|
||||||
|
|||||||
+114
-14
@@ -1,5 +1,9 @@
|
|||||||
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
|
import {
|
||||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
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 {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +13,6 @@ import {
|
|||||||
import {getResizedDimensions} from '../../src/lib/media/util'
|
import {getResizedDimensions} from '../../src/lib/media/util'
|
||||||
|
|
||||||
const mockResizedImage = {
|
const mockResizedImage = {
|
||||||
path: 'file://resized-image.jpg',
|
|
||||||
size: 100,
|
size: 100,
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
@@ -20,10 +23,26 @@ describe('downloadAndResize', () => {
|
|||||||
const errorSpy = jest.spyOn(global.console, 'error')
|
const errorSpy = jest.spyOn(global.console, 'error')
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockedCreateResizedImage = manipulateAsync as jest.Mock
|
let savedImageCount = 0
|
||||||
mockedCreateResizedImage.mockResolvedValue({
|
const mockedManipulate = ImageManipulator.manipulate as jest.Mock
|
||||||
uri: 'file://resized-image.jpg',
|
mockedManipulate.mockImplementation(() => {
|
||||||
|
const image = {
|
||||||
...mockResizedImage,
|
...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)
|
const result = await downloadAndResize(opts)
|
||||||
expect(result).toEqual(mockResizedImage)
|
expect(result).toEqual({
|
||||||
|
...mockResizedImage,
|
||||||
|
path: 'file://resized-image-7.jpg',
|
||||||
|
})
|
||||||
expect(createDownloadResumable).toHaveBeenCalledWith(
|
expect(createDownloadResumable).toHaveBeenCalledWith(
|
||||||
opts.uri,
|
opts.uri,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -57,20 +79,98 @@ describe('downloadAndResize', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// First time it gets called is to get dimensions
|
// First time it gets called is to get dimensions.
|
||||||
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
|
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
|
// The mocked source image is 100x100, below maxDimension, so it is not
|
||||||
// downsized.
|
// downsized.
|
||||||
expect(manipulateAsync).toHaveBeenCalledWith(
|
const secondContext = (ImageManipulator.manipulate as jest.Mock).mock
|
||||||
expect.any(String),
|
.results[1].value
|
||||||
[{resize: {height: 100, width: 100}}],
|
expect(secondContext.resize).toHaveBeenCalledWith({
|
||||||
{format: SaveFormat.JPEG, compress: 1.0},
|
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,
|
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 () => {
|
it('should return undefined for invalid URI', async () => {
|
||||||
const opts: DownloadAndResizeOpts = {
|
const opts: DownloadAndResizeOpts = {
|
||||||
uri: 'invalid-uri',
|
uri: 'invalid-uri',
|
||||||
|
|||||||
@@ -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', () => {
|
describe('getLikelyType', () => {
|
||||||
it('correctly handles non-parsed url', async () => {
|
it('correctly handles non-parsed url', () => {
|
||||||
const output = await getLikelyType('https://example.com')
|
const output = getLikelyType('https://example.com')
|
||||||
expect(output).toEqual(LikelyType.HTML)
|
expect(output).toEqual(LikelyType.HTML)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles non-string urls without crashing', async () => {
|
it('handles non-string urls without crashing', () => {
|
||||||
const output = await getLikelyType('123')
|
const output = getLikelyType('123')
|
||||||
expect(output).toEqual(LikelyType.Other)
|
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
@@ -20,6 +20,7 @@ module.exports = function (_config) {
|
|||||||
|
|
||||||
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||||
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
|
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 IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
|
||||||
|
|
||||||
const ASSOCIATED_DOMAINS = [
|
const ASSOCIATED_DOMAINS = [
|
||||||
@@ -234,6 +235,25 @@ module.exports = function (_config) {
|
|||||||
checkAutomatically: 'NEVER',
|
checkAutomatically: 'NEVER',
|
||||||
},
|
},
|
||||||
plugins: [
|
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-video',
|
||||||
'expo-localization',
|
'expo-localization',
|
||||||
'expo-web-browser',
|
'expo-web-browser',
|
||||||
@@ -271,8 +291,8 @@ module.exports = function (_config) {
|
|||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
compileSdkVersion: 36,
|
compileSdkVersion: 36,
|
||||||
targetSdkVersion: 35,
|
targetSdkVersion: 36,
|
||||||
buildToolsVersion: '35.0.0',
|
buildToolsVersion: '36.0.0',
|
||||||
buildReactNativeFromSource: IS_PRODUCTION,
|
buildReactNativeFromSource: IS_PRODUCTION,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -285,7 +305,6 @@ module.exports = function (_config) {
|
|||||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'react-native-compressor',
|
|
||||||
[
|
[
|
||||||
'@bitdrift/react-native',
|
'@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 |
+24
-3
@@ -36,15 +36,36 @@ jest.mock('expo-file-system/legacy', () => ({
|
|||||||
createDownloadResumable: jest.fn(),
|
createDownloadResumable: jest.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('expo-image-manipulator', () => ({
|
jest.mock('expo-image-manipulator', () => {
|
||||||
manipulateAsync: jest.fn().mockResolvedValue({
|
const createContext = () => {
|
||||||
|
const image = {
|
||||||
|
height: 100,
|
||||||
|
release: jest.fn(),
|
||||||
|
saveAsync: jest.fn().mockResolvedValue({
|
||||||
|
height: 100,
|
||||||
uri: 'file://resized-image',
|
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: {
|
SaveFormat: {
|
||||||
JPEG: 'jpeg',
|
JPEG: 'jpeg',
|
||||||
WEBP: 'webp',
|
WEBP: 'webp',
|
||||||
},
|
},
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
jest.mock('expo-camera', () => ({
|
jest.mock('expo-camera', () => ({
|
||||||
Camera: {
|
Camera: {
|
||||||
|
|||||||
+7
-2
@@ -92,6 +92,7 @@
|
|||||||
"app.bsky.graph.muteActor",
|
"app.bsky.graph.muteActor",
|
||||||
"app.bsky.graph.muteActorList",
|
"app.bsky.graph.muteActorList",
|
||||||
"app.bsky.graph.muteThread",
|
"app.bsky.graph.muteThread",
|
||||||
|
"app.bsky.graph.referencelistoptout",
|
||||||
"app.bsky.graph.searchStarterPacks",
|
"app.bsky.graph.searchStarterPacks",
|
||||||
"app.bsky.graph.searchStarterPacksV2",
|
"app.bsky.graph.searchStarterPacksV2",
|
||||||
"app.bsky.graph.starterpack",
|
"app.bsky.graph.starterpack",
|
||||||
@@ -426,7 +427,7 @@
|
|||||||
},
|
},
|
||||||
"app.bsky.embed.video": {
|
"app.bsky.embed.video": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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": {
|
"app.bsky.feed.defs": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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": {
|
"app.bsky.graph.defs": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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": {
|
"app.bsky.graph.follow": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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",
|
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.muteThread",
|
||||||
"cid": "bafyreib6ppci3qzye6wktkm4byxtb5mnl2vg22fm7oawcdvof2tfogx4dy"
|
"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": {
|
"app.bsky.graph.searchStarterPacks": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks",
|
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks",
|
||||||
"cid": "bafyreia446ip6mbnwpml6hlvxab7jtsud7zczde3u6zmnsa3op4bpxu7um"
|
"cid": "bafyreia446ip6mbnwpml6hlvxab7jtsud7zczde3u6zmnsa3op4bpxu7um"
|
||||||
|
|||||||
@@ -9,9 +9,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"alt": {
|
"alt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 10000,
|
"description": "Alt text description of the video, for accessibility."
|
||||||
"description": "Alt text description of the video, for accessibility.",
|
|
||||||
"maxGraphemes": 1000
|
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"type": "blob",
|
"type": "blob",
|
||||||
@@ -51,9 +49,7 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"alt": {
|
"alt": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"maxLength": 10000,
|
|
||||||
"maxGraphemes": 1000
|
|
||||||
},
|
},
|
||||||
"cid": {
|
"cid": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -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": {
|
"requestLess": {
|
||||||
"type": "token",
|
"type": "token",
|
||||||
"description": "Request that less content like the given feed item be shown in the feed"
|
"description": "Request that less content like the given feed item be shown in the feed"
|
||||||
@@ -194,6 +216,11 @@
|
|||||||
"bookmarked": {
|
"bookmarked": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"knownLikers": {
|
||||||
|
"ref": "#knownLikers",
|
||||||
|
"type": "ref",
|
||||||
|
"description": "This property is present only in selected cases, as an optimization."
|
||||||
|
},
|
||||||
"threadMuted": {
|
"threadMuted": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -202,37 +229,10 @@
|
|||||||
},
|
},
|
||||||
"embeddingDisabled": {
|
"embeddingDisabled": {
|
||||||
"type": "boolean"
|
"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."
|
"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": {
|
"feedViewPost": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
"subject": {
|
"subject": {
|
||||||
"ref": "app.bsky.actor.defs#profileView",
|
"ref": "app.bsky.actor.defs#profileView",
|
||||||
"type": "ref"
|
"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": {
|
"blocked": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "at-uri"
|
"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
|
||||||
|
}
|
||||||
@@ -61,7 +61,8 @@ The component uses a class-based approach to expose imperative methods (`present
|
|||||||
- Preserves status/nav bar appearance from host activity
|
- Preserves status/nav bar appearance from host activity
|
||||||
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
|
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
|
||||||
- Forwards touch events to React Native event system
|
- 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
|
- Based on React Native's ReactModalHostView pattern
|
||||||
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
|
- **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).
|
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
|
## Props
|
||||||
|
|
||||||
```typescript
|
```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.
|
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
|
### Platform Differences
|
||||||
|
|
||||||
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
|
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
|
||||||
|
|||||||
+139
-5
@@ -21,6 +21,12 @@ import expo.modules.kotlin.AppContext
|
|||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
import expo.modules.kotlin.views.ExpoView
|
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(
|
class BottomSheetView(
|
||||||
context: Context,
|
context: Context,
|
||||||
appContext: AppContext,
|
appContext: AppContext,
|
||||||
@@ -38,7 +44,11 @@ class BottomSheetView(
|
|||||||
private var lastObservedContentHeight: Float = 0f
|
private var lastObservedContentHeight: Float = 0f
|
||||||
private var pendingLayoutUpdate: Boolean = false
|
private var pendingLayoutUpdate: Boolean = false
|
||||||
|
|
||||||
private val screenHeight: Float =
|
// 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) {
|
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
|
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||||
context.resources.displayMetrics.heightPixels
|
context.resources.displayMetrics.heightPixels
|
||||||
@@ -73,6 +83,11 @@ class BottomSheetView(
|
|||||||
private val onSnapPointChange by EventDispatcher()
|
private val onSnapPointChange by EventDispatcher()
|
||||||
private val onStateChange 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
|
var disableDrag = false
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
@@ -99,10 +114,11 @@ class BottomSheetView(
|
|||||||
field = if (value < 0) 0f else dpToPx(value)
|
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) {
|
set(value) {
|
||||||
val px = dpToPx(value)
|
field = dpToPx(value)
|
||||||
field = if (px > this.screenHeight) this.screenHeight else px
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isOpen: Boolean = false
|
private var isOpen: Boolean = false
|
||||||
@@ -140,6 +156,38 @@ class BottomSheetView(
|
|||||||
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
||||||
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
||||||
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
|
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)
|
SheetManager.add(this)
|
||||||
}
|
}
|
||||||
@@ -151,9 +199,84 @@ class BottomSheetView(
|
|||||||
r: Int,
|
r: Int,
|
||||||
b: Int,
|
b: Int,
|
||||||
) {
|
) {
|
||||||
|
this.seedCanvasSize()
|
||||||
this.present()
|
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() {
|
private fun destroy() {
|
||||||
this.stopObservingContentHeight()
|
this.stopObservingContentHeight()
|
||||||
this.isClosing = false
|
this.isClosing = false
|
||||||
@@ -178,6 +301,15 @@ class BottomSheetView(
|
|||||||
|
|
||||||
val contentHeight = this.getContentHeight()
|
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 activityWindow: Window? = null
|
||||||
var currentContext = context
|
var currentContext = context
|
||||||
while (currentContext != null) {
|
while (currentContext != null) {
|
||||||
@@ -425,8 +557,10 @@ class BottomSheetView(
|
|||||||
|
|
||||||
private fun getTargetHeight(): Float {
|
private fun getTargetHeight(): Float {
|
||||||
val contentHeight = this.getContentHeight()
|
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 {
|
return when {
|
||||||
contentHeight > maxHeight -> maxHeight
|
contentHeight > effectiveMaxHeight -> effectiveMaxHeight
|
||||||
contentHeight < minHeight -> minHeight
|
contentHeight < minHeight -> minHeight
|
||||||
else -> contentHeight
|
else -> contentHeight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ const IS_IOS15 =
|
|||||||
Platform.OS === 'ios' &&
|
Platform.OS === 'ios' &&
|
||||||
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
||||||
Number(Platform.Version.split('.').at(0)) < 16
|
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<
|
export class BottomSheetNativeComponent extends Component<
|
||||||
BottomSheetViewProps,
|
BottomSheetViewProps,
|
||||||
@@ -148,24 +144,35 @@ function BottomSheetNativeComponentInner({
|
|||||||
const {height: screenHeight} = useWindowDimensions()
|
const {height: screenHeight} = useWindowDimensions()
|
||||||
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
|
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 (
|
return (
|
||||||
<NativeView
|
<NativeView
|
||||||
{...rest}
|
{...rest}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
onStateChange={onStateChange}
|
onStateChange={onStateChange}
|
||||||
ref={nativeViewRef}
|
ref={nativeViewRef}
|
||||||
style={{
|
/*
|
||||||
|
* 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',
|
position: 'absolute',
|
||||||
height: sheetHeight,
|
height: screenHeight - insets.top,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}
|
||||||
|
: {position: 'absolute'}
|
||||||
|
}
|
||||||
containerBackgroundColor={backgroundColor}>
|
containerBackgroundColor={backgroundColor}>
|
||||||
<View
|
<View
|
||||||
style={[
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-55
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-62
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-96
@@ -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)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-156
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-582
@@ -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
|
|
||||||
}
|
|
||||||
-125
@@ -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
|
|
||||||
}
|
|
||||||
@@ -296,19 +296,6 @@
|
|||||||
"count": 1
|
"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": {
|
"src/components/StarterPack/QrCodeDialog.tsx": {
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 5
|
"count": 5
|
||||||
@@ -317,14 +304,6 @@
|
|||||||
"count": 4
|
"count": 4
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/StarterPack/ShareDialog.tsx": {
|
|
||||||
"typescript/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"typescript/require-await": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
|
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
|
||||||
"typescript/no-explicit-any": {
|
"typescript/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -406,21 +385,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialogs/StarterPackDialog.tsx": {
|
|
||||||
"typescript/no-misused-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialogs/SwitchAccount.tsx": {
|
"src/components/dialogs/SwitchAccount.tsx": {
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx": {
|
|
||||||
"typescript/no-floating-promises": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
|
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
|
||||||
"typescript/no-explicit-any": {
|
"typescript/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -557,11 +526,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/lib/async/until.ts": {
|
|
||||||
"typescript/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/lib/broadcast/stub.ts": {
|
"src/lib/broadcast/stub.ts": {
|
||||||
"typescript/no-explicit-any": {
|
"typescript/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -674,7 +638,7 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 5
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/lib/media/manip.web.ts": {
|
"src/lib/media/manip.web.ts": {
|
||||||
@@ -870,14 +834,6 @@
|
|||||||
"count": 1
|
"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": {
|
"src/screens/ProfileList/components/SubscribeMenu.tsx": {
|
||||||
"typescript/no-misused-promises": {
|
"typescript/no-misused-promises": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -1009,22 +965,6 @@
|
|||||||
"count": 1
|
"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": {
|
"src/state/a11y.tsx": {
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -1191,7 +1131,7 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
},
|
},
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 2
|
"count": 1
|
||||||
},
|
},
|
||||||
"typescript/no-unsafe-member-access": {
|
"typescript/no-unsafe-member-access": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -1226,7 +1166,7 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
},
|
},
|
||||||
"typescript/no-floating-promises": {
|
"typescript/no-floating-promises": {
|
||||||
"count": 3
|
"count": 2
|
||||||
},
|
},
|
||||||
"typescript/no-unsafe-member-access": {
|
"typescript/no-unsafe-member-access": {
|
||||||
"count": 3
|
"count": 3
|
||||||
@@ -1246,11 +1186,6 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/state/queries/starter-packs.ts": {
|
|
||||||
"typescript/require-await": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/state/queries/suggested-follows.ts": {
|
"src/state/queries/suggested-follows.ts": {
|
||||||
"no-unused-vars": {
|
"no-unused-vars": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -1318,11 +1253,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/com/composer/drafts/DraftsButton.tsx": {
|
|
||||||
"typescript/no-misused-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/view/com/composer/drafts/state/queries.ts": {
|
"src/view/com/composer/drafts/state/queries.ts": {
|
||||||
"typescript/no-explicit-any": {
|
"typescript/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -1533,11 +1463,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/screens/Home.tsx": {
|
|
||||||
"typescript/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/view/screens/ModerationBlockedAccounts.tsx": {
|
"src/view/screens/ModerationBlockedAccounts.tsx": {
|
||||||
"typescript/no-misused-promises": {
|
"typescript/no-misused-promises": {
|
||||||
"count": 3
|
"count": 3
|
||||||
|
|||||||
+5
-4
@@ -112,7 +112,7 @@
|
|||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||||
"@bsky.app/peek-menu": "^0.3.2",
|
"@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/sift": "^0.3.9",
|
||||||
"@bsky.app/tapper": "^0.6.1",
|
"@bsky.app/tapper": "^0.6.1",
|
||||||
"@bsky.app/video": "0.3.6",
|
"@bsky.app/video": "0.3.6",
|
||||||
@@ -159,6 +159,7 @@
|
|||||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
"bcp-47": "^2.1.0",
|
"bcp-47": "^2.1.0",
|
||||||
"bcp-47-match": "^2.0.3",
|
"bcp-47-match": "^2.0.3",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"email-validator": "^2.0.4",
|
"email-validator": "^2.0.4",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
@@ -229,7 +230,6 @@
|
|||||||
"react-is": "19",
|
"react-is": "19",
|
||||||
"react-keyed-flatten-children": "^5.0.0",
|
"react-keyed-flatten-children": "^5.0.0",
|
||||||
"react-native": "0.86.0",
|
"react-native": "0.86.0",
|
||||||
"react-native-compressor": "1.13.0",
|
|
||||||
"react-native-date-picker": "^5.0.13",
|
"react-native-date-picker": "^5.0.13",
|
||||||
"react-native-device-attest": "^0.1.6",
|
"react-native-device-attest": "^0.1.6",
|
||||||
"react-native-drawer-layout": "^4.2.3",
|
"react-native-drawer-layout": "^4.2.3",
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
"react-native-pager-view": "6.8.0",
|
"react-native-pager-view": "6.8.0",
|
||||||
"react-native-progress": "^5.0.1",
|
"react-native-progress": "^5.0.1",
|
||||||
"react-native-qrcode-styled": "^0.3.3",
|
"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-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.26.2",
|
"react-native-screens": "4.26.2",
|
||||||
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
|
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-web-webview": "^1.0.2",
|
"react-native-web-webview": "^1.0.2",
|
||||||
"react-native-webview": "^13.16.1",
|
"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-remove-scroll-bar": "^2.3.8",
|
||||||
"react-responsive": "^10.0.1",
|
"react-responsive": "^10.0.1",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
@@ -305,6 +305,7 @@
|
|||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"react-native-dotenv": "^3.4.11",
|
"react-native-dotenv": "^3.4.11",
|
||||||
"react-refresh": "^0.14.0",
|
"react-refresh": "^0.14.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"svgo": "^4.0.2",
|
"svgo": "^4.0.2",
|
||||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
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
|
index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8454eab96 100644
|
||||||
--- a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
--- a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
|
||||||
@@ -13,3 +29,16 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
|
|||||||
// Check for Content-Type
|
// Check for Content-Type
|
||||||
val skipContentTypes = listOf(
|
val skipContentTypes = listOf(
|
||||||
"text/event-stream", // Server Sent Events
|
"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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,3 +3,15 @@
|
|||||||
### Android: bitdrift interceptor
|
### Android: bitdrift interceptor
|
||||||
|
|
||||||
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
|
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.
|
|
||||||
@@ -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
@@ -23,180 +23,6 @@ index 1b02e8b2d39672063551411d5c403a69b671a869..b3481c1b98b45dea769035140dc2fd8d
|
|||||||
- (void)setFrame:(CGRect)frame
|
- (void)setFrame:(CGRect)frame
|
||||||
{
|
{
|
||||||
[super setFrame: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
|
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||||
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
|
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
|
||||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
--- 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();
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
# ***This second part of this patch is load bearing, do not remove.***
|
# ***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
|
## 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
|
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
|
Issue: https://github.com/react/react-native/issues/53450#issuecomment-3298157830
|
||||||
Bandaid fix taken from: https://github.com/react/react-native/commit/581d643a9e59fd88f93757f80194e1efd11bd0e5
|
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).
|
||||||
|
|||||||
Generated
+689
-369
File diff suppressed because it is too large
Load Diff
+3
-6
@@ -17,9 +17,8 @@ overrides:
|
|||||||
'@react-native/normalize-colors': '0.86.0'
|
'@react-native/normalize-colors': '0.86.0'
|
||||||
'@expo/image-utils': '0.8.12'
|
'@expo/image-utils': '0.8.12'
|
||||||
'@types/estree': '1.0.6'
|
'@types/estree': '1.0.6'
|
||||||
'react-native-compressor': '1.13.0'
|
'react-native-reanimated': '4.6.0'
|
||||||
'react-native-reanimated': '4.5.3'
|
'react-native-worklets': '0.12.1'
|
||||||
'react-native-worklets': '0.11.3'
|
|
||||||
'psl': '1.9.0'
|
'psl': '1.9.0'
|
||||||
'@types/psl': '1.1.1'
|
'@types/psl': '1.1.1'
|
||||||
'react-native-screens': '4.26.2'
|
'react-native-screens': '4.26.2'
|
||||||
@@ -30,6 +29,7 @@ allowBuilds:
|
|||||||
'esbuild': false
|
'esbuild': false
|
||||||
'unrs-resolver': true
|
'unrs-resolver': true
|
||||||
patchedDependencies:
|
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
|
'@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-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
|
'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-notifications@57.0.7': patches/expo-notifications@57.0.7.patch
|
||||||
'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch
|
'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch
|
||||||
expo@57.0.8: patches/expo@57.0.8.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-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-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-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-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-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-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-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
|
'react-native@0.86.0': patches/react-native@0.86.0.patch
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- '@atproto/*'
|
- '@atproto/*'
|
||||||
|
|||||||
+1
-2
@@ -126,9 +126,8 @@ function InnerApp() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`session: resume failed`, {message: e})
|
logger.warn(`session: resume failed`, {message: e})
|
||||||
} finally {
|
|
||||||
setIsReady(true)
|
|
||||||
}
|
}
|
||||||
|
setIsReady(true)
|
||||||
}
|
}
|
||||||
const account = readLastActiveAccount()
|
const account = readLastActiveAccount()
|
||||||
void onLaunch(account)
|
void onLaunch(account)
|
||||||
|
|||||||
+1
-2
@@ -105,9 +105,8 @@ function InnerApp() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('session: resumeSession failed', {message: e})
|
logger.warn('session: resumeSession failed', {message: e})
|
||||||
} finally {
|
|
||||||
setIsReady(true)
|
|
||||||
}
|
}
|
||||||
|
setIsReady(true)
|
||||||
}
|
}
|
||||||
const account = readLastActiveAccount()
|
const account = readLastActiveAccount()
|
||||||
void onLaunch(account)
|
void onLaunch(account)
|
||||||
|
|||||||
+21
-3
@@ -80,6 +80,7 @@ import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
|
|||||||
import HashtagScreen from '#/screens/Hashtag'
|
import HashtagScreen from '#/screens/Hashtag'
|
||||||
import {LogScreen} from '#/screens/Log'
|
import {LogScreen} from '#/screens/Log'
|
||||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||||
|
import {renderMessagesSplitViewLayout} from '#/screens/Messages/components/splitView/MessagesSplitViewLayout'
|
||||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||||
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
|
||||||
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
|
||||||
@@ -88,6 +89,9 @@ import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
|||||||
import {ModerationScreen} from '#/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
|
||||||
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
|
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 {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
|
||||||
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
|
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
|
||||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
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 {InviteScannerScreen} from '#/features/inviteFriends'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||||
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
|
|
||||||
|
|
||||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||||
|
|
||||||
@@ -184,6 +187,21 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
|||||||
getComponent={() => ModerationInboxScreen}
|
getComponent={() => ModerationInboxScreen}
|
||||||
options={{title: title(msg`Moderation inbox`), requireAuth: true}}
|
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
|
<Stack.Screen
|
||||||
name="ModerationModlists"
|
name="ModerationModlists"
|
||||||
getComponent={() => ModerationModlistsScreen}
|
getComponent={() => ModerationModlistsScreen}
|
||||||
@@ -563,12 +581,12 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="StarterPackWizard"
|
name="StarterPackWizard"
|
||||||
getComponent={() => Wizard}
|
getComponent={() => Wizard}
|
||||||
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
|
options={{title: title(msg`Create a Starter Pack`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="StarterPackEdit"
|
name="StarterPackEdit"
|
||||||
getComponent={() => Wizard}
|
getComponent={() => Wizard}
|
||||||
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
options={{title: title(msg`Edit your Starter Pack`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="VideoFeed"
|
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 {ScrollView, View} from 'react-native'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
@@ -76,7 +76,7 @@ export function NoAccessScreen() {
|
|||||||
const geolocationString = createGeolocationString(geolocation, i18n.locale)
|
const geolocationString = createGeolocationString(geolocation, i18n.locale)
|
||||||
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
|
const isUsingGPS = !!geolocation.deviceGeolocation?.countryCode && IS_NATIVE
|
||||||
|
|
||||||
useEffect(() => {
|
const onShown = useEffectEvent(() => {
|
||||||
// just counting overall hits here
|
// just counting overall hits here
|
||||||
ax.metric(`blockedGeoOverlay:shown`, {})
|
ax.metric(`blockedGeoOverlay:shown`, {})
|
||||||
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
ax.metric(`ageAssurance:noAccessScreen:shown`, {
|
||||||
@@ -85,8 +85,10 @@ export function NoAccessScreen() {
|
|||||||
hasDeclaredAge,
|
hasDeclaredAge,
|
||||||
canUpdateBirthday,
|
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(() => {
|
const onPressLogout = useCallback(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
|||||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||||
import debounce from 'lodash.debounce'
|
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 {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {
|
import {
|
||||||
@@ -347,9 +347,15 @@ export type OtherRequiredData = {
|
|||||||
birthdate: string | undefined
|
birthdate: string | undefined
|
||||||
actorDeclaration?: chat.bsky.actor.declaration.Main
|
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}) {
|
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||||
return ['otherRequiredData', did]
|
return ['otherRequiredData', did]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOtherRequiredData({
|
async function getOtherRequiredData({
|
||||||
accountClient,
|
accountClient,
|
||||||
}: {
|
}: {
|
||||||
@@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||||
const res = await networkRetry(3, () =>
|
await qc.fetchQuery({
|
||||||
getOtherRequiredData({accountClient}),
|
...otherRequiredDataRetryOptions,
|
||||||
)
|
queryKey: qk,
|
||||||
qc.setQueryData<OtherRequiredData>(qk, res)
|
queryFn: () => getOtherRequiredData({accountClient}),
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as Error
|
const e = err as Error
|
||||||
logger.warn(`prefetchOtherRequiredData: failed`, {
|
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||||
@@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() {
|
|||||||
const did = accountClient.did
|
const did = accountClient.did
|
||||||
return useQuery(
|
return useQuery(
|
||||||
{
|
{
|
||||||
|
...otherRequiredDataRetryOptions,
|
||||||
enabled: !!did,
|
enabled: !!did,
|
||||||
initialData: () => {
|
initialData: () => {
|
||||||
if (!did) return
|
if (!did) return
|
||||||
return getOtherRequiredDataFromCache({did})
|
return getOtherRequiredDataFromCache({did})
|
||||||
},
|
},
|
||||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||||
|
retryOnMount: false,
|
||||||
async queryFn() {
|
async queryFn() {
|
||||||
return getOtherRequiredData({accountClient})
|
return getOtherRequiredData({accountClient})
|
||||||
},
|
},
|
||||||
@@ -722,6 +731,11 @@ export type AgeAssuranceServerData = {
|
|||||||
*/
|
*/
|
||||||
state: app.bsky.ageassurance.defs.State | undefined
|
state: app.bsky.ageassurance.defs.State | undefined
|
||||||
metadata: AgeAssuranceMetadata | 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,
|
* 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
|
* if they've granted access there. Already resolved from the region-keyed
|
||||||
@@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
|||||||
declaredAge: undefined,
|
declaredAge: undefined,
|
||||||
birthdate: undefined,
|
birthdate: undefined,
|
||||||
},
|
},
|
||||||
|
otherRequiredDataStatus: 'pending',
|
||||||
deviceSignals: undefined,
|
deviceSignals: undefined,
|
||||||
})
|
})
|
||||||
export function useAgeAssuranceServerDataContext() {
|
export function useAgeAssuranceServerDataContext() {
|
||||||
@@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
|
|||||||
const {data: config} = useConfigQuery()
|
const {data: config} = useConfigQuery()
|
||||||
const serverState = useServerStateQuery()
|
const serverState = useServerStateQuery()
|
||||||
const {state, metadata} = serverState.data || {}
|
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.
|
// `select` resolves the cached region-keyed map to the current region.
|
||||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||||
const ctx = useMemo(
|
const ctx = useMemo(
|
||||||
@@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
|
|||||||
: undefined,
|
: undefined,
|
||||||
birthdate: data?.birthdate,
|
birthdate: data?.birthdate,
|
||||||
},
|
},
|
||||||
|
otherRequiredDataStatus,
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
}),
|
}),
|
||||||
[config, state, data, metadata, deviceSignals],
|
[config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getDeviceSignalsFromCacheForRegion,
|
getDeviceSignalsFromCacheForRegion,
|
||||||
getOtherRequiredDataFromCache,
|
getOtherRequiredDataFromCache,
|
||||||
getServerStateFromCache,
|
getServerStateFromCache,
|
||||||
|
type OtherRequiredDataStatus,
|
||||||
useAgeAssuranceServerDataContext,
|
useAgeAssuranceServerDataContext,
|
||||||
} from '#/ageAssurance/data'
|
} from '#/ageAssurance/data'
|
||||||
import {logger} from '#/ageAssurance/logger'
|
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 +
|
* server state before computing access based on AA config from the server +
|
||||||
* geolocation and other data.
|
* geolocation and other data.
|
||||||
*/
|
*/
|
||||||
function computeAgeAssuranceState({
|
export function computeAgeAssuranceState({
|
||||||
hasSession,
|
hasSession,
|
||||||
geolocation,
|
geolocation,
|
||||||
config,
|
config,
|
||||||
state,
|
state,
|
||||||
metadata,
|
metadata,
|
||||||
|
otherRequiredDataStatus,
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
}: {
|
}: {
|
||||||
hasSession: boolean
|
hasSession: boolean
|
||||||
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
|
|||||||
config?: app.bsky.ageassurance.defs.Config
|
config?: app.bsky.ageassurance.defs.Config
|
||||||
state?: app.bsky.ageassurance.defs.State
|
state?: app.bsky.ageassurance.defs.State
|
||||||
metadata?: AgeAssuranceMetadata
|
metadata?: AgeAssuranceMetadata
|
||||||
|
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||||
deviceSignals?: AgeRange.AgeRangeResponse
|
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
|
* Otherwise, we need to compute the access based on the latest data. For
|
||||||
* accounts with an accurate birthdate, our default fallback rules should
|
* accounts with an accurate birthdate, our default fallback rules should
|
||||||
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
geolocation,
|
geolocation,
|
||||||
state: state.state,
|
state: state.state,
|
||||||
metadata,
|
metadata,
|
||||||
|
otherRequiredDataStatus: 'success',
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|||||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
const geolocation = useGeolocation()
|
const geolocation = useGeolocation()
|
||||||
const {config, state, metadata, deviceSignals} =
|
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
|
||||||
useAgeAssuranceServerDataContext()
|
useAgeAssuranceServerDataContext()
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
|||||||
geolocation,
|
geolocation,
|
||||||
state,
|
state,
|
||||||
metadata,
|
metadata,
|
||||||
|
otherRequiredDataStatus,
|
||||||
deviceSignals,
|
deviceSignals,
|
||||||
}),
|
}),
|
||||||
[hasSession, geolocation, config, state, metadata, deviceSignals],
|
[
|
||||||
|
hasSession,
|
||||||
|
geolocation,
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
metadata,
|
||||||
|
otherRequiredDataStatus,
|
||||||
|
deviceSignals,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
|
|||||||
lastInitiatedAt?: string
|
lastInitiatedAt?: string
|
||||||
status: AgeAssuranceStatus
|
status: AgeAssuranceStatus
|
||||||
access: AgeAssuranceAccess
|
access: AgeAssuranceAccess
|
||||||
error?: 'config' // maybe other specific cases in the future
|
isLoading?: boolean
|
||||||
|
error?: 'config' | 'account-data'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgeAssuranceFlags = {
|
export type AgeAssuranceFlags = {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import {useMemo} from 'react'
|
|
||||||
|
|
||||||
import {type Breakpoint, useBreakpoints} from '#/alf/breakpoints'
|
import {type Breakpoint, useBreakpoints} from '#/alf/breakpoints'
|
||||||
import * as tokens from '#/alf/tokens'
|
import * as tokens from '#/alf/tokens'
|
||||||
|
|
||||||
@@ -44,23 +42,26 @@ export function useGutters([top, right, bottom, left]: [
|
|||||||
Gutter,
|
Gutter,
|
||||||
Gutter,
|
Gutter,
|
||||||
]): Gutters
|
]): Gutters
|
||||||
export function useGutters([top, right, bottom, left]: Gutter[]) {
|
export function useGutters(gutter: Gutter[]) {
|
||||||
const {activeBreakpoint} = useBreakpoints()
|
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) {
|
if (right === undefined) {
|
||||||
right = bottom = left = top
|
right = bottom = left = top
|
||||||
} else if (bottom === undefined) {
|
} else if (bottom === undefined) {
|
||||||
bottom = top
|
bottom = top
|
||||||
left = right
|
left = right
|
||||||
}
|
}
|
||||||
return useMemo(() => {
|
|
||||||
return {
|
return {
|
||||||
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
|
paddingTop: top === 0 ? 0 : gutters[top][activeBreakpoint || 'default'],
|
||||||
paddingRight:
|
paddingRight:
|
||||||
right === 0 ? 0 : gutters[right][activeBreakpoint || 'default'],
|
right === 0 ? 0 : gutters[right][activeBreakpoint || 'default'],
|
||||||
paddingBottom:
|
paddingBottom:
|
||||||
bottom === 0 ? 0 : gutters[bottom][activeBreakpoint || 'default'],
|
bottom === 0 ? 0 : gutters[bottom][activeBreakpoint || 'default'],
|
||||||
paddingLeft:
|
paddingLeft: left === 0 ? 0 : gutters[left][activeBreakpoint || 'default'],
|
||||||
left === 0 ? 0 : gutters[left][activeBreakpoint || 'default'],
|
|
||||||
}
|
}
|
||||||
}, [activeBreakpoint, top, right, bottom, left])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,21 +77,19 @@ export function getFeatures() {
|
|||||||
|
|
||||||
export function getFeatureDescription(feature: Features, i18n: I18n) {
|
export function getFeatureDescription(feature: Features, i18n: I18n) {
|
||||||
switch (feature) {
|
switch (feature) {
|
||||||
case Features.CanonicalPostNumberingEnable:
|
case Features.PostFeedKnownLikersEnable:
|
||||||
return {
|
return {
|
||||||
key: feature,
|
key: feature,
|
||||||
name: i18n._(
|
name: i18n._(
|
||||||
msg({
|
msg({
|
||||||
message: 'Thread numbering',
|
message: 'Social proofing on posts',
|
||||||
comment:
|
comment: 'Name for a feature flag',
|
||||||
'Name for a feature flag (See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.)',
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
description: i18n._(
|
description: i18n._(
|
||||||
msg({
|
msg({
|
||||||
message:
|
message: 'Spot posts your friends and follows have liked.',
|
||||||
'See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.',
|
comment: 'Description of a feature flag (Social proofing on posts)',
|
||||||
comment: 'Description of a feature flag (Thread numbering)',
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ export enum Features {
|
|||||||
GroupChatsDisable = 'group_chats:disable',
|
GroupChatsDisable = 'group_chats:disable',
|
||||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
|
PostFeedKnownLikersEnable = 'post_feed:known_likers:enable',
|
||||||
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
PostThreadKnownLikersEnable = 'post_thread:known_likers:enable',
|
||||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||||
FollowSortEnable = 'follow_sort:enable',
|
FollowSortEnable = 'follow_sort:enable',
|
||||||
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
|
||||||
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
|
|
||||||
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
|
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
|
||||||
ModerationInboxEnable = 'moderation_inbox:enable',
|
ModerationInboxEnable = 'moderation_inbox:enable',
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ const Context = createContext<AnalyticsBaseContextType>({
|
|||||||
geolocation: device.get(['geolocationServiceResponse']) || {
|
geolocation: device.get(['geolocationServiceResponse']) || {
|
||||||
countryCode: '',
|
countryCode: '',
|
||||||
regionCode: '',
|
regionCode: '',
|
||||||
|
city: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -181,6 +182,7 @@ export function AnalyticsContext({
|
|||||||
}
|
}
|
||||||
const deviceId = useDeviceId() ?? 'unknown'
|
const deviceId = useDeviceId() ?? 'unknown'
|
||||||
const sessionId = useSessionId()
|
const sessionId = useSessionId()
|
||||||
|
// only IP based, never GPS
|
||||||
const geolocation = useGeolocationServiceResponse()
|
const geolocation = useGeolocationServiceResponse()
|
||||||
const parentContext = useContext(Context)
|
const parentContext = useContext(Context)
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type Events = {
|
|||||||
| 'SignupQueued'
|
| 'SignupQueued'
|
||||||
| 'Deactivated'
|
| 'Deactivated'
|
||||||
| 'Takendown'
|
| 'Takendown'
|
||||||
|
| 'AgeAssuranceDataUnavailableScreen'
|
||||||
| 'AgeAssuranceNoAccessScreen'
|
| 'AgeAssuranceNoAccessScreen'
|
||||||
scope: 'current' | 'every'
|
scope: 'current' | 'every'
|
||||||
}
|
}
|
||||||
@@ -450,6 +451,7 @@ export type Events = {
|
|||||||
'post:view': {
|
'post:view': {
|
||||||
uri: string
|
uri: string
|
||||||
authorDid: string
|
authorDid: string
|
||||||
|
isReply: boolean
|
||||||
logContext:
|
logContext:
|
||||||
| 'FeedItem'
|
| 'FeedItem'
|
||||||
| 'PostThreadItem'
|
| 'PostThreadItem'
|
||||||
@@ -703,6 +705,7 @@ export type Events = {
|
|||||||
}
|
}
|
||||||
'starterPack:removeUser': {
|
'starterPack:removeUser': {
|
||||||
starterPack?: string
|
starterPack?: string
|
||||||
|
context?: 'opt-out'
|
||||||
}
|
}
|
||||||
'starterPack:share': {
|
'starterPack:share': {
|
||||||
starterPack: string
|
starterPack: string
|
||||||
@@ -715,6 +718,10 @@ export type Events = {
|
|||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
'starterPack:delete': {}
|
'starterPack:delete': {}
|
||||||
|
'starterPack:optOut': {
|
||||||
|
starterPack: string
|
||||||
|
action: 'optOut' | 'undo'
|
||||||
|
}
|
||||||
'starterPack:create': {
|
'starterPack:create': {
|
||||||
setName: boolean
|
setName: boolean
|
||||||
setDescription: boolean
|
setDescription: boolean
|
||||||
@@ -1396,6 +1403,41 @@ export type Events = {
|
|||||||
playlist: string
|
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) ===
|
// === Video upload funnel (Frontend Spec section D) ===
|
||||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||||
// session end-to-end) + engine (compression engine id, e.g.
|
// session end-to-end) + engine (compression engine id, e.g.
|
||||||
|
|||||||
@@ -11,14 +11,18 @@ import {
|
|||||||
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
|
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
|
||||||
* type guard.
|
* type guard.
|
||||||
*/
|
*/
|
||||||
export function useMeta(metadata?: MergeableMetadata) {
|
function markMemoized<T extends MergeableMetadata>(m: T): T {
|
||||||
const m = useMemo(() => metadata, [metadata])
|
|
||||||
if (!m) return
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
m.__meta = true
|
m.__meta = true
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMeta(metadata?: MergeableMetadata) {
|
||||||
|
const m = useMemo(() => metadata, [metadata])
|
||||||
|
if (!m) return
|
||||||
|
return markMemoized(m)
|
||||||
|
}
|
||||||
|
|
||||||
export function accountToSessionMetadata(
|
export function accountToSessionMetadata(
|
||||||
account: SessionAccount | undefined,
|
account: SessionAccount | undefined,
|
||||||
): SessionMetadata | undefined {
|
): SessionMetadata | undefined {
|
||||||
|
|||||||
@@ -13,13 +13,17 @@ export function AvatarStack({
|
|||||||
size = 26,
|
size = 26,
|
||||||
numPending,
|
numPending,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
borderWidth = 1,
|
||||||
|
overlap,
|
||||||
}: {
|
}: {
|
||||||
profiles: bsky.profile.AnyProfileView[]
|
profiles: bsky.profile.AnyProfileView[]
|
||||||
size?: number
|
size?: number
|
||||||
numPending?: number
|
numPending?: number
|
||||||
backgroundColor?: string
|
backgroundColor?: string
|
||||||
|
borderWidth?: number
|
||||||
|
overlap?: number
|
||||||
}) {
|
}) {
|
||||||
const translation = size / 3 // overlap by 1/3
|
const translation = overlap ?? size / 3
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ export function AvatarStack({
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
left: i * -translation,
|
left: i * -translation,
|
||||||
borderWidth: 1,
|
borderWidth,
|
||||||
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
|
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
zIndex: 3 - i,
|
zIndex: 3 - i,
|
||||||
@@ -63,7 +67,7 @@ export function AvatarStack({
|
|||||||
]}>
|
]}>
|
||||||
{item.profile && (
|
{item.profile && (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={size - 2}
|
size={size - borderWidth * 2}
|
||||||
avatar={item.profile.avatar}
|
avatar={item.profile.avatar}
|
||||||
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
|
type={item.profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
moderation={item.moderation.ui('avatar')}
|
moderation={item.moderation.ui('avatar')}
|
||||||
|
|||||||
@@ -362,9 +362,9 @@ export function Composer({
|
|||||||
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
|
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
|
||||||
onScroll={e => {
|
onScroll={e => {
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
inputScrollSharedValue.value = (e.target as any).scrollTop
|
inputScrollSharedValue.set((e.target as any).scrollTop)
|
||||||
} else {
|
} else {
|
||||||
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
|
inputScrollSharedValue.set(e.nativeEvent.contentOffset.y)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
// @ts-expect-error web only
|
// @ts-expect-error web only
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ export function AuxiliaryView({
|
|||||||
transform: [
|
transform: [
|
||||||
{
|
{
|
||||||
translateY:
|
translateY:
|
||||||
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
|
Math.max(ensureOnScreenTranslationSV.get(), translationSV.get()) *
|
||||||
animationSV.get(),
|
animationSV.get(),
|
||||||
},
|
},
|
||||||
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
|
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
|
||||||
@@ -544,21 +544,17 @@ export function AuxiliaryView({
|
|||||||
const onLayout = useCallback(() => {
|
const onLayout = useCallback(() => {
|
||||||
if (!measurement) return
|
if (!measurement) return
|
||||||
|
|
||||||
let translation = 0
|
|
||||||
|
|
||||||
// vibes based, just assuming it'll fit within this space. revisit if we use
|
// vibes based, just assuming it'll fit within this space. revisit if we use
|
||||||
// AuxiliaryView for something tall
|
// AuxiliaryView for something tall
|
||||||
const TOP_INSET = topInset + 80
|
const TOP_INSET = topInset + 80
|
||||||
|
|
||||||
const distanceMessageFromTop = measurement.y - TOP_INSET
|
const distanceMessageFromTop = measurement.y - TOP_INSET
|
||||||
if (distanceMessageFromTop < 0) {
|
const minimumTranslation = -distanceMessageFromTop
|
||||||
translation = -distanceMessageFromTop
|
|
||||||
}
|
|
||||||
|
|
||||||
// normally, the context menu is responsible for measuring itself and moving everything into the right place
|
// 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
|
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
|
||||||
if (mode === 'auxiliary-only') {
|
if (mode === 'auxiliary-only') {
|
||||||
translationSV.set(translation)
|
translationSV.set(Math.max(minimumTranslation, 0))
|
||||||
ensureOnScreenTranslationSV.set(0)
|
ensureOnScreenTranslationSV.set(0)
|
||||||
}
|
}
|
||||||
// however, we also need to make sure that for super tall triggers, we don't go off the screen
|
// 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
|
// 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
|
// increase for an edge case
|
||||||
else {
|
else {
|
||||||
ensureOnScreenTranslationSV.set(translation)
|
ensureOnScreenTranslationSV.set(minimumTranslation)
|
||||||
}
|
}
|
||||||
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
|
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeDialogs.current.set(id, control)
|
activeDialogs.current.set(id, control)
|
||||||
return () => {
|
return () => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
activeDialogs.current.delete(id)
|
activeDialogs.current.delete(id)
|
||||||
}
|
}
|
||||||
}, [id, activeDialogs])
|
}, [id, activeDialogs])
|
||||||
|
|||||||
@@ -73,13 +73,19 @@ export function Outer({
|
|||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (cb && typeof cb === 'function') {
|
/*
|
||||||
|
* 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.
|
// 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')
|
// 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
|
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
|
||||||
// 'Step 1', 'Step 3', 'Step 2'.
|
// 'Step 1', 'Step 3', 'Step 2'.
|
||||||
setTimeout(cb)
|
setTimeout(cb)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(`Dialog closeCallback failed`, {
|
logger.error(`Dialog closeCallback failed`, {
|
||||||
message: e.message,
|
message: e.message,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import {useCallback} from 'react'
|
|
||||||
import {init} from 'emoji-mart'
|
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}
|
* @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} = {}) {
|
async function loadEmojiData() {
|
||||||
const preload = useCallback(async () => {
|
|
||||||
if (loadRequested) return
|
if (loadRequested) return
|
||||||
loadRequested = true
|
loadRequested = true
|
||||||
try {
|
try {
|
||||||
const data = (await import('@emoji-mart/data')).default
|
const data = (await import('@emoji-mart/data')).default
|
||||||
init({data})
|
init({data})
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}, [])
|
}
|
||||||
if (immediate) preload()
|
|
||||||
return preload
|
export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) {
|
||||||
|
if (immediate) loadEmojiData()
|
||||||
|
return loadEmojiData
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-10
@@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
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 * as Layout from '#/components/Layout'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function Error({
|
export function Error({
|
||||||
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
onRetry,
|
onRetry,
|
||||||
onGoBack,
|
onGoBack,
|
||||||
hideBackButton,
|
hideBackButton,
|
||||||
|
secondaryAction,
|
||||||
|
isRetrying,
|
||||||
}: {
|
}: {
|
||||||
|
icon?: React.ComponentType<SVGIconProps>
|
||||||
title?: string
|
title?: string
|
||||||
message?: string
|
message?: string
|
||||||
onRetry?: () => unknown
|
onRetry?: () => unknown
|
||||||
onGoBack?: () => unknown
|
onGoBack?: () => unknown
|
||||||
hideBackButton?: boolean
|
hideBackButton?: boolean
|
||||||
|
isRetrying?: boolean
|
||||||
|
secondaryAction?: {
|
||||||
|
label: string
|
||||||
|
accessibilityLabel?: string
|
||||||
|
onPress: () => unknown
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const goBack = useGoBack(onGoBack)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Center
|
<Layout.Center
|
||||||
@@ -35,8 +46,11 @@ export function Error({
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{paddingTop: 175, paddingBottom: 110},
|
{paddingTop: 175, paddingBottom: 110},
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
<View style={[a.w_full, a.align_center, a.gap_lg, a.px_md]}>
|
||||||
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
{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
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_md,
|
a.text_md,
|
||||||
@@ -51,29 +65,61 @@ export function Error({
|
|||||||
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
label={l`Press to retry`}
|
label={l`Press to retry`}
|
||||||
onPress={onRetry}
|
onPress={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
size="large">
|
size="large">
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Retry</Trans>
|
<Trans>Retry</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
{isRetrying && <ButtonIcon icon={Loader} />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!hideBackButton && (
|
{!hideBackButton && secondaryAction ? (
|
||||||
|
<Button
|
||||||
|
color={onRetry ? 'secondary' : 'primary'}
|
||||||
|
label={secondaryAction.accessibilityLabel ?? secondaryAction.label}
|
||||||
|
onPress={secondaryAction.onPress}
|
||||||
|
disabled={isRetrying}
|
||||||
|
size="large">
|
||||||
|
<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
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color={onRetry ? 'secondary' : 'primary'}
|
color={hasRetry ? 'secondary' : 'primary'}
|
||||||
label={l`Return to previous page`}
|
label={l`Return to previous page`}
|
||||||
onPress={goBack}
|
onPress={goBack}
|
||||||
|
disabled={isRetrying}
|
||||||
size="large">
|
size="large">
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Go Back</Trans>
|
<Trans>Go Back</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Layout.Center>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ function SaveButtonInner({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const pinned = pin || false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (savedFeedConfig) {
|
if (savedFeedConfig) {
|
||||||
await removeFeed(savedFeedConfig)
|
await removeFeed(savedFeedConfig)
|
||||||
@@ -303,7 +305,7 @@ function SaveButtonInner({
|
|||||||
{
|
{
|
||||||
type,
|
type,
|
||||||
value: uri,
|
value: uri,
|
||||||
pinned: pin || false,
|
pinned,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ export function InterestTabs({
|
|||||||
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
|
||||||
const [tabOffsets, setTabOffsets] = useState<{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 onInitialLayout = useNonReactiveCallback(() => {
|
||||||
const index = interests.indexOf(selectedInterest)
|
const index = interests.indexOf(selectedInterest)
|
||||||
scrollIntoViewIfNeeded(index)
|
scrollIntoViewIfNeeded(index)
|
||||||
@@ -65,16 +75,6 @@ export function InterestTabs({
|
|||||||
}
|
}
|
||||||
}, [tabOffsets, onInitialLayout])
|
}, [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) {
|
function handleSelectTab(index: number) {
|
||||||
const tab = interests[index]
|
const tab = interests[index]
|
||||||
onSelectTab(tab)
|
onSelectTab(tab)
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
|||||||
import {APP_LANGUAGES} from '#/locale/languages'
|
import {APP_LANGUAGES} from '#/locale/languages'
|
||||||
import * as Select from '#/components/Select'
|
import * as Select from '#/components/Select'
|
||||||
|
|
||||||
|
const DEFAULT_ITEMS = APP_LANGUAGES.map(l => ({
|
||||||
|
label: l.name,
|
||||||
|
value: l.code2,
|
||||||
|
}))
|
||||||
|
|
||||||
export function LanguageSelect({
|
export function LanguageSelect({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
items = APP_LANGUAGES.map(l => ({
|
items = DEFAULT_ITEMS,
|
||||||
label: l.name,
|
|
||||||
value: l.code2,
|
|
||||||
})),
|
|
||||||
label,
|
label,
|
||||||
disabledBlueskySupportedLanguageSanitization = false,
|
disabledBlueskySupportedLanguageSanitization = false,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
|
|||||||
IS_IOS && align === 'platform' && a.text_center,
|
IS_IOS && align === 'platform' && a.text_center,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
]}
|
]}
|
||||||
numberOfLines={2}>
|
numberOfLines={1}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ const ImageItem = ({
|
|||||||
|
|
||||||
// Keep track of when we're entering or leaving scaled rendering.
|
// Keep track of when we're entering or leaving scaled rendering.
|
||||||
// Note: DO NOT move any logic reading animated values outside this function.
|
// Note: DO NOT move any logic reading animated values outside this function.
|
||||||
|
function handleZoom(nextIsScaled: boolean) {
|
||||||
|
setIsScaled(nextIsScaled)
|
||||||
|
onZoom(nextIsScaled)
|
||||||
|
}
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => {
|
() => {
|
||||||
if (pinchScale.get() !== 1) {
|
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.
|
// 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.
|
// If the user tried to pan too hard, this function will provide the negative panning to stay in bounds.
|
||||||
function getExtraTranslationToStayInBounds(
|
function getExtraTranslationToStayInBounds(
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function DialogInner({
|
|||||||
if (isMe) {
|
if (isMe) {
|
||||||
if (profile.joinedViaStarterPack) {
|
if (profile.joinedViaStarterPack) {
|
||||||
return _(
|
return _(
|
||||||
msg`You joined Bluesky using a starter pack ${timeAgoString} ago`,
|
msg`You joined Bluesky using a Starter Pack ${timeAgoString} ago`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return _(msg`You joined Bluesky ${timeAgoString} ago`)
|
return _(msg`You joined Bluesky ${timeAgoString} ago`)
|
||||||
@@ -110,7 +110,7 @@ function DialogInner({
|
|||||||
} else {
|
} else {
|
||||||
if (profile.joinedViaStarterPack) {
|
if (profile.joinedViaStarterPack) {
|
||||||
return _(
|
return _(
|
||||||
msg`${profileName} joined Bluesky using a starter pack ${timeAgoString} ago`,
|
msg`${profileName} joined Bluesky using a Starter Pack ${timeAgoString} ago`,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return _(msg`${profileName} joined Bluesky ${timeAgoString} ago`)
|
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 NavigationProp} from '#/lib/routes/types'
|
||||||
import {
|
import {
|
||||||
type EmbedPlayerParams,
|
type EmbedPlayerParams,
|
||||||
|
getEmbedPlayerMediaType,
|
||||||
getPlayerAspect,
|
getPlayerAspect,
|
||||||
} from '#/lib/strings/embed-player'
|
} from '#/lib/strings/embed-player'
|
||||||
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
||||||
@@ -32,6 +33,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
|
|||||||
import {Fill} from '#/components/Fill'
|
import {Fill} from '#/components/Fill'
|
||||||
import {KeepAwake} from '#/components/KeepAwake'
|
import {KeepAwake} from '#/components/KeepAwake'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
import {type app} from '#/lexicons'
|
import {type app} from '#/lexicons'
|
||||||
|
|
||||||
@@ -121,9 +123,11 @@ function Player({
|
|||||||
export function ExternalPlayer({
|
export function ExternalPlayer({
|
||||||
link,
|
link,
|
||||||
params,
|
params,
|
||||||
|
post,
|
||||||
}: {
|
}: {
|
||||||
link: app.bsky.embed.external.ViewExternal
|
link: app.bsky.embed.external.ViewExternal
|
||||||
params: EmbedPlayerParams
|
params: EmbedPlayerParams
|
||||||
|
post?: app.bsky.feed.defs.PostView
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
@@ -131,10 +135,31 @@ export function ExternalPlayer({
|
|||||||
const windowDims = useWindowDimensions()
|
const windowDims = useWindowDimensions()
|
||||||
const externalEmbedsPrefs = useExternalEmbedsPrefs()
|
const externalEmbedsPrefs = useExternalEmbedsPrefs()
|
||||||
const consentDialogControl = useDialogControl()
|
const consentDialogControl = useDialogControl()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
const [isPlayerActive, setIsPlayerActive] = useState(false)
|
const [isPlayerActive, setIsPlayerActive] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
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(() => {
|
const aspect = useMemo(() => {
|
||||||
return getPlayerAspect({
|
return getPlayerAspect({
|
||||||
type: params.type,
|
type: params.type,
|
||||||
@@ -202,14 +227,14 @@ export function ExternalPlayer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPlayerActive(true)
|
activatePlayer()
|
||||||
},
|
},
|
||||||
[externalEmbedsPrefs, consentDialogControl, params.source],
|
[externalEmbedsPrefs, consentDialogControl, params.source, activatePlayer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAcceptConsent = useCallback(() => {
|
const onAcceptConsent = useCallback(() => {
|
||||||
setIsPlayerActive(true)
|
activatePlayer()
|
||||||
}, [])
|
}, [activatePlayer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ import {GifEmbed} from './Gif'
|
|||||||
export const ExternalEmbed = ({
|
export const ExternalEmbed = ({
|
||||||
link,
|
link,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
post,
|
||||||
style,
|
style,
|
||||||
hideAlt,
|
hideAlt,
|
||||||
}: {
|
}: {
|
||||||
link: app.bsky.embed.external.ViewExternal
|
link: app.bsky.embed.external.ViewExternal
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
|
post?: app.bsky.feed.defs.PostView
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
hideAlt?: boolean
|
hideAlt?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
@@ -120,7 +122,11 @@ export const ExternalEmbed = ({
|
|||||||
{embedPlayerParams?.isGif ? (
|
{embedPlayerParams?.isGif ? (
|
||||||
<ExternalGif link={link} params={embedPlayerParams} />
|
<ExternalGif link={link} params={embedPlayerParams} />
|
||||||
) : embedPlayerParams ? (
|
) : embedPlayerParams ? (
|
||||||
<ExternalPlayer link={link} params={embedPlayerParams} />
|
<ExternalPlayer
|
||||||
|
link={link}
|
||||||
|
params={embedPlayerParams}
|
||||||
|
post={post}
|
||||||
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {BlueskyVideoView} from '@bsky.app/video'
|
|||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {HITSLOP_30} from '#/lib/constants'
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
|
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
|
||||||
import {useAutoplayDisabled} from '#/state/preferences'
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||||
@@ -26,6 +27,7 @@ export function VideoEmbedInnerNative({
|
|||||||
setStatus,
|
setStatus,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setIsActive,
|
setIsActive,
|
||||||
|
onPlaybackStart,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
ref: React.Ref<{togglePlayback: () => void}>
|
ref: React.Ref<{togglePlayback: () => void}>
|
||||||
@@ -33,6 +35,7 @@ export function VideoEmbedInnerNative({
|
|||||||
setStatus: (status: 'playing' | 'paused') => void
|
setStatus: (status: 'playing' | 'paused') => void
|
||||||
setIsLoading: (isLoading: boolean) => void
|
setIsLoading: (isLoading: boolean) => void
|
||||||
setIsActive: (isActive: boolean) => void
|
setIsActive: (isActive: boolean) => void
|
||||||
|
onPlaybackStart: (autoplay: boolean) => void
|
||||||
/**
|
/**
|
||||||
* Called with the native error message before the component throws to the
|
* Called with the native error message before the component throws to the
|
||||||
* surrounding error boundary.
|
* surrounding error boundary.
|
||||||
@@ -46,6 +49,7 @@ export function VideoEmbedInnerNative({
|
|||||||
const [muted, setMuted] = useVideoMuteState()
|
const [muted, setMuted] = useVideoMuteState()
|
||||||
const reportDialogMetadata = useReportDialogMetadataContext()
|
const reportDialogMetadata = useReportDialogMetadataContext()
|
||||||
const maxTimeRemainingSeconds = useRef(0)
|
const maxTimeRemainingSeconds = useRef(0)
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [timeRemaining, setTimeRemaining] = useState(0)
|
const [timeRemaining, setTimeRemaining] = useState(0)
|
||||||
@@ -62,12 +66,13 @@ export function VideoEmbedInnerNative({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isGif = embed.presentation === 'gif'
|
const isGif = embed.presentation === 'gif'
|
||||||
|
const autoplay = !autoplayDisabled && !isWithinMessage
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_1, a.relative]}>
|
<View style={[a.flex_1, a.relative]}>
|
||||||
<BlueskyVideoView
|
<BlueskyVideoView
|
||||||
url={embed.playlist}
|
url={embed.playlist}
|
||||||
autoplay={!autoplayDisabled && !isWithinMessage}
|
autoplay={autoplay}
|
||||||
beginMuted={isGif || (autoplayDisabled ? false : muted)}
|
beginMuted={isGif || (autoplayDisabled ? false : muted)}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
onActiveChange={e => {
|
onActiveChange={e => {
|
||||||
@@ -88,21 +93,27 @@ export function VideoEmbedInnerNative({
|
|||||||
onTimeRemainingChange={e => {
|
onTimeRemainingChange={e => {
|
||||||
const {timeRemaining} = e.nativeEvent
|
const {timeRemaining} = e.nativeEvent
|
||||||
setTimeRemaining(timeRemaining)
|
setTimeRemaining(timeRemaining)
|
||||||
if (
|
if (Number.isFinite(timeRemaining) && timeRemaining >= 0) {
|
||||||
!isGif &&
|
|
||||||
reportDialogMetadata &&
|
|
||||||
Number.isFinite(timeRemaining) &&
|
|
||||||
timeRemaining >= 0
|
|
||||||
) {
|
|
||||||
maxTimeRemainingSeconds.current = Math.max(
|
maxTimeRemainingSeconds.current = Math.max(
|
||||||
maxTimeRemainingSeconds.current,
|
maxTimeRemainingSeconds.current,
|
||||||
timeRemaining,
|
timeRemaining,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
!playbackStartTrackedRef.current &&
|
||||||
|
hasPlaybackStarted(
|
||||||
|
maxTimeRemainingSeconds.current - timeRemaining,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
playbackStartTrackedRef.current = true
|
||||||
|
onPlaybackStart(autoplay)
|
||||||
|
}
|
||||||
|
if (!isGif && reportDialogMetadata) {
|
||||||
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
|
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
maxTimeRemainingSeconds.current - timeRemaining,
|
maxTimeRemainingSeconds.current - timeRemaining,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onError={e => {
|
onError={e => {
|
||||||
onError?.(e.nativeEvent.error)
|
onError?.(e.nativeEvent.error)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = {
|
|||||||
setActive: () => void
|
setActive: () => void
|
||||||
onScreen: boolean
|
onScreen: boolean
|
||||||
lastKnownTime: React.RefObject<number | undefined>
|
lastKnownTime: React.RefObject<number | undefined>
|
||||||
|
onPlaybackStart: (autoplay: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HLSUnsupportedError extends Error {
|
export class HLSUnsupportedError extends Error {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react/macro'
|
|||||||
import type * as HlsTypes from 'hls.js'
|
import type * as HlsTypes from 'hls.js'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||||
@@ -29,6 +30,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
setActive,
|
setActive,
|
||||||
onScreen,
|
onScreen,
|
||||||
lastKnownTime,
|
lastKnownTime,
|
||||||
|
onPlaybackStart,
|
||||||
}: VideoEmbedInnerWebProps) {
|
}: VideoEmbedInnerWebProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
@@ -40,6 +42,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
const [isFullscreen] = useFullscreen(containerRef)
|
const [isFullscreen] = useFullscreen(containerRef)
|
||||||
const isGif = embed.presentation === 'gif'
|
const isGif = embed.presentation === 'gif'
|
||||||
const reportDialogMetadata = useReportDialogMetadataContext()
|
const reportDialogMetadata = useReportDialogMetadataContext()
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
|
|
||||||
// send error up to error boundary
|
// send error up to error boundary
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
@@ -79,6 +82,13 @@ export function VideoEmbedInnerWeb({
|
|||||||
onTimeUpdate={e => {
|
onTimeUpdate={e => {
|
||||||
const currentTime = e.currentTarget.currentTime
|
const currentTime = e.currentTarget.currentTime
|
||||||
lastKnownTime.current = currentTime
|
lastKnownTime.current = currentTime
|
||||||
|
if (
|
||||||
|
!playbackStartTrackedRef.current &&
|
||||||
|
hasPlaybackStarted(currentTime)
|
||||||
|
) {
|
||||||
|
playbackStartTrackedRef.current = true
|
||||||
|
onPlaybackStart(!focused)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!isGif &&
|
!isGif &&
|
||||||
reportDialogMetadata &&
|
reportDialogMetadata &&
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
embed: app.bsky.embed.video.View
|
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 [key, setKey] = useState(0)
|
||||||
|
|
||||||
const renderError = useCallback(
|
const renderError = useCallback(
|
||||||
@@ -52,7 +53,7 @@ export function VideoEmbed({embed}: Props) {
|
|||||||
|
|
||||||
const contents = (
|
const contents = (
|
||||||
<ErrorBoundary renderError={renderError} key={key}>
|
<ErrorBoundary renderError={renderError} key={key}>
|
||||||
<InnerWrapper embed={embed} />
|
<InnerWrapper embed={embed} post={post} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ export function VideoEmbed({embed}: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InnerWrapper({embed}: Props) {
|
function InnerWrapper({embed, post}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const ref = useRef<{togglePlayback: () => void}>(null)
|
const ref = useRef<{togglePlayback: () => void}>(null)
|
||||||
@@ -86,6 +87,8 @@ function InnerWrapper({embed}: Props) {
|
|||||||
* the active position cost nothing.
|
* the active position cost nothing.
|
||||||
*/
|
*/
|
||||||
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
||||||
|
const impressionTrackedRef = useRef(false)
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
telemetryRef.current?.deactivated()
|
telemetryRef.current?.deactivated()
|
||||||
@@ -121,15 +124,37 @@ function InnerWrapper({embed}: Props) {
|
|||||||
setIsActive={active => {
|
setIsActive={active => {
|
||||||
setIsActive(active)
|
setIsActive(active)
|
||||||
if (active) {
|
if (active) {
|
||||||
telemetryRef.current ??= createPlaybackTelemetry({
|
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',
|
surface: 'feed',
|
||||||
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
|
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
telemetryRef.current.activated()
|
telemetryRef.current.activated()
|
||||||
} else {
|
} else {
|
||||||
telemetryRef.current?.deactivated()
|
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 => {
|
onError={error => {
|
||||||
telemetryRef.current?.error(error)
|
telemetryRef.current?.error(error)
|
||||||
ax.metric('video:playback:failed', {
|
ax.metric('video:playback:failed', {
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ const noop = () => {}
|
|||||||
*/
|
*/
|
||||||
const MIN_CARD_WIDTH = 280
|
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 t = useTheme()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const {
|
const {
|
||||||
@@ -47,13 +53,28 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
|
|||||||
currentActiveView,
|
currentActiveView,
|
||||||
} = useActiveVideoWeb()
|
} = useActiveVideoWeb()
|
||||||
const [onScreen, setOnScreen] = useState(false)
|
const [onScreen, setOnScreen] = useState(false)
|
||||||
|
const [meaningfullyVisible, setMeaningfullyVisible] = useState(false)
|
||||||
const [isFullscreen] = useFullscreen()
|
const [isFullscreen] = useFullscreen()
|
||||||
const lastKnownTime = useRef<number | undefined>(undefined)
|
const lastKnownTime = useRef<number | undefined>(undefined)
|
||||||
|
const impressionTrackedRef = useRef(false)
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
const isGif = embed.presentation === 'gif'
|
const isGif = embed.presentation === 'gif'
|
||||||
// GIFs don't participate in the "one video at a time" system
|
// GIFs don't participate in the "one video at a time" system
|
||||||
const active = isGif || activeFromContext
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
if (!ref.current) return
|
||||||
if (isFullscreen && !IS_WEB_FIREFOX) 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]
|
const entry = entries[0]
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
setOnScreen(entry.isIntersecting)
|
setOnScreen(entry.isIntersecting)
|
||||||
|
setMeaningfullyVisible(
|
||||||
|
entry.isIntersecting && entry.intersectionRatio >= 0.5,
|
||||||
|
)
|
||||||
// GIFs don't send position - they don't compete to be the active video
|
// GIFs don't send position - they don't compete to be the active video
|
||||||
if (!isGif) {
|
if (!isGif) {
|
||||||
sendPosition(
|
sendPosition(
|
||||||
@@ -179,6 +203,17 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
|
|||||||
setActive={setActive}
|
setActive={setActive}
|
||||||
onScreen={onScreen}
|
onScreen={onScreen}
|
||||||
lastKnownTime={lastKnownTime}
|
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>
|
</OnlyNearScreen>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ function MediaEmbed({
|
|||||||
<ExternalEmbed
|
<ExternalEmbed
|
||||||
link={embed.view.external}
|
link={embed.view.external}
|
||||||
onOpen={rest.onOpen}
|
onOpen={rest.onOpen}
|
||||||
|
post={rest.post}
|
||||||
style={[a.mt_sm, rest.style]}
|
style={[a.mt_sm, rest.style]}
|
||||||
/>
|
/>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
@@ -144,7 +145,7 @@ function MediaEmbed({
|
|||||||
<ContentHider
|
<ContentHider
|
||||||
modui={rest.moderation?.ui('contentMedia')}
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
activeStyle={[a.mt_sm]}>
|
activeStyle={[a.mt_sm]}>
|
||||||
<VideoEmbed embed={embed.view} />
|
<VideoEmbed embed={embed.view} post={rest.post} />
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +429,7 @@ let PostMenuItems = ({
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
}
|
||||||
ax.metric('postMenu:blockAccount', {
|
ax.metric('postMenu:blockAccount', {
|
||||||
uri: postUri,
|
uri: postUri,
|
||||||
authorDid: postAuthor.did,
|
authorDid: postAuthor.did,
|
||||||
@@ -437,7 +437,6 @@ let PostMenuItems = ({
|
|||||||
feedDescriptor: feedFeedback.feedDescriptor,
|
feedDescriptor: feedFeedback.feedDescriptor,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const onMuteAuthor = async () => {
|
const onMuteAuthor = async () => {
|
||||||
if (postAuthor.viewer?.muted) {
|
if (postAuthor.viewer?.muted) {
|
||||||
@@ -452,14 +451,13 @@ let PostMenuItems = ({
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
}
|
||||||
ax.metric('postMenu:unmuteAccount', {
|
ax.metric('postMenu:unmuteAccount', {
|
||||||
uri: postUri,
|
uri: postUri,
|
||||||
authorDid: postAuthor.did,
|
authorDid: postAuthor.did,
|
||||||
logContext,
|
logContext,
|
||||||
feedDescriptor: feedFeedback.feedDescriptor,
|
feedDescriptor: feedFeedback.feedDescriptor,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await queueMute()
|
await queueMute()
|
||||||
@@ -472,7 +470,7 @@ let PostMenuItems = ({
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
}
|
||||||
ax.metric('postMenu:muteAccount', {
|
ax.metric('postMenu:muteAccount', {
|
||||||
uri: postUri,
|
uri: postUri,
|
||||||
authorDid: postAuthor.did,
|
authorDid: postAuthor.did,
|
||||||
@@ -481,7 +479,6 @@ let PostMenuItems = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const onReportMisclassification = () => {
|
const onReportMisclassification = () => {
|
||||||
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
|
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
|
||||||
|
|||||||
@@ -107,9 +107,10 @@ let PostControls = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingLike = post.viewer?.like
|
||||||
try {
|
try {
|
||||||
setHasLikeIconBeenToggled(true)
|
setHasLikeIconBeenToggled(true)
|
||||||
if (!post.viewer?.like) {
|
if (!existingLike) {
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionLike',
|
event: 'app.bsky.feed.defs#interactionLike',
|
||||||
@@ -137,8 +138,9 @@ let PostControls = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingRepost = post.viewer?.repost
|
||||||
try {
|
try {
|
||||||
if (!post.viewer?.repost) {
|
if (!existingRepost) {
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionRepost',
|
event: 'app.bsky.feed.defs#interactionRepost',
|
||||||
|
|||||||
@@ -86,17 +86,20 @@ export function ProfileBadges({
|
|||||||
|
|
||||||
const gap = isOnTheSmallSide ? a.gap_2xs : a.gap_xs
|
const gap = isOnTheSmallSide ? a.gap_2xs : a.gap_xs
|
||||||
const padding = gap.gap / 2
|
const padding = gap.gap / 2
|
||||||
|
const hitSlops = []
|
||||||
let visibleBadgeIndex = 0
|
let visibleBadgeIndex = 0
|
||||||
const hitSlops = badgeVisibility.map(isVisible => {
|
for (const isVisible of badgeVisibility) {
|
||||||
if (!isVisible) return HITSLOP_20
|
if (!isVisible) {
|
||||||
|
hitSlops.push(HITSLOP_20)
|
||||||
|
continue
|
||||||
|
}
|
||||||
const index = visibleBadgeIndex++
|
const index = visibleBadgeIndex++
|
||||||
return {
|
hitSlops.push({
|
||||||
...HITSLOP_20,
|
...HITSLOP_20,
|
||||||
left: index === 0 ? HITSLOP_20.left : padding,
|
left: index === 0 ? HITSLOP_20.left : padding,
|
||||||
right: index === badgeCount - 1 ? HITSLOP_20.right : padding,
|
right: index === badgeCount - 1 ? HITSLOP_20.right : padding,
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.align_center, gap, style]}>
|
<View style={[a.flex_row, a.align_center, gap, style]}>
|
||||||
|
|||||||
@@ -491,16 +491,21 @@ export function FollowButtonInner({
|
|||||||
const onPressFollow = async (e: GestureResponderEvent) => {
|
const onPressFollow = async (e: GestureResponderEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
const displayNameOrHandle = profile.displayName || profile.handle
|
||||||
try {
|
try {
|
||||||
await queueFollow()
|
await queueFollow()
|
||||||
Toast.show(
|
Toast.show(
|
||||||
l`Following ${sanitizeDisplayName(
|
l`Following ${sanitizeDisplayName(
|
||||||
profile.displayName || profile.handle,
|
displayNameOrHandle,
|
||||||
moderation.ui('displayName'),
|
moderation.ui('displayName'),
|
||||||
)}`,
|
)}`,
|
||||||
)
|
)
|
||||||
onPressProp?.(e)
|
if (onPressProp) {
|
||||||
onFollow?.()
|
onPressProp(e)
|
||||||
|
}
|
||||||
|
if (onFollow) {
|
||||||
|
onFollow()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error
|
const err = e as Error
|
||||||
if (err?.name !== 'AbortError') {
|
if (err?.name !== 'AbortError') {
|
||||||
@@ -514,15 +519,18 @@ export function FollowButtonInner({
|
|||||||
const onPressUnfollow = async (e: GestureResponderEvent) => {
|
const onPressUnfollow = async (e: GestureResponderEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
const displayNameOrHandle = profile.displayName || profile.handle
|
||||||
try {
|
try {
|
||||||
await queueUnfollow()
|
await queueUnfollow()
|
||||||
Toast.show(
|
Toast.show(
|
||||||
l`No longer following ${sanitizeDisplayName(
|
l`No longer following ${sanitizeDisplayName(
|
||||||
profile.displayName || profile.handle,
|
displayNameOrHandle,
|
||||||
moderation.ui('displayName'),
|
moderation.ui('displayName'),
|
||||||
)}`,
|
)}`,
|
||||||
)
|
)
|
||||||
onPressProp?.(e)
|
if (onPressProp) {
|
||||||
|
onPressProp(e)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error
|
const err = e as Error
|
||||||
if (err?.name !== 'AbortError') {
|
if (err?.name !== 'AbortError') {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user