Compare commits

...

45 Commits

Author SHA1 Message Date
Claude 08d7946c49 Replace nightly build with a nightly changelog post
Native nightly builds are redundant now that changes ship over the air,
so the nightly workflow no longer builds anything. It now only posts the
list of commits since the previous post to the QA team's Slack channel,
and posts nothing when there are no new commits. The commit marker
artifact keeps its name so the first run continues from the last nightly.

The OTA workflow's fallback native iOS build is assigned to the "QA Team"
TestFlight group so testers still get native builds when one is needed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157o74F6wX3aTghhc7DLbzd
2026-09-06 19:12:55 +00:00
Claude 4af3f47d56 Note that the build Slack webhook posts to the QA team channel
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157o74F6wX3aTghhc7DLbzd
2026-09-06 19:08:36 +00:00
Claude a7a394b9aa Remove nightly build, add OTA fallback builds to QA Team with changelog
The nightly native build is redundant now that changes ship over the air:
the only time testers need a new native build is when the OTA workflow
detects a native fingerprint or version change and falls back to building.

Move the useful parts of the nightly into that fallback path instead:

- Assign the fallback iOS build to the "QA Team" TestFlight group
- Generate a changelog of commits since the last native testflight build,
  tracked via a "testflight-native-build-commit" artifact that only
  advances after both native builds succeed
- Pass the changelog as the TestFlight "What to Test" notes (new
  `changelog` input on build-submit-ios.yml for workflow_call)
- Post the iOS and Android builds with the changelog to Slack

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157o74F6wX3aTghhc7DLbzd
2026-09-06 19:07:00 +00:00
pfrazee 86db8cdff6 Nightly source-language update 2026-09-06 02:25:11 +00:00
Spence Pope ee32926ed5 Restore expanded post styles (#11668)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-09-04 22:23:54 -05:00
DS Boyce 5091e4e25a Fix Android thread number alignment across font scales (#11667) 2026-09-04 22:09:42 -05:00
pfrazee c9a96859d1 Nightly source-language update 2026-09-05 02:24:07 +00:00
DS Boyce e595243b1f Add more data to trending metrics (#11565) 2026-09-04 16:11:20 -07:00
Samuel Newman 1556804f78 Bump version from 1.132.0 to 1.133.0 (#11663) 2026-09-04 11:58:53 -07:00
DS Boyce 2950ecf2b5 Create Mod Inbox action details screen (#11657) 2026-09-04 09:21:40 -07:00
Samuel Newman 4296e4aafd Add carousel Slack alerts for nightly Maestro failures (#11645) 2026-09-04 07:20:26 -07:00
Samuel Newman a197340bce Migrate Expo image manipulator API (#11661) 2026-09-04 06:59:55 -07:00
Samuel Newman 48f96d6421 APP-3031: Fix contact imports after Expo 57 (#11658) 2026-09-04 15:32:27 +03:00
pfrazee f69ad16fed Nightly source-language update 2026-09-04 02:29:03 +00:00
DS Boyce edcd363fcb Use title case for Starter Pack (#11653) 2026-09-03 12:26:51 -07:00
DS Boyce 23d8fcf5ea Create mod inbox report details screen (#11641) 2026-09-03 10:20:46 -07:00
DS Boyce 3e8a48989a Create Mod Inbox settings screen (#11640) 2026-09-03 09:30:21 -07:00
Samuel Newman 8af501de62 v1.132.0 release prep (#11644) 2026-09-03 03:49:50 -07:00
Spence Pope 68e56eaea0 Add starter pack reference-list opt-out UI (#11578) 2026-09-03 00:08:48 -07:00
pfrazee bb747c5f26 Nightly source-language update 2026-09-03 03:05:07 +00:00
Samuel Newman 58ca227922 APP-2977: Handle AppView polling errors (#11590) 2026-09-03 01:00:56 +03:00
Samuel Newman af3fbcc940 APP-3015: Fix link metadata type detection (#11637) 2026-09-02 13:50:13 -07:00
DS Boyce 7b8e50aeb2 Create Mod Inbox account status banner (#11636) 2026-09-02 13:12:27 -07:00
DS Boyce 93e0d266fb Remove gate for OP thread numbering (#11639) 2026-09-02 14:15:35 -05:00
Samuel Newman 457b2be680 APP-2997: Handle age assurance data load failures (#11612)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-09-02 11:35:14 -07:00
Samuel Newman d6e5961adf APP-2687: Fix threaded view replies breaking after rotating the lightbox (#11638)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:31:55 -07:00
DS Boyce e08bcd0228 Create moderation inbox reports list (#11570) 2026-09-02 10:02:21 -07:00
Oleksii Bulenok 87329397a4 APP-2974: Fix composer not scrollable when Reduced Motion is on (#11628)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-09-02 16:31:22 +00:00
Spence Pope 99715b9a67 Add video playback analytics events (#11629) 2026-09-02 11:59:44 -04:00
DS Boyce ae971db765 Preserve thread numbering in placeholders (#11616) 2026-09-02 18:53:49 +03:00
Samuel Newman 83cd5033b2 Improve rotation text clipping (#11487) 2026-09-02 08:50:03 -07:00
Samuel Newman e9378654d6 Fix Starter Pack share link on iOS (#11626) 2026-09-02 08:35:01 -07:00
Samuel Newman 1add59f80c Truncate handles in collapsed profile headers (#11577) 2026-09-02 08:33:30 -07:00
Samuel Newman 7d39aa3422 APP-3014: prevent scheduler delegate use-after-free (#11632) 2026-09-02 08:26:23 -07:00
DS Boyce 701d7c4659 Fix localization for Following and Discover feed names (#11572) 2026-09-02 09:24:34 -05:00
Samuel Newman f288e18baa Pull latest lexicons (video alt text fix) (#11631) 2026-09-02 06:48:55 -07:00
pfrazee 076cdd650d Nightly source-language update 2026-09-02 02:40:19 +00:00
Eric Bailey 56efeee5e2 Vendor updated lexicons, WILL FAIL UPDATE (#11630) 2026-09-01 18:33:53 -05:00
DS Boyce 2ffa02c82a Fix post view attribution for feed replies (#11622) 2026-09-01 13:59:31 -07:00
Samuel Newman 35705ff8bf Fix bottom sheet content width on Android tablets (native-owned canvas sizing) (#11396) 2026-09-01 17:04:08 +03:00
dependabot[bot] 2c60c45022 Bump actions/setup-java from 5.7.0 to 6.0.0 (#11619) 2026-09-01 14:29:53 +03:00
dependabot[bot] 8b793d0843 Bump the actions group with 2 updates (#11618)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 03:54:47 -07:00
DS Boyce 96e6baa47b Stabilize native composer focus (#11624) 2026-08-31 21:54:13 -07:00
Eric Bailey 5be7d72011 APP-2983: Fix RTL post alignment on native (#11600) 2026-08-31 21:59:24 -05:00
pfrazee 4881224d2f Nightly source-language update 2026-09-01 02:26:42 +00:00
244 changed files with 81119 additions and 68555 deletions
@@ -0,0 +1,55 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import sharp from 'sharp'
export async function frameSlackScreenshots({
inputPath,
outputPath,
outputDir,
}) {
const payload = JSON.parse(fs.readFileSync(inputPath, 'utf8'))
if (!Array.isArray(payload.file_uploads)) {
throw new Error('Slack upload payload must contain a file_uploads array')
}
fs.mkdirSync(outputDir, {recursive: true})
const framedUploads = []
for (const [index, upload] of payload.file_uploads.entries()) {
if (
typeof upload.file !== 'string' ||
typeof upload.filename !== 'string'
) {
throw new Error(`Invalid Slack file upload at index ${index}`)
}
const filename = `${path.parse(path.basename(upload.filename)).name}.png`
const framedFile = path.join(outputDir, filename)
await sharp(upload.file)
.resize(1600, 1200, {fit: 'contain', background: '#f8f8f8'})
.png()
.toFile(framedFile)
framedUploads.push({
...upload,
file: framedFile,
filename,
highlight_type: 'png',
})
}
payload.file_uploads = framedUploads
fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`)
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
) {
const [inputPath, outputPath, outputDir] = process.argv.slice(2)
if (!inputPath || !outputPath || !outputDir) {
throw new Error(
'Usage: frame-slack-screenshots.mjs <input.json> <output.json> <output-dir>',
)
}
await frameSlackScreenshots({inputPath, outputPath, outputDir})
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import sharp from 'sharp'
import {frameSlackScreenshots} from './frame-slack-screenshots.mjs'
test('frames Slack screenshots as 4:3 PNGs', async t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-slack-frame-'))
t.after(() => fs.rmSync(root, {recursive: true, force: true}))
const source = path.join(root, 'source.png')
const input = path.join(root, 'input.json')
const output = path.join(root, 'output.json')
const outputDir = path.join(root, 'images')
await sharp({
create: {
width: 2,
height: 4,
channels: 3,
background: '#ffffff',
},
})
.png()
.toFile(source)
fs.writeFileSync(
input,
JSON.stringify({
file_uploads: [
{
file: source,
filename: '1-android-login.png',
alt_text: 'Android failure screenshot for login',
},
],
}),
)
await frameSlackScreenshots({inputPath: input, outputPath: output, outputDir})
const payload = JSON.parse(fs.readFileSync(output, 'utf8'))
const {data, info} = await sharp(payload.file_uploads[0].file)
.raw()
.toBuffer({resolveWithObject: true})
const pixelAt = (x, y) => {
const offset = (y * info.width + x) * info.channels
return Array.from(data.subarray(offset, offset + 3))
}
assert.equal(info.width, 1600)
assert.equal(info.height, 1200)
assert.deepEqual(pixelAt(0, 0), [248, 248, 248])
assert.deepEqual(pixelAt(800, 600), [255, 255, 255])
assert.equal(payload.file_uploads[0].filename, '1-android-login.png')
assert.equal(payload.file_uploads[0].highlight_type, 'png')
})
+330
View File
@@ -0,0 +1,330 @@
import path from 'node:path'
const CAROUSEL_LIMIT = 10
function concise(value, limit) {
return value.length > limit ? `${value.slice(0, limit - 1)}` : value
}
function slackEscape(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
function mrkdwnText(value, limit) {
return concise(slackEscape(value), limit)
}
function pluralize(count, singular) {
return `${count} ${singular}${count === 1 ? '' : 's'}`
}
function stateFor(platforms) {
if (platforms.some(platform => platform.status === 'cancelled')) {
return 'cancelled'
}
if (platforms.some(platform => platform.failures.length > 0)) {
return 'failed'
}
if (platforms.some(platform => platform.failed)) {
return 'setup_failed'
}
return 'passed'
}
function statePresentation(state, failureCount) {
if (state === 'cancelled') {
return {
header: '⏹️ Nightly Maestro E2E cancelled',
summary: 'results may be incomplete',
fallback: 'Nightly Maestro E2E was cancelled. Results may be incomplete.',
}
}
if (state === 'setup_failed') {
return {
header: '⚠️ Nightly Maestro setup failed',
summary: 'no complete test results',
fallback: 'Nightly Maestro E2E setup failed before tests could complete.',
}
}
if (state === 'failed') {
return {
header: '🚨 Nightly Maestro E2E failed',
summary: `${pluralize(failureCount, 'failed flow')}`,
fallback: `Nightly Maestro E2E failed with ${pluralize(failureCount, 'failed flow')}.`,
}
}
return {
header: '✅ Nightly Maestro E2E passed',
summary: 'all platforms passed',
fallback: 'Nightly Maestro E2E passed on all platforms.',
}
}
function platformStatus(platform) {
if (platform.status === 'cancelled') return '⏹️ Cancelled'
if (platform.status === 'skipped') return '⏭️ Skipped'
if (platform.failures.length > 0) {
return `❌ Failed · ${pluralize(platform.failures.length, 'flow')}`
}
if (platform.failed && !platform.hasJUnit) return '⚠️ Setup failed'
if (platform.failed) return '❌ Failed'
return '✅ Passed'
}
function selectFailures(platforms, limit = CAROUSEL_LIMIT) {
const queues = platforms.map(platform =>
platform.failures.map(failure => ({platform, failure})),
)
const selected = []
while (selected.length < limit && queues.some(queue => queue.length > 0)) {
for (const queue of queues) {
const next = queue.shift()
if (next) selected.push(next)
if (selected.length === limit) break
}
}
return selected
}
function uploadFilename({platform, failure}, index) {
const extension = path.extname(failure.screenshot) || '.png'
const slug = failure.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
return `${index + 1}-${platform.name.toLowerCase()}-${slug || 'failed-flow'}${extension}`
}
function buildUploadPayload(selectedFailures) {
return {
file_uploads: selectedFailures
.filter(({failure}) => failure.screenshot)
.map((entry, index) => ({
file: entry.failure.screenshot,
filename: uploadFilename(entry, index),
highlight_type: 'png',
alt_text: `${entry.platform.name} failure screenshot for ${entry.failure.name}`,
})),
}
}
function buildThreadPayload(platforms) {
const lines = ['*All Maestro failure details*']
for (const platform of platforms) {
if (platform.failures.length === 0) continue
lines.push('', `*${slackEscape(platform.name)}*`)
for (const failure of platform.failures) {
lines.push(
`• *${mrkdwnText(failure.name, 140)}*\n ${mrkdwnText(failure.message, 300)}`,
)
}
if (platform.artifactUrl) {
lines.push(`<${platform.artifactUrl}|Open ${platform.name} artifacts>`)
}
}
return {text: lines.join('\n')}
}
function buildCarousel(selectedFailures, slackFileIds) {
let screenshotIndex = 0
const elements = selectedFailures.map(({platform, failure}, index) => {
const slackFileId = failure.screenshot
? slackFileIds[screenshotIndex++]
: undefined
return {
type: 'card',
block_id: `maestro_failure_${index + 1}`,
title: {
type: 'mrkdwn',
text: `*${mrkdwnText(failure.name, 140)}*`,
verbatim: true,
},
subtitle: {
type: 'mrkdwn',
text: `${platform.name} · failed flow`,
verbatim: true,
},
...(slackFileId
? {
hero_image: {
type: 'image',
slack_file: {id: slackFileId},
alt_text: `${platform.name} failure screenshot for ${failure.name}`,
},
}
: {}),
body: {
type: 'mrkdwn',
text: mrkdwnText(failure.message, 190),
verbatim: true,
},
...(platform.artifactUrl
? {
subtext: {
type: 'mrkdwn',
text: `<${platform.artifactUrl}|Open logs and artifacts>`,
verbatim: true,
},
}
: {}),
}
})
return {type: 'carousel', block_id: 'maestro_failures', elements}
}
function diagnosticBlock(platform) {
const phase = mrkdwnText(
platform.phase || 'No phase metadata was captured',
220,
)
if (platform.status === 'cancelled') {
return {
type: 'section',
text: {
type: 'mrkdwn',
text: `*${platform.name} cancelled*\nLatest phase: ${phase}\nResults may be incomplete.`,
},
}
}
if (!platform.hasJUnit) {
return {
type: 'section',
text: {
type: 'mrkdwn',
text: `*${platform.name} setup failed*\nLatest phase: ${phase}\nNo JUnit results were produced.`,
},
}
}
return {
type: 'section',
text: {
type: 'mrkdwn',
text: `*${platform.name} job failed*\nLatest phase: ${phase}`,
},
}
}
function footerBlock(platforms, runUrl) {
const links = [`<${runUrl}|Open workflow>`]
for (const platform of platforms) {
if (platform.artifactUrl) {
links.push(`<${platform.artifactUrl}|${platform.name} artifacts>`)
}
}
return {
type: 'section',
text: {type: 'mrkdwn', text: links.join(' • ')},
}
}
export function buildSlackMessage({
platforms,
sha,
runUrl,
commitUrl,
slackFileIds = [],
}) {
const state = stateFor(platforms)
const failureCount = platforms.reduce(
(total, platform) => total + platform.failures.length,
0,
)
const presentation = statePresentation(state, failureCount)
const allFailures = selectFailures(platforms, failureCount)
const selectedFailures = allFailures.slice(0, CAROUSEL_LIMIT)
const uploadPayload = buildUploadPayload(allFailures)
const detailBlocks = platforms
.filter(
platform =>
platform.status === 'cancelled' ||
(platform.failed && platform.failures.length === 0),
)
.map(diagnosticBlock)
if (selectedFailures.length > 0) {
detailBlocks.push(buildCarousel(selectedFailures, slackFileIds), {
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Showing ${selectedFailures.length} of ${pluralize(failureCount, 'failed flow')} • full details and screenshots are in the thread`,
},
],
})
}
const shortSha = sha.slice(0, 12)
const blocks = [
{
type: 'header',
text: {type: 'plain_text', text: presentation.header},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Commit <${commitUrl}|\`${shortSha}\`> • ${presentation.summary}`,
},
],
},
{
type: 'section',
fields: platforms.map(platform => ({
type: 'mrkdwn',
text: `*${platform.name}*\n${platformStatus(platform)}`,
})),
},
...(detailBlocks.length > 0 ? [{type: 'divider'}, ...detailBlocks] : []),
footerBlock(platforms, runUrl),
]
return {
state,
failureCount,
screenshotCount: uploadPayload.file_uploads.length,
uploadPayload,
threadPayload: buildThreadPayload(platforms),
payload: {text: presentation.fallback, blocks},
}
}
export function extractSlackFileIds(response) {
if (!response) return []
let parsed = response
if (typeof response === 'string') {
try {
parsed = JSON.parse(response)
} catch {
return []
}
}
const ids = []
const seen = new Set()
function visit(value) {
if (Array.isArray(value)) {
for (const item of value) visit(item)
return
}
if (!value || typeof value !== 'object') return
if (
typeof value.id === 'string' &&
/^F[A-Z0-9]+$/.test(value.id) &&
!seen.has(value.id)
) {
seen.add(value.id)
ids.push(value.id)
}
for (const child of Object.values(value)) visit(child)
}
visit(parsed)
return ids
}
+194
View File
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs'
import {screenshotsByFlow} from './summarize-maestro.mjs'
function platform({
name,
status = 'success',
failed = false,
failures = [],
phase = 'Completed',
hasJUnit = true,
}) {
return {
name,
status,
failed,
failures,
phase,
hasJUnit,
artifactUrl: `https://example.com/${name.toLowerCase()}`,
}
}
function build(platforms, slackFileIds = []) {
return buildSlackMessage({
platforms,
sha: '1234567890abcdef',
runUrl: 'https://example.com/run',
commitUrl: 'https://example.com/commit',
slackFileIds,
})
}
test('builds a screenshot carousel for failed flows', () => {
const platforms = [
platform({
name: 'iOS',
status: 'failure',
failed: true,
failures: [
{
name: 'composer',
message: 'Element not found',
screenshot: '/tmp/screenshot-(composer).png',
},
],
}),
platform({name: 'Android'}),
]
const summary = build(platforms, ['F123ABC'])
const carousel = summary.payload.blocks.find(
block => block.type === 'carousel',
)
assert.equal(summary.state, 'failed')
assert.equal(summary.screenshotCount, 1)
assert.equal(
summary.uploadPayload.file_uploads[0].file,
platforms[0].failures[0].screenshot,
)
assert.equal(summary.uploadPayload.file_uploads[0].highlight_type, 'png')
assert.deepEqual(carousel.elements[0].hero_image.slack_file, {id: 'F123ABC'})
})
test('selects failures across both platforms for the carousel', () => {
const failures = prefix =>
Array.from({length: 7}, (_, index) => ({
name: `${prefix}-${index}`,
message: 'Failed',
screenshot: `/tmp/${prefix}-${index}.png`,
}))
const summary = build([
platform({
name: 'iOS',
status: 'failure',
failed: true,
failures: failures('ios'),
}),
platform({
name: 'Android',
status: 'failure',
failed: true,
failures: failures('android'),
}),
])
const carousel = summary.payload.blocks.find(
block => block.type === 'carousel',
)
assert.equal(carousel.elements.length, 10)
assert.equal(summary.failureCount, 14)
assert.equal(summary.screenshotCount, 14)
assert.equal(summary.uploadPayload.file_uploads.length, 14)
assert.match(summary.threadPayload.text, /ios-6/)
assert.match(summary.threadPayload.text, /android-6/)
assert.equal(carousel.elements[0].subtitle.text, 'iOS · failed flow')
assert.equal(carousel.elements[1].subtitle.text, 'Android · failed flow')
})
test('uses a cancellation presentation for partial results', () => {
const summary = build([
platform({
name: 'iOS',
status: 'cancelled',
failed: true,
failures: [],
phase: 'Building iOS development client',
hasJUnit: false,
}),
platform({
name: 'Android',
status: 'cancelled',
failed: true,
failures: [],
phase: 'Building Android development client',
hasJUnit: false,
}),
])
assert.equal(summary.state, 'cancelled')
assert.equal(
summary.payload.blocks[0].text.text,
'⏹️ Nightly Maestro E2E cancelled',
)
assert.match(summary.payload.text, /cancelled/)
assert.equal(summary.screenshotCount, 0)
})
test('distinguishes setup failures from failed Maestro flows', () => {
const summary = build([
platform({
name: 'iOS',
status: 'failure',
failed: true,
failures: [],
phase: 'Starting Metro',
hasJUnit: false,
}),
platform({name: 'Android', status: 'skipped'}),
])
assert.equal(summary.state, 'setup_failed')
assert.equal(
summary.payload.blocks[0].text.text,
'⚠️ Nightly Maestro setup failed',
)
assert.equal(
summary.payload.blocks.some(block => block.type === 'carousel'),
false,
)
})
test('extracts file ids from single and multi-file upload responses', () => {
const response = {
ok: true,
files: [
{
ok: true,
files: [{id: 'FONE'}, {id: 'FTWO'}],
},
],
}
assert.deepEqual(extractSlackFileIds(response), ['FONE', 'FTWO'])
assert.deepEqual(
extractSlackFileIds(JSON.stringify({ok: true, files: [{id: 'FTHREE'}]})),
['FTHREE'],
)
assert.deepEqual(extractSlackFileIds('not json'), [])
})
test('selects the newest Maestro screenshot for each flow', () => {
const screenshots = screenshotsByFlow([
'/tmp/screenshot-❌-300-(composer).png',
'/tmp/screenshot-❌-100-(composer).png',
'/tmp/screenshot-❌-200-(login).png',
'/tmp/artifacts/maestro/composer-self-label/screenshots/step-020-tapOnElement-openMediaBtn.png',
'/tmp/artifacts/maestro/composer-self-label/screenshots/step-010-launchApp.png',
'/tmp/not-a-maestro-screenshot.png',
])
assert.equal(
screenshots.get('composer'),
'/tmp/screenshot-❌-300-(composer).png',
)
assert.equal(screenshots.get('login'), '/tmp/screenshot-❌-200-(login).png')
assert.equal(
screenshots.get('composer-self-label'),
'/tmp/artifacts/maestro/composer-self-label/screenshots/step-020-tapOnElement-openMediaBtn.png',
)
assert.equal(screenshots.size, 3)
})
+80 -104
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs'
const ENTITY_REPLACEMENTS = {
'&amp;': '&',
'&apos;': "'",
@@ -101,6 +103,54 @@ function readPhase(root) {
return phaseFile ? fs.readFileSync(phaseFile, 'utf8').trim() : ''
}
function screenshotMetadata(file) {
const legacyMatch = path
.basename(file)
.match(/^screenshot-.*?-(\d+)-\((.+)\)\.(?:gif|jpe?g|png)$/i)
if (legacyMatch) {
return {
file,
order: Number(legacyMatch[1]),
flowName: legacyMatch[2],
}
}
const parts = file.split(/[\\/]/)
const screenshotsIndex = parts.lastIndexOf('screenshots')
if (
screenshotsIndex < 2 ||
!parts.slice(0, screenshotsIndex - 1).includes('maestro')
) {
return undefined
}
const step = parts.at(-1)?.match(/^step-(\d+)-.*\.(?:gif|jpe?g|png)$/i)
return step
? {
file,
order: Number(step[1]),
flowName: parts[screenshotsIndex - 1],
}
: undefined
}
export function screenshotsByFlow(files) {
const screenshots = new Map()
for (const file of files) {
const screenshot = screenshotMetadata(file)
if (!screenshot) continue
const current = screenshots.get(screenshot.flowName)
if (!current || screenshot.order > current.order) {
screenshots.set(screenshot.flowName, screenshot)
}
}
return new Map(
[...screenshots].map(([flowName, screenshot]) => [
flowName,
screenshot.file,
]),
)
}
function platformResult({name, status, root, artifactUrl}) {
const files = walk(root)
const reports = files.filter(file => /(?:report|junit).*\.xml$/i.test(file))
@@ -115,7 +165,12 @@ function platformResult({name, status, root, artifactUrl}) {
)
// A cancelled or timed-out Maestro run may never flush JUnit. Its CLI log is
// streamed continuously, so use those failure lines when JUnit has no detail.
const failures = junitFailures.length > 0 ? junitFailures : cliFailures
const rawFailures = junitFailures.length > 0 ? junitFailures : cliFailures
const screenshots = screenshotsByFlow(files)
const failures = rawFailures.map(failure => ({
...failure,
screenshot: screenshots.get(failure.name),
}))
// A skipped platform (e.g. iOS while temporarily disabled) is not a failure
// as long as it produced no flow failures.
const failed =
@@ -131,52 +186,15 @@ function platformResult({name, status, root, artifactUrl}) {
}
}
function statusEmoji(status) {
if (status === 'success') return ':white_check_mark:'
if (status === 'skipped') return ':fast_forward:'
return ':x:'
}
function slackEscape(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
function platformBlock(platform) {
function githubSummary({state, platforms, shortSha, runUrl, commitUrl}) {
const outcome =
state === 'cancelled'
? 'cancelled'
: state === 'passed'
? 'passed'
: 'failed'
const lines = [
`${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``,
]
if (platform.failures.length > 0) {
for (const failure of platform.failures.slice(0, 8)) {
lines.push(
`• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`,
)
}
if (platform.failures.length > 8) {
lines.push(`• …and ${platform.failures.length - 8} more failed flows`)
}
} else if (platform.failed && !platform.hasJUnit) {
lines.push(
`• *Setup phase:* ${slackEscape(platform.phase || 'No phase metadata was captured')}`,
)
} else if (platform.failed) {
lines.push(
`• Job failed after JUnit was written; latest phase: ${slackEscape(platform.phase || 'unknown')}`,
)
}
if (platform.artifactUrl) {
lines.push(
`• <${platform.artifactUrl}|Open ${platform.name} logs and artifacts>`,
)
}
return lines.join('\n').slice(0, 3000)
}
function githubSummary({notify, platforms, shortSha, runUrl, commitUrl}) {
const lines = [
`# Nightly Maestro E2E ${notify ? 'failed' : 'passed'}`,
`# Nightly Maestro E2E ${outcome}`,
'',
`- Commit: [\`${shortSha}\`](${commitUrl})`,
`- Workflow run: [open run](${runUrl})`,
@@ -235,6 +253,7 @@ export function buildSummary({
sha,
runUrl,
commitUrl,
slackFileIds = [],
}) {
const platforms = [
platformResult({
@@ -252,73 +271,29 @@ export function buildSummary({
]
const notify = platforms.some(platform => platform.failed)
const shortSha = sha.slice(0, 12)
const lines = [
':rotating_light: *Nightly Maestro E2E failed*',
`*Commit:* <${commitUrl}|\`${shortSha}\`>`,
`*Workflow run:* <${runUrl}|open run>`,
'',
]
for (const platform of platforms) {
lines.push(
`${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``,
)
if (platform.failures.length > 0) {
for (const failure of platform.failures.slice(0, 10)) {
lines.push(
`• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`,
)
}
if (platform.failures.length > 10) {
lines.push(`• …and ${platform.failures.length - 10} more failed flows`)
}
} else if (platform.failed && !platform.hasJUnit) {
lines.push(
`• Setup phase: ${platform.phase || 'No phase metadata was captured'}`,
)
} else if (platform.failed) {
lines.push(
`• The job failed after JUnit was written (latest phase: ${platform.phase || 'unknown'})`,
)
}
if (platform.artifactUrl) {
lines.push(
`• <${platform.artifactUrl}|${platform.name} logs and artifacts>`,
)
}
lines.push('')
}
const text = lines.join('\n').trim()
const blocks = [
{
type: 'header',
text: {type: 'plain_text', text: 'Nightly Maestro E2E failed'},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Commit:* <${commitUrl}|\`${shortSha}\`>\n*Workflow run:* <${runUrl}|open run>`,
},
},
{type: 'divider'},
...platforms.flatMap((platform, index) => [
{type: 'section', text: {type: 'mrkdwn', text: platformBlock(platform)}},
...(index < platforms.length - 1 ? [{type: 'divider'}] : []),
]),
]
const slack = buildSlackMessage({
platforms,
sha,
runUrl,
commitUrl,
slackFileIds,
})
return {
notify,
state: slack.state,
platforms,
githubSummary: githubSummary({
notify,
state: slack.state,
platforms,
shortSha,
runUrl,
commitUrl,
}),
payload: {text, blocks},
failureCount: slack.failureCount,
screenshotCount: slack.screenshotCount,
uploadPayload: slack.uploadPayload,
threadPayload: slack.threadPayload,
payload: slack.payload,
}
}
@@ -351,6 +326,7 @@ if (
sha: args.sha,
runUrl: args['run-url'],
commitUrl: args['commit-url'],
slackFileIds: extractSlackFileIds(args['slack-upload-response']),
})
process.stdout.write(`${JSON.stringify(summary)}\n`)
}
+2 -2
View File
@@ -88,7 +88,7 @@ jobs:
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "17"
@@ -195,7 +195,7 @@ jobs:
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
# like the build job so the toolchain is deterministic.
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "17"
@@ -265,7 +265,7 @@ jobs:
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
testFlightGroup: none
testFlightGroup: "QA Team"
# OTA rebuilds don't need the xlarge builder used for releases
runner: macos-26
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
fetch-depth: 1
- name: 🤖 Claude
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with:
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
fetch-depth: 1
- name: 🤖 Claude review
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with:
-200
View File
@@ -1,200 +0,0 @@
---
name: Nightly Build
on:
schedule:
- cron: "10 2 * * *" # run at 2:10 AM UTC, after the nightly i18n job
workflow_dispatch:
# Deploys happen via EAS using EXPO_TOKEN; the GITHUB_TOKEN only checks out code
permissions:
contents: read
jobs:
# Generate the changelog once, shared by both platforms. The range covers commits since
# the previous nightly, whose commit SHA is stored as a "nightly-build-commit" artifact
# (advanced by the record job below, only after both builds succeed).
prepare:
name: Prepare release notes
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
outputs:
notes: ${{ steps.notes.outputs.notes }}
steps:
- name: ⬇️ Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: 📝 Generate release notes
id: notes
env:
GH_TOKEN: ${{ github.token }}
run: |
# Find the most recent non-expired nightly-build-commit artifact and read its SHA
# The artifacts API returns results newest-first, so the most recent marker is on
# page 1 — no --paginate needed (which would run the jq aggregation per page and
# could emit multiple URLs). Take the first non-expired match.
prev=""
url=$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=nightly-build-commit&per_page=100" \
--jq 'first(.artifacts[] | select(.expired == false)) | .archive_download_url' \
2>/dev/null || true)
if [ -n "$url" ] && [ "$url" != "null" ]; then
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o marker.zip "$url" \
&& unzip -o -q marker.zip; then
prev=$(cat nightly-build-commit.txt 2>/dev/null | tr -d '[:space:]')
fi
rm -f marker.zip nightly-build-commit.txt
fi
if [ -n "$prev" ] && git cat-file -e "${prev}^{commit}" 2>/dev/null; then
echo "Generating notes since previous nightly: $prev"
range="${prev}..HEAD"
else
echo "No reachable previous nightly commit; falling back to last 30 commits."
range="HEAD~30..HEAD"
fi
notes=$(git log --no-merges --pretty=format:'- %s' "$range" 2>/dev/null | head -n 50)
if [ -z "$notes" ]; then
notes="Nightly build — no new commits since the last nightly."
fi
# Cap the whole changelog to keep the Slack message a reasonable size.
# head -c caps the combined stream; cut -c would only cap each line independently.
notes=$(printf '%s' "$notes" | head -c 3900)
{
echo "notes<<NOTES_EOF"
echo "$notes"
echo "NOTES_EOF"
} >> "$GITHUB_OUTPUT"
ios:
name: Nightly iOS Build
needs: [prepare]
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
testFlightGroup: "QA Team"
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
# so the nightly build never hands the reusable workflow the entire repo secret store.
secrets:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
android:
name: Nightly Android Build
needs: [prepare]
# build-submit-android.yml contains an attachToRelease job that requests contents: write.
# That job is skipped for nightly (it needs a production tag build), but GitHub statically
# validates the reusable-workflow permission ceiling, so the caller must grant it here.
permissions:
contents: write
uses: ./.github/workflows/build-submit-android.yml
with:
profile: testflight-android
# Pass only the secrets the reusable workflow declares, rather than `secrets: inherit`,
# so the nightly build never hands the reusable workflow the entire repo secret store.
secrets:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
ENV_TOKEN: ${{ secrets.ENV_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
BITDRIFT_API_KEY: ${{ secrets.BITDRIFT_API_KEY }}
EXPO_PUBLIC_GCP_PROJECT_ID: ${{ secrets.EXPO_PUBLIC_GCP_PROJECT_ID }}
GOOGLE_SERVICES_TOKEN: ${{ secrets.GOOGLE_SERVICES_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SLACK_CLIENT_ALERT_WEBHOOK: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
notify-ios:
name: Notify Slack of iOS nightly
needs: [prepare, ios]
runs-on: ubuntu-latest
steps:
- name: 📝 Build Slack payload
id: payload
env:
NOTES: ${{ needs.prepare.outputs.notes }}
VERSION: ${{ needs.ios.outputs.package-version }}
BUILD_NUMBER: ${{ needs.ios.outputs.build-number }}
run: |
text="*Nightly iOS build available in TestFlight (QA Team)*
Version ${VERSION} (${BUILD_NUMBER})
${NOTES}"
payload=$(jq -n --arg text "$text" '{text: $text}')
{
echo "payload<<PAYLOAD_EOF"
echo "$payload"
echo "PAYLOAD_EOF"
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.payload.outputs.payload }}
notify-android:
name: Notify Slack of Android nightly
needs: [prepare, android]
runs-on: ubuntu-latest
steps:
- name: 📝 Build Slack payload
id: payload
env:
NOTES: ${{ needs.prepare.outputs.notes }}
VERSION: ${{ needs.android.outputs.package-version }}
VERSION_CODE: ${{ needs.android.outputs.version-code }}
run: |
text="*Nightly Android build available (Internal track)*
Version ${VERSION} (${VERSION_CODE})
${NOTES}"
payload=$(jq -n --arg text "$text" '{text: $text}')
{
echo "payload<<PAYLOAD_EOF"
echo "$payload"
echo "PAYLOAD_EOF"
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.payload.outputs.payload }}
# Advance the nightly marker only after both builds succeed, so a failed night's commits
# roll into the next successful nightly's notes rather than being silently dropped.
record:
name: Record nightly commit
needs: [ios, android]
runs-on: ubuntu-latest
steps:
- name: ✏️ Write nightly commit marker
env:
GITHUB_SHA: ${{ github.sha }}
run: echo "$GITHUB_SHA" > nightly-build-commit.txt
- name: 🚀 Upload nightly commit marker
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-build-commit
path: nightly-build-commit.txt
retention-days: 90
+112
View File
@@ -0,0 +1,112 @@
---
name: Nightly Changelog
on:
schedule:
- cron: "10 2 * * *" # run at 2:10 AM UTC, after the nightly i18n job
workflow_dispatch:
# Only reads the repo and its artifacts; posting to Slack uses the webhook secret
permissions:
contents: read
jobs:
changelog:
name: Post changelog to QA team
if: github.repository == 'bluesky-social/social-app'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: ⬇️ Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# The range covers commits since the previous post, whose commit SHA is stored as a
# "nightly-build-commit" artifact. The name is kept from the retired nightly build
# workflow so the first run of this one continues from where that left off. The
# marker is advanced at the end of the job, so a failed post's commits roll into the
# next successful one rather than being silently dropped.
- name: 📝 Generate changelog
id: notes
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY_ID: ${{ github.repository_id }}
run: |
prev=""
url=$(gh api \
"repos/${GITHUB_REPOSITORY}/actions/artifacts?name=nightly-build-commit&per_page=100" \
--jq "[.artifacts[] | select(
.expired == false and
.workflow_run.head_repository_id == (\$ENV.REPOSITORY_ID | tonumber)
)] | max_by(.created_at) | .archive_download_url" \
2>/dev/null || true)
if [ -n "$url" ] && [ "$url" != "null" ]; then
if curl -sSL -H "Authorization: Bearer $GH_TOKEN" -o marker.zip "$url" \
&& unzip -o -q marker.zip; then
prev=$(tr -d '[:space:]' < nightly-build-commit.txt 2>/dev/null || true)
fi
rm -f marker.zip nightly-build-commit.txt
fi
if [ -n "$prev" ] && git cat-file -e "${prev}^{commit}" 2>/dev/null; then
echo "Generating changelog since previous post: $prev"
range_args=("${prev}..HEAD")
else
echo "No reachable previous marker; falling back to the last 24 hours."
range_args=(--since="24 hours ago" HEAD)
fi
notes=$(git log --no-merges --pretty=format:'- %s' "${range_args[@]}" | head -n 50)
if [ -z "$notes" ]; then
echo "No new commits; nothing to post."
echo "has-changes=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Cap the whole changelog to keep the Slack message a reasonable size.
# head -c caps the combined stream; cut -c would only cap each line independently.
notes=$(printf '%s' "$notes" | head -c 3900)
{
echo "has-changes=true"
echo "notes<<NOTES_EOF"
echo "$notes"
echo "NOTES_EOF"
} >> "$GITHUB_OUTPUT"
- name: 📝 Build Slack payload
id: payload
if: ${{ steps.notes.outputs.has-changes == 'true' }}
env:
NOTES: ${{ steps.notes.outputs.notes }}
run: |
text="*What changed on main since yesterday*
${NOTES}"
payload=$(jq -n --arg text "$text" '{text: $text}')
{
echo "payload<<PAYLOAD_EOF"
echo "$payload"
echo "PAYLOAD_EOF"
} >> "$GITHUB_OUTPUT"
- name: 🔔 Notify Slack
if: ${{ steps.notes.outputs.has-changes == 'true' }}
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
webhook: ${{ secrets.NIGHTLY_BUILDS_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.payload.outputs.payload }}
# Steps run in order, so a failed Slack post stops the job before the marker moves.
- name: ✏️ Write commit marker
env:
GITHUB_SHA: ${{ github.sha }}
run: echo "$GITHUB_SHA" > nightly-build-commit.txt
- name: 🚀 Upload commit marker
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-build-commit
path: nightly-build-commit.txt
retention-days: 90
+157 -13
View File
@@ -15,7 +15,7 @@ concurrency:
env:
CI: "1"
MAESTRO_VERSION: "2.6.1"
MAESTRO_VERSION: "2.10.0"
MAESTRO_DRIVER_STARTUP_TIMEOUT: "180000"
MAESTRO_CLI_NO_ANALYTICS: "1"
MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: "true"
@@ -51,7 +51,7 @@ jobs:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: temurin
java-version: "17"
@@ -62,12 +62,12 @@ jobs:
- name: 🔤 Compile translations
uses: ./.github/actions/compile-i18n
- name: 📥 Install Maestro 2.6.1
- name: 📥 Install Maestro 2.10.0
run: |
echo "Installing Maestro" > artifacts/ios/phase.txt
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \
| shasum -a 256 --check
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
@@ -176,7 +176,7 @@ jobs:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: temurin
java-version: "17"
@@ -187,12 +187,12 @@ jobs:
- name: 🔤 Compile translations
uses: ./.github/actions/compile-i18n
- name: 📥 Install Maestro 2.6.1
- name: 📥 Install Maestro 2.10.0
run: |
echo "Installing Maestro" > artifacts/android/phase.txt
curl -fsSL -o "$RUNNER_TEMP/maestro.zip" \
"https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
echo "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b $RUNNER_TEMP/maestro.zip" \
echo "29b675e10cc12080e445e9bfb2e2b4e4dfb9c0f2e30d5884120d258b5e1cd991 $RUNNER_TEMP/maestro.zip" \
| shasum -a 256 --check
unzip -q "$RUNNER_TEMP/maestro.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/maestro/bin" >> "$GITHUB_PATH"
@@ -411,14 +411,158 @@ jobs:
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
> e2e-summary.json
echo "notify=$(jq -r .notify e2e-summary.json)" >> "$GITHUB_OUTPUT"
echo "payload=$(jq -c .payload e2e-summary.json)" >> "$GITHUB_OUTPUT"
{
echo "notify=$(jq -r .notify e2e-summary.json)"
echo "failure_count=$(jq -r .failureCount e2e-summary.json)"
echo "screenshot_count=$(jq -r .screenshotCount e2e-summary.json)"
} >> "$GITHUB_OUTPUT"
jq .uploadPayload e2e-summary.json > slack-screenshot-upload.json
jq -r .githubSummary e2e-summary.json >> "$GITHUB_STEP_SUMMARY"
- name: 🔔 Notify Slack of E2E failures
- name: 📦 Set up pnpm for Slack screenshot framing
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: 🔧 Set up Node for Slack screenshot framing
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: package.json
cache: pnpm
- name: 📦 Install Slack screenshot framing dependencies
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
run: pnpm install --frozen-lockfile
- name: 🖼️ Frame failure screenshots for Slack cards
id: frame_screenshots
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
run: |
node .github/scripts/frame-slack-screenshots.mjs \
slack-screenshot-upload.json \
slack-screenshot-upload-framed.json \
slack-screenshots
- name: 📝 Build Slack message
if: steps.summary.outputs.notify == 'true'
env:
ANDROID_STATUS: ${{ needs.android.result }}
IOS_STATUS: ${{ needs.ios.result }}
SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }}
run: |
node .github/scripts/summarize-maestro.mjs \
--ios-status "$IOS_STATUS" \
--android-status "$ANDROID_STATUS" \
--ios-root downloaded-artifacts/ios \
--android-root downloaded-artifacts/android \
--artifact-urls artifact-links.json \
--sha "$GITHUB_SHA" \
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
> e2e-summary-for-slack.json
jq --arg channel "$SLACK_CHANNEL_ID" \
'.payload + {channel: $channel, unfurl_links: false, unfurl_media: false}' \
e2e-summary-for-slack.json > slack-message.json
- name: 🔔 Notify Slack of E2E result
id: notify_slack
if: steps.summary.outputs.notify == 'true'
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }}
webhook-type: incoming-webhook
payload: ${{ steps.summary.outputs.payload }}
method: chat.postMessage
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-message.json
errors: true
- name: 🧵 Prepare Slack thread payloads
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.failure_count != '0'
env:
FRAMING_OUTCOME: ${{ steps.frame_screenshots.outcome }}
SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }}
SLACK_THREAD_TS: ${{ steps.notify_slack.outputs.ts }}
run: |
upload_payload=slack-screenshot-upload.json
if [ "$FRAMING_OUTCOME" = "success" ]; then
upload_payload=slack-screenshot-upload-framed.json
fi
jq --arg channel "$SLACK_CHANNEL_ID" --arg thread_ts "$SLACK_THREAD_TS" \
'.threadPayload + {channel: $channel, thread_ts: $thread_ts, unfurl_links: false, unfurl_media: false}' \
e2e-summary-for-slack.json > slack-thread-details.json
jq --arg channel_id "$SLACK_CHANNEL_ID" --arg thread_ts "$SLACK_THREAD_TS" \
'. + {channel_id: $channel_id, thread_ts: $thread_ts}' \
"$upload_payload" > slack-screenshot-upload-thread.json
- name: 🧾 Post all failure details to Slack thread
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.failure_count != '0'
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.postMessage
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-thread-details.json
errors: true
- name: 🖼️ Upload failure screenshots to Slack thread
id: upload_screenshots
if: >-
steps.summary.outputs.notify == 'true' &&
steps.summary.outputs.screenshot_count != '0'
continue-on-error: true
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: files.uploadV2
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-screenshot-upload-thread.json
errors: true
- name: ⏳ Wait for Slack to process failure screenshots
if: steps.upload_screenshots.outcome == 'success'
run: sleep 5
- name: 📝 Build Slack message with screenshots
if: steps.upload_screenshots.outcome == 'success'
env:
ANDROID_STATUS: ${{ needs.android.result }}
IOS_STATUS: ${{ needs.ios.result }}
SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }}
SLACK_THREAD_TS: ${{ steps.notify_slack.outputs.ts }}
SLACK_UPLOAD_RESPONSE: ${{ steps.upload_screenshots.outputs.response }}
run: |
node .github/scripts/summarize-maestro.mjs \
--ios-status "$IOS_STATUS" \
--android-status "$ANDROID_STATUS" \
--ios-root downloaded-artifacts/ios \
--android-root downloaded-artifacts/android \
--artifact-urls artifact-links.json \
--sha "$GITHUB_SHA" \
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
--slack-upload-response "$SLACK_UPLOAD_RESPONSE" \
> e2e-summary-with-slack-files.json
jq --arg channel "$SLACK_CHANNEL_ID" --arg ts "$SLACK_THREAD_TS" \
'.payload + {channel: $channel, ts: $ts, unfurl_links: false, unfurl_media: false}' \
e2e-summary-with-slack-files.json > slack-message-update.json
- name: 🔄 Add screenshots to Slack message
if: steps.upload_screenshots.outcome == 'success'
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.update
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
payload-file-path: slack-message-update.json
errors: true
@@ -35,7 +35,7 @@ jobs:
commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po
- name: 🚀 Push source lang to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
upload_sources: true
upload_sources_args: "-b main"
+4
View File
@@ -30,6 +30,10 @@ appId: xyz.blueskyweb.app
id: "confirmBtn"
- tapOn:
id: "composerPublishBtn"
- extendedWaitUntil:
notVisible:
id: "composePostView"
timeout: 30000
- tapOn:
id: "e2eRefreshHome"
- assertVisible: "Adult Content"
@@ -60,6 +60,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "onboardingContinue"
- assertVisible: "What are your interests?"
- tapOn: "Animals"
- tapOn:
id: "onboardingContinue"
- assertVisible: "Suggested for you"
+1
View File
@@ -41,6 +41,7 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "onboardingContinue"
- assertVisible: "What are your interests?"
- tapOn: "Animals"
- tapOn:
id: "onboardingContinue"
- assertVisible: "Suggested for you"
+15 -27
View File
@@ -3,34 +3,22 @@ appId: xyz.blueskyweb.app
- launchApp:
appId: "xyz.blueskyweb.app"
clearState: true
arguments:
"-EXDevMenuIsOnboardingFinished": true
- runFlow:
when:
platform: iOS
commands:
- extendedWaitUntil:
visible: "http://localhost:8081"
timeout: 60000
- tapOn: "http://localhost:8081"
- runFlow:
when:
platform: Android
commands:
- extendedWaitUntil:
visible: "http://10.0.2.2:8081"
timeout: 60000
- tapOn: "http://10.0.2.2:8081"
- extendedWaitUntil:
visible: "Continue"
timeout: 180000
- tapOn: "Continue"
- back
- extendedWaitUntil:
visible:
id: e2eProxyHeaderInput
timeout: 180000
- tapOn:
id: e2eProxyHeaderInput
- inputText: ${output.result}
- pressKey: Enter
- extendedWaitUntil:
visible: "Sign in"
timeout: 180000
- retry:
maxRetries: 3
commands:
- tapOn:
id: e2eProxyHeaderInput
- eraseText
- inputText: ${output.result}
- pressKey: Enter
- extendedWaitUntil:
visible:
id: e2eSignInAlice
timeout: 10000
+115 -15
View File
@@ -1,5 +1,9 @@
import {createDownloadResumable, deleteAsync} from 'expo-file-system/legacy'
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
import {
createDownloadResumable,
deleteAsync,
getInfoAsync,
} from 'expo-file-system/legacy'
import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'
import {IMAGE_SIZE_CONFIG_2K_1MB} from '../../src/lib/constants'
import {
@@ -9,7 +13,6 @@ import {
import {getResizedDimensions} from '../../src/lib/media/util'
const mockResizedImage = {
path: 'file://resized-image.jpg',
size: 100,
width: 100,
height: 100,
@@ -20,10 +23,26 @@ describe('downloadAndResize', () => {
const errorSpy = jest.spyOn(global.console, 'error')
beforeEach(() => {
const mockedCreateResizedImage = manipulateAsync as jest.Mock
mockedCreateResizedImage.mockResolvedValue({
uri: 'file://resized-image.jpg',
...mockResizedImage,
let savedImageCount = 0
const mockedManipulate = ImageManipulator.manipulate as jest.Mock
mockedManipulate.mockImplementation(() => {
const image = {
...mockResizedImage,
release: jest.fn(),
uri: 'file://rendered-image.jpg',
saveAsync: jest.fn().mockImplementation(() => {
savedImageCount += 1
return Promise.resolve({
uri: `file://resized-image-${savedImageCount}.jpg`,
...mockResizedImage,
})
}),
}
return {
release: jest.fn(),
renderAsync: jest.fn().mockResolvedValue(image),
resize: jest.fn(),
}
})
})
@@ -48,7 +67,10 @@ describe('downloadAndResize', () => {
}
const result = await downloadAndResize(opts)
expect(result).toEqual(mockResizedImage)
expect(result).toEqual({
...mockResizedImage,
path: 'file://resized-image-7.jpg',
})
expect(createDownloadResumable).toHaveBeenCalledWith(
opts.uri,
expect.anything(),
@@ -57,20 +79,98 @@ describe('downloadAndResize', () => {
},
)
// First time it gets called is to get dimensions
expect(manipulateAsync).toHaveBeenCalledWith(expect.any(String), [], {})
// First time it gets called is to get dimensions.
expect(ImageManipulator.manipulate).toHaveBeenNthCalledWith(
1,
expect.any(String),
)
const firstContext = (ImageManipulator.manipulate as jest.Mock).mock
.results[0].value
expect(firstContext.resize).not.toHaveBeenCalled()
// The mocked source image is 100x100, below maxDimension, so it is not
// downsized.
expect(manipulateAsync).toHaveBeenCalledWith(
expect.any(String),
[{resize: {height: 100, width: 100}}],
{format: SaveFormat.JPEG, compress: 1.0},
const secondContext = (ImageManipulator.manipulate as jest.Mock).mock
.results[1].value
expect(secondContext.resize).toHaveBeenCalledWith({
height: 100,
width: 100,
})
const lastContext = (
ImageManipulator.manipulate as jest.Mock
).mock.results.at(-1)!.value
const resizedImage = await lastContext.renderAsync.mock.results[0].value
expect(resizedImage.saveAsync).toHaveBeenCalledWith(
expect.objectContaining({format: SaveFormat.JPEG, compress: 1.0}),
)
expect(deleteAsync).toHaveBeenCalledWith(expect.any(String), {
const deletedPaths = (deleteAsync as jest.Mock).mock.calls.map(
([path]) => path,
)
expect(deletedPaths).toEqual(
expect.arrayContaining([
'file://resized-image-1.jpg',
'file://resized-image-2.jpg',
'file://resized-image-3.jpg',
'file://resized-image-4.jpg',
'file://resized-image-5.jpg',
'file://resized-image-6.jpg',
]),
)
expect(deletedPaths).not.toContain('file://resized-image-7.jpg')
})
it('deletes a partial download when downloading fails', async () => {
const mockedFetch = createDownloadResumable as jest.Mock
mockedFetch.mockReturnValue({
cancelAsync: jest.fn(),
downloadAsync: jest.fn().mockRejectedValue(new Error('download failed')),
})
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
maxDimension: 2000,
maxSize: 500000,
timeout: 10000,
}
await expect(downloadAndResize(opts)).rejects.toThrow('download failed')
expect(deleteAsync).toHaveBeenCalledWith(expect.stringMatching(/\.bin$/), {
idempotent: true,
})
})
it('deletes every intermediate image when resizing fails', async () => {
const mockedFetch = createDownloadResumable as jest.Mock
mockedFetch.mockReturnValue({
cancelAsync: jest.fn(),
downloadAsync: jest
.fn()
.mockResolvedValue({uri: 'file://downloaded-image.jpg'}),
})
;(getInfoAsync as jest.Mock)
.mockResolvedValueOnce({exists: true, size: 100})
.mockRejectedValueOnce(new Error('stat failed'))
const opts: DownloadAndResizeOpts = {
uri: 'https://example.com/image.jpg',
maxDimension: 2000,
maxSize: 500000,
timeout: 10000,
}
await expect(downloadAndResize(opts)).rejects.toThrow('stat failed')
const deletedPaths = (deleteAsync as jest.Mock).mock.calls.map(
([path]) => path,
)
expect(deletedPaths).toEqual(
expect.arrayContaining([
'file://resized-image-1.jpg',
'file://resized-image-2.jpg',
'file://resized-image-3.jpg',
]),
)
})
it('should return undefined for invalid URI', async () => {
const opts: DownloadAndResizeOpts = {
uri: 'invalid-uri',
+50 -5
View File
@@ -1,13 +1,58 @@
import {getLikelyType, LikelyType} from '../../src/lib/link-meta/link-meta'
import {
getLikelyType,
getLinkMeta,
LikelyType,
} from '../../src/lib/link-meta/link-meta'
describe('getLikelyType', () => {
it('correctly handles non-parsed url', async () => {
const output = await getLikelyType('https://example.com')
it('correctly handles non-parsed url', () => {
const output = getLikelyType('https://example.com')
expect(output).toEqual(LikelyType.HTML)
})
it('handles non-string urls without crashing', async () => {
const output = await getLikelyType('123')
it('handles non-string urls without crashing', () => {
const output = getLikelyType('123')
expect(output).toEqual(LikelyType.Other)
})
})
describe('getLinkMeta', () => {
const originalFetch = global.fetch
afterEach(() => {
global.fetch = originalFetch
})
it('fetches metadata for stream.place routes that look like files', async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: () =>
Promise.resolve({
error: '',
description: 'AT Protocol livestreams',
image: 'https://stream.place/thumbnail.jpg',
title: 'atproto.com on stream.place',
}),
})
global.fetch = fetchMock
const output = await getLinkMeta('https://stream.place/atproto.com')
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(output).toMatchObject({
description: 'AT Protocol livestreams',
image: 'https://stream.place/thumbnail.jpg',
likelyType: LikelyType.HTML,
title: 'atproto.com on stream.place',
})
})
it('skips metadata fetching for direct image URLs', async () => {
const fetchMock = jest.fn()
global.fetch = fetchMock
const output = await getLinkMeta('https://example.com/image.JPEG')
expect(fetchMock).not.toHaveBeenCalled()
expect(output).toMatchObject({likelyType: LikelyType.Image})
})
})
+20
View File
@@ -20,6 +20,7 @@ module.exports = function (_config) {
const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production'
const IS_E2E = process.env.EXPO_PUBLIC_ENV === 'e2e'
const IS_DEV = !IS_TESTFLIGHT && !IS_PRODUCTION
const ASSOCIATED_DOMAINS = [
@@ -234,6 +235,25 @@ module.exports = function (_config) {
checkAutomatically: 'NEVER',
},
plugins: [
[
'expo-dev-client',
{
toolsButton: false,
...(IS_E2E
? {
launchMode: 'most-recent',
skipOnboarding: true,
showMenuAtLaunch: false,
ios: {
defaultLaunchURL: 'http://localhost:8081',
},
android: {
defaultLaunchURL: 'http://10.0.2.2:8081',
},
}
: {}),
},
],
'expo-video',
'expo-localization',
'expo-web-browser',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 12a8 8 0 1 0-16 0 8 8 0 0 0 16 0m2 0c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-10.843.256-.47-3.768a1.324 1.324 0 1 1 2.627 0l-.47 3.768a.85.85 0 0 1-1.687 0M12 17a1.2 1.2 0 1 0 0-2.4 1.2 1.2 0 0 0 0 2.4"/></svg>

After

Width:  |  Height:  |  Size: 334 B

+30 -9
View File
@@ -36,15 +36,36 @@ jest.mock('expo-file-system/legacy', () => ({
createDownloadResumable: jest.fn(),
}))
jest.mock('expo-image-manipulator', () => ({
manipulateAsync: jest.fn().mockResolvedValue({
uri: 'file://resized-image',
}),
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}))
jest.mock('expo-image-manipulator', () => {
const createContext = () => {
const image = {
height: 100,
release: jest.fn(),
saveAsync: jest.fn().mockResolvedValue({
height: 100,
uri: 'file://resized-image',
width: 100,
}),
width: 100,
}
return {
crop: jest.fn(),
release: jest.fn(),
renderAsync: jest.fn().mockResolvedValue(image),
resize: jest.fn(),
}
}
return {
ImageManipulator: {
manipulate: jest.fn(createContext),
},
SaveFormat: {
JPEG: 'jpeg',
WEBP: 'webp',
},
}
})
jest.mock('expo-camera', () => ({
Camera: {
+7 -2
View File
@@ -92,6 +92,7 @@
"app.bsky.graph.muteActor",
"app.bsky.graph.muteActorList",
"app.bsky.graph.muteThread",
"app.bsky.graph.referencelistoptout",
"app.bsky.graph.searchStarterPacks",
"app.bsky.graph.searchStarterPacksV2",
"app.bsky.graph.starterpack",
@@ -426,7 +427,7 @@
},
"app.bsky.embed.video": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.embed.video",
"cid": "bafyreiaqos23yv3t4ptrxily6s6qea5fcxfjzjlm42zq46xweby2mkgr4m"
"cid": "bafyreihoxb7lvczityqcv2s5od3rllmkee7tn3m4qg6wn34kd6m45p6v24"
},
"app.bsky.feed.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.defs",
@@ -538,7 +539,7 @@
},
"app.bsky.graph.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.defs",
"cid": "bafyreifcipomli7yggtl46xufgxlnrw7se6xmsdxmzgfcz2tiu76ljatxm"
"cid": "bafyreief2f7zpllyicjugbn7faohmnzwujeiytfzj76uckxmrqmtdvechy"
},
"app.bsky.graph.follow": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow",
@@ -632,6 +633,10 @@
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.muteThread",
"cid": "bafyreib6ppci3qzye6wktkm4byxtb5mnl2vg22fm7oawcdvof2tfogx4dy"
},
"app.bsky.graph.referencelistoptout": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.referencelistoptout",
"cid": "bafyreifode2cfu7x7yamiorzg66u46l4zdr2yxmtuoikrup7j2dhwzxf3q"
},
"app.bsky.graph.searchStarterPacks": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.searchStarterPacks",
"cid": "bafyreia446ip6mbnwpml6hlvxab7jtsud7zczde3u6zmnsa3op4bpxu7um"
+2 -6
View File
@@ -9,9 +9,7 @@
"properties": {
"alt": {
"type": "string",
"maxLength": 10000,
"description": "Alt text description of the video, for accessibility.",
"maxGraphemes": 1000
"description": "Alt text description of the video, for accessibility."
},
"video": {
"type": "blob",
@@ -51,9 +49,7 @@
],
"properties": {
"alt": {
"type": "string",
"maxLength": 10000,
"maxGraphemes": 1000
"type": "string"
},
"cid": {
"type": "string",
+27 -27
View File
@@ -169,6 +169,28 @@
}
}
},
"knownLikers": {
"type": "object",
"required": [
"count",
"actors"
],
"properties": {
"count": {
"type": "integer"
},
"actors": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileViewBasic",
"type": "ref"
},
"maxLength": 5,
"minLength": 0
}
},
"description": "The post's likers whom you also follow"
},
"requestLess": {
"type": "token",
"description": "Request that less content like the given feed item be shown in the feed"
@@ -194,6 +216,11 @@
"bookmarked": {
"type": "boolean"
},
"knownLikers": {
"ref": "#knownLikers",
"type": "ref",
"description": "This property is present only in selected cases, as an optimization."
},
"threadMuted": {
"type": "boolean"
},
@@ -202,37 +229,10 @@
},
"embeddingDisabled": {
"type": "boolean"
},
"knownLikers": {
"description": "This property is present only in selected cases, as an optimization.",
"type": "ref",
"ref": "#knownLikers"
}
},
"description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests."
},
"knownLikers": {
"type": "object",
"description": "The post's likers whom you also follow",
"required": [
"count",
"actors"
],
"properties": {
"count": {
"type": "integer"
},
"actors": {
"type": "array",
"minLength": 0,
"maxLength": 5,
"items": {
"type": "ref",
"ref": "app.bsky.actor.defs#profileViewBasic"
}
}
}
},
"feedViewPost": {
"type": "object",
"required": [
+10
View File
@@ -100,6 +100,11 @@
"subject": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref"
},
"subjectOptedOut": {
"type": "boolean",
"const": true,
"description": "Set to true when the subject has opted out of appearing in the reference list. Only set when the viewer owns the list."
}
}
},
@@ -228,6 +233,11 @@
"blocked": {
"type": "string",
"format": "at-uri"
},
"referenceListOptOut": {
"type": "string",
"format": "at-uri",
"description": "The authenticated viewer's app.bsky.graph.referencelistoptout record URI for this reference list. Only set for reference lists. A client can delete this record to undo the opt-out."
}
}
},
@@ -0,0 +1,30 @@
{
"id": "app.bsky.graph.referencelistoptout",
"defs": {
"main": {
"key": "tid",
"type": "record",
"record": {
"type": "object",
"required": [
"subject",
"createdAt"
],
"properties": {
"subject": {
"type": "string",
"format": "at-uri",
"description": "Canonical, DID-based AT URI of the app.bsky.graph.list record from which the author requests omission."
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
},
"description": "Record requesting that its author be omitted from the public presentation of a reference list. This record is only enforced when the subject list's current purpose is app.bsky.graph.defs#referencelist. AppView indexes at most one record per actor and list pair, and ignores duplicate records."
}
},
"$type": "com.atproto.lexicon.schema",
"lexicon": 1
}
+24 -1
View File
@@ -61,7 +61,8 @@ The component uses a class-based approach to expose imperative methods (`present
- Preserves status/nav bar appearance from host activity
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
- Forwards touch events to React Native event system
- Updates shadow node size to match window dimensions
- Reports its measured width to `BottomSheetView` so the content canvas can follow it
- Also carries the legacy `UIManagerModule.updateNodeSize()` shadow node sizing, which only runs on the old architecture
- Based on React Native's ReactModalHostView pattern
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
@@ -74,6 +75,24 @@ Both platforms detect content height changes natively without JS bridge round-tr
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
### Content Canvas Sizing
The "canvas" is the size the sheet content is laid out on by Yoga. **On Android the native side owns it**; on iOS it is still sized from JS.
- **Android**: JS renders unsized `flex: 1` content and `BottomSheetView` pushes the canvas size into the Fabric shadow tree through `ExpoView`'s `setViewSize` state channel (`shadowNodeProxy.setViewSize()`). Only native knows the real sheet frame - Material caps the frame at 640dp on tablets and centers it, and it changes on rotation.
- **iOS**: `BottomSheetNativeComponent` sets `height: screenHeight - insets.top` and `width: '100%'` on the native view. Moving iOS onto the same state channel is deferred: it needs on-device iteration on iOS 26 sheet geometry (large-detent and floating-card metrics, where the visible sheet is shorter than the window minus the top inset).
How the Android path works:
- The JS style on the native view **must not set `width` or `height`** on Android. `ExpoViewComponentDescriptor::adopt()` only applies the state size on an axis where the style leaves that dimension undefined, so a style dimension would silently win.
- The two axes come from different places, and the distinction is load-bearing:
- **Width** is authoritatively the dialog container's measured width, reported through `DialogRootViewGroup`'s size-change listener - that is the real sheet width, with the horizontal window insets and Material's 640dp cap already applied. It is seeded from `min(window width, material_bottom_sheet_max_width)` on the first `onLayout` so content has something to lay out in before the dialog exists.
- **Height** is always computed natively as `screenHeight - statusBarHeight` (matching the behavior's `expandedOffset`) - the whole expanded frame, **never** the dialog's measured height. The canvas has to be room for the content to grow *into*, because the content's height is what drives the snap points. Sizing it from the dialog's own height is circular: `BottomSheetBehavior` measures the container against the sheet, so the canvas collapses onto the content height and the content is then pinned - extra `ScrollView` padding (the Android keyboard path) or a longer list becomes scroll extent instead of a height change, `OnLayoutChangeListener` never fires, and the sheet stops responding to its content.
- Seeding runs once per open cycle - re-seeding would fight the width the dialog reported and the two would push each other back and forth.
- Because the content measures 0x0 until that first state commit lands, `present()` bails out early when the content height is still zero. The commit resizes the native view, which re-fires `onLayout`, which re-enters `present()` - so presentation self-retries rather than needing an explicit callback. Full-height sheets skip the check, since they don't need a content measurement.
- Rotation is handled by the container push: the RN activity handles configuration changes itself, so the view is never recreated. `screenHeight` is read per access so the computed height follows the rotation, and the container reports the new width (plus a deferred `updateLayout()` to reposition the sheet).
- On the **old architecture** there is no state channel (`stateWrapper` is null, so `setViewSize` no-ops) and Android falls back to `DialogRootViewGroup`'s legacy `UIManagerModule.updateNodeSize()` path. The `present()` gate is skipped there for the same reason - nothing would ever resize the view.
## Props
```typescript
@@ -213,6 +232,10 @@ BottomSheetNativeComponent.dismissAll()
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
5. **Tablet Width**: Material caps the sheet frame at 640dp (`material_bottom_sheet_max_width`, the `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`) and centers it horizontally, so on tablets the sheet is narrower than the screen. `BottomSheetView` reads that cap from resources when seeding the canvas width, and the dialog container's measured width then corrects it - see [Content Canvas Sizing](#content-canvas-sizing).
6. **Rotation**: The RN activity handles configuration changes itself, so a rotation resizes the display without recreating `BottomSheetView`. Screen height is therefore read per access rather than cached, and `maxHeight` is stored unclamped and clamped against the current screen at use time.
### Platform Differences
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
@@ -21,6 +21,12 @@ import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
/**
* Fallback for Material's `material_bottom_sheet_max_width` dimen (in dp), used only
* if the resource lookup fails. 640dp is the value Material ships.
*/
private const val FALLBACK_MAX_SHEET_WIDTH_DP = 640f
class BottomSheetView(
context: Context,
appContext: AppContext,
@@ -38,26 +44,30 @@ class BottomSheetView(
private var lastObservedContentHeight: Float = 0f
private var pendingLayoutUpdate: Boolean = false
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
// Computed per read rather than cached at construction: the RN activity handles
// configuration changes itself, so a rotation resizes the display without
// recreating this view and a cached value would stay stale for the sheet's life.
private val screenHeight: Float
get() =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
@@ -73,6 +83,11 @@ class BottomSheetView(
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Last canvas size (in dp) pushed into the shadow tree, so repeated layout
// passes don't spam state updates
private var lastPushedCanvasWidth: Float = -1f
private var lastPushedCanvasHeight: Float = -1f
var disableDrag = false
set(value) {
field = value
@@ -99,10 +114,11 @@ class BottomSheetView(
field = if (value < 0) 0f else dpToPx(value)
}
var maxHeight = this.screenHeight
// Stored unclamped (in px) because screenHeight can change under us on rotation.
// The clamp against the screen happens at use time, in getTargetHeight().
var maxHeight = Float.MAX_VALUE
set(value) {
val px = dpToPx(value)
field = if (px > this.screenHeight) this.screenHeight else px
field = dpToPx(value)
}
private var isOpen: Boolean = false
@@ -140,6 +156,38 @@ class BottomSheetView(
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
// The dialog container's measured WIDTH is the authoritative canvas width: it
// already accounts for the window's horizontal insets, Material's max-width cap on
// tablets and the current rotation. DialogRootViewGroup's own updateNodeSize() path
// is a no-op on the new architecture (getNativeModule(UIManagerModule) returns null
// under Fabric), so this state channel is what actually gets the width across there.
//
// Its measured HEIGHT is deliberately ignored - see canvasHeight.
this.dialogRootViewGroup.setOnSizeChangeListener(
object : DialogRootViewGroup.OnSizeChangeListener {
override fun onSizeChange(
width: Int,
height: Int,
) {
val density = context.resources.displayMetrics.density
pushCanvasSize(width / density, canvasHeight / density)
// onSizeChanged fires from inside a layout pass, so defer the reposition:
// updateLayout() reads child heights that aren't final yet. This is what
// makes the sheet settle back into place after a rotation. It no-ops for
// fullHeight sheets, which is correct - those are pinned to the expanded
// offset either way.
if ((isOpen || isOpening) && !isClosing) {
post {
if ((isOpen || isOpening) && !isClosing) {
updateLayout()
}
}
}
}
},
)
}
SheetManager.add(this)
}
@@ -151,9 +199,84 @@ class BottomSheetView(
r: Int,
b: Int,
) {
this.seedCanvasSize()
this.present()
}
/**
* The height, in px, of the canvas the sheet content is laid out on. This is the whole
* expanded frame (the behavior's expandedOffset is the status bar height), NOT the
* sheet's current height.
*
* That distinction is the whole ballgame. The content's height is what drives the snap
* points, so the canvas has to be room to grow *into*. Sizing the canvas from the
* dialog's own measured height is circular - BottomSheetBehavior measures the dialog
* container against the sheet, so the canvas collapses onto the content height, and from
* then on the content is pinned: extra ScrollView padding (the Android keyboard path) or
* a longer list just becomes scroll extent instead of a height change, the
* OnLayoutChangeListener never fires, and the sheet stops responding to its content.
*/
private val canvasHeight: Float
get() = screenHeight - getStatusBarHeight()
/**
* JS renders the sheet content unsized, so before the first state commit it measures
* 0x0 and present() has no content height to derive snap points from. Seed the canvas
* here to kick that off - the dialog container reports the authoritative width later,
* via its OnSizeChangeListener.
*
* Runs at most once per open cycle. It has to: each state commit re-fires onLayout, so
* re-seeding would fight the width the dialog reported and the two would push each other
* back and forth forever.
*
* stateWrapper is assigned while Fabric mounts the view, before the first layout pass,
* so setViewSize() should already reach the shadow tree from here. On the old
* architecture it is null and this no-ops, which is fine: DialogRootViewGroup's legacy
* updateNodeSize() path still sizes the content there.
*/
private fun seedCanvasSize() {
if (lastPushedCanvasWidth > 0f) return
val density = context.resources.displayMetrics.density
val widthPx =
minOf(
context.resources.displayMetrics.widthPixels
.toFloat(),
getMaxSheetWidth(),
)
this.pushCanvasSize(widthPx / density, canvasHeight / density)
}
/**
* Sets the size of this view's shadow node, which is the canvas the sheet content is
* laid out on. Deduped because both onLayout and the dialog container's size changes
* can re-report an unchanged size.
*/
private fun pushCanvasSize(
widthDp: Float,
heightDp: Float,
) {
if (widthDp <= 0f || heightDp <= 0f) return
if (widthDp == lastPushedCanvasWidth && heightDp == lastPushedCanvasHeight) return
lastPushedCanvasWidth = widthDp
lastPushedCanvasHeight = heightDp
this.shadowNodeProxy.setViewSize(widthDp.toDouble(), heightDp.toDouble())
}
/**
* Material caps the sheet frame at `material_bottom_sheet_max_width` (the
* `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`, which our dialog theme
* inherits from) and centers it horizontally, so on tablets the sheet is narrower than
* the display. Returns the cap in px.
*/
private fun getMaxSheetWidth(): Float =
try {
resources
.getDimensionPixelSize(com.google.android.material.R.dimen.material_bottom_sheet_max_width)
.toFloat()
} catch (e: android.content.res.Resources.NotFoundException) {
FALLBACK_MAX_SHEET_WIDTH_DP * context.resources.displayMetrics.density
}
private fun destroy() {
this.stopObservingContentHeight()
this.isClosing = false
@@ -178,6 +301,15 @@ class BottomSheetView(
val contentHeight = this.getContentHeight()
// The content is unsized until the canvas size we pushed lands in the shadow tree,
// so bail and let this retry itself: the state commit resizes this view, that
// re-fires onLayout, and onLayout re-enters present(). Full-height sheets don't
// need a content measurement, so they can go ahead immediately.
//
// Only gate when there is a state channel to wait on. Without one (old architecture)
// nothing would ever resize this view, and the sheet would never present.
if (stateWrapper != null && !fullHeight && contentHeight <= 0f) return
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
@@ -425,8 +557,10 @@ class BottomSheetView(
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
// maxHeight is stored unclamped, so clamp it against the current screen here
val effectiveMaxHeight = minOf(this.maxHeight, this.screenHeight)
return when {
contentHeight > maxHeight -> maxHeight
contentHeight > effectiveMaxHeight -> effectiveMaxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
@@ -34,10 +34,6 @@ const IS_IOS15 =
Platform.OS === 'ios' &&
// semvar - can be 3 segments, so can't use Number(Platform.Version)
Number(Platform.Version.split('.').at(0)) < 16
// older android versions (15 and below) aren't naturally edge-to-edge
// and behave a little differently
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
@@ -148,24 +144,35 @@ function BottomSheetNativeComponentInner({
const {height: screenHeight} = useWindowDimensions()
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
// sigh... on older Android versions, screenHeight does not include safe area insets
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
// for the sheet content
const sheetHeight = IS_NON_E2E_ANDROID
? screenHeight + insets.bottom
: screenHeight - insets.top
return (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
position: 'absolute',
height: sheetHeight,
width: '100%',
}}
/*
* On Android the native side owns this view's size - the canvas the sheet
* content is laid out on - and pushes it into the Fabric shadow tree through
* ExpoView's `setViewSize` state channel. It knows the real sheet frame
* (window insets, Material's max-width cap on tablets, rotation), which JS
* can only guess at. `width` and `height` must stay unset there:
* `ExpoViewComponentDescriptor::adopt()` only applies the state size on an
* axis where the style leaves that dimension undefined, so a style dimension
* would silently win and clip the content again.
*
* iOS still sizes the canvas from JS. Moving it onto the same state channel
* needs on-device iteration on iOS 26 sheet geometry (large-detent and
* floating-card metrics), so it is deferred.
*/
style={
Platform.OS === 'ios'
? {
position: 'absolute',
height: screenHeight - insets.top,
width: '100%',
}
: {position: 'absolute'}
}
containerBackgroundColor={backgroundColor}>
<View
style={[
+1 -71
View File
@@ -296,19 +296,6 @@
"count": 1
}
},
"src/components/StarterPack/Main/ProfilesList.tsx": {
"typescript/no-misused-promises": {
"count": 1
}
},
"src/components/StarterPack/ProfileStarterPacks.tsx": {
"typescript/no-explicit-any": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 2
}
},
"src/components/StarterPack/QrCodeDialog.tsx": {
"typescript/no-floating-promises": {
"count": 5
@@ -317,14 +304,6 @@
"count": 4
}
},
"src/components/StarterPack/ShareDialog.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/require-await": {
"count": 1
}
},
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
"typescript/no-explicit-any": {
"count": 1
@@ -406,21 +385,11 @@
"count": 1
}
},
"src/components/dialogs/StarterPackDialog.tsx": {
"typescript/no-misused-promises": {
"count": 1
}
},
"src/components/dialogs/SwitchAccount.tsx": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx": {
"typescript/no-floating-promises": {
"count": 2
}
},
"src/components/dialogs/lists/CreateOrEditListDialog.tsx": {
"typescript/no-explicit-any": {
"count": 2
@@ -557,11 +526,6 @@
"count": 2
}
},
"src/lib/async/until.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"src/lib/broadcast/stub.ts": {
"typescript/no-explicit-any": {
"count": 1
@@ -674,7 +638,7 @@
"count": 1
},
"typescript/no-floating-promises": {
"count": 5
"count": 3
}
},
"src/lib/media/manip.web.ts": {
@@ -870,14 +834,6 @@
"count": 1
}
},
"src/screens/ProfileList/components/MoreOptionsMenu.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 5
}
},
"src/screens/ProfileList/components/SubscribeMenu.tsx": {
"typescript/no-misused-promises": {
"count": 2
@@ -1009,22 +965,6 @@
"count": 1
}
},
"src/screens/StarterPack/StarterPackScreen.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/require-await": {
"count": 1
}
},
"src/screens/StarterPack/Wizard/index.tsx": {
"typescript/no-floating-promises": {
"count": 2
},
"typescript/require-await": {
"count": 1
}
},
"src/state/a11y.tsx": {
"typescript/no-floating-promises": {
"count": 1
@@ -1246,11 +1186,6 @@
"count": 2
}
},
"src/state/queries/starter-packs.ts": {
"typescript/require-await": {
"count": 1
}
},
"src/state/queries/suggested-follows.ts": {
"no-unused-vars": {
"count": 1
@@ -1528,11 +1463,6 @@
"count": 1
}
},
"src/view/screens/Home.tsx": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/view/screens/ModerationBlockedAccounts.tsx": {
"typescript/no-misused-promises": {
"count": 3
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.132.0",
"version": "1.133.0",
"private": true,
"engines": {
"node": ">=24.19.0"
@@ -112,7 +112,7 @@
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.3.2",
"@bsky.app/react-native-uitextview": "^2.7.0",
"@bsky.app/react-native-uitextview": "^2.7.1",
"@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6",
@@ -159,6 +159,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"bidi-js": "^1.0.3",
"date-fns": "^4.4.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.6.0",
@@ -239,7 +240,7 @@
"react-native-pager-view": "6.8.0",
"react-native-progress": "^5.0.1",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "~4.5.3",
"react-native-reanimated": "~4.6.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.26.2",
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
@@ -249,7 +250,7 @@
"react-native-web": "^0.21.0",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.16.1",
"react-native-worklets": "0.11.3",
"react-native-worklets": "0.12.1",
"react-remove-scroll-bar": "^2.3.8",
"react-responsive": "^10.0.1",
"react-textarea-autosize": "^8.5.3",
@@ -304,6 +305,7 @@
"prettier": "3.9.6",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0",
"sharp": "^0.35.4",
"svgo": "^4.0.2",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "npm:@typescript/typescript6@^6.0.2",
+29
View File
@@ -1,3 +1,19 @@
diff --git a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
index a2d707ce76ceb35456eb075d06473ac98ad8b5eb..be2e3d66031d2bbd6208a7cdc1700a9daefb1267 100644
--- a/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
+++ b/android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp
@@ -24,10 +24,7 @@ namespace expo {
return;
}
- workletRuntime->executeSync([func = std::move(func)](jsi::Runtime &rt) -> jsi::Value {
- func(rt);
- return jsi::Value::undefined();
- });
+ workletRuntime->runSync(func);
}
} // namespace expo
diff --git a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt b/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8454eab96 100644
--- a/android/src/main/java/expo/modules/kotlin/devtools/ExpoNetworkInspectOkHttpInterceptors.kt
@@ -13,3 +29,16 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
// Check for Content-Type
val skipContentTypes = listOf(
"text/event-stream", // Server Sent Events
diff --git a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
index 126545fae12dc3af71e0ea382b976ef430e62d17..d97163a2e9a9db09f43786bc919f2fa229f21901 100644
--- a/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
+++ b/ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm
@@ -233,7 +233,7 @@ - (void)executeWorkletWithRuntimeHandle:(id)runtimeHandle
return;
}
- workletRuntime->executeSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
+ workletRuntime->runSync([worklet, arguments](jsi::Runtime &rt) -> jsi::Value {
return callWorklet(rt, worklet, arguments);
});
}
+12
View File
@@ -3,3 +3,15 @@
### Android: bitdrift interceptor
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
### iOS + Android: worklets `runSync` migration
Backport of https://github.com/expo/expo/pull/49366 ("[sdk-57] Backport
WorkletRuntime runSync migration"). react-native-worklets 0.12 removed the
deprecated `WorkletRuntime::executeSync`, so the worklets adapters in
`ios/WorkletsAdapter/ExpoWorkletsBridgeProvider.mm` and
`android/src/main/cpp/worklets/WorkletJSCallInvoker.cpp` fail to compile
against it. The patch swaps both call sites to `runSync` (available since
worklets 0.7.0). Required for the react-native-reanimated 4.6.0 /
react-native-worklets 0.12.1 upgrade; drop once expo-modules-core ships a
version containing that PR.
-283
View File
@@ -1,283 +0,0 @@
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
index 8603591..20d042b 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
@@ -62,11 +62,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<facebook::react::UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<facebook::react::UIManager> &uiManager,
const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker
#endif
)
@@ -74,11 +74,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
contextContainer_(contextContainer),
componentDescriptorRegistry_(componentDescriptorRegistry),
uiRuntime_(uiRuntime),
- uiScheduler_(uiScheduler)
+ uiScheduler_(uiScheduler),
+ uiManager_(uiManager)
#ifdef ANDROID
,
preserveMountedTags_(filterUnmountedTagsFunction),
- uiManager_(uiManager),
jsInvoker_(jsInvoker)
#endif
{
@@ -98,10 +98,10 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
SharedComponentDescriptorRegistry componentDescriptorRegistry_;
jsi::Runtime &uiRuntime_;
const std::shared_ptr<UIScheduler> uiScheduler_;
+ std::shared_ptr<facebook::react::UIManager> uiManager_;
PreserveMountedTagsFunction preserveMountedTags_;
#ifdef ANDROID
- std::shared_ptr<facebook::react::UIManager> uiManager_;
std::shared_ptr<facebook::react::CallInvoker> jsInvoker_;
void restoreOpacityInCaseOfFlakyEnteringAnimation(SurfaceId surfaceId) const;
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
index fcc677f..115971a 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
@@ -67,11 +67,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<UIManager> &uiManager,
const std::shared_ptr<CallInvoker> &jsInvoker
#endif
)
@@ -80,11 +80,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
componentDescriptorRegistry,
contextContainer,
uiRuntime,
- uiScheduler
+ uiScheduler,
+ uiManager
#ifdef ANDROID
,
filterUnmountedTagsFunction,
- uiManager,
jsInvoker
#endif
),
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
index df53d8d..735f138 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
@@ -1,6 +1,7 @@
#include <reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h>
#include <react/debug/react_native_assert.h>
+#include <react/renderer/mounting/ShadowTree.h>
#include <react/renderer/mounting/ShadowViewMutation.h>
#include <memory>
@@ -60,14 +61,37 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
parseRemoveMutations(movedViews, mutations, roots);
- auto shouldAnimate = !surfacesToRemove_.contains(surfaceId);
- surfacesToRemove_.erase(surfaceId);
+ // Consume the teardown mark only on the transaction that actually clears
+ // the root — pulls emitted for animation frames must not eat it early.
+ auto shouldAnimate = true;
+ const auto removesRootChildren = std::ranges::any_of(mutations, [surfaceId](const auto &mutation) {
+ return mutation.type == ShadowViewMutation::Remove && mutation.parentTag == surfaceId;
+ });
+ if (removesRootChildren) {
+ shouldAnimate = surfacesToRemove_.erase(surfaceId) == 0;
+ }
handleRemovals(filteredMutations, roots, deadNodes, shouldAnimate);
handleUpdatesAndEnterings(filteredMutations, movedViews, mutations, propsParserContext, surfaceId);
addOngoingAnimations(surfaceId, filteredMutations);
+ // The LayoutAnimationDriver can emit a final keyframe update in the same
+ // transaction as the deferred Remove/Delete it withheld for a delete
+ // animation. We emit removals before updates, so such an update would
+ // otherwise reach the mounting layer after its view was deleted.
+ std::unordered_set<Tag> deletedTags;
+ for (const auto &mutation : filteredMutations) {
+ if (mutation.type == ShadowViewMutation::Delete) {
+ deletedTags.insert(mutation.oldChildShadowView.tag);
+ }
+ }
+ if (!deletedTags.empty()) {
+ std::erase_if(filteredMutations, [&deletedTags](const auto &mutation) {
+ return mutation.type == ShadowViewMutation::Update && deletedTags.contains(mutation.newChildShadowView.tag);
+ });
+ }
+
return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
}
@@ -998,23 +1022,22 @@ inline bool MutationNode::isMutationNode() {
return true;
}
-// UIManagerAnimationDelegate
-
-void LayoutAnimationsProxy_Legacy::uiManagerDidConfigureNextLayoutAnimation(
- jsi::Runtime &runtime,
- const RawValue &config,
- const jsi::Value &successCallbackValue,
- const jsi::Value &failureCallbackValue) const {}
+// UIManagerCommitHook
-void LayoutAnimationsProxy_Legacy::setComponentDescriptorRegistry(
- const SharedComponentDescriptorRegistry &componentDescriptorRegistry) {}
-
-bool LayoutAnimationsProxy_Legacy::shouldAnimateFrame() const {
- return false;
-}
-
-void LayoutAnimationsProxy_Legacy::stopSurface(SurfaceId surfaceId) {
- surfacesToRemove_.insert(surfaceId);
+// Surface teardown commits an empty root (SurfaceHandler::stop) before the
+// teardown transaction is pulled — mark it so pullTransaction skips exit
+// animations. Reading the ShadowTreeRegistry here instead would deadlock (#8579).
+RootShadowNode::Unshared LayoutAnimationsProxy_Legacy::shadowTreeWillCommit(
+ const ShadowTree &shadowTree,
+ const RootShadowNode::Shared & /*oldRootShadowNode*/,
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept {
+ auto lock = std::unique_lock<std::recursive_mutex>(mutex);
+ if (newRootShadowNode->getChildren().empty()) {
+ surfacesToRemove_.insert(shadowTree.getSurfaceId());
+ } else {
+ surfacesToRemove_.erase(shadowTree.getSurfaceId());
+ }
+ return newRootShadowNode;
}
} // namespace reanimated
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
index 57cc134..1a2966c 100644
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
@@ -3,8 +3,8 @@
#include <react/renderer/componentregistry/ComponentDescriptorFactory.h>
#include <react/renderer/mounting/MountingOverrideDelegate.h>
#include <react/renderer/scheduler/Scheduler.h>
-#include <react/renderer/uimanager/UIManagerAnimationDelegate.h>
#include <react/renderer/uimanager/UIManagerBinding.h>
+#include <react/renderer/uimanager/UIManagerCommitHook.h>
#include <reanimated/Compat/WorkletsApi.h>
#include <reanimated/LayoutAnimations/LayoutAnimationsManager.h>
#include <reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h>
@@ -102,7 +102,7 @@ struct SurfaceContext {
};
struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
- public UIManagerAnimationDelegate,
+ public UIManagerCommitHook,
public std::enable_shared_from_this<LayoutAnimationsProxy_Legacy> {
mutable std::unordered_map<Tag, std::shared_ptr<Node>> nodeForTag_;
mutable std::recursive_mutex mutex;
@@ -116,11 +116,11 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
const std::shared_ptr<const ContextContainer> &contextContainer,
jsi::Runtime &uiRuntime,
- const std::shared_ptr<UIScheduler> &uiScheduler
+ const std::shared_ptr<UIScheduler> &uiScheduler,
+ const std::shared_ptr<UIManager> &uiManager
#ifdef ANDROID
,
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
- const std::shared_ptr<UIManager> &uiManager,
const std::shared_ptr<CallInvoker> &jsInvoker
#endif
)
@@ -129,14 +129,19 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
componentDescriptorRegistry,
contextContainer,
uiRuntime,
- uiScheduler
+ uiScheduler,
+ uiManager
#ifdef ANDROID
,
filterUnmountedTagsFunction,
- uiManager,
jsInvoker
#endif
) {
+ uiManager->registerCommitHook(*this);
+ }
+
+ ~LayoutAnimationsProxy_Legacy() override {
+ uiManager_->unregisterCommitHook(*this);
}
void startEnteringAnimation(const int tag, ShadowViewMutation &mutation) const;
@@ -206,19 +211,15 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
const TransactionTelemetry &telemetry,
ShadowViewMutationList mutations) const override;
- // UIManagerAnimationDelegate
-
- void uiManagerDidConfigureNextLayoutAnimation(
- jsi::Runtime &runtime,
- const RawValue &config,
- const jsi::Value &successCallbackValue,
- const jsi::Value &failureCallbackValue) const override;
-
- void setComponentDescriptorRegistry(const SharedComponentDescriptorRegistry &componentDescriptorRegistry) override;
+ // UIManagerCommitHook
- bool shouldAnimateFrame() const override;
+ void commitHookWasRegistered(const UIManager &uiManager) noexcept override {}
+ void commitHookWasUnregistered(const UIManager &uiManager) noexcept override {}
- void stopSurface(SurfaceId surfaceId) override;
+ RootShadowNode::Unshared shadowTreeWillCommit(
+ const ShadowTree &shadowTree,
+ const RootShadowNode::Shared &oldRootShadowNode,
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept override;
};
} // namespace reanimated
diff --git a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
index 2b68ff7..d08b1ae 100644
--- a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
+++ b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
@@ -1235,22 +1235,19 @@ void ReanimatedModuleProxy::initializeLayoutAnimationsProxy() {
#endif
layoutAnimationsProxy_ = std::move(layoutAnimationsProxyExperimental);
} else {
- auto layoutAnimationsProxyLegacy = std::make_shared<LayoutAnimationsProxy_Legacy>(
+ layoutAnimationsProxy_ = std::make_shared<LayoutAnimationsProxy_Legacy>(
layoutAnimationsManager_,
componentDescriptorRegistry,
scheduler->getContextContainer(),
getJSIRuntimeFromWorkletRuntime(uiRuntime_),
- uiScheduler_
+ uiScheduler_,
+ uiManager_
#ifdef ANDROID
,
filterUnmountedTagsFunction_,
- uiManager_,
jsInvoker_
#endif
);
- // TODO (future): support in experimental
- uiManager_->setAnimationDelegate(layoutAnimationsProxyLegacy.get());
- layoutAnimationsProxy_ = std::move(layoutAnimationsProxyLegacy);
}
}
}
@@ -1,27 +0,0 @@
# react-native-reanimated@4.5.3.patch
Backport of https://github.com/software-mansion/react-native-reanimated/pull/9901
("refactor(LayoutAnimations): stop taking over UIManagerAnimationDelegate").
Reanimated's legacy `LayoutAnimationsProxy_Legacy` registered itself as the
`UIManagerAnimationDelegate` only to receive `stopSurface`. Occupying that slot
overwrites the `LayoutAnimationDriver` that React Native installs there, which
silently breaks `LayoutAnimation.configureNext` for the whole app.
The patch makes the proxy detect surface teardown itself via a
`UIManagerCommitHook` (a commit with an empty root marks the surface in
`surfacesToRemove_`), frees the animation-delegate slot, and drops final
keyframe `Update` mutations for views deleted in the same transaction (a
deterministic `configureNext` delete-animation crash found in this app).
`uiManager` moves from Android-only to shared constructor args since the hook
registration needs it on both platforms.
Only the `packages/react-native-reanimated` part of the PR is included (the
`apps/fabric-example` hunk is not part of the published package), and the hunks
were rebased onto the 4.5.3 release sources.
Note that upstream's own `pullTransaction` rework in 4.5.3 (the new
`reconcileContradictedRemovals`) covers a different case - a `Create`/`Insert`
contradicting a *withheld* exit removal - and does not subsume the deleted-tag
`Update` filter here, which guards against the `LayoutAnimationDriver` final
keyframe. That driver only runs at all once this patch frees the delegate slot.
@@ -1,88 +0,0 @@
diff --git a/lib/module/threads.js b/lib/module/threads.js
index c17e314..71f3cf7 100644
--- a/lib/module/threads.js
+++ b/lib/module/threads.js
@@ -1,7 +1,6 @@
'use strict';
import { IS_JEST } from './platformChecker';
-import { mockedRequestAnimationFrame } from "./runLoop/uiRuntime/mockedRequestAnimationFrame.js";
export function scheduleOnUI(worklet, ...args) {
enqueueUI(worklet, args);
}
@@ -23,38 +22,50 @@ export function scheduleOnRN(fun, ...args) {
queueMicrotask(args.length ? () => fun(...args) : fun);
}
export function runOnUIAsync(worklet, ...args) {
- return new Promise(resolve => {
- enqueueUI(worklet, args, resolve);
+ return new Promise((resolve, reject) => {
+ enqueueUI(worklet, args, resolve, reject);
});
}
let runOnUIQueue = [];
-function enqueueUI(worklet, args, resolve) {
- if (IS_JEST) {
- mockedRequestAnimationFrame(() => {
- const result = worklet(...args);
- resolve?.(result);
- });
- } else {
- const job = [worklet, args, resolve];
- runOnUIQueue.push(job);
- if (runOnUIQueue.length === 1) {
+function enqueueUI(worklet, args, resolve, reject) {
+ const job = [worklet, args, resolve, reject];
+ runOnUIQueue.push(job);
+ if (runOnUIQueue.length === 1) {
+ if (IS_JEST) {
flushUIQueue();
+ } else {
+ queueMicrotask(flushUIQueue);
}
}
}
+let offset = 0;
function flushUIQueue() {
- queueMicrotask(() => {
- const queue = runOnUIQueue;
- runOnUIQueue = [];
- requestAnimationFrameImpl(() => {
- queue.forEach(([workletFunction, workletArgs, jobResolve]) => {
- const result = workletFunction(...workletArgs);
- if (jobResolve) {
- jobResolve(result);
+ const queue = runOnUIQueue;
+ runOnUIQueue = [];
+ requestAnimationFrame(() => {
+ offset = 0;
+ while (queue.length > offset) {
+ try {
+ drainUIQueue(queue);
+ } catch (e) {
+ const [, , , jobReject] = queue[offset - 1];
+ if (jobReject) {
+ jobReject(e);
+ } else {
+ console.error(e);
}
- });
- });
+ }
+ }
});
}
-const requestAnimationFrameImpl = !globalThis.requestAnimationFrame ? mockedRequestAnimationFrame : globalThis.requestAnimationFrame;
-//# sourceMappingURL=threads.js.map
\ No newline at end of file
+function drainUIQueue(queue) {
+ while (queue.length > offset) {
+ const [workletFunction, workletArgs, jobResolve] = queue[offset];
+ offset++;
+ const result = workletFunction(...workletArgs);
+ if (jobResolve) {
+ jobResolve(result);
+ }
+ }
+}
+//# sourceMappingURL=threads.js.map
@@ -1,37 +0,0 @@
# react-native-worklets@0.11.3.patch
Backport of https://github.com/software-mansion/react-native-reanimated/pull/10167
("fix(Worklets): web scheduleOnUI implementation on errors").
## The bug
On web, `scheduleOnUI`/`runOnUI` batch their callbacks per animation frame and
run them with `queue.forEach(...)`. If any callback in the batch throws,
`forEach` aborts immediately and every callback still queued after it is
silently dropped - it never runs, and any `runOnUIAsync` promise for it never
resolves or rejects.
Reanimated's own internals rely on those callbacks running in order (e.g. to
populate `frameCallbackRegistry`), so a single throwing worklet in a batch can
leave later, unrelated frame callbacks referencing state that was never set
up, surfacing as:
```
TypeError: can't access property "startTime", this.frameCallbackRegistry.get(...) is undefined
```
## The fix
Replace the `forEach` batch runner with a `while` loop (`drainUIQueue`) that
tracks its position via an `offset`, wrapped in a `try`/`catch`. A throw now
only aborts the *current* callback: the loop resumes at the next queued job
instead of abandoning the rest of the batch. Errors are routed to the
matching `runOnUIAsync` promise's `reject` (a new second argument threaded
through `enqueueUI`) if there is one, or `console.error`-ed otherwise, rather
than crashing the whole frame.
Only `lib/module/threads.js` (the compiled web entry point actually loaded by
the app's webpack build) is patched - `src/threads.ts` is unused here since
this repo's web build resolves the package's `module` field, and native
platforms use the separate `threads.native.ts` implementation untouched by
this PR.
+217 -196
View File
@@ -23,180 +23,6 @@ index 1b02e8b2d39672063551411d5c403a69b671a869..b3481c1b98b45dea769035140dc2fd8d
- (void)setFrame:(CGRect)frame
{
[super setFrame:frame];
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index a087536f3af0d33b13fe38d8abd1bc6d7935def2..01f5c884ea4772350c0ebe6263723d97632f2b74 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -396,7 +396,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
MAP_SCROLL_VIEW_PROP(zoomScale);
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
+ // When disabling centerContent, reset inset to prop value
+ // (enabling is handled automatically by the setCenterContent: setter)
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
+ }
+
+ // Only apply contentInset from props if centerContent is disabled
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
}
@@ -523,7 +531,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
}
}
- return isPointInside ? self : nil;
+ return isPointInside ? _scrollView : nil;
}
/*
@@ -1133,6 +1141,11 @@ - (RCTVirtualViewContainerState *)virtualViewContainerState
return _virtualViewContainerState;
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
index ed306d7cadbf36a2fed79be8bd9d68b5dca135bd..d447dad534fefa9fcbdbbde6dcbbdcddadd5a824 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
@@ -18,6 +18,7 @@ __attribute__((deprecated("This API will be removed along with the legacy archit
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+@property (nonatomic, copy) UIColor *customTintColor;
@end
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
index 2dc86e464264c9450eef18d7b153d35bf6a5cc55..6661dc69a04766afa0284d6e83839b219e98cf57 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
@@ -25,6 +25,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
+ UIColor *_customTintColor;
}
- (instancetype)init
@@ -60,6 +61,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ [self setTintColor:_customTintColor];
+}
+
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -225,6 +232,18 @@ - (void)refreshControlValueChanged
}
}
+// Fix for https://github.com/facebook/react-native/issues/43388
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
+// is set before the refresh control gets added to the scrollview. We'll call this
+// function whenever the superview changes. We'll also call it if the value of customTintColor
+// changes.
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
@end
#endif // RCT_REMOVE_LEGACY_ARCH
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
index 1e9ff527f4e6691d716da624031113a397876981..44329c5422c6f24d8a437fa35c6f2bad6bf8622b 100644
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -24,11 +24,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
index 59775241c80bec99ad3ec080f2425aacc8900c24..426de3aa77cda2032d7b0991e2ca3f8482a438d3 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
@@ -459,6 +459,13 @@ public open class ReactViewGroup public constructor(context: Context?) :
inSubviewClippingLoop = true
var clippedSoFar = 0
for (i in 0..<allChildrenCount) {
+ // Reentrant child removal during this loop can compact allChildren and leave a null at
+ // an index below allChildrenCount. A null entry means the view is already detached, so
+ // treat it as clipped instead of crashing.
+ if (childArray[i] == null) {
+ clippedSoFar++
+ continue
+ }
try {
updateSubviewClipStatus(clippingRect, i, clippedSoFar, excludedViewsSet)
} catch (ex: IndexOutOfBoundsException) {
@@ -496,7 +503,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
) {
assertOnUiThread()
- val child = checkNotNull(allChildren?.get(idx))
+ // allChildren can be mutated reentrantly while a clipping pass is running, so a stale
+ // index can point at a null slot. Skip it instead of crashing.
+ val child = allChildren?.get(idx) ?: return
val intersects = clippingRect.intersects(child.left, child.top, child.right, child.bottom)
var needUpdateClippingRecursive = false
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
index 9b04cadc22f5ae7b105f9f9875a242b53188cf03..b2b27626edc46625ac2372a13977d700948835b6 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
@@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f
font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];
if (font != nullptr) {
fontNames = [UIFont fontNamesForFamilyName:font.familyName];
- fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font);
+ fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font);
} else {
// Failback to system font.
font = RCTDefaultFontWithFontProperties(fontProperties);
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
index ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce9c63137b 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
@@ -389,8 +389,9 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
size.height = enumeratedLinesHeight;
}
- size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
+ CGFloat epsilon = 0.001;
+ size = (CGSize){ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
@@ -371,11 +197,225 @@ 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
@@ -532,25 +532,3 @@ std::shared_ptr<const ShadowNode> UIManager::findShadowNodeByTag_DEPRECATED(
@@ -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
@@ -403,25 +443,6 @@ index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492fac
- }
+ auto rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
+ const auto* rootShadowNode = rootShadowNodeHolder.get();
diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
--- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
@@ -827,9 +827,17 @@
} 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;
if (rootShadowNode != nullptr) {
const auto& children = rootShadowNode->getChildren();
+16
View File
@@ -1,5 +1,21 @@
# ***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
+685 -349
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -17,8 +17,8 @@ overrides:
'@react-native/normalize-colors': '0.86.0'
'@expo/image-utils': '0.8.12'
'@types/estree': '1.0.6'
'react-native-reanimated': '4.5.3'
'react-native-worklets': '0.11.3'
'react-native-reanimated': '4.6.0'
'react-native-worklets': '0.12.1'
'psl': '1.9.0'
'@types/psl': '1.1.1'
'react-native-screens': '4.26.2'
@@ -43,10 +43,8 @@ patchedDependencies:
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
'react-native-keyboard-controller@1.21.9': patches/react-native-keyboard-controller@1.21.9.patch
'react-native-pager-view@6.8.0': patches/react-native-pager-view@6.8.0.patch
'react-native-reanimated@4.5.3': patches/react-native-reanimated@4.5.3.patch
'react-native-screens@4.26.2': patches/react-native-screens@4.26.2.patch
'react-native-svg@15.15.4': patches/react-native-svg@15.15.4.patch
react-native-worklets@0.11.3: patches/react-native-worklets@0.11.3.patch
'react-native@0.86.0': patches/react-native@0.86.0.patch
minimumReleaseAgeExclude:
- '@atproto/*'
+21 -3
View File
@@ -80,6 +80,7 @@ import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {renderMessagesSplitViewLayout} from '#/screens/Messages/components/splitView/MessagesSplitViewLayout'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
@@ -88,6 +89,9 @@ import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
import {ModerationInboxScreen} from '#/screens/ModerationInbox'
import {ModerationInboxReportDetailsScreen} from '#/screens/ModerationInbox/Report'
import {ModerationInboxSettingsScreen} from '#/screens/ModerationInbox/Settings'
import {ModerationInboxSubjectDetailsScreen} from '#/screens/ModerationInbox/Subject'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
import {NotificationsActivityListScreen} from '#/screens/Notifications/ActivityList'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
@@ -140,7 +144,6 @@ import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
import {InviteScannerScreen} from '#/features/inviteFriends'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {renderMessagesSplitViewLayout} from './screens/Messages/components/splitView/MessagesSplitViewLayout'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -184,6 +187,21 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => ModerationInboxScreen}
options={{title: title(msg`Moderation inbox`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInboxSettings"
getComponent={() => ModerationInboxSettingsScreen}
options={{title: title(msg`Mod inbox settings`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInboxReportDetails"
getComponent={() => ModerationInboxReportDetailsScreen}
options={{title: title(msg`Your report`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationInboxSubjectDetails"
getComponent={() => ModerationInboxSubjectDetailsScreen}
options={{title: title(msg`Notice`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
@@ -563,12 +581,12 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
<Stack.Screen
name="StarterPackWizard"
getComponent={() => Wizard}
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
options={{title: title(msg`Create a Starter Pack`), requireAuth: true}}
/>
<Stack.Screen
name="StarterPackEdit"
getComponent={() => Wizard}
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
options={{title: title(msg`Edit your Starter Pack`), requireAuth: true}}
/>
<Stack.Screen
name="VideoFeed"
@@ -0,0 +1,30 @@
import {useLingui} from '@lingui/react/macro'
import {useSessionApi} from '#/state/session'
import {Error} from '#/components/Error'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji'
import {useOtherRequiredDataQuery} from '#/ageAssurance/data'
import {IS_WEB} from '#/env'
export function DataUnavailableScreen() {
const {t: l} = useLingui()
const {logoutCurrentAccount} = useSessionApi()
const {isFetching, refetch} = useOtherRequiredDataQuery()
return (
<Error
icon={EmojiSadIcon}
title={l`Unable to load your account`}
message={l`We couldn't load your account settings. Check your internet connection and try again.`}
onRetry={() => void refetch()}
isRetrying={isFetching}
secondaryAction={{
label: l`Sign out`,
onPress: () => {
if (IS_WEB) history.pushState(null, '', '/')
logoutCurrentAccount('AgeAssuranceDataUnavailableScreen')
},
}}
/>
)
}
+34 -7
View File
@@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry'
import {isRetryableRequestError, networkRetry} from '#/lib/async/retry'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time'
import {
@@ -347,9 +347,15 @@ export type OtherRequiredData = {
birthdate: string | undefined
actorDeclaration?: chat.bsky.actor.declaration.Main
}
export type OtherRequiredDataStatus = 'pending' | 'error' | 'success'
const otherRequiredDataRetryOptions = {
retry: (failureCount: number, error: unknown) =>
failureCount < 2 && isRetryableRequestError(error),
}
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
return ['otherRequiredData', did]
}
async function getOtherRequiredData({
accountClient,
}: {
@@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({
try {
logger.debug(`prefetchOtherRequiredData: resolving...`)
const res = await networkRetry(3, () =>
getOtherRequiredData({accountClient}),
)
qc.setQueryData<OtherRequiredData>(qk, res)
await qc.fetchQuery({
...otherRequiredDataRetryOptions,
queryKey: qk,
queryFn: () => getOtherRequiredData({accountClient}),
})
} catch (err) {
const e = err as Error
logger.warn(`prefetchOtherRequiredData: failed`, {
@@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() {
const did = accountClient.did
return useQuery(
{
...otherRequiredDataRetryOptions,
enabled: !!did,
initialData: () => {
if (!did) return
return getOtherRequiredDataFromCache({did})
},
queryKey: createOtherRequiredDataQueryKey({did: did!}),
retryOnMount: false,
async queryFn() {
return getOtherRequiredData({accountClient})
},
@@ -722,6 +731,11 @@ export type AgeAssuranceServerData = {
*/
state: app.bsky.ageassurance.defs.State | undefined
metadata: AgeAssuranceMetadata | undefined
/**
* Whether the account data needed to compute age assurance is available.
* A successful response without a birthdate is still `success`.
*/
otherRequiredDataStatus: OtherRequiredDataStatus
/**
* The native on-device age signals for the region the user is currently in,
* if they've granted access there. Already resolved from the region-keyed
@@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
declaredAge: undefined,
birthdate: undefined,
},
otherRequiredDataStatus: 'pending',
deviceSignals: undefined,
})
export function useAgeAssuranceServerDataContext() {
@@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
const {data: config} = useConfigQuery()
const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery()
const {data, errorUpdatedAt, status} = useOtherRequiredDataQuery()
/*
* A data-less query returns to `pending` and clears `error` while refetching,
* but retains `errorUpdatedAt`. Keep the error screen mounted until data
* loads successfully.
*/
const otherRequiredDataStatus: OtherRequiredDataStatus =
data !== undefined
? 'success'
: status === 'error' || errorUpdatedAt > 0
? 'error'
: 'pending'
// `select` resolves the cached region-keyed map to the current region.
const {data: deviceSignals} = useDeviceSignalsQuery()
const ctx = useMemo(
@@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
: undefined,
birthdate: data?.birthdate,
},
otherRequiredDataStatus,
deviceSignals,
}),
[config, state, data, metadata, deviceSignals],
[config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
)
return (
<AgeAssuranceServerDataContext.Provider value={ctx}>
+85
View File
@@ -0,0 +1,85 @@
import {computeAgeAssuranceState} from '#/ageAssurance/state'
import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types'
jest.mock('#/ageAssurance/data', () => ({}))
jest.mock('#/ageAssurance/logger', () => ({
logger: {
debug: jest.fn(),
warn: jest.fn(),
},
}))
jest.mock('#/state/session', () => ({}))
const geolocation = {
countryCode: undefined,
regionCode: undefined,
}
describe('computeAgeAssuranceState', () => {
it('computes access while required account data is pending', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
otherRequiredDataStatus: 'pending',
}),
).toMatchObject({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
})
})
it('denies access when required account data fails', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
otherRequiredDataStatus: 'error',
}),
).toEqual({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
error: 'account-data',
})
})
it('computes access after a successful response without a birthdate', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation,
config: {regions: []},
metadata: {birthdate: undefined},
otherRequiredDataStatus: 'success',
}),
).toMatchObject({
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
})
})
it('preserves authoritative terminal server state without account data', () => {
expect(
computeAgeAssuranceState({
hasSession: true,
geolocation: {countryCode: 'AA', regionCode: undefined},
config: {
regions: [
{
countryCode: 'AA',
minAccessAge: 13,
rules: [],
},
],
},
state: {status: 'blocked', access: 'none'},
otherRequiredDataStatus: 'error',
}),
).toMatchObject({
status: AgeAssuranceStatus.Blocked,
access: AgeAssuranceAccess.None,
})
})
})
+24 -3
View File
@@ -9,6 +9,7 @@ import {
getDeviceSignalsFromCacheForRegion,
getOtherRequiredDataFromCache,
getServerStateFromCache,
type OtherRequiredDataStatus,
useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
@@ -35,12 +36,13 @@ import {device} from '#/storage'
* server state before computing access based on AA config from the server +
* geolocation and other data.
*/
function computeAgeAssuranceState({
export function computeAgeAssuranceState({
hasSession,
geolocation,
config,
state,
metadata,
otherRequiredDataStatus,
deviceSignals,
}: {
hasSession: boolean
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
config?: app.bsky.ageassurance.defs.Config
state?: app.bsky.ageassurance.defs.State
metadata?: AgeAssuranceMetadata
otherRequiredDataStatus: OtherRequiredDataStatus
deviceSignals?: AgeRange.AgeRangeResponse
}) {
/**
@@ -93,6 +96,14 @@ function computeAgeAssuranceState({
}
}
if (otherRequiredDataStatus === 'error') {
return {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.None,
error: 'account-data' as const,
}
}
/*
* Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
geolocation,
state: state.state,
metadata,
otherRequiredDataStatus: 'success',
deviceSignals,
})
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, metadata, deviceSignals} =
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
useAgeAssuranceServerDataContext()
return useMemo(
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
geolocation,
state,
metadata,
otherRequiredDataStatus,
deviceSignals,
}),
[hasSession, geolocation, config, state, metadata, deviceSignals],
[
hasSession,
geolocation,
config,
state,
metadata,
otherRequiredDataStatus,
deviceSignals,
],
)
}
+2 -1
View File
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
access: AgeAssuranceAccess
error?: 'config' // maybe other specific cases in the future
isLoading?: boolean
error?: 'config' | 'account-data'
}
export type AgeAssuranceFlags = {
-18
View File
@@ -93,24 +93,6 @@ export function getFeatureDescription(feature: Features, i18n: I18n) {
}),
),
}
case Features.CanonicalPostNumberingEnable:
return {
key: feature,
name: i18n._(
msg({
message: 'Thread numbering',
comment:
'Name for a feature flag (See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.)',
}),
),
description: i18n._(
msg({
message:
'See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.',
comment: 'Description of a feature flag (Thread numbering)',
}),
),
}
default:
return null
}
-1
View File
@@ -22,7 +22,6 @@ export enum Features {
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable',
OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable',
CanonicalPostNumberingEnable = 'canonical_post_numbering:enable',
ContentVisibilitySettingsEnable = 'content_visibility_settings:enable',
ModerationInboxEnable = 'moderation_inbox:enable',
+44
View File
@@ -44,6 +44,7 @@ export type Events = {
| 'SignupQueued'
| 'Deactivated'
| 'Takendown'
| 'AgeAssuranceDataUnavailableScreen'
| 'AgeAssuranceNoAccessScreen'
scope: 'current' | 'every'
}
@@ -450,6 +451,7 @@ export type Events = {
'post:view': {
uri: string
authorDid: string
isReply: boolean
logContext:
| 'FeedItem'
| 'PostThreadItem'
@@ -703,6 +705,7 @@ export type Events = {
}
'starterPack:removeUser': {
starterPack?: string
context?: 'opt-out'
}
'starterPack:share': {
starterPack: string
@@ -715,6 +718,10 @@ export type Events = {
count: number
}
'starterPack:delete': {}
'starterPack:optOut': {
starterPack: string
action: 'optOut' | 'undo'
}
'starterPack:create': {
setName: boolean
setDescription: boolean
@@ -767,12 +774,14 @@ export type Events = {
}
'trendingTopic:seen': {
context: 'sidebar' | 'interstitial' | 'explore'
feedUri?: string
recId?: string
rank: number
feedSliceIndex?: number
}
'trendingTopic:click': {
context: 'sidebar' | 'interstitial' | 'explore'
feedUri?: string
recId?: string
rank: number
feedSliceIndex?: number
@@ -1396,6 +1405,41 @@ export type Events = {
playlist: string
}
/**
* The playable video was meaningfully visible. This is an exposure event,
* not proof that playback started. Fires once per mounted video item.
*/
'video:impression': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
}
/**
* Playback advanced far enough to render the first frame. Preloading and
* merely becoming active do not count. Fires once per mounted video item;
* automatic loops do not produce another event.
*/
'video:playback:start': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
autoplay: boolean
}
/**
* The user activated a third-party media player. Cross-origin players do
* not expose confirmed playback consistently, so this must not be treated
* as equivalent to video:playback:start without an explicit methodology.
*/
'externalEmbed:playerActivated': {
postUri?: string
postAuthorDid?: string
source: string
playerType: string
mediaType: 'video' | 'audio' | 'gif' | 'other'
}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
+59 -13
View File
@@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type Props as SVGIconProps} from '#/components/icons/common'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function Error({
icon: Icon,
title,
message,
onRetry,
onGoBack,
hideBackButton,
secondaryAction,
isRetrying,
}: {
icon?: React.ComponentType<SVGIconProps>
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
isRetrying?: boolean
secondaryAction?: {
label: string
accessibilityLabel?: string
onPress: () => unknown
}
}) {
const {t: l} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
return (
<Layout.Center
@@ -35,8 +46,11 @@ export function Error({
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
<View style={[a.w_full, a.align_center, a.gap_lg, a.px_md]}>
{Icon && <Icon size="4xl" fill={t.atoms.text_contrast_medium.color} />}
<Text style={[a.font_semi_bold, a.text_3xl, a.text_center]}>
{title}
</Text>
<Text
style={[
a.text_md,
@@ -51,29 +65,61 @@ export function Error({
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
{onRetry && (
<Button
variant="solid"
color="primary"
label={l`Press to retry`}
onPress={onRetry}
disabled={isRetrying}
size="large">
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
{isRetrying && <ButtonIcon icon={Loader} />}
</Button>
)}
{!hideBackButton && (
{!hideBackButton && secondaryAction ? (
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`}
onPress={goBack}
label={secondaryAction.accessibilityLabel ?? secondaryAction.label}
onPress={secondaryAction.onPress}
disabled={isRetrying}
size="large">
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
<ButtonText>{secondaryAction.label}</ButtonText>
</Button>
)}
) : !hideBackButton ? (
<GoBackButton
hasRetry={Boolean(onRetry)}
isRetrying={isRetrying}
onGoBack={onGoBack}
/>
) : null}
</View>
</Layout.Center>
)
}
function GoBackButton({
hasRetry,
isRetrying,
onGoBack,
}: {
hasRetry: boolean
isRetrying?: boolean
onGoBack?: () => unknown
}) {
const {t: l} = useLingui()
const goBack = useGoBack(onGoBack)
return (
<Button
variant="solid"
color={hasRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`}
onPress={goBack}
disabled={isRetrying}
size="large">
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
</Button>
)
}
+1 -1
View File
@@ -219,7 +219,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
numberOfLines={1}>
{children}
</Text>
)
+2 -2
View File
@@ -102,7 +102,7 @@ function DialogInner({
if (isMe) {
if (profile.joinedViaStarterPack) {
return _(
msg`You joined Bluesky using a starter pack ${timeAgoString} ago`,
msg`You joined Bluesky using a Starter Pack ${timeAgoString} ago`,
)
} else {
return _(msg`You joined Bluesky ${timeAgoString} ago`)
@@ -110,7 +110,7 @@ function DialogInner({
} else {
if (profile.joinedViaStarterPack) {
return _(
msg`${profileName} joined Bluesky using a starter pack ${timeAgoString} ago`,
msg`${profileName} joined Bluesky using a Starter Pack ${timeAgoString} ago`,
)
} else {
return _(msg`${profileName} joined Bluesky ${timeAgoString} ago`)
@@ -22,6 +22,7 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {
type EmbedPlayerParams,
getEmbedPlayerMediaType,
getPlayerAspect,
} from '#/lib/strings/embed-player'
import {useExternalEmbedsPrefs} from '#/state/preferences'
@@ -32,6 +33,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {KeepAwake} from '#/components/KeepAwake'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {type app} from '#/lexicons'
@@ -121,9 +123,11 @@ function Player({
export function ExternalPlayer({
link,
params,
post,
}: {
link: app.bsky.embed.external.ViewExternal
params: EmbedPlayerParams
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
@@ -131,10 +135,31 @@ export function ExternalPlayer({
const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl()
const ax = useAnalytics()
const [isPlayerActive, setIsPlayerActive] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const activatePlayer = useCallback(() => {
if (!isPlayerActive) {
ax.metric('externalEmbed:playerActivated', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
source: params.source,
playerType: params.type,
mediaType: getEmbedPlayerMediaType(params.type),
})
}
setIsPlayerActive(true)
}, [
ax,
isPlayerActive,
params.source,
params.type,
post?.author.did,
post?.uri,
])
const aspect = useMemo(() => {
return getPlayerAspect({
type: params.type,
@@ -202,14 +227,14 @@ export function ExternalPlayer({
return
}
setIsPlayerActive(true)
activatePlayer()
},
[externalEmbedsPrefs, consentDialogControl, params.source],
[externalEmbedsPrefs, consentDialogControl, params.source, activatePlayer],
)
const onAcceptConsent = useCallback(() => {
setIsPlayerActive(true)
}, [])
activatePlayer()
}, [activatePlayer])
return (
<>
@@ -27,11 +27,13 @@ import {GifEmbed} from './Gif'
export const ExternalEmbed = ({
link,
onOpen,
post,
style,
hideAlt,
}: {
link: app.bsky.embed.external.ViewExternal
onOpen?: () => void
post?: app.bsky.feed.defs.PostView
style?: StyleProp<ViewStyle>
hideAlt?: boolean
}) => {
@@ -120,7 +122,11 @@ export const ExternalEmbed = ({
{embedPlayerParams?.isGif ? (
<ExternalGif link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
<ExternalPlayer
link={link}
params={embedPlayerParams}
post={post}
/>
) : undefined}
<View
@@ -4,6 +4,7 @@ import {BlueskyVideoView} from '@bsky.app/video'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
@@ -26,6 +27,7 @@ export function VideoEmbedInnerNative({
setStatus,
setIsLoading,
setIsActive,
onPlaybackStart,
onError,
}: {
ref: React.Ref<{togglePlayback: () => void}>
@@ -33,6 +35,7 @@ export function VideoEmbedInnerNative({
setStatus: (status: 'playing' | 'paused') => void
setIsLoading: (isLoading: boolean) => void
setIsActive: (isActive: boolean) => void
onPlaybackStart: (autoplay: boolean) => void
/**
* Called with the native error message before the component throws to the
* surrounding error boundary.
@@ -46,6 +49,7 @@ export function VideoEmbedInnerNative({
const [muted, setMuted] = useVideoMuteState()
const reportDialogMetadata = useReportDialogMetadataContext()
const maxTimeRemainingSeconds = useRef(0)
const playbackStartTrackedRef = useRef(false)
const [isPlaying, setIsPlaying] = useState(false)
const [timeRemaining, setTimeRemaining] = useState(0)
@@ -62,12 +66,13 @@ export function VideoEmbedInnerNative({
}
const isGif = embed.presentation === 'gif'
const autoplay = !autoplayDisabled && !isWithinMessage
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
autoplay={autoplay}
beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]}
onActiveChange={e => {
@@ -88,20 +93,26 @@ export function VideoEmbedInnerNative({
onTimeRemainingChange={e => {
const {timeRemaining} = e.nativeEvent
setTimeRemaining(timeRemaining)
if (
!isGif &&
reportDialogMetadata &&
Number.isFinite(timeRemaining) &&
timeRemaining >= 0
) {
if (Number.isFinite(timeRemaining) && timeRemaining >= 0) {
maxTimeRemainingSeconds.current = Math.max(
maxTimeRemainingSeconds.current,
timeRemaining,
)
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(
maxTimeRemainingSeconds.current - timeRemaining,
)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(autoplay)
}
if (!isGif && reportDialogMetadata) {
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
}
}
}}
onError={e => {
@@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = {
setActive: () => void
onScreen: boolean
lastKnownTime: React.RefObject<number | undefined>
onPlaybackStart: (autoplay: boolean) => void
}
export class HLSUnsupportedError extends Error {
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react/macro'
import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
@@ -29,6 +30,7 @@ export function VideoEmbedInnerWeb({
setActive,
onScreen,
lastKnownTime,
onPlaybackStart,
}: VideoEmbedInnerWebProps) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
@@ -40,6 +42,7 @@ export function VideoEmbedInnerWeb({
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
const reportDialogMetadata = useReportDialogMetadataContext()
const playbackStartTrackedRef = useRef(false)
// send error up to error boundary
const [error, setError] = useState<Error | null>(null)
@@ -79,6 +82,13 @@ export function VideoEmbedInnerWeb({
onTimeUpdate={e => {
const currentTime = e.currentTarget.currentTime
lastKnownTime.current = currentTime
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(currentTime)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(!focused)
}
if (
!isGif &&
reportDialogMetadata &&
+26 -3
View File
@@ -23,9 +23,10 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}
export function VideoEmbed({embed}: Props) {
export function VideoEmbed({embed, post}: Props) {
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -52,7 +53,7 @@ export function VideoEmbed({embed}: Props) {
const contents = (
<ErrorBoundary renderError={renderError} key={key}>
<InnerWrapper embed={embed} />
<InnerWrapper embed={embed} post={post} />
</ErrorBoundary>
)
@@ -69,7 +70,7 @@ export function VideoEmbed({embed}: Props) {
)
}
function InnerWrapper({embed}: Props) {
function InnerWrapper({embed, post}: Props) {
const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null)
@@ -86,6 +87,8 @@ function InnerWrapper({embed}: Props) {
* the active position cost nothing.
*/
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
useEffect(() => {
return () => {
telemetryRef.current?.deactivated()
@@ -121,6 +124,15 @@ function InnerWrapper({embed}: Props) {
setIsActive={active => {
setIsActive(active)
if (active) {
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',
@@ -132,6 +144,17 @@ function InnerWrapper({embed}: Props) {
telemetryRef.current?.deactivated()
}
}}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
autoplay,
})
}}
onError={error => {
telemetryRef.current?.error(error)
ax.metric('video:playback:failed', {
@@ -37,7 +37,13 @@ const noop = () => {}
*/
const MIN_CARD_WIDTH = 280
export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
export function VideoEmbed({
embed,
post,
}: {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {
@@ -47,13 +53,28 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
currentActiveView,
} = useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
const [meaningfullyVisible, setMeaningfullyVisible] = useState(false)
const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(undefined)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
const ax = useAnalytics()
const isGif = embed.presentation === 'gif'
// GIFs don't participate in the "one video at a time" system
const active = isGif || activeFromContext
useEffect(() => {
if (!meaningfullyVisible || impressionTrackedRef.current) return
impressionTrackedRef.current = true
ax.metric('video:impression', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
})
}, [ax, isGif, meaningfullyVisible, post?.author.did, post?.uri])
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !IS_WEB_FIREFOX) return
@@ -62,6 +83,9 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
setMeaningfullyVisible(
entry.isIntersecting && entry.intersectionRatio >= 0.5,
)
// GIFs don't send position - they don't compete to be the active video
if (!isGif) {
sendPosition(
@@ -179,6 +203,17 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
setActive={setActive}
onScreen={onScreen}
lastKnownTime={lastKnownTime}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
autoplay,
})
}}
/>
</OnlyNearScreen>
</ErrorBoundary>
+2 -1
View File
@@ -134,6 +134,7 @@ function MediaEmbed({
<ExternalEmbed
link={embed.view.external}
onOpen={rest.onOpen}
post={rest.post}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>
@@ -144,7 +145,7 @@ function MediaEmbed({
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<VideoEmbed embed={embed.view} />
<VideoEmbed embed={embed.view} post={rest.post} />
</ContentHider>
)
}
+35 -30
View File
@@ -1,4 +1,5 @@
import {View} from 'react-native'
import {type ViewStyle} from 'react-native'
import {StyleProp} from 'react-native'
import {AtUri} from '@atproto/syntax'
import {moderateProfile} from '@bsky/sdk/moderation'
import {Trans, useLingui} from '@lingui/react/macro'
@@ -22,11 +23,11 @@ import {type app} from '#/lexicons'
export function KnownLikers({
post,
feature,
variant = 'thread',
outerStyle,
}: {
post: app.bsky.feed.defs.PostView
feature: Features
variant?: 'feed' | 'thread'
outerStyle?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -61,7 +62,6 @@ export function KnownLikers({
moderation.ui('displayName'),
),
}))
const isFeed = variant === 'feed'
const rowLabel =
names.length >= 2
? l`Liked by ${names[0].displayName} and ${names[1].displayName}`
@@ -83,31 +83,36 @@ export function KnownLikers({
)
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>
<Link
to={likesHref}
label={rowLabel}
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_xs,
a.flex_shrink,
outerStyle,
]}
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>
)
}
+7 -3
View File
@@ -2,6 +2,7 @@ import {useMemo} from 'react'
import {type StyleProp, type TextStyle} from 'react-native'
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
import {isRTLText} from '#/lib/strings/text-direction'
import {toShortUrl} from '#/lib/strings/url-helpers'
import {android, atoms as a, flatten, type TextStyleProp} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
@@ -9,6 +10,7 @@ import {InlineLinkText, type LinkProps} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichTextTag} from '#/components/RichTextTag'
import {Text, type TextProps} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
@@ -82,15 +84,17 @@ export function RichText({
}
}, [value])
const plainStyles = style
const {text, facets} = richText
const plainStyles: StyleProp<TextStyle> = [
style,
IS_NATIVE && isRTLText(text) ? {textAlign: 'right'} : null,
]
const suffixStyles =
suffix && suffixOffset
? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset})
: null
const interactiveStyles = [plainStyles, interactiveStyle]
const {text, facets} = richText
if (!facets?.length) {
if (isOnlyEmoji(text)) {
const flattenedStyle = flatten(style)
@@ -2,22 +2,31 @@ import {forwardRef, useCallback, useImperativeHandle, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import {AtUri} from '@atproto/syntax'
import {type ModerationOpts} from '@bsky/sdk/moderation'
import {useLingui} from '@lingui/react/macro'
import {Trans} from '@lingui/react/macro'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {cleanError} from '#/lib/strings/errors'
import {useAllListMembersQuery} from '#/state/queries/list-members'
import {useListMembershipRemoveMutation} from '#/state/queries/list-memberships'
import {useSession} from '#/state/session'
import {List, type ListRef} from '#/view/com/util/List'
import {type SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Default as ProfileCard} from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE, IS_WEB} from '#/env'
import {type app} from '#/lexicons'
function keyExtractor(item: app.bsky.actor.defs.ProfileView, index: number) {
return `${item.did}-${index}`
function keyExtractor(item: app.bsky.graph.defs.ListItemView) {
return item.uri
}
interface ProfilesListProps {
@@ -42,26 +51,26 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
// The server returns these sorted by descending creation date, so we want to invert
const profiles = data
const listItems = data
?.filter(
p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
)
.map(p => p.subject)
.reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did
const getSortedProfiles = () => {
if (!profiles) return
if (!isOwn) return profiles
if (!listItems) return
const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
return myIndex !== -1
? [
profiles[myIndex],
...profiles.slice(0, myIndex),
...profiles.slice(myIndex + 1),
]
: profiles
return [...listItems].sort((a, b) => {
if (a.subjectOptedOut !== b.subjectOptedOut) {
return a.subjectOptedOut ? -1 : 1
}
if (isOwn) {
if (a.subject.did === currentAccount?.did) return -1
if (b.subject.did === currentAccount?.did) return 1
}
return 0
})
}
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
@@ -77,7 +86,7 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
const renderItem = ({
item,
index,
}: ListRenderItemInfo<app.bsky.actor.defs.ProfileView>) => {
}: ListRenderItemInfo<app.bsky.graph.defs.ListItemView>) => {
return (
<View
style={[
@@ -86,14 +95,23 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
(IS_WEB || index !== 0) && a.border_t,
]}>
<ProfileCard
profile={item}
profile={item.subject}
moderationOpts={moderationOpts}
logContext="StarterPackProfilesList"
/>
{item.subjectOptedOut ? (
<OptedOutControls item={item} listUri={listUri} canRemove={isOwn} />
) : null}
</View>
)
}
const onRefresh = async () => {
setIsPTRing(true)
await refetch()
setIsPTRing(false)
}
if (!data) {
return (
<View
@@ -127,12 +145,71 @@ export const ProfilesList = forwardRef<SectionRef, ProfilesListProps>(
desktopFixedHeight
initialNumToRender={initialNumToRender}
refreshing={isPTRing}
onRefresh={async () => {
setIsPTRing(true)
await refetch()
setIsPTRing(false)
}}
onRefresh={() => void onRefresh()}
/>
)
},
)
function OptedOutControls({
item,
listUri,
canRemove,
}: {
item: app.bsky.graph.defs.ListItemView
listUri: string
canRemove: boolean
}) {
const {t: l} = useLingui()
const ax = useAnalytics()
const [isRemoved, setIsRemoved] = useState(false)
const {mutate: removeMembership, isPending} = useListMembershipRemoveMutation(
{
onSuccess: () => {
setIsRemoved(true)
Toast.show(l`Removed from Starter Pack`)
},
onError: error =>
Toast.show(cleanError(error), {
type: 'error',
}),
},
)
if (isRemoved) return null
return (
<Admonition.Outer type="info" style={[a.mt_sm]}>
<Admonition.Row style={[a.align_center]}>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>Opted out of this Starter Pack</Trans>
</Admonition.Text>
</Admonition.Content>
{canRemove ? (
<Admonition.Button
label={l`Remove user from Starter Pack`}
color="secondary"
disabled={isPending}
onPress={() => {
ax.metric('starterPack:removeUser', {context: 'opt-out'})
removeMembership({
listUri,
actorDid: item.subject.did,
membershipUri: item.uri,
})
}}>
{isPending ? (
<ButtonIcon icon={Loader} />
) : (
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
)}
</Admonition.Button>
) : null}
</Admonition.Row>
</Admonition.Outer>
)
}
@@ -6,9 +6,7 @@ import {
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
@@ -54,7 +52,7 @@ interface ProfileFeedgensProps {
isMe: boolean
emptyStateMessage?: string
emptyStateButton?: EmptyStateButtonProps
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
emptyStateIcon?: React.ComponentType | React.ReactElement
}
function keyExtractor(item: app.bsky.graph.defs.StarterPackViewBasic) {
@@ -90,7 +88,7 @@ export function ProfileStarterPacks({
const {isTabletOrDesktop} = useWebMediaQueries()
const items = data?.pages.flatMap(page => page.starterPacks)
const {_} = useLingui()
const {t: l} = useLingui()
const EmptyComponent = useCallback(() => {
if (emptyStateMessage || emptyStateButton || emptyStateIcon) {
@@ -101,9 +99,7 @@ export function ProfileStarterPacks({
iconSize="3xl"
message={
emptyStateMessage ??
_(
msg`Starter packs let you share your favorite feeds and people with your friends.`,
)
l`Starter Packs let you share your favorite feeds and people with your friends.`
}
button={emptyStateButton}
/>
@@ -111,7 +107,7 @@ export function ProfileStarterPacks({
)
}
return <Empty />
}, [_, emptyStateMessage, emptyStateButton, emptyStateIcon])
}, [l, emptyStateMessage, emptyStateButton, emptyStateIcon])
useImperativeHandle(ref, () => ({
scrollToTop: () => {},
@@ -122,7 +118,7 @@ export function ProfileStarterPacks({
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh starter packs', {message: err})
logger.error('Failed to refresh Starter Packs', {message: err})
}
setIsPTRing(false)
}, [refetch, setIsPTRing])
@@ -132,7 +128,7 @@ export function ProfileStarterPacks({
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more starter packs', {message: err})
logger.error('Failed to load more Starter Packs', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
@@ -179,8 +175,8 @@ export function ProfileStarterPacks({
}}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
onRefresh={onRefresh}
onEndReached={() => void onEndReached()}
onRefresh={() => void onRefresh()}
ListEmptyComponent={
data ? (isMe ? EmptyComponent : undefined) : FeedLoadingPlaceholder
}
@@ -193,7 +189,7 @@ export function ProfileStarterPacks({
}
function CreateAnother() {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
@@ -207,7 +203,7 @@ function CreateAnother() {
t.atoms.border_contrast_low,
]}>
<Button
label={_(msg`Create a starter pack`)}
label={l`Create a Starter Pack`}
variant="solid"
color="secondary"
size="small"
@@ -223,7 +219,7 @@ function CreateAnother() {
}
function Empty() {
const {_} = useLingui()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const confirmDialogControl = useDialogControl()
const followersDialogControl = useDialogControl()
@@ -244,7 +240,7 @@ function Empty() {
setIsGenerating(false)
},
onError: e => {
logger.error('Failed to generate starter pack', {safeMessage: e})
logger.error('Failed to generate Starter Pack', {safeMessage: e})
setIsGenerating(false)
if (e.message.includes('NOT_ENOUGH_FOLLOWERS')) {
followersDialogControl.open()
@@ -265,7 +261,7 @@ function Empty() {
const wrappedOpenConfirmDialog = requireEmailVerification(openConfirmDialog, {
instructions: [
<Trans key="confirm">
Before creating a starter pack, you must first verify your email.
Before creating a Starter Pack, you must first verify your email.
</Trans>,
],
})
@@ -275,7 +271,7 @@ function Empty() {
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
instructions: [
<Trans key="nav">
Before creating a starter pack, you must first verify your email.
Before creating a Starter Pack, you must first verify your email.
</Trans>,
],
})
@@ -292,18 +288,18 @@ function Empty() {
]}>
<View style={[a.gap_xs]}>
<Text style={[a.font_semi_bold, a.text_lg, {color: 'white'}]}>
<Trans>You haven't created a starter pack yet!</Trans>
<Trans>You haven't created a Starter Pack yet!</Trans>
</Text>
<Text style={[a.text_md, {color: 'white'}]}>
<Trans>
Starter packs let you easily share your favorite feeds and people
Starter Packs let you easily share your favorite feeds and people
with your friends.
</Trans>
</Text>
</View>
<View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
<Button
label={_(msg`Create a starter pack for me`)}
label={l`Create a Starter Pack for me`}
variant="ghost"
color="primary"
size="small"
@@ -316,7 +312,7 @@ function Empty() {
{isGenerating && <Loader size="md" />}
</Button>
<Button
label={_(msg`Create a starter pack`)}
label={l`Create a Starter Pack`}
variant="ghost"
color="primary"
size="small"
@@ -333,11 +329,10 @@ function Empty() {
</ButtonText>
</Button>
</View>
<Prompt.Outer control={confirmDialogControl}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Generate a starter pack</Trans>
<Trans>Generate a Starter Pack</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
@@ -349,12 +344,12 @@ function Empty() {
<Prompt.Actions>
<Prompt.Action
color="primary"
cta={_(msg`Choose for me`)}
cta={l`Choose for me`}
onPress={generate}
/>
<Prompt.Action
color="secondary"
cta={_(msg`Let me choose`)}
cta={l`Let me choose`}
onPress={() => {
navigation.navigate('StarterPackWizard', {})
}}
@@ -363,21 +358,17 @@ function Empty() {
</Prompt.Outer>
<Prompt.Basic
control={followersDialogControl}
title={_(msg`Oops!`)}
description={_(
msg`You must be following at least seven other people to generate a starter pack.`,
)}
title={l`Oops!`}
description={l`You must be following at least seven other people to generate a Starter Pack.`}
onConfirm={() => {}}
showCancel={false}
/>
<Prompt.Basic
control={errorDialogControl}
title={_(msg`Oops!`)}
description={_(
msg`An error occurred while generating your starter pack. Want to try again?`,
)}
title={l`Oops!`}
description={l`An error occurred while generating your Starter Pack. Want to try again?`}
onConfirm={generate}
confirmButtonCta={_(msg`Retry`)}
confirmButtonCta={l`Retry`}
/>
</LinearGradientBackground>
)
+1 -1
View File
@@ -164,7 +164,7 @@ export function QrCodeDialog({
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create a QR code for a starter pack`)}>
label={_(msg`Create a QR code for a Starter Pack`)}>
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
<Suspense fallback={<Loading />}>
{!link ? (
+6 -5
View File
@@ -53,14 +53,15 @@ function ShareDialogInner({
const imageUrl = getStarterPackOgCard(starterPack)
const onShareLink = async () => {
const onShareLink = () => {
if (!link) return
shareUrl(link)
ax.metric('starterPack:share', {
starterPack: starterPack.uri,
shareType: 'link',
})
control.close()
control.close(() => {
void shareUrl(link)
})
}
const saveImageToAlbum = useSaveImageToMediaLibrary()
@@ -80,11 +81,11 @@ function ShareDialogInner({
<View style={[!gtMobile && a.gap_lg]}>
<View style={[a.gap_sm, gtMobile && a.pb_lg]}>
<Text style={[a.font_semi_bold, a.text_2xl]}>
<Trans>Invite people to this starter pack!</Trans>
<Trans>Invite people to this Starter Pack!</Trans>
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>
Share this starter pack and help people join your community on
Share this Starter Pack and help people join your community on
Bluesky.
</Trans>
</Text>
+10 -12
View File
@@ -2,9 +2,7 @@ import {useMemo} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AtUri} from '@atproto/syntax'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -59,7 +57,7 @@ export function Card({
}) {
const {record, creator, joinedAllTimeCount} = starterPack
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const isOwnStarterPack = creator?.did === currentAccount?.did
@@ -84,8 +82,8 @@ export function Card({
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{isOwnStarterPack
? _(msg`Starter pack by you`)
: _(msg`Starter pack by ${sanitizeHandle(creator.handle, '@')}`)}
? l`Starter Pack by you`
: l`Starter Pack by ${sanitizeHandle(creator.handle, '@')}`}
</Text>
</View>
</View>
@@ -96,7 +94,7 @@ export function Card({
) : null}
{!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
<Text style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
<Trans comment="Number of users (always at least 50) who have joined Bluesky using a specific starter pack">
<Trans comment="Number of users (always at least 50) who have joined Bluesky using a specific Starter Pack">
<Plural value={joinedAllTimeCount} other="# users have" /> joined!
</Trans>
</Text>
@@ -110,7 +108,7 @@ export function useStarterPackLink({
}: {
view: bsky.starterPack.AnyStarterPackView
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const qc = useQueryClient()
const {rkey, handleOrDid} = useMemo(() => {
const rkey = new AtUri(view.uri).rkey
@@ -125,8 +123,8 @@ export function useStarterPackLink({
return {
to: `/starter-pack/${handleOrDid}/${rkey}`,
label: bsky.isType(app.bsky.graph.starterpack, view.record)
? _(msg`Navigate to ${view.record.name}`)
: _(msg`Navigate to starter pack`),
? l`Navigate to ${view.record.name}`
: l`Navigate to Starter Pack`,
precache,
}
}
@@ -139,7 +137,7 @@ export function Link({
onPress?: () => void
children: BaseLinkProps['children']
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const queryClient = useQueryClient()
const {record} = starterPack
const {rkey, handleOrDid} = useMemo(() => {
@@ -155,7 +153,7 @@ export function Link({
return (
<BaseLink
to={`/starter-pack/${handleOrDid}/${rkey}`}
label={_(msg`Navigate to ${record.name}`)}
label={l`Navigate to ${record.name}`}
onPress={() => {
precacheResolvedUri(
queryClient,
@@ -5,9 +5,7 @@ import {
type ModerationOpts,
type ModerationUI,
} from '@bsky/sdk/moderation'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -36,6 +34,7 @@ function WizardListCard({
avatar,
included,
disabled,
subjectOptedOut,
moderationUi,
}: {
type: 'user' | 'algo'
@@ -48,18 +47,19 @@ function WizardListCard({
avatar?: string
included?: boolean
disabled?: boolean
subjectOptedOut?: boolean
moderationUi: ModerationUI
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
return (
<Toggle.Item
name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)}
name={type === 'user' ? l`Person toggle` : l`Feed toggle`}
label={
included
? _(msg`Remove ${displayName} from starter pack`)
: _(msg`Add ${displayName} to starter pack`)
? l`Remove ${displayName} from Starter Pack`
: l`Add ${displayName} to Starter Pack`
}
value={included}
disabled={btnType === 'remove' || disabled}
@@ -97,12 +97,17 @@ function WizardListCard({
numberOfLines={1}>
{subtitle}
</Text>
{subjectOptedOut ? (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Opted out</Trans>
</Text>
) : null}
</View>
{btnType === 'checkbox' ? (
<Checkbox />
) : !disabled ? (
<Button
label={_(msg`Remove`)}
label={l`Remove`}
variant="solid"
color="secondary"
size="small"
@@ -123,22 +128,26 @@ export function WizardProfileCard({
dispatch,
profile,
moderationOpts,
subjectOptedOut = false,
}: {
btnType: 'checkbox' | 'remove'
state: WizardState
dispatch: (action: WizardAction) => void
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
subjectOptedOut?: boolean
}) {
const ax = useAnalytics()
const {currentAccount} = useSession()
// Determine the "main" profile for this starter pack - either targetDid or current account
// Determine the "main" profile for this Starter Pack - either targetDid or current account
const targetProfileDid = state.targetDid || currentAccount?.did
const isTarget = profile.did === targetProfileDid
const included = isTarget || state.profiles.some(p => p.did === profile.did)
const disabled =
isTarget || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE)
subjectOptedOut ||
isTarget ||
(!included && state.profiles.length >= STARTER_PACK_MAX_SIZE)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName)
@@ -169,6 +178,7 @@ export function WizardProfileCard({
avatar={profile.avatar}
included={included}
disabled={disabled}
subjectOptedOut={subjectOptedOut}
moderationUi={moderationUi}
/>
)
+20 -3
View File
@@ -5,7 +5,7 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useCallOnce} from '#/lib/once'
// import {makeProfileLink} from '#/lib/routes/links'
// import {feedUriToHref} from '#/lib/strings/url-helpers'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {atoms as a, native, useTheme} from '#/alf'
import {Link as InternalLink, type LinkProps} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
@@ -72,7 +72,8 @@ export function TrendingTopicLink({
recId?: string
} & Omit<LinkProps, 'to' | 'label'>) {
const topic = useTopic(raw)
useTrendingTopicSeen(metricContext, rank, recId)
const feedUri = getTrendingTopicFeedUri(raw)
useTrendingTopicSeen(metricContext, feedUri, rank, recId)
return (
<InternalLink
@@ -87,6 +88,7 @@ export function TrendingTopicLink({
export function useTrendingTopicSeen(
context: Metrics['trendingTopic:seen']['context'],
feedUri: string | undefined,
rank: number,
recId?: string,
feedSliceIndex?: number,
@@ -95,6 +97,7 @@ export function useTrendingTopicSeen(
const trackSeen = useCallOnce(() => {
ax.metric('trendingTopic:seen', {
context,
feedUri,
rank,
feedSliceIndex,
recId,
@@ -106,6 +109,20 @@ export function useTrendingTopicSeen(
}, [trackSeen])
}
export function getTrendingTopicFeedUri(
topic: app.bsky.unspecced.defs.TrendView,
): string | undefined {
const match = topic.link.match(/^\/profile\/([^/]+)\/feed\/([^/?#]+)/)
if (!match) return undefined
return makeRecordUri(
decodeURIComponent(match[1]),
'app.bsky.feed.generator',
decodeURIComponent(match[2]),
)
}
type ParsedTrendingTopic =
| {
type: 'topic' | 'tag' | 'starter-pack' | 'unknown'
@@ -149,7 +166,7 @@ export function useTopic(
} else if (link.startsWith('/starter-pack')) {
return {
type: 'starter-pack',
label: l`Browse starter pack ${displayName}`,
label: l`Browse Starter Pack ${displayName}`,
displayName,
uri: undefined,
url: link,
+4 -4
View File
@@ -114,7 +114,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
style={[
a.flex_row,
a.align_center,
{gap: 3},
a.gap_xs,
(hovered || focused || pressed) && native({opacity: 0.5}),
style,
]}>
@@ -122,16 +122,16 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
color={
isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400
}
width={12}
width={16}
settings={settings}
/>
<Text
style={[
a.text_xs,
a.text_sm,
a.leading_tight,
isThreadAuthor
? {color: t.palette.primary_500}
: t.atoms.text_contrast_high,
: t.atoms.text_contrast_medium,
(hovered || focused || pressed) && web(a.underline),
]}>
{description}
@@ -1,7 +1,7 @@
import {useContext} from 'react'
import {Alert, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as Contacts from 'expo-contacts'
import * as Contacts from 'expo-contacts/legacy'
import {type Un$Typed} from '@atproto/lex'
import {type Client} from '@atproto/lex'
import {toDatetimeString} from '@atproto/syntax'
+1 -1
View File
@@ -1,6 +1,6 @@
import {createContext, useContext, useReducer} from 'react'
import {type GestureResponderEvent} from 'react-native'
import {type ExistingContact} from 'expo-contacts'
import {type ExistingContact} from 'expo-contacts/legacy'
import {type CountryCode} from '#/lib/international-telephone-codes'
import type * as bsky from '#/types/bsky'
+2 -2
View File
@@ -8,7 +8,7 @@ import {Trans} from '@lingui/react/macro'
import {EMBED_SCRIPT} from '#/lib/constants'
import {niceDate} from '#/lib/strings/time'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
@@ -103,7 +103,7 @@ function EmbedDialogInner({
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
return (
<Dialog.Inner label={_(msg`Embed post`)} style={[{maxWidth: 500}]}>
<Dialog.Inner label={_(msg`Embed post`)} style={[web({maxWidth: 500})]}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold]}>
+2 -2
View File
@@ -8,7 +8,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
@@ -45,7 +45,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
return (
<Dialog.ScrollableInner
label={_(msg`Sign in to Bluesky or create a new account`)}
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
style={[a.w_full, gtMobile && web({width: 'auto', maxWidth: 420})]}>
<View style={[!IS_NATIVE && a.p_2xl]}>
<View
style={[
+21 -25
View File
@@ -1,8 +1,6 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
@@ -67,7 +65,7 @@ export function StarterPackDialog({
const wrappedNavToWizard = requireEmailVerification(navToWizard, {
instructions: [
<Trans key="nav">
Before creating a starter pack, you must first verify your email.
Before creating a Starter Pack, you must first verify your email.
</Trans>,
],
})
@@ -85,7 +83,7 @@ export function StarterPackDialog({
}
function Empty({onStartWizard}: {onStartWizard: () => void}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
return (
@@ -96,18 +94,17 @@ function Empty({onStartWizard}: {onStartWizard: () => void}) {
fill={t.atoms.border_contrast_medium.borderColor}
/>
<Text style={[a.text_center]}>
<Trans>You have no starter packs.</Trans>
<Trans>You have no Starter Packs.</Trans>
</Text>
</View>
<View style={[a.align_center]}>
<Button
label={_(msg`Create starter pack`)}
label={l`Create Starter Pack`}
color="secondary_inverted"
size="small"
onPress={onStartWizard}>
<ButtonText>
<Trans comment="Text on button to create a new starter pack">
<Trans comment="Text on button to create a new Starter Pack">
Create
</Trans>
</ButtonText>
@@ -128,7 +125,7 @@ function StarterPackList({
enabled?: boolean
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {t: l} = useLingui()
const {data: subject} = useProfileQuery({did: targetDid})
const {
@@ -178,10 +175,10 @@ function StarterPackList({
native(a.pt_lg),
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Add to starter packs</Trans>
<Trans>Add to Starter Packs</Trans>
</Text>
<Button
label={_(msg`Close`)}
label={l`Close`}
onPress={onClose}
variant="ghost"
color="secondary"
@@ -196,15 +193,15 @@ function StarterPackList({
<View
style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Trans>New starter pack</Trans>
<Trans>New Starter Pack</Trans>
</Text>
<Button
label={_(msg`Create starter pack`)}
label={l`Create Starter Pack`}
color="secondary_inverted"
size="small"
onPress={onStartWizard}>
<ButtonText>
<Trans comment="Text on button to create a new starter pack">
<Trans comment="Text on button to create a new Starter Pack">
Create
</Trans>
</ButtonText>
@@ -234,7 +231,7 @@ function StarterPackList({
? () => 'starter_pack_dialog_loader'
: (item: StarterPackWithMembership) => item.starterPack.uri
}
onEndReached={onEndReached}
onEndReached={() => void onEndReached()}
onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
@@ -257,7 +254,7 @@ function StarterPackItem({
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const isSelf = subject?.did === currentAccount?.did
@@ -268,26 +265,26 @@ function StarterPackItem({
useListMembershipAddMutation({
subject,
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
Toast.show(l`Added to Starter Pack`)
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to add to starter pack', {safeMessage: err})
logger.error('Failed to add to Starter Pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'})
Toast.show(l`Failed to add to Starter Pack`, {type: 'error'})
},
})
const {mutate: removeMembership, isPending: isPendingRemove} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
Toast.show(l`Removed from Starter Pack`)
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to remove from starter pack', {safeMessage: err})
logger.error('Failed to remove from Starter Pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
Toast.show(l`Failed to remove from Starter Pack`, {type: 'error'})
},
})
@@ -363,9 +360,8 @@ function StarterPackItem({
)}
</View>
</View>
<Button
label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
label={isInPack ? l`Remove` : l`Add`}
color={isInPack ? 'secondary' : 'primary_subtle'}
size="tiny"
disabled={isPending || isSelf}
@@ -2,8 +2,7 @@ import {View} from 'react-native'
import {TID} from '@atproto/common-web'
import {type $Typed} from '@atproto/lex'
import {AtUri, type AtUriString, toDatetimeString} from '@atproto/syntax'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -33,7 +32,7 @@ export function CreateListFromStarterPackDialog({
control: Dialog.DialogControlProps
starterPack: app.bsky.graph.defs.StarterPackView
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const appviewClient = useAppviewClient()
const pdsClient = usePdsClient()
@@ -102,7 +101,7 @@ export function CreateListFromStarterPackDialog({
await until(
5,
1e3,
(res: {items: unknown[]}) => res.items.length > 0,
res => !!res?.items.length,
() =>
appviewClient.call(app.bsky.graph.getList, {
list: listUri as AtUriString,
@@ -115,7 +114,7 @@ export function CreateListFromStarterPackDialog({
})(),
)
queryClient.invalidateQueries({queryKey: ['list-members', listUri]})
void queryClient.invalidateQueries({queryKey: ['list-members', listUri]})
ax.metric('starterPack:convertToList', {
starterPack: starterPack.uri,
@@ -123,7 +122,7 @@ export function CreateListFromStarterPackDialog({
})
} catch (e) {
logger.error('Failed to add members to list', {safeMessage: e})
Toast.show(_(msg`List created, but failed to add some members`), {
Toast.show(l`List created, but failed to add some members`, {
type: 'error',
})
}
@@ -133,7 +132,7 @@ export function CreateListFromStarterPackDialog({
const onListCreated = (listUri: string) => {
loadingDialogControl.open()
addMembersAndNavigate(listUri)
void addMembersAndNavigate(listUri)
}
return (
@@ -144,24 +143,24 @@ export function CreateListFromStarterPackDialog({
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create list from starter pack`)}
label={l`Create list from Starter Pack`}
style={web({maxWidth: 400})}>
<View style={[a.gap_lg]}>
<Text style={[a.text_xl, a.font_bold]}>
<Trans>Create list from starter pack</Trans>
<Trans>Create list from Starter Pack</Trans>
</Text>
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
This will create a new list with the same name, description, and
members as this starter pack.
members as this Starter Pack.
</Trans>
</Text>
<Admonition type="tip">
<Trans>
Changes to the starter pack will not be reflected in the list
Changes to the Starter Pack will not be reflected in the list
after creation. The list will be an independent copy.
</Trans>
</Admonition>
@@ -176,7 +175,7 @@ export function CreateListFromStarterPackDialog({
a.pt_sm,
]}>
<Button
label={_(msg`Create list`)}
label={l`Create list`}
onPress={onPressCreate}
size={platform({
web: 'small',
@@ -188,7 +187,7 @@ export function CreateListFromStarterPackDialog({
</ButtonText>
</Button>
<Button
label={_(msg`Cancel`)}
label={l`Cancel`}
onPress={() => control.close()}
size={platform({
web: 'small',
@@ -204,7 +203,6 @@ export function CreateListFromStarterPackDialog({
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
<CreateOrEditListDialog
control={createDialogControl}
purpose="app.bsky.graph.defs#curatelist"
@@ -215,13 +213,12 @@ export function CreateListFromStarterPackDialog({
avatar: starterPack.list?.avatar,
}}
/>
<Dialog.Outer
control={loadingDialogControl}
nativeOptions={{preventDismiss: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Adding members to list...`)}
label={l`Adding members to list...`}
style={web({maxWidth: 400})}>
<View style={[a.align_center, a.gap_lg, a.py_5xl]}>
<Loader size="xl" />
@@ -187,7 +187,7 @@ function DialogInner({
ImageMeta | undefined | null
>()
// When creating with pre-filled values (from starter pack), consider dirty
// When creating with pre-filled values (from Starter Pack), consider dirty
// immediately so the Save button is enabled
const hasInitialValuesForCreate = !list && initialValues != null
const dirty =
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
@@ -38,7 +38,8 @@ export function InitialVerificationAnnouncement() {
<Dialog.ScrollableInner
label={_(msg`Announcing verification on Bluesky`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.align_start, a.gap_xl]}>
<View
@@ -53,7 +53,7 @@ export function useLandingEntry() {
}, 500)
void (async () => {
// Check for starter pack
// Check for Starter Pack
let uri: string | null | undefined
if (IS_ANDROID) {
+1 -1
View File
@@ -11,7 +11,7 @@ export function useLandingEntry() {
const href = window.location.href
const url = new URL(href)
// Check for starter pack
// Check for Starter Pack
const atUri = httpStarterPackUriToAtUri(href)
if (atUri) {
// Determines if an App Clip is loading this landing page
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const ExclamationCircle_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: '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',
})
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {usePdsClient, useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
@@ -62,7 +62,8 @@ function Inner({}: {control: DialogControlProps}) {
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.gap_xl]}>
{status === 'loading' ? (
@@ -27,6 +27,7 @@ import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {SubtleHover} from '#/components/SubtleHover'
import {
getTrendingTopicFeedUri,
TrendingTopicsPrompt,
useTrendingTopicSeen,
} from '#/components/TrendingTopics'
@@ -154,6 +155,7 @@ function Inner({feedSliceIndex}: {feedSliceIndex: number}) {
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
feedUri: getTrendingTopicFeedUri(trend),
rank,
feedSliceIndex,
recId: trending.recId,
@@ -195,7 +197,13 @@ function TrendRow({
const actors = useModerateTrendingActors(trend.actors)
const formattedPostCount = formatCount(i18n, trend.postCount)
useTrendingTopicSeen('interstitial', rank, recId, feedSliceIndex)
useTrendingTopicSeen(
'interstitial',
getTrendingTopicFeedUri(trend),
rank,
recId,
feedSliceIndex,
)
return (
<Link
+21 -22
View File
@@ -1,71 +1,70 @@
import {useMemo} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {type ParsedReportSubject} from './types'
export function useCopyForSubject(subject: ParsedReportSubject) {
const {_} = useLingui()
const {t: l} = useLingui()
return useMemo(() => {
switch (subject.type) {
case 'account': {
return {
title: _(msg`Report this user`),
subtitle: _(msg`Why should this user be reviewed?`),
title: l`Report this user`,
subtitle: l`Why should this user be reviewed?`,
}
}
case 'status': {
return {
title: _(msg`Report this livestream`),
subtitle: _(msg`Why should this livestream be reviewed?`),
title: l`Report this livestream`,
subtitle: l`Why should this livestream be reviewed?`,
}
}
case 'post': {
return {
title: _(msg`Report this post`),
subtitle: _(msg`Why should this post be reviewed?`),
title: l`Report this post`,
subtitle: l`Why should this post be reviewed?`,
}
}
case 'list': {
return {
title: _(msg`Report this list`),
subtitle: _(msg`Why should this list be reviewed?`),
title: l`Report this list`,
subtitle: l`Why should this list be reviewed?`,
}
}
case 'feed': {
return {
title: _(msg`Report this feed`),
subtitle: _(msg`Why should this feed be reviewed?`),
title: l`Report this feed`,
subtitle: l`Why should this feed be reviewed?`,
}
}
case 'starterPack': {
return {
title: _(msg`Report this starter pack`),
subtitle: _(msg`Why should this starter pack be reviewed?`),
title: l`Report this Starter Pack`,
subtitle: l`Why should this Starter Pack be reviewed?`,
}
}
case 'convoMessage': {
switch (subject.view) {
case 'convo': {
return {
title: _(msg`Report this conversation`),
subtitle: _(msg`Why should this conversation be reviewed?`),
title: l`Report this conversation`,
subtitle: l`Why should this conversation be reviewed?`,
}
}
case 'message': {
return {
title: _(msg`Report this message`),
subtitle: _(msg`Why should this message be reviewed?`),
title: l`Report this message`,
subtitle: l`Why should this message be reviewed?`,
}
}
}
}
case 'convo': {
return {
title: _(msg`Report this conversation`),
subtitle: _(msg`Why should this conversation be reviewed?`),
title: l`Report this conversation`,
subtitle: l`Why should this conversation be reviewed?`,
}
}
}
}, [_, subject])
}, [l, subject])
}
@@ -18,7 +18,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {useMyLabelersQuery} from '#/state/queries/preferences'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {atoms as a, useGutters, useTheme, web} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -334,7 +334,7 @@ function Inner(
testID="report:dialog"
label={l`Report dialog`}
ref={ref}
style={[a.w_full, {maxWidth: 500}]}>
style={[a.w_full, web({maxWidth: 500})]}>
<View style={[a.gap_2xl, IS_NATIVE && a.pt_md]}>
<StepOuter>
<StepTitle
@@ -8,7 +8,7 @@ import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -78,7 +78,8 @@ function Inner({
<Dialog.ScrollableInner
label={label}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_semi_bold, a.pr_4xl, a.leading_tight]}>
@@ -7,7 +7,7 @@ import {Trans} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {VerifierCheck} from '#/components/icons/VerifierCheck'
@@ -65,7 +65,8 @@ function Inner({
<Dialog.ScrollableInner
label={label}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.gap_lg]}>
<View
+8
View File
@@ -1,2 +1,10 @@
// TS6.0 enables noUncheckedSideEffectImports
declare module '*.css'
declare module 'bidi-js' {
type Bidi = {
getBidiCharTypeName(character: string): string
}
export default function bidiFactory(): Bidi
}
+54
View File
@@ -0,0 +1,54 @@
import {type app} from '#/lexicons'
import {createFeedViewPostsSlices} from './feed-manip'
jest.mock('./feed/home', () => ({
FALLBACK_MARKER_POST: {post: {uri: 'at://did:plc:test/app.bsky.feed.post/1'}},
}))
const author = {
$type: 'app.bsky.actor.defs#profileViewBasic',
did: 'did:plc:alice',
handle: 'alice.test',
} as app.bsky.actor.defs.ProfileViewBasic
function post(id: string) {
return {
$type: 'app.bsky.feed.defs#postView',
uri: `at://did:plc:alice/app.bsky.feed.post/${id}`,
cid: id,
author,
record: {
$type: 'app.bsky.feed.post',
text: id,
createdAt: '2026-08-31T00:00:00.000Z',
},
indexedAt: '2026-08-31T00:00:00.000Z',
} as app.bsky.feed.defs.PostView
}
describe('createFeedViewPostsSlices', () => {
it('preserves selected numbering and infers hydrated parent and root numbering', () => {
const root = post('root')
const parent = post('parent')
const selected = post('selected')
const feedPost = {
post: selected,
reply: {root, parent},
opThreadPostIndex: 3,
opThreadPostCount: 4,
} as app.bsky.feed.defs.FeedViewPost & {
opThreadPostIndex: number
opThreadPostCount: number
}
const [slice] = createFeedViewPostsSlices([feedPost])
expect(
slice.items.map(item => [item.post.uri, item.postNumbering]),
).toEqual([
[root.uri, {opThreadPostIndex: 1, opThreadPostCount: 4}],
[parent.uri, {opThreadPostIndex: 2, opThreadPostCount: 4}],
[selected.uri, {opThreadPostIndex: 3, opThreadPostCount: 4}],
])
})
})
+20 -14
View File
@@ -9,7 +9,7 @@ export type FeedPostNumbering = Pick<
'opThreadPostIndex' | 'opThreadPostCount'
>
type ValidFeedPostNumbering = Required<FeedPostNumbering>
export type ValidFeedPostNumbering = Required<FeedPostNumbering>
// AppView adds these fields to feed responses ahead of their feed lexicon.
type FeedViewPost = app.bsky.feed.defs.FeedViewPost & FeedPostNumbering
@@ -62,7 +62,7 @@ export type FeedTunerFn = (
type FeedSliceItem = {
post: app.bsky.feed.defs.PostView
record: app.bsky.feed.post.Main
postNumbering: FeedPostNumbering | undefined
postNumbering: ValidFeedPostNumbering | undefined
parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined
isParentBlocked: boolean
isParentNotFound: boolean
@@ -88,7 +88,7 @@ export class FeedViewPostsSlice {
constructor(
feedPost: FeedViewPost,
postNumberingByUri: Map<string, FeedPostNumbering>,
postNumberingByUri: Map<string, ValidFeedPostNumbering>,
) {
const {post, reply, reason} = feedPost
this.items = []
@@ -286,6 +286,22 @@ export class FeedViewPostsSlice {
}
}
export function createFeedViewPostsSlices(
feed: FeedViewPost[],
): FeedViewPostsSlice[] {
const postNumberingByUri = new Map<string, ValidFeedPostNumbering>()
for (const item of feed) {
const postNumbering = getPostNumbering(item)
if (postNumbering) {
postNumberingByUri.set(item.post.uri, postNumbering)
}
}
return feed
.map(item => new FeedViewPostsSlice(item, postNumberingByUri))
.filter(slice => slice.items.length > 0 || slice.isFallbackMarker)
}
export class FeedTuner {
seenKeys: Set<string> = new Set()
seenUris: Set<string> = new Set()
@@ -299,17 +315,7 @@ export class FeedTuner {
dryRun: false,
},
): FeedViewPostsSlice[] {
const postNumberingByUri = new Map<string, FeedPostNumbering>()
for (const item of feed) {
const postNumbering = getPostNumbering(item)
if (postNumbering) {
postNumberingByUri.set(item.post.uri, postNumbering)
}
}
let slices: FeedViewPostsSlice[] = feed
.map(item => new FeedViewPostsSlice(item, postNumberingByUri))
.filter(s => s.items.length > 0 || s.isFallbackMarker)
let slices = createFeedViewPostsSlices(feed)
// run the custom tuners
for (const tunerFn of this.tunerFns) {
+8
View File
@@ -0,0 +1,8 @@
import {isRetryableRequestError} from '#/lib/async/retry'
describe('retry', () => {
it('identifies retryable request errors', () => {
expect(isRetryableRequestError(new TypeError('Failed to fetch'))).toBe(true)
expect(isRetryableRequestError(new Error('Invalid request'))).toBe(false)
})
})
+5 -1
View File
@@ -1,5 +1,9 @@
import {timeout} from '#/lib/async/timeout'
import {isNetworkError} from '#/lib/strings/errors'
import {isNetworkError, shouldRetryError} from '#/lib/strings/errors'
export function isRetryableRequestError(error: unknown) {
return isNetworkError(error) || shouldRetryError(error)
}
export async function retry<P>(
retries: number,
+42
View File
@@ -0,0 +1,42 @@
import {describe, expect, it, jest} from '@jest/globals'
import {until} from './until'
describe('until', () => {
it('passes attempt errors to the condition', async () => {
const error = new Error('failed')
const fn = jest
.fn<() => Promise<string>>()
.mockRejectedValueOnce(error)
.mockResolvedValue('ready')
const cond = jest.fn((value: string | undefined) => value === 'ready')
await expect(until(2, 0, cond, fn)).resolves.toBe(true)
expect(fn).toHaveBeenCalledTimes(2)
expect(cond).toHaveBeenNthCalledWith(1, undefined, error)
expect(cond).toHaveBeenNthCalledWith(2, 'ready', undefined)
})
it('returns false when every attempt rejects', async () => {
const fn = jest
.fn<() => Promise<string>>()
.mockRejectedValue(new Error('failed'))
const cond = jest.fn((_value: string | undefined) => false)
await expect(until(2, 0, cond, fn)).resolves.toBe(false)
expect(fn).toHaveBeenCalledTimes(2)
expect(cond).toHaveBeenCalledTimes(2)
})
it('can stop when an attempt rejects', async () => {
const error = new Error('failed')
const fn = jest.fn<() => Promise<string>>().mockRejectedValue(error)
const cond = jest.fn(
(_value: string | undefined, err: unknown) => err === error,
)
await expect(until(2, 0, cond, fn)).resolves.toBe(true)
expect(fn).toHaveBeenCalledTimes(1)
expect(cond).toHaveBeenCalledWith(undefined, error)
})
})
+6 -5
View File
@@ -1,9 +1,12 @@
import {timeout} from './timeout'
/**
* Retries an async operation until its result or error matches `cond`.
*/
export async function until<T>(
retries: number,
delay: number,
cond: (v: T, err: any) => boolean,
cond: (v: T | undefined, err: unknown) => boolean,
fn: () => Promise<T>,
): Promise<boolean> {
while (retries > 0) {
@@ -12,10 +15,8 @@ export async function until<T>(
if (cond(v, undefined)) {
return true
}
} catch (e: any) {
// TODO: change the type signature of cond to accept undefined
// however this breaks every existing usage of until -sfn
if (cond(undefined as unknown as T, e)) {
} catch (err) {
if (cond(undefined, err)) {
return true
}
}
+4 -1
View File
@@ -141,7 +141,10 @@ function createListItem({
async function whenAppViewReady(
client: Client,
uri: string,
fn: (res?: app.bsky.graph.getStarterPack.$OutputBody) => boolean,
fn: (
res: app.bsky.graph.getStarterPack.$OutputBody | undefined,
err: unknown,
) => boolean,
) {
await until(
5, // 5 tries
+1
View File
@@ -24,6 +24,7 @@ export function usePostViewTracking(
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
isReply: !!post.record.reply,
logContext,
})
},
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import {
ImageManipulator,
type ImageManipulatorContext,
type ImageResult,
type SaveOptions,
} from 'expo-image-manipulator'
export async function renderImage(
source: string,
manipulate?: (context: ImageManipulatorContext) => void,
saveOptions?: SaveOptions,
): Promise<ImageResult> {
const context = ImageManipulator.manipulate(source)
try {
manipulate?.(context)
const image = await context.renderAsync()
try {
return await image.saveAsync(saveOptions)
} finally {
image.release()
}
} finally {
context.release()
}
}

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