Merge branch 'main' into @tomekzaw/nano-icons-feed-icons

This commit is contained in:
Tomek Zawadzki
2026-08-31 13:34:48 +02:00
88 changed files with 937 additions and 2629 deletions
+2 -3
View File
@@ -275,8 +275,8 @@ module.exports = function (_config) {
}, },
android: { android: {
compileSdkVersion: 36, compileSdkVersion: 36,
targetSdkVersion: 35, targetSdkVersion: 36,
buildToolsVersion: '35.0.0', buildToolsVersion: '36.0.0',
buildReactNativeFromSource: IS_PRODUCTION, buildReactNativeFromSource: IS_PRODUCTION,
}, },
}, },
@@ -289,7 +289,6 @@ module.exports = function (_config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
}, },
], ],
'react-native-compressor',
[ [
'@bitdrift/react-native', '@bitdrift/react-native',
{ {
@@ -1,15 +0,0 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.blueskyvideocompress'
version = '1.0.0'
android {
namespace "expo.modules.blueskyvideocompress"
defaultConfig {
versionCode 1
versionName "1.0.0"
}
}
@@ -1,55 +0,0 @@
package expo.modules.blueskyvideocompress
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
object CodecSelector {
// Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500
private val AVC_DENYLIST = setOf(
"c2.qti.avc.encoder"
)
private val SOFTWARE_PREFIXES = listOf(
"OMX.google.",
"c2.android.",
"c2.google."
)
data class EncoderInfo(
val name: String,
val mime: String,
val isHardware: Boolean
)
fun findEncoder(mime: String, requireHardware: Boolean): EncoderInfo? {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val candidates = codecList.codecInfos
.filter { it.isEncoder }
.filter { it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } }
.filter { !it.name.contains("secure", ignoreCase = true) }
.filter { !(mime == MediaFormat.MIMETYPE_VIDEO_AVC && AVC_DENYLIST.contains(it.name)) }
val hardware = candidates.filter { isHardware(it) }
val selected = if (requireHardware) {
hardware.firstOrNull()
} else {
hardware.firstOrNull() ?: candidates.firstOrNull()
}
selected ?: return null
return EncoderInfo(
name = selected.name,
mime = mime,
isHardware = isHardware(selected)
)
}
private fun isHardware(info: MediaCodecInfo): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
} else {
SOFTWARE_PREFIXES.none { info.name.startsWith(it, ignoreCase = true) }
}
}
}
@@ -1,62 +0,0 @@
package expo.modules.blueskyvideocompress
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.util.concurrent.ConcurrentHashMap
class ExpoBlueskyVideoCompressModule : Module() {
private val activeCompressors = ConcurrentHashMap<Int, VideoCompressor>()
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyVideoCompress")
Events("onProgress")
AsyncFunction("probe") { uri: String ->
val context = appContext.reactContext
?: throw Error("React context is null")
return@AsyncFunction VideoProber.probe(context, uri)
}
AsyncFunction("compress") { uri: String, options: Map<String, Any?> ->
val context = appContext.reactContext
?: throw Error("React context is null")
val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0
val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920
val codecPref = (options["codec"] as? String) ?: "auto"
val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1)
val jobId = (options["jobId"] as? Number)?.toInt() ?: 0
val compressor = VideoCompressor(
context = context,
uriString = uri,
targetBitrate = targetBitrate,
maxSize = maxSize,
codecPref = codecPref,
frameRateCap = frameRateCap,
jobId = jobId,
onProgress = { id, progress ->
sendEvent("onProgress", mapOf(
"id" to id,
"progress" to progress
))
}
)
activeCompressors[jobId] = compressor
try {
val result = compressor.compress()
activeCompressors.remove(jobId)
return@AsyncFunction result
} catch (e: Exception) {
activeCompressors.remove(jobId)
throw e
}
}
Function("cancel") { jobId: Int ->
activeCompressors.remove(jobId)?.cancel()
}
}
}
@@ -1,96 +0,0 @@
package expo.modules.blueskyvideocompress
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.view.Surface
class InputSurface(private val surface: Surface) {
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
init {
eglSetup()
}
private fun eglSetup() {
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
if (eglDisplay === EGL14.EGL_NO_DISPLAY) {
throw RuntimeException("unable to get EGL14 display")
}
val version = IntArray(2)
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
throw RuntimeException("unable to initialize EGL14")
}
val attribList = intArrayOf(
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
EGL14.EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0)
checkEglError("eglChooseConfig")
val contextAttribs = intArrayOf(
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
EGL14.EGL_NONE
)
eglContext = EGL14.eglCreateContext(
eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0
)
checkEglError("eglCreateContext")
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
eglSurface = EGL14.eglCreateWindowSurface(
eglDisplay, configs[0], surface, surfaceAttribs, 0
)
checkEglError("eglCreateWindowSurface")
}
fun makeCurrent() {
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
checkEglError("eglMakeCurrent")
}
fun swapBuffers(): Boolean {
return EGL14.eglSwapBuffers(eglDisplay, eglSurface)
}
fun setPresentationTime(nsecs: Long) {
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
}
fun release() {
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(
eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT
)
EGL14.eglDestroySurface(eglDisplay, eglSurface)
EGL14.eglDestroyContext(eglDisplay, eglContext)
EGL14.eglReleaseThread()
EGL14.eglTerminate(eglDisplay)
}
surface.release()
eglDisplay = EGL14.EGL_NO_DISPLAY
eglContext = EGL14.EGL_NO_CONTEXT
eglSurface = EGL14.EGL_NO_SURFACE
}
private fun checkEglError(msg: String) {
val error = EGL14.eglGetError()
if (error != EGL14.EGL_SUCCESS) {
throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}")
}
}
}
@@ -1,61 +0,0 @@
package expo.modules.blueskyvideocompress
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
class OutputSurface : SurfaceTexture.OnFrameAvailableListener {
private val renderer = TextureRenderer()
private var surfaceTexture: SurfaceTexture? = null
private val stMatrix = FloatArray(16)
private val callbackThread = HandlerThread("OutputSurfaceCallbacks")
val surface: Surface
@Volatile
private var frameAvailable = false
private val frameSyncObject = Object()
init {
renderer.surfaceCreated()
callbackThread.start()
val handler = Handler(callbackThread.looper)
surfaceTexture = SurfaceTexture(renderer.getTextureId()).also {
it.setOnFrameAvailableListener(this, handler)
}
surface = Surface(surfaceTexture)
}
fun release() {
surface.release()
surfaceTexture?.release()
surfaceTexture = null
callbackThread.quitSafely()
}
fun awaitNewImage() {
val timeoutMs = 2500L
synchronized(frameSyncObject) {
while (!frameAvailable) {
frameSyncObject.wait(timeoutMs)
if (!frameAvailable) {
throw RuntimeException("Surface frame wait timed out")
}
}
frameAvailable = false
}
surfaceTexture!!.updateTexImage()
}
fun drawImage() {
surfaceTexture!!.getTransformMatrix(stMatrix)
renderer.drawFrame(stMatrix)
}
override fun onFrameAvailable(st: SurfaceTexture) {
synchronized(frameSyncObject) {
frameAvailable = true
frameSyncObject.notifyAll()
}
}
}
@@ -1,156 +0,0 @@
package expo.modules.blueskyvideocompress
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.Matrix
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
class TextureRenderer {
companion object {
private const val FLOAT_SIZE_BYTES = 4
private const val STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
private const val POS_OFFSET = 0
private const val UV_OFFSET = 3
private val VERTICES = floatArrayOf(
-1.0f, -1.0f, 0f, 0f, 0f,
1.0f, -1.0f, 0f, 1f, 0f,
-1.0f, 1.0f, 0f, 0f, 1f,
1.0f, 1.0f, 0f, 1f, 1f,
)
private const val VERTEX_SHADER = """
uniform mat4 uMVPMatrix;
uniform mat4 uSTMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
}
"""
private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTextureCoord);
}
"""
}
private val vertices: FloatBuffer =
ByteBuffer.allocateDirect(VERTICES.size * FLOAT_SIZE_BYTES)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply { put(VERTICES); position(0) }
private val mvpMatrix = FloatArray(16)
private var program = 0
private var textureId = -1
private var uMVPMatrixHandle = 0
private var uSTMatrixHandle = 0
private var aPositionHandle = 0
private var aTextureCoordHandle = 0
init {
Matrix.setIdentityM(mvpMatrix, 0)
}
fun getTextureId(): Int = textureId
fun surfaceCreated() {
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
textureId = textures[0]
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
GLES20.glTexParameterf(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()
)
GLES20.glTexParameterf(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE
)
}
fun drawFrame(stMatrix: FloatArray) {
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
vertices.position(POS_OFFSET)
GLES20.glVertexAttribPointer(
aPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
)
GLES20.glEnableVertexAttribArray(aPositionHandle)
vertices.position(UV_OFFSET)
GLES20.glVertexAttribPointer(
aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
)
GLES20.glEnableVertexAttribArray(aTextureCoordHandle)
GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(aPositionHandle)
GLES20.glDisableVertexAttribArray(aTextureCoordHandle)
}
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
val program = GLES20.glCreateProgram()
GLES20.glAttachShader(program, vertexShader)
GLES20.glAttachShader(program, fragmentShader)
GLES20.glLinkProgram(program)
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] != GLES20.GL_TRUE) {
val log = GLES20.glGetProgramInfoLog(program)
GLES20.glDeleteProgram(program)
throw RuntimeException("Could not link program: $log")
}
return program
}
private fun loadShader(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
val compiled = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
if (compiled[0] == 0) {
val log = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
throw RuntimeException("Could not compile shader $type: $log")
}
return shader
}
}
@@ -1,582 +0,0 @@
package expo.modules.blueskyvideocompress
import android.content.Context
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import android.net.Uri
import android.os.Build
import android.util.Log
import java.io.File
import java.nio.ByteBuffer
import java.util.UUID
class VideoCompressor(
private val context: Context,
private val uriString: String,
private val targetBitrate: Int,
private val maxSize: Int,
private val codecPref: String,
private val frameRateCap: Int,
private val jobId: Int,
private val onProgress: (Int, Double) -> Unit
) {
companion object {
private const val TAG = "BskyVideoCompress"
private const val TIMEOUT_DEQUEUE = 100L
private const val I_FRAME_INTERVAL = 3
}
@Volatile
private var isCancelled = false
fun cancel() {
isCancelled = true
}
fun compress(): Map<String, Any> {
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
val tryHevc = codecPref == "hevc"
if (tryHevc) {
try {
return doCompress(useHevc = true, allowSoftwareFallback = false)
} catch (e: Exception) {
if (codecPref == "hevc" || isCancelled) throw e
Log.w(TAG, "HEVC encode failed, falling back to h264", e)
}
}
return doCompress(useHevc = false, allowSoftwareFallback = true)
}
private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map<String, Any> {
val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC
val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback)
?: throw RuntimeException("No encoder for $mime")
try {
return runPipeline(encoderInfo, useHevc)
} catch (e: Exception) {
if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e
Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e)
val sw = CodecSelector.findEncoder(mime, requireHardware = false)
?.takeIf { !it.isHardware }
?: throw e
return runPipeline(sw, useHevc)
}
}
private fun runPipeline(
encoderInfo: CodecSelector.EncoderInfo,
useHevc: Boolean
): Map<String, Any> {
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
var extractor: MediaExtractor? = null
var muxer: MediaMuxer? = null
var encoder: MediaCodec? = null
var decoder: MediaCodec? = null
var inputSurface: InputSurface? = null
var outputSurface: OutputSurface? = null
var muxerStarted = false
var outputDims = Pair(0, 0)
var durationUs = 0L
try {
val uri = Uri.parse(uriString)
extractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
extractor.setDataSource(context, uri, null)
} else {
extractor.setDataSource(uriString)
}
var videoTrackIndex = -1
var audioTrackIndex = -1
var videoFormat: MediaFormat? = null
var audioFormat: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (trackMime.startsWith("video/") && videoTrackIndex == -1) {
videoTrackIndex = i
videoFormat = format
} else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) {
audioTrackIndex = i
audioFormat = format
}
}
if (videoTrackIndex == -1 || videoFormat == null) {
throw RuntimeException("No video track found")
}
val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
videoFormat.getInteger(MediaFormat.KEY_ROTATION)
} else 0
durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) {
videoFormat.getLong(MediaFormat.KEY_DURATION)
} else 0L
val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
} else 30
outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize)
val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat)
val transcodedAudio: TranscodedAudio? = if (
audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio
) {
transcodeAudioToAAC(audioTrackIndex, audioFormat)
} else null
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val effectiveBitrate = if (targetBitrate > 0) {
targetBitrate
} else if (useHevc) 2_500_000 else 3_000_000
val encoderFormat = MediaFormat.createVideoFormat(
encoderInfo.mime, outputDims.first, outputDims.second
).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate)
setInteger(
MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
)
setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709)
setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setInteger(MediaFormat.KEY_PRIORITY, 0)
setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap)
if (useHevc) {
setInteger(
MediaFormat.KEY_PROFILE,
MediaCodecInfo.CodecProfileLevel.HEVCProfileMain
)
} else {
setInteger(
MediaFormat.KEY_PROFILE,
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
)
setInteger(
MediaFormat.KEY_LEVEL,
MediaCodecInfo.CodecProfileLevel.AVCLevel41
)
}
}
}
encoder = MediaCodec.createByCodecName(encoderInfo.name)
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val encoderInputSurface = encoder.createInputSurface()
inputSurface = InputSurface(encoderInputSurface)
inputSurface.makeCurrent()
outputSurface = OutputSurface()
encoder.start()
decoder = MediaCodec.createDecoderByType(
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
)
// Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may
// ignore the hint, but where supported it produces correct BT.709 pixels
// for the encoder rather than HDR pixels mislabeled as SDR.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
videoFormat.setInteger(
MediaFormat.KEY_COLOR_TRANSFER_REQUEST,
MediaFormat.COLOR_TRANSFER_SDR_VIDEO
)
}
decoder.configure(videoFormat, outputSurface.surface, null, 0)
decoder.start()
extractor.selectTrack(videoTrackIndex)
val frameDropEnabled = sourceFps > frameRateCap
val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L
var nextTargetPtsUs = 0L
var muxerVideoTrack = -1
var muxerAudioTrack = -1
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var decoderDone = false
var outputDone = false
var lastProgressMs = 0L
while (!outputDone && !isCancelled) {
if (!inputDone) {
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (idx >= 0) {
val buf = decoder.getInputBuffer(idx)
if (buf != null) {
val sz = extractor.readSampleData(buf, 0)
if (sz < 0) {
decoder.queueInputBuffer(
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
} else {
decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0)
extractor.advance()
}
}
}
}
if (!decoderDone) {
val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
if (status >= 0) {
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val shouldRender = if (isEos) {
false
} else if (frameDropEnabled) {
if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
true
} else false
} else true
decoder.releaseOutputBuffer(status, shouldRender)
if (shouldRender) {
outputSurface.awaitNewImage()
outputSurface.drawImage()
inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
inputSurface.swapBuffers()
}
if (isEos) {
encoder.signalEndOfInputStream()
decoderDone = true
}
}
}
var encoderDrained = false
while (!outputDone && !isCancelled && !encoderDrained) {
val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0)
when {
encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true
encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (!muxerStarted) {
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
if (audioTrackIndex >= 0 && audioFormat != null) {
if (shouldPassthroughAudio) {
muxerAudioTrack = muxer.addTrack(audioFormat)
} else if (transcodedAudio != null) {
muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat)
}
}
muxer.start()
muxerStarted = true
}
}
encIdx >= 0 -> {
val data = encoder.getOutputBuffer(encIdx)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
bufferInfo.size = 0
}
if (data != null && bufferInfo.size > 0 && muxerStarted) {
muxer.writeSampleData(muxerVideoTrack, data, bufferInfo)
}
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
encoder.releaseOutputBuffer(encIdx, false)
if (isEos) {
outputDone = true
} else if (durationUs > 0) {
val now = System.currentTimeMillis()
if (now - lastProgressMs >= 100) {
lastProgressMs = now
val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
.coerceIn(0.0, 1.0)
onProgress(jobId, p)
}
}
}
}
}
}
if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) {
if (shouldPassthroughAudio) {
passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack)
} else if (transcodedAudio != null) {
writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack)
}
}
} finally {
try { decoder?.stop() } catch (_: Exception) {}
try { decoder?.release() } catch (_: Exception) {}
try { encoder?.stop() } catch (_: Exception) {}
try { encoder?.release() } catch (_: Exception) {}
try { outputSurface?.release() } catch (_: Exception) {}
try { inputSurface?.release() } catch (_: Exception) {}
try { extractor?.release() } catch (_: Exception) {}
try {
if (muxerStarted) muxer?.stop()
muxer?.release()
} catch (_: Exception) {}
}
if (isCancelled) {
outputFile.delete()
throw RuntimeException("Compression cancelled")
}
onProgress(jobId, 1.0)
val durationSeconds = durationUs / 1_000_000.0
return mapOf(
"uri" to "file://${outputFile.absolutePath}",
"size" to outputFile.length(),
"mimeType" to "video/mp4",
"width" to outputDims.first,
"height" to outputDims.second,
"duration" to durationSeconds,
"codec" to if (useHevc) "hevc" else "h264"
)
}
private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) {
val audioExtractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
} else {
audioExtractor.setDataSource(uriString)
}
audioExtractor.selectTrack(audioTrackIndex)
audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
val buffer = ByteBuffer.allocate(256 * 1024)
val info = MediaCodec.BufferInfo()
try {
while (!isCancelled) {
val sz = audioExtractor.readSampleData(buffer, 0)
if (sz < 0) break
info.offset = 0
info.size = sz
info.presentationTimeUs = audioExtractor.sampleTime
info.flags = audioExtractor.sampleFlags
muxer.writeSampleData(muxerAudioTrack, buffer, info)
audioExtractor.advance()
}
} finally {
audioExtractor.release()
}
}
private fun canPassthroughAudio(format: MediaFormat): Boolean {
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
return mime == MediaFormat.MIMETYPE_AUDIO_AAC
}
private data class TranscodedAudio(
val outputFormat: MediaFormat,
val samples: List<Sample>
) {
data class Sample(
val bytes: ByteArray,
val presentationTimeUs: Long,
val flags: Int
)
}
// Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer
// can take it. iOS always re-encodes to AAC; without this Android would drop
// the audio track entirely.
private fun transcodeAudioToAAC(
audioTrackIndex: Int,
sourceFormat: MediaFormat
): TranscodedAudio? {
val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null
val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT))
sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2
val audioExtractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
} else {
audioExtractor.setDataSource(uriString)
}
audioExtractor.selectTrack(audioTrackIndex)
var decoder: MediaCodec? = null
var encoder: MediaCodec? = null
try {
decoder = MediaCodec.createDecoderByType(sourceMime)
decoder.configure(sourceFormat, null, null, 0)
decoder.start()
val encoderFormat = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount
).apply {
setInteger(
MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC
)
setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024)
}
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
encoder.start()
val samples = mutableListOf<TranscodedAudio.Sample>()
var outputFormat: MediaFormat? = null
var inputDone = false
var decoderDone = false
var encoderInputSignalled = false
var encoderDone = false
val info = MediaCodec.BufferInfo()
while (!encoderDone && !isCancelled) {
if (!inputDone) {
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (idx >= 0) {
val buf = decoder.getInputBuffer(idx)
if (buf != null) {
val sz = audioExtractor.readSampleData(buf, 0)
if (sz < 0) {
decoder.queueInputBuffer(
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
} else {
decoder.queueInputBuffer(
idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags
)
audioExtractor.advance()
}
}
}
}
if (!decoderDone) {
val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
when {
status == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {}
status >= 0 -> {
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val data = decoder.getOutputBuffer(status)
if (data != null && info.size > 0) {
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (encInIdx >= 0) {
val encInBuf = encoder.getInputBuffer(encInIdx)
if (encInBuf != null) {
encInBuf.clear()
data.position(info.offset)
data.limit(info.offset + info.size)
encInBuf.put(data)
encoder.queueInputBuffer(
encInIdx, 0, info.size, info.presentationTimeUs, 0
)
}
}
}
decoder.releaseOutputBuffer(status, false)
if (isEos) {
if (!encoderInputSignalled) {
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10)
if (encInIdx >= 0) {
encoder.queueInputBuffer(
encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
encoderInputSignalled = true
}
}
decoderDone = true
}
}
}
}
val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
when {
encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
outputFormat = encoder.outputFormat
}
encOutIdx >= 0 -> {
val data = encoder.getOutputBuffer(encOutIdx)
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
if (data != null && info.size > 0 && !isConfig) {
val bytes = ByteArray(info.size)
data.position(info.offset)
data.get(bytes, 0, info.size)
samples.add(
TranscodedAudio.Sample(
bytes = bytes,
presentationTimeUs = info.presentationTimeUs,
flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv()
)
)
}
encoder.releaseOutputBuffer(encOutIdx, false)
if (isEos) encoderDone = true
}
}
}
val fmt = outputFormat ?: return null
return TranscodedAudio(fmt, samples)
} catch (e: Exception) {
Log.w(TAG, "Audio transcode failed; dropping audio", e)
return null
} finally {
try { decoder?.stop() } catch (_: Exception) {}
try { decoder?.release() } catch (_: Exception) {}
try { encoder?.stop() } catch (_: Exception) {}
try { encoder?.release() } catch (_: Exception) {}
audioExtractor.release()
}
}
private fun writeTranscodedAudio(
samples: List<TranscodedAudio.Sample>,
muxer: MediaMuxer,
muxerAudioTrack: Int
) {
val info = MediaCodec.BufferInfo()
for (sample in samples) {
if (isCancelled) break
val buffer = ByteBuffer.wrap(sample.bytes)
info.offset = 0
info.size = sample.bytes.size
info.presentationTimeUs = sample.presentationTimeUs
info.flags = sample.flags
muxer.writeSampleData(muxerAudioTrack, buffer, info)
}
}
private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair<Int, Int> {
val isRotated = rotation == 90 || rotation == 270
val displayW = if (isRotated) srcH else srcW
val displayH = if (isRotated) srcW else srcH
val scale: Float = when {
displayW <= maxSize && displayH <= maxSize -> 1.0f
displayW > displayH -> maxSize.toFloat() / displayW
else -> maxSize.toFloat() / displayH
}
return Pair(
roundToEven((displayW * scale).toInt()),
roundToEven((displayH * scale).toInt())
)
}
private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1
}
@@ -1,125 +0,0 @@
package expo.modules.blueskyvideocompress
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
object VideoProber {
fun probe(context: Context, uriString: String): Map<String, Any> {
val uri = Uri.parse(uriString)
val retriever = MediaMetadataRetriever()
try {
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
retriever.setDataSource(context, uri)
} else {
retriever.setDataSource(uriString)
}
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toIntOrNull() ?: 0
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toIntOrNull() ?: 0
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull() ?: 0L
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull() ?: 0
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull() ?: 0
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
?.equals("yes") ?: false
val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
?.toFloatOrNull() ?: 0f
val fileSize = getFileSize(context, uriString)
val extractor = MediaExtractor()
var codec = "unknown"
var mimeType = "video/mp4"
var extractedFrameRate = frameRate
var isHDR = false
try {
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
extractor.setDataSource(context, uri, null)
} else {
extractor.setDataSource(uriString)
}
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME)
if (mime?.startsWith("video/") == true) {
mimeType = mime
codec = mime.removePrefix("video/")
if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) {
extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
format.containsKey(MediaFormat.KEY_COLOR_TRANSFER)
) {
val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER)
isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG ||
transfer == MediaFormat.COLOR_TRANSFER_ST2084
}
// Dolby Vision tracks use codec-specific mimes that aren't covered by
// KEY_COLOR_TRANSFER on every device.
if (mime.contains("dolby-vision", ignoreCase = true)) {
isHDR = true
}
break
}
}
} finally {
extractor.release()
}
val durationSeconds = durationMs / 1000.0
val effectiveBitrate = if (bitrate > 0) {
bitrate
} else if (durationSeconds > 0 && fileSize > 0) {
(fileSize * 8 / durationSeconds).toInt()
} else {
0
}
return mapOf(
"width" to width,
"height" to height,
"duration" to durationSeconds,
"bitrate" to effectiveBitrate,
"fileSize" to fileSize,
"mimeType" to mimeType,
"codec" to codec,
"hasAudio" to hasAudio,
"frameRate" to extractedFrameRate.toDouble(),
"rotation" to rotation,
"isHDR" to isHDR
)
} finally {
retriever.release()
}
}
private fun getFileSize(context: Context, uriString: String): Long {
return try {
if (uriString.startsWith("content://")) {
context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use {
it.statSize
} ?: 0L
} else {
val path = if (uriString.startsWith("file://")) {
uriString.removePrefix("file://")
} else {
uriString
}
java.io.File(path).length()
}
} catch (_: Exception) {
0L
}
}
}
@@ -1,9 +0,0 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["ExpoBlueskyVideoCompressModule"]
},
"android": {
"modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"]
}
}
@@ -1,87 +0,0 @@
import {type EventSubscription} from 'expo-modules-core'
import NativeModule from './src/ExpoBlueskyVideoCompressModule'
import {
type CodecPreference,
type CompressCallbacks,
type CompressOptions,
type CompressResult,
type VideoMetadata,
} from './src/types'
export type {
CodecPreference,
CompressCallbacks,
CompressOptions,
CompressResult,
VideoMetadata,
}
class AbortError extends Error {
name = 'AbortError'
constructor() {
super('Aborted')
}
}
let jobIdCounter = 0
export function probe(uri: string): Promise<VideoMetadata> {
return NativeModule.probe(uri)
}
export function compress(
uri: string,
options: CompressOptions = {},
callbacks?: CompressCallbacks,
): Promise<CompressResult> {
const jobId = ++jobIdCounter
let subscription: EventSubscription | undefined
if (callbacks?.signal?.aborted) {
return Promise.reject(new AbortError())
}
const nativeOptions = {
targetBitrate: options.targetBitrate ?? 0,
maxSize: options.maxSize ?? 1920,
codec: options.codec ?? 'auto',
frameRateCap: options.frameRateCap ?? 30,
jobId,
}
return new Promise<CompressResult>((resolve, reject) => {
if (callbacks?.onProgress) {
subscription = NativeModule.addListener(
'onProgress',
(event: {id: number; progress: number}) => {
if (event.id === jobId) {
callbacks.onProgress!(event.progress)
}
},
)
}
const abortHandler = () => {
NativeModule.cancel(jobId)
subscription?.remove()
reject(new AbortError())
}
if (callbacks?.signal) {
callbacks.signal.addEventListener('abort', abortHandler, {once: true})
}
NativeModule.compress(uri, nativeOptions)
.then(result => {
callbacks?.signal?.removeEventListener('abort', abortHandler)
subscription?.remove()
resolve(result)
})
.catch((error: unknown) => {
callbacks?.signal?.removeEventListener('abort', abortHandler)
subscription?.remove()
reject(error instanceof Error ? error : new Error(String(error)))
})
})
}
@@ -1,21 +0,0 @@
import VideoToolbox
enum CodecCapability {
static let isHardwareHEVCEncodeAvailable: Bool = {
var encoderListCF: CFArray?
let status = VTCopyVideoEncoderList(nil, &encoderListCF)
guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else {
return false
}
return encoderList.contains { encoder in
guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int,
codecTypeValue == Int(kCMVideoCodecType_HEVC) else {
return false
}
if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool {
return isHardware
}
return true
}
}()
}
@@ -1,20 +0,0 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyVideoCompress'
s.version = '1.0.0'
s.summary = 'Hardware-accelerated video compression for Bluesky'
s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '15.1' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -1,74 +0,0 @@
import AVFoundation
import ExpoModulesCore
public class ExpoBlueskyVideoCompressModule: Module {
private var activeCompressors: [Int: VideoCompressor] = [:]
private let activeCompressorsLock = NSLock()
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyVideoCompress")
Events("onProgress")
AsyncFunction("probe") { (uri: String) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
return try await VideoProber.probe(url: url)
}
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
let targetBitrate = options["targetBitrate"] as? Int ?? 0
let maxSize = options["maxSize"] as? Int ?? 1920
let codecPref = options["codec"] as? String ?? "auto"
let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30)
let jobId = options["jobId"] as? Int ?? 0
let compressor = VideoCompressor(
url: url,
targetBitrate: targetBitrate,
maxSize: maxSize,
codecPref: codecPref,
frameRateCap: frameRateCap,
jobId: jobId,
onProgress: { [weak self] id, progress in
self?.sendEvent("onProgress", [
"id": id,
"progress": progress,
])
}
)
self.setCompressor(jobId, compressor)
do {
let result = try await compressor.compress()
self.setCompressor(jobId, nil)
return result
} catch {
self.setCompressor(jobId, nil)
throw error
}
}
Function("cancel") { (jobId: Int) in
self.cancelCompressor(jobId)
}
}
private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) {
activeCompressorsLock.lock()
defer { activeCompressorsLock.unlock() }
if let compressor = compressor {
activeCompressors[jobId] = compressor
} else {
activeCompressors.removeValue(forKey: jobId)
}
}
private func cancelCompressor(_ jobId: Int) {
activeCompressorsLock.lock()
let compressor = activeCompressors.removeValue(forKey: jobId)
activeCompressorsLock.unlock()
compressor?.cancel()
}
}
@@ -1,410 +0,0 @@
import AVFoundation
import VideoToolbox
class VideoCompressor {
private let url: URL
private let targetBitrate: Int
private let maxSize: Int
private let codecPref: String
private let frameRateCap: Int
private let jobId: Int
private let onProgress: (Int, Double) -> Void
private var isCancelled = false
init(
url: URL,
targetBitrate: Int,
maxSize: Int,
codecPref: String,
frameRateCap: Int,
jobId: Int,
onProgress: @escaping (Int, Double) -> Void
) {
self.url = url
self.targetBitrate = targetBitrate
self.maxSize = maxSize
self.codecPref = codecPref
self.frameRateCap = frameRateCap
self.jobId = jobId
self.onProgress = onProgress
}
func cancel() {
isCancelled = true
}
func compress() async throws -> [String: Any] {
let asset = AVURLAsset(
url: url,
options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]
)
let duration = try await asset.load(.duration)
let totalSeconds = CMTimeGetSeconds(duration)
guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) }
let videoTracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = videoTracks.first else {
throw err("No video track found", code: 1)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
let displaySize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
let useHEVC: Bool
switch codecPref {
case "hevc": useHEVC = true
default: useHEVC = false
}
let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264
let profileLevel: String = useHEVC
? kVTProfileLevel_HEVC_Main_AutoLevel as String
: kVTProfileLevel_H264_High_AutoLevel as String
let effectiveBitrate = targetBitrate > 0
? targetBitrate
: (useHEVC ? 2_500_000 : 3_000_000)
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mp4")
let videoComposition = makeRotatingComposition(
videoTrack: videoTrack,
preferredTransform: preferredTransform,
naturalSize: naturalSize,
outputSize: outputSize,
duration: duration
)
let reader = try AVAssetReader(asset: asset)
let videoReaderSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
let videoReaderOutput = AVAssetReaderVideoCompositionOutput(
videoTracks: [videoTrack],
videoSettings: videoReaderSettings
)
videoReaderOutput.videoComposition = videoComposition
videoReaderOutput.alwaysCopiesSampleData = false
guard reader.canAdd(videoReaderOutput) else {
throw err("Cannot read video track", code: 3)
}
reader.add(videoReaderOutput)
var compressionProps: [String: Any] = [
AVVideoAverageBitRateKey: effectiveBitrate,
AVVideoProfileLevelKey: profileLevel,
AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30),
AVVideoExpectedSourceFrameRateKey: frameRateCap,
AVVideoAllowFrameReorderingKey: false,
kVTCompressionPropertyKey_RealTime as String: true,
]
let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5)
compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [
peakBytesPerSecond, 1.0
] as CFArray
let videoColorProps: [String: Any] = [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
]
let videoWriterSettings: [String: Any] = [
AVVideoCodecKey: codecType,
AVVideoWidthKey: outputSize.width,
AVVideoHeightKey: outputSize.height,
AVVideoColorPropertiesKey: videoColorProps,
AVVideoCompressionPropertiesKey: compressionProps,
]
let videoWriterInput = AVAssetWriterInput(
mediaType: .video,
outputSettings: videoWriterSettings
)
videoWriterInput.expectsMediaDataInRealTime = false
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
writer.metadata = []
guard writer.canAdd(videoWriterInput) else {
throw err("Cannot write video track", code: 4)
}
writer.add(videoWriterInput)
var audioReaderOutput: AVAssetReaderTrackOutput?
var audioWriterInput: AVAssetWriterInput?
if let audioTrack = audioTracks.first {
let audioDecoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false,
]
let audioOutput = AVAssetReaderTrackOutput(
track: audioTrack,
outputSettings: audioDecoderSettings
)
audioOutput.alwaysCopiesSampleData = false
if reader.canAdd(audioOutput) {
reader.add(audioOutput)
audioReaderOutput = audioOutput
let audioEncoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: 128_000,
]
let audioInput = AVAssetWriterInput(
mediaType: .audio,
outputSettings: audioEncoderSettings
)
audioInput.expectsMediaDataInRealTime = false
if writer.canAdd(audioInput) {
writer.add(audioInput)
audioWriterInput = audioInput
}
}
}
guard reader.startReading() else {
throw reader.error ?? err("Reader failed to start", code: 8)
}
guard writer.startWriting() else {
throw writer.error ?? err("Writer failed to start", code: 9)
}
writer.startSession(atSourceTime: .zero)
let minFrameIntervalSeconds = 1.0 / Double(frameRateCap)
let minFrameInterval = CMTime(
seconds: minFrameIntervalSeconds,
preferredTimescale: 600
)
await withTaskGroup(of: Void.self) { group in
group.addTask { [self] in
await processVideoTrack(
readerOutput: videoReaderOutput,
writerInput: videoWriterInput,
totalDuration: totalSeconds,
minFrameInterval: minFrameInterval
)
}
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
group.addTask { [self] in
await processAudioTrack(
readerOutput: audioOutput,
writerInput: audioInput
)
}
}
await group.waitForAll()
}
if isCancelled {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw err("Compression cancelled", code: 5)
}
if reader.status == .failed {
let error = reader.error ?? err("Reader failed", code: 6)
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw error
}
await writer.finishWriting()
if writer.status == .failed {
let error = writer.error ?? err("Writer failed", code: 7)
try? FileManager.default.removeItem(at: outputURL)
throw error
}
onProgress(jobId, 1.0)
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
let fileSize = attributes[.size] as? Int ?? 0
return [
"uri": outputURL.absoluteString,
"size": fileSize,
"mimeType": "video/mp4",
"width": outputSize.width,
"height": outputSize.height,
"duration": totalSeconds,
"codec": useHEVC ? "hevc" : "h264",
]
}
private func processVideoTrack(
readerOutput: AVAssetReaderOutput,
writerInput: AVAssetWriterInput,
totalDuration: Double,
minFrameInterval: CMTime
) async {
var lastProgressTime: CFAbsoluteTime = 0
var lastAppendedPTS: CMTime?
var finished = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.video")
) {
let finish = {
if !finished {
finished = true
writerInput.markAsFinished()
continuation.resume()
}
}
while writerInput.isReadyForMoreMediaData {
if finished { return }
if self.isCancelled {
finish()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
finish()
return
}
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if let last = lastAppendedPTS {
let delta = CMTimeSubtract(pts, last)
if CMTimeCompare(delta, minFrameInterval) < 0 {
continue
}
}
lastAppendedPTS = pts
if !writerInput.append(sampleBuffer) {
finish()
return
}
let now = CFAbsoluteTimeGetCurrent()
if now - lastProgressTime >= 0.1 {
lastProgressTime = now
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
self.onProgress(self.jobId, progress)
}
}
}
}
}
private func processAudioTrack(
readerOutput: AVAssetReaderOutput,
writerInput: AVAssetWriterInput
) async {
var finished = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.audio")
) {
let finish = {
if !finished {
finished = true
writerInput.markAsFinished()
continuation.resume()
}
}
while writerInput.isReadyForMoreMediaData {
if finished { return }
if self.isCancelled {
finish()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
finish()
return
}
if !writerInput.append(sampleBuffer) {
finish()
return
}
}
}
}
}
private func makeRotatingComposition(
videoTrack: AVAssetTrack,
preferredTransform: CGAffineTransform,
naturalSize: CGSize,
outputSize: (width: Int, height: Int),
duration: CMTime
) -> AVMutableVideoComposition {
let composition = AVMutableVideoComposition()
composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height)
composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap))
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
let translate = CGAffineTransform(
translationX: -rotatedRect.minX,
y: -rotatedRect.minY
)
let displaySize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let scaleX = CGFloat(outputSize.width) / displaySize.width
let scaleY = CGFloat(outputSize.height) / displaySize.height
let scale = CGAffineTransform(scaleX: scaleX, y: scaleY)
let combined = preferredTransform.concatenating(translate).concatenating(scale)
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
layerInstruction.setTransform(combined, at: .zero)
let instruction = AVMutableVideoCompositionInstruction()
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
instruction.layerInstructions = [layerInstruction]
composition.instructions = [instruction]
return composition
}
private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) {
let cap = CGFloat(maxSize)
let scale: CGFloat
if displaySize.width <= cap && displaySize.height <= cap {
scale = 1.0
} else if displaySize.width > displaySize.height {
scale = cap / displaySize.width
} else {
scale = cap / displaySize.height
}
return (
roundToEven(Int(displaySize.width * scale)),
roundToEven(Int(displaySize.height * scale))
)
}
private func roundToEven(_ value: Int) -> Int {
return value % 2 == 0 ? value : value - 1
}
private func err(_ message: String, code: Int) -> NSError {
return NSError(
domain: "ExpoBlueskyVideoCompress",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
@@ -1,105 +0,0 @@
import AVFoundation
import UniformTypeIdentifiers
struct VideoProber {
static func probe(url: URL) async throws -> [String: Any] {
let asset = AVURLAsset(url: url)
let duration = try await asset.load(.duration)
let tracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = tracks.first else {
throw NSError(
domain: "ExpoBlueskyVideoCompress",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
var codec = "unknown"
var isHDR = false
if let formatDescription = formatDescriptions.first {
let subType = CMFormatDescriptionGetMediaSubType(formatDescription)
codec = fourCCToString(subType)
// Dolby Vision codecs are HDR by definition.
let dolbyVisionSubtypes: Set<String> = ["dvhe", "dvh1", "dvav", "dva1"]
if dolbyVisionSubtypes.contains(codec) {
isHDR = true
} else if let extensions = CMFormatDescriptionGetExtensions(formatDescription)
as? [String: Any]
{
let transferKey = kCMFormatDescriptionExtension_TransferFunction as String
if let transfer = extensions[transferKey] as? String {
let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String
let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String
isHDR = transfer == hlg || transfer == pq
}
}
}
let rotation = rotationFromTransform(preferredTransform)
let isRotated = rotation == 90 || rotation == 270
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
let hasAudio = !audioTracks.isEmpty
let fileSize: Int
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attributes[.size] as? Int {
fileSize = size
} else {
fileSize = 0
}
let mimeType: String
if let utType = UTType(filenameExtension: url.pathExtension) {
mimeType = utType.preferredMIMEType ?? "video/mp4"
} else {
mimeType = "video/mp4"
}
let durationSeconds = CMTimeGetSeconds(duration)
var bitrate = Int(estimatedDataRate)
if bitrate == 0 && durationSeconds > 0 {
bitrate = Int(Double(fileSize * 8) / durationSeconds)
}
return [
"width": width,
"height": height,
"duration": durationSeconds,
"bitrate": bitrate,
"fileSize": fileSize,
"mimeType": mimeType,
"codec": codec,
"hasAudio": hasAudio,
"frameRate": nominalFrameRate,
"rotation": rotation,
"isHDR": isHDR
]
}
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
let angle = atan2(transform.b, transform.a)
let degrees = Int(round(angle * 180.0 / .pi))
return ((degrees % 360) + 360) % 360
}
private static func fourCCToString(_ code: FourCharCode) -> String {
let chars: [Character] = [
Character(UnicodeScalar((code >> 24) & 0xFF)!),
Character(UnicodeScalar((code >> 16) & 0xFF)!),
Character(UnicodeScalar((code >> 8) & 0xFF)!),
Character(UnicodeScalar(code & 0xFF)!)
]
return String(chars).trimmingCharacters(in: .whitespaces)
}
}
@@ -1,23 +0,0 @@
import {type EventSubscription, requireNativeModule} from 'expo-modules-core'
import {
type CompressResult,
type NativeCompressOptions,
type VideoMetadata,
} from './types'
type ProgressEvent = {id: number; progress: number}
interface ExpoBlueskyVideoCompressModule {
probe(uri: string): Promise<VideoMetadata>
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
cancel(jobId: number): void
addListener(
eventName: 'onProgress',
listener: (event: ProgressEvent) => void,
): EventSubscription
}
export default requireNativeModule<ExpoBlueskyVideoCompressModule>(
'ExpoBlueskyVideoCompress',
)
@@ -1,45 +0,0 @@
export type CodecPreference = 'auto' | 'hevc' | 'h264'
export type VideoMetadata = {
width: number
height: number
duration: number
bitrate: number
fileSize: number
mimeType: string
codec: string
hasAudio: boolean
frameRate: number
rotation: number
isHDR: boolean
}
export type CompressOptions = {
targetBitrate?: number
maxSize?: number
codec?: CodecPreference
frameRateCap?: number
}
export type CompressCallbacks = {
onProgress?: (progress: number) => void
signal?: AbortSignal
}
export type CompressResult = {
uri: string
size: number
mimeType: string
width: number
height: number
duration: number
codec: 'h264' | 'hevc'
}
export type NativeCompressOptions = {
targetBitrate: number
maxSize: number
codec: CodecPreference
frameRateCap: number
jobId: number
}
+2 -2
View File
@@ -1191,7 +1191,7 @@
"count": 2 "count": 2
}, },
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 2 "count": 1
}, },
"typescript/no-unsafe-member-access": { "typescript/no-unsafe-member-access": {
"count": 2 "count": 2
@@ -1226,7 +1226,7 @@
"count": 3 "count": 3
}, },
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 3 "count": 2
}, },
"typescript/no-unsafe-member-access": { "typescript/no-unsafe-member-access": {
"count": 3 "count": 3
+1 -2
View File
@@ -117,7 +117,7 @@
"@bsky.app/tapper": "^0.6.1", "@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6", "@bsky.app/video": "0.3.6",
"@bsky.app/video-compressor": "0.2.0", "@bsky.app/video-compressor": "0.2.0",
"@bsky/sdk": "1.0.1", "@bsky/sdk": "1.1.0",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@expo/html-elements": "^0.12.5", "@expo/html-elements": "^0.12.5",
@@ -229,7 +229,6 @@
"react-is": "19", "react-is": "19",
"react-keyed-flatten-children": "^5.0.0", "react-keyed-flatten-children": "^5.0.0",
"react-native": "0.86.0", "react-native": "0.86.0",
"react-native-compressor": "1.13.0",
"react-native-date-picker": "^5.0.13", "react-native-date-picker": "^5.0.13",
"react-native-device-attest": "^0.1.6", "react-native-device-attest": "^0.1.6",
"react-native-drawer-layout": "^4.2.3", "react-native-drawer-layout": "^4.2.3",
@@ -1,59 +0,0 @@
diff --git a/android/build.gradle b/android/build.gradle
index 5071139f8ee5fbba085d2afe3b2093de8eda915c..84bee34a238c6510169f6b6bdb0fda0594c77136 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -115,7 +115,6 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation 'org.mp4parser:isoparser:1.9.56'
- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1'
implementation 'javazoom:jlayer:1.0.1'
}
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
deleted file mode 100644
index 9292d3ee50776bd9d7760b8dcf6d123d44b4e31b..0000000000000000000000000000000000000000
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
deleted file mode 100644
index c6551828014437a14dc8f2f19488b647dba1bbe1..0000000000000000000000000000000000000000
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
deleted file mode 100644
index 42040b4916573463415ef2f57789b3c4fa25d135..0000000000000000000000000000000000000000
diff --git a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
index 446d4fb8b69e7cfdb51b29603aa2d52aac1ab8c8..f02190992dac823b6bbf2d77880a25adc48f16c7 100644
--- a/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
+++ b/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) {
promise: Promise) {
try {
- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise)
+ // Skip compression on Android to avoid libandroidlame dependency
+ // Return the original file URL without compression
+ promise.resolve(fileUrl)
} catch (ex: Exception) {
promise.reject(ex)
}
diff --git a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
index c14b727e930f4114765bfbe15b742ddcdeaa392f..1198908fcc66eeeea5e537085d7632a0d4b04545 100644
--- a/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
+++ b/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
@@ -7,7 +7,6 @@ import android.provider.OpenableColumns
import android.util.Log
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
-import com.reactnativecompressor.Audio.AudioCompressor
import com.reactnativecompressor.Video.VideoCompressor.CompressionListener
import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass
import java.io.FileNotFoundException
@@ -152,10 +151,6 @@ object Utils {
}
}
- fun addLog(log: String) {
- Log.d(AudioCompressor.TAG, log)
- }
-
val exifAttributes = arrayOf(
"FNumber",
"ApertureValue",
@@ -1,5 +0,0 @@
# react-native-compressor
Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738
This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025.
+5 -22
View File
@@ -208,7 +208,6 @@ overrides:
'@react-native/normalize-colors': 0.86.0 '@react-native/normalize-colors': 0.86.0
'@expo/image-utils': 0.8.12 '@expo/image-utils': 0.8.12
'@types/estree': 1.0.6 '@types/estree': 1.0.6
react-native-compressor: 1.13.0
react-native-reanimated: 4.5.3 react-native-reanimated: 4.5.3
react-native-worklets: 0.11.3 react-native-worklets: 0.11.3
psl: 1.9.0 psl: 1.9.0
@@ -226,7 +225,6 @@ patchedDependencies:
expo-notifications@57.0.7: 542d7d2024b364ba601b7f2af041353dccde555db0e6ecf0b5948c90e6b33a75 expo-notifications@57.0.7: 542d7d2024b364ba601b7f2af041353dccde555db0e6ecf0b5948c90e6b33a75
expo-updates@57.0.10: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487 expo-updates@57.0.10: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487
expo@57.0.8: 1722861d12907a2d412d04230545b2fcbfa5547a79f99d763cb3e8de6f3a6f2a expo@57.0.8: 1722861d12907a2d412d04230545b2fcbfa5547a79f99d763cb3e8de6f3a6f2a
react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925
react-native-date-picker@5.0.13: 92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd react-native-date-picker@5.0.13: 92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd
react-native-dotenv@3.4.11: 16b34eb974399935d3cf7c2526534f906991ccd876215176658347059cdd2021 react-native-dotenv@3.4.11: 16b34eb974399935d3cf7c2526534f906991ccd876215176658347059cdd2021
react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130 react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130
@@ -297,8 +295,8 @@ importers:
specifier: 0.2.0 specifier: 0.2.0
version: 0.2.0(expo@57.0.8)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) version: 0.2.0(expo@57.0.8)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
'@bsky/sdk': '@bsky/sdk':
specifier: 1.0.1 specifier: 1.1.0
version: 1.0.1(@atproto/lex@0.3.8) version: 1.1.0(@atproto/lex@0.3.8)
'@emoji-mart/data': '@emoji-mart/data':
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1 version: 1.2.1
@@ -632,9 +630,6 @@ importers:
react-native: react-native:
specifier: 0.86.0 specifier: 0.86.0
version: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) version: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1)
react-native-compressor:
specifier: 1.13.0
version: 1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
react-native-date-picker: react-native-date-picker:
specifier: ^5.0.13 specifier: ^5.0.13
version: 5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) version: 5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)
@@ -1753,8 +1748,8 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
'@bsky/sdk@1.0.1': '@bsky/sdk@1.1.0':
resolution: {integrity: sha512-COhrmIZFsUJ1gj4gvqm2UWkRhTbmXZAL1osTZKgSaFftD4Xy0RRl8S9SRluCxo4/VPw/R3p7NQZ4X3BQFbUjVQ==} resolution: {integrity: sha512-6ZSrhNa6KC0arcimBG79Otk+FGZ15Br1YaEeatncbJYfWXGsw9l9nPjZ38IVpSW7UqQ/uhZ+WSQGnVUAdekzaw==}
engines: {node: '>=22.12.0'} engines: {node: '>=22.12.0'}
peerDependencies: peerDependencies:
'@atproto/lex': ^0.3.0 '@atproto/lex': ^0.3.0
@@ -8212,13 +8207,6 @@ packages:
react: '>=18.0.0' react: '>=18.0.0'
react-is: '>=18.0.0' react-is: '>=18.0.0'
react-native-compressor@1.13.0:
resolution: {integrity: sha512-vJYbrcjz2S7kgE3Q7444F71CjEDq5Qg6sGn67N9fJ0I1K6EhB/ZxpULdDe3FxJ4h/ncJc0oxOzNWZhxtGTqcQw==}
engines: {node: '>= 16.0.0'}
peerDependencies:
react: '*'
react-native: '*'
react-native-date-picker@5.0.13: react-native-date-picker@5.0.13:
resolution: {integrity: sha512-qCLUODZVsJetO5zuoXjw1D39K527XWqBG8sOfhWdHyPzf13h8RXR1/RSKd1N0fdRDi5GdyizYmB0lPAK12/hbw==} resolution: {integrity: sha512-qCLUODZVsJetO5zuoXjw1D39K527XWqBG8sOfhWdHyPzf13h8RXR1/RSKd1N0fdRDi5GdyizYmB0lPAK12/hbw==}
peerDependencies: peerDependencies:
@@ -10842,7 +10830,7 @@ snapshots:
react: 19.2.3 react: 19.2.3
react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1)
'@bsky/sdk@1.0.1(@atproto/lex@0.3.8)': '@bsky/sdk@1.1.0(@atproto/lex@0.3.8)':
dependencies: dependencies:
'@atproto-labs/handle-resolver': 0.4.8 '@atproto-labs/handle-resolver': 0.4.8
'@atproto/lex': 0.3.8 '@atproto/lex': 0.3.8
@@ -18289,11 +18277,6 @@ snapshots:
react: 19.2.3 react: 19.2.3
react-is: 19.2.6 react-is: 19.2.6
react-native-compressor@1.13.0(patch_hash=58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3):
dependencies:
react: 19.2.3
react-native: 0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1)
react-native-date-picker@5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): react-native-date-picker@5.0.13(patch_hash=92943fb79d17d7342a29bbb12b0d8ee3cf6f7bca12ed322dc8d124a3e9fb75bd)(react-native@0.86.0(patch_hash=dd549527bb84c88acc7b0b1d521c9f4b666fda484c75cd0b282f8e9838524909)(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/jest-preset@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(@react-native/metro-config@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3):
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
-2
View File
@@ -17,7 +17,6 @@ overrides:
'@react-native/normalize-colors': '0.86.0' '@react-native/normalize-colors': '0.86.0'
'@expo/image-utils': '0.8.12' '@expo/image-utils': '0.8.12'
'@types/estree': '1.0.6' '@types/estree': '1.0.6'
'react-native-compressor': '1.13.0'
'react-native-reanimated': '4.5.3' 'react-native-reanimated': '4.5.3'
'react-native-worklets': '0.11.3' 'react-native-worklets': '0.11.3'
'psl': '1.9.0' 'psl': '1.9.0'
@@ -38,7 +37,6 @@ patchedDependencies:
'expo-notifications@57.0.7': patches/expo-notifications@57.0.7.patch 'expo-notifications@57.0.7': patches/expo-notifications@57.0.7.patch
'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch 'expo-updates@57.0.10': patches/expo-updates@57.0.10.patch
expo@57.0.8: patches/expo@57.0.8.patch expo@57.0.8: patches/expo@57.0.8.patch
'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch
'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch 'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch
'react-native-dotenv@3.4.11': patches/react-native-dotenv@3.4.11.patch 'react-native-dotenv@3.4.11': patches/react-native-dotenv@3.4.11.patch
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch 'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
+1 -2
View File
@@ -126,9 +126,8 @@ function InnerApp() {
} }
} catch (e) { } catch (e) {
logger.warn(`session: resume failed`, {message: e}) logger.warn(`session: resume failed`, {message: e})
} finally {
setIsReady(true)
} }
setIsReady(true)
} }
const account = readLastActiveAccount() const account = readLastActiveAccount()
void onLaunch(account) void onLaunch(account)
+1 -2
View File
@@ -105,9 +105,8 @@ function InnerApp() {
} }
} catch (e) { } catch (e) {
logger.warn('session: resumeSession failed', {message: e}) logger.warn('session: resumeSession failed', {message: e})
} finally {
setIsReady(true)
} }
setIsReady(true)
} }
const account = readLastActiveAccount() const account = readLastActiveAccount()
void onLaunch(account) void onLaunch(account)
+2
View File
@@ -117,6 +117,7 @@ const Context = createContext<AnalyticsBaseContextType>({
geolocation: device.get(['geolocationServiceResponse']) || { geolocation: device.get(['geolocationServiceResponse']) || {
countryCode: '', countryCode: '',
regionCode: '', regionCode: '',
city: '',
}, },
}, },
}) })
@@ -181,6 +182,7 @@ export function AnalyticsContext({
} }
const deviceId = useDeviceId() ?? 'unknown' const deviceId = useDeviceId() ?? 'unknown'
const sessionId = useSessionId() const sessionId = useSessionId()
// only IP based, never GPS
const geolocation = useGeolocationServiceResponse() const geolocation = useGeolocationServiceResponse()
const parentContext = useContext(Context) const parentContext = useContext(Context)
/* /*
+4 -8
View File
@@ -530,7 +530,7 @@ export function AuxiliaryView({
transform: [ transform: [
{ {
translateY: translateY:
(ensureOnScreenTranslationSV.get() || translationSV.get()) * Math.max(ensureOnScreenTranslationSV.get(), translationSV.get()) *
animationSV.get(), animationSV.get(),
}, },
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])}, {scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
@@ -544,21 +544,17 @@ export function AuxiliaryView({
const onLayout = useCallback(() => { const onLayout = useCallback(() => {
if (!measurement) return if (!measurement) return
let translation = 0
// vibes based, just assuming it'll fit within this space. revisit if we use // vibes based, just assuming it'll fit within this space. revisit if we use
// AuxiliaryView for something tall // AuxiliaryView for something tall
const TOP_INSET = topInset + 80 const TOP_INSET = topInset + 80
const distanceMessageFromTop = measurement.y - TOP_INSET const distanceMessageFromTop = measurement.y - TOP_INSET
if (distanceMessageFromTop < 0) { const minimumTranslation = -distanceMessageFromTop
translation = -distanceMessageFromTop
}
// normally, the context menu is responsible for measuring itself and moving everything into the right place // normally, the context menu is responsible for measuring itself and moving everything into the right place
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here // however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
if (mode === 'auxiliary-only') { if (mode === 'auxiliary-only') {
translationSV.set(translation) translationSV.set(Math.max(minimumTranslation, 0))
ensureOnScreenTranslationSV.set(0) ensureOnScreenTranslationSV.set(0)
} }
// however, we also need to make sure that for super tall triggers, we don't go off the screen // however, we also need to make sure that for super tall triggers, we don't go off the screen
@@ -567,7 +563,7 @@ export function AuxiliaryView({
// we'll just have to live with it for now, fixing it would be possible but be a large complexity // we'll just have to live with it for now, fixing it would be possible but be a large complexity
// increase for an edge case // increase for an edge case
else { else {
ensureOnScreenTranslationSV.set(translation) ensureOnScreenTranslationSV.set(minimumTranslation)
} }
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV]) }, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
@@ -429,7 +429,7 @@ let PostMenuItems = ({
type: 'error', type: 'error',
}) })
} }
} finally { }
ax.metric('postMenu:blockAccount', { ax.metric('postMenu:blockAccount', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
@@ -437,7 +437,6 @@ let PostMenuItems = ({
feedDescriptor: feedFeedback.feedDescriptor, feedDescriptor: feedFeedback.feedDescriptor,
}) })
} }
}
const onMuteAuthor = async () => { const onMuteAuthor = async () => {
if (postAuthor.viewer?.muted) { if (postAuthor.viewer?.muted) {
@@ -452,14 +451,13 @@ let PostMenuItems = ({
type: 'error', type: 'error',
}) })
} }
} finally { }
ax.metric('postMenu:unmuteAccount', { ax.metric('postMenu:unmuteAccount', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
logContext, logContext,
feedDescriptor: feedFeedback.feedDescriptor, feedDescriptor: feedFeedback.feedDescriptor,
}) })
}
} else { } else {
try { try {
await queueMute() await queueMute()
@@ -472,7 +470,7 @@ let PostMenuItems = ({
type: 'error', type: 'error',
}) })
} }
} finally { }
ax.metric('postMenu:muteAccount', { ax.metric('postMenu:muteAccount', {
uri: postUri, uri: postUri,
authorDid: postAuthor.did, authorDid: postAuthor.did,
@@ -481,7 +479,6 @@ let PostMenuItems = ({
}) })
} }
} }
}
const onReportMisclassification = () => { const onReportMisclassification = () => {
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
@@ -270,10 +270,6 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
// Track seen profiles // Track seen profiles
const seenProfilesRef = useRef<Set<string>>(new Set()) const seenProfilesRef = useRef<Set<string>>(new Set())
const itemsRef = useRef(items)
itemsRef.current = items
const selectedInterestRef = useRef(selectedInterest)
selectedInterestRef.current = selectedInterest
const onViewableItemsChanged = useNonReactiveCallback( const onViewableItemsChanged = useNonReactiveCallback(
({viewableItems}: {viewableItems: ViewToken[]}) => { ({viewableItems}: {viewableItems: ViewToken[]}) => {
@@ -282,7 +278,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
if (item.type === 'profile') { if (item.type === 'profile') {
if (!seenProfilesRef.current.has(item.profile.did)) { if (!seenProfilesRef.current.has(item.profile.did)) {
seenProfilesRef.current.add(item.profile.did) seenProfilesRef.current.add(item.profile.did)
const position = itemsRef.current.findIndex( const position = items.findIndex(
i => i.type === 'profile' && i.profile.did === item.profile.did, i => i.type === 'profile' && i.profile.did === item.profile.did,
) )
ax.metric('suggestedUser:seen', { ax.metric('suggestedUser:seen', {
@@ -292,9 +288,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
position: position !== -1 ? position : 0, position: position !== -1 ? position : 0,
suggestedDid: item.profile.did, suggestedDid: item.profile.did,
category: category:
selectedInterestRef.current === FOR_YOU_TAB selectedInterest === FOR_YOU_TAB ? null : selectedInterest,
? null
: selectedInterestRef.current,
}) })
} }
} }
+5 -16
View File
@@ -1,11 +1,5 @@
import {Fragment, useMemo, useRef} from 'react' import {Fragment, useMemo, useRef} from 'react'
import { import {Keyboard, type StyleProp, View, type ViewStyle} from 'react-native'
Keyboard,
Platform,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {AtUri} from '@atproto/syntax' import {AtUri} from '@atproto/syntax'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -32,7 +26,7 @@ import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Gr
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import {app} from '#/lexicons' import {app} from '#/lexicons'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
@@ -110,14 +104,9 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
} }
onPress={onPressOpen} onPress={onPressOpen}
{...(isThreadAuthor {...(isThreadAuthor
? Platform.select({ ? IS_WEB
web: { ? {onHoverIn: prefetch}
onHoverIn: prefetch, : {onPressIn: prefetch}
},
native: {
onPressIn: prefetch,
},
})
: {})} : {})}
hitSlop={HITSLOP_10}> hitSlop={HITSLOP_10}>
{({hovered, focused, pressed}) => ( {({hovered, focused, pressed}) => (
@@ -9,7 +9,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {mergeRefs} from '#/lib/merge-refs' import {mergeRefs} from '#/lib/merge-refs'
import {atoms as a, ios, platform, useTheme} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_ANDROID, IS_IOS} from '#/env' import {IS_ANDROID, IS_IOS} from '#/env'
@@ -94,7 +94,11 @@ export function OTPInput({
</View> </View>
<TextInput <TextInput
// SMS autofill is borked on iOS if you open the keyboard immediately -sfn // SMS autofill is borked on iOS if you open the keyboard immediately -sfn
onLayout={ios(() => setTimeout(() => innerRef.current?.focus(), 100))} onLayout={
IS_IOS
? () => setTimeout(() => innerRef.current?.focus(), 100)
: undefined
}
autoFocus={IS_ANDROID} autoFocus={IS_ANDROID}
accessible accessible
accessibilityLabel={label} accessibilityLabel={label}
@@ -95,9 +95,8 @@ function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) {
safeMessage: e.message, safeMessage: e.message,
}) })
} }
} finally {
setIsRequesting(false)
} }
setIsRequesting(false)
} }
return ( return (
@@ -241,9 +241,8 @@ export function PostInteractionSettingsDialogControlledInner(
type: 'error', type: 'error',
}, },
) )
} finally {
setIsSaving(false)
} }
setIsSaving(false)
}, [ }, [
_, _,
ax, ax,
+7 -1
View File
@@ -47,7 +47,13 @@ export function SearchInput({
<TextField.Root> <TextField.Root>
<TextField.Icon icon={MagnifyingGlassIcon} /> <TextField.Icon icon={MagnifyingGlassIcon} />
<TextField.Input <TextField.Input
inputRef={mergeRefs([internalRef, ref])} /*
* Deferred into the callback: React Compiler only special-cases the
* `ref` prop, so a merged ref built during render and handed to
* `inputRef` reads as accessing a ref. `mergeRefs` already returns a
* fresh function per render, so this adds no identity churn.
*/
inputRef={node => mergeRefs([internalRef, ref])(node)}
label={label || l`Search`} label={label || l`Search`}
value={value} value={value}
placeholder={l`Search`} placeholder={l`Search`}
+5 -2
View File
@@ -26,6 +26,7 @@ import {
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
const Context = createContext<{ const Context = createContext<{
inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null> | null inputRef: React.RefObject<React.ComponentRef<typeof TextInput> | null> | null
@@ -101,11 +102,13 @@ export function Root({children, isInvalid = false, style}: RootProps) {
{zIndex: 0}, {zIndex: 0},
style, style,
]} ]}
{...web({ {...(IS_WEB
? {
onClick: () => inputRef.current?.focus(), onClick: () => inputRef.current?.focus(),
onMouseOver: onHoverIn, onMouseOver: onHoverIn,
onMouseOut: onHoverOut, onMouseOut: onHoverOut,
})}> }
: {})}>
{children} {children}
</View> </View>
</Context.Provider> </Context.Provider>
@@ -319,9 +319,8 @@ function Inner(
type: 'setError', type: 'setError',
error, error,
}) })
} finally {
setIsPending(false)
} }
setIsPending(false)
}, [logger, submitReport, props, state, ax, l, videoTimestampSeconds]) }, [logger, submitReport, props, state, ax, l, videoTimestampSeconds])
useCallOnce(() => { useCallOnce(() => {
+1
View File
@@ -9,4 +9,5 @@ export const GEOLOCATION_SERVICE_URL = `${GEOLOCATION_URL}/geolocation`
export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = { export const FALLBACK_GEOLOCATION_SERVICE_RESPONSE: Geolocation = {
countryCode: undefined, countryCode: undefined,
regionCode: undefined, regionCode: undefined,
city: undefined,
} }
+2
View File
@@ -1,6 +1,8 @@
export type Geolocation = { export type Geolocation = {
countryCode: string | undefined countryCode: string | undefined
regionCode: string | undefined regionCode: string | undefined
/** Only populated by the IP-based geolocation service. */
city?: string
serviceGeolocation?: Geolocation serviceGeolocation?: Geolocation
deviceGeolocation?: Geolocation deviceGeolocation?: Geolocation
} }
+40
View File
@@ -0,0 +1,40 @@
import {type Client} from '@atproto/lex'
import {CustomFeedAPI} from './custom'
jest.mock('#/state/preferences/languages', () => ({
getAppLanguageAsContentLanguage: () => '',
getContentLanguages: () => [],
}))
jest.mock('./utils', () => ({
createBskyTopicsHeader: () => ({}),
isBlueskyOwnedFeed: () => false,
}))
describe('CustomFeedAPI', () => {
it('preserves the cursor from an empty logged-out fallback page', async () => {
const originalFetch = global.fetch
const fetchMock: jest.MockedFunction<typeof fetch> = jest
.fn()
.mockResolvedValueOnce(Response.json({feed: []}))
.mockResolvedValueOnce(Response.json({feed: [], cursor: 'next'}))
global.fetch = fetchMock
const api = new CustomFeedAPI({
client: {did: undefined} as unknown as Client,
feedParams: {
feed: 'at://did:example:feed/app.bsky.feed.generator/test',
},
})
try {
await expect(api.fetch({cursor: undefined, limit: 10})).resolves.toEqual({
cursor: 'next',
feed: [],
})
expect(fetchMock).toHaveBeenCalledTimes(2)
} finally {
global.fetch = originalFetch
}
})
})
+1 -5
View File
@@ -96,7 +96,7 @@ export class CustomFeedAPI implements FeedAPI {
const feed = const feed =
data.feed.length > limit ? data.feed.slice(0, limit) : data.feed data.feed.length > limit ? data.feed.slice(0, limit) : data.feed
return { return {
cursor: feed.length ? data.cursor : undefined, cursor: data.cursor,
feed, feed,
} }
} }
@@ -154,13 +154,9 @@ async function loggedOutFetch(
// no data, try again with language headers removed // no data, try again with language headers removed
data = await getFeedOrNull(params, '') data = await getFeedOrNull(params, '')
if (data?.feed?.length) {
return data return data
} }
return null
}
/** /**
* A logged-out `getFeed` read that resolves to null on a response error. * A logged-out `getFeed` read that resolves to null on a response error.
* *
+1 -3
View File
@@ -48,10 +48,8 @@ export class LikesFeedAPI implements FeedAPI {
cursor, cursor,
limit, limit,
}) })
// HACKFIX: the API incorrectly returns a cursor when there are no items -sfn
const isEmptyPage = data.feed.length === 0
return { return {
cursor: isEmptyPage ? undefined : data.cursor, cursor: data.cursor,
feed: data.feed, feed: data.feed,
} }
} }
+106
View File
@@ -0,0 +1,106 @@
import {type Client} from '@atproto/lex'
import {app} from '#/lexicons'
import {MergeFeedAPI} from './merge'
const post = {} as app.bsky.feed.defs.FeedViewPost
describe('MergeFeedAPI', () => {
it('drains a terminal following queue without restarting the source', async () => {
const api = new MergeFeedAPI({
client: {} as Client,
feedParams: {},
feedTuners: [],
})
api.reset()
api.following.queue = [post, post, post]
api.following.hasMore = false
const first = await api.fetch({cursor: 'started', limit: 2})
const second = await api.fetch({cursor: first.cursor, limit: 2})
expect(first.feed).toHaveLength(2)
expect(first.cursor).toBeDefined()
expect(second.feed).toHaveLength(1)
expect(second.cursor).toBeUndefined()
})
it('stops when the following source repeats its cursor', async () => {
const call = jest
.fn()
.mockResolvedValueOnce({feed: [], cursor: 'a'})
.mockResolvedValueOnce({feed: [], cursor: 'a'})
const api = new MergeFeedAPI({
client: {call} as unknown as Client,
feedParams: {},
feedTuners: [],
})
const first = await api.fetch({cursor: undefined, limit: 1})
const second = await api.fetch({cursor: first.cursor, limit: 1})
expect(first.cursor).toBeDefined()
expect(second.cursor).toBeUndefined()
expect(call).toHaveBeenCalledTimes(2)
})
it('stops when the following source returns an empty cursor', async () => {
const call = jest.fn().mockResolvedValue({feed: [], cursor: ''})
const api = new MergeFeedAPI({
client: {call} as unknown as Client,
feedParams: {},
feedTuners: [],
})
const result = await api.fetch({cursor: undefined, limit: 1})
expect(result.cursor).toBeUndefined()
expect(call).toHaveBeenCalledTimes(1)
})
it('stops when the following source cycles to an earlier cursor', async () => {
const call = jest
.fn()
.mockResolvedValueOnce({feed: [], cursor: 'a'})
.mockResolvedValueOnce({feed: [], cursor: 'b'})
.mockResolvedValueOnce({feed: [], cursor: 'a'})
const api = new MergeFeedAPI({
client: {call} as unknown as Client,
feedParams: {},
feedTuners: [],
})
const first = await api.fetch({cursor: undefined, limit: 1})
const second = await api.fetch({cursor: first.cursor, limit: 1})
const third = await api.fetch({cursor: second.cursor, limit: 1})
expect(third.cursor).toBeUndefined()
expect(call).toHaveBeenCalledTimes(3)
})
it('drains a terminal custom-feed queue without restarting the source', async () => {
const call = jest.fn()
const api = new MergeFeedAPI({
client: {call} as unknown as Client,
feedParams: {
mergeFeedEnabled: true,
mergeFeedSources: [
'at://did:example:feed/app.bsky.feed.generator/test',
],
},
feedTuners: [],
})
api.reset()
api.following.hasMore = false
api.customFeeds[0].queue = [post, post, post]
api.customFeeds[0].hasMore = false
const first = await api.fetch({cursor: 'started', limit: 2})
const second = await api.fetch({cursor: first.cursor, limit: 2})
expect(first.feed).toHaveLength(2)
expect(second.feed).toHaveLength(1)
expect(second.cursor).toBeUndefined()
expect(call).not.toHaveBeenCalled()
})
})
+17 -7
View File
@@ -108,8 +108,8 @@ export class MergeFeedAPI implements FeedAPI {
const promises = [] const promises = []
// always keep following topped up // always keep following topped up while the source has another page
if (this.following.numReady < limit) { if (this.following.hasMore && this.following.numReady < limit) {
await this.following.fetchNext(60) await this.following.fetchNext(60)
} }
@@ -125,7 +125,7 @@ export class MergeFeedAPI implements FeedAPI {
!this.following.hasMore && this.following.numReady < limit !this.following.hasMore && this.following.numReady < limit
if (this.params.mergeFeedEnabled || outOfFollows) { if (this.params.mergeFeedEnabled || outOfFollows) {
for (const feed of feeds) { for (const feed of feeds) {
if (feed.numReady < 5) { if (feed.hasMore && feed.numReady < 5) {
promises.push(feed.fetchNext(10)) promises.push(feed.fetchNext(10))
} }
} }
@@ -145,8 +145,12 @@ export class MergeFeedAPI implements FeedAPI {
} }
} }
const hasMore =
this.following.hasMore ||
this.following.numReady > 0 ||
this.customFeeds.some(feed => feed.hasMore || feed.numReady > 0)
return { return {
cursor: String(this.itemCursor), cursor: hasMore ? String(this.itemCursor) : undefined,
feed: posts, feed: posts,
} }
} }
@@ -155,8 +159,8 @@ export class MergeFeedAPI implements FeedAPI {
const i = this.itemCursor++ const i = this.itemCursor++
const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
const canSample = candidateFeeds.length > 0 const canSample = candidateFeeds.length > 0
const hasFollows = this.following.hasMore
const hasFollowsReady = this.following.numReady > 0 const hasFollowsReady = this.following.numReady > 0
const hasFollows = this.following.hasMore || hasFollowsReady
// this condition establishes the frequency that custom feeds are woven into follows // this condition establishes the frequency that custom feeds are woven into follows
const shouldSample = const shouldSample =
@@ -187,6 +191,7 @@ class MergeFeedSource {
feedTuners: FeedTunerFn[] feedTuners: FeedTunerFn[]
sourceInfo: ReasonFeedSource | undefined sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined cursor: string | undefined = undefined
seenCursors = new Set<string>()
queue: app.bsky.feed.defs.FeedViewPost[] = [] queue: app.bsky.feed.defs.FeedViewPost[] = []
hasMore = true hasMore = true
@@ -221,11 +226,16 @@ class MergeFeedSource {
const page = await this._getFeed(this.cursor, n) const page = await this._getFeed(this.cursor, n)
if (page) { if (page) {
this.cursor = page.cursor this.cursor = page.cursor
if (page.feed.length) { const cursor = this.cursor
this.queue = this.queue.concat(page.feed) if (cursor) {
this.hasMore = !this.seenCursors.has(cursor)
this.seenCursors.add(cursor)
} else { } else {
this.hasMore = false this.hasMore = false
} }
if (page.feed.length) {
this.queue = this.queue.concat(page.feed)
}
} else { } else {
this.hasMore = false this.hasMore = false
} }
+1 -2
View File
@@ -54,9 +54,8 @@ export function useAccountSwitcher() {
Toast.show(_(msg`Please sign in as @${account.handle}`), { Toast.show(_(msg`Please sign in as @${account.handle}`), {
type: 'warning', type: 'warning',
}) })
} finally {
setPendingDid(null)
} }
setPendingDid(null)
}, },
[_, ax, resumeSession, requestSwitchToAccount, pendingDid], [_, ax, resumeSession, requestSwitchToAccount, pendingDid],
) )
+8 -8
View File
@@ -1,12 +1,12 @@
import {useRef} from 'react' import {useState} from 'react'
import {Animated} from 'react-native' import {Animated} from 'react-native'
export function useAnimatedValue(initialValue: number) { export function useAnimatedValue(initialValue: number) {
const lazyRef = useRef<Animated.Value>(undefined) /*
* A lazy `useState` initialiser rather than a lazily-populated ref: both
if (lazyRef.current === undefined) { * construct once and keep the same instance, but reading a ref during render
lazyRef.current = new Animated.Value(initialValue) * is a Rules of React violation.
} */
const [value] = useState(() => new Animated.Value(initialValue))
return lazyRef.current return value
} }
+6 -1
View File
@@ -74,8 +74,13 @@ export function useDraggableScroll<
} }
}, [cursor]) }, [cursor])
/*
* Deferred into the callback so the merge does not happen during the render of
* whichever component uses this hook - see SearchInput for the full reasoning.
*/
const refs = useMemo( const refs = useMemo(
() => mergeRefs(outerRef ? [ref, outerRef] : [ref]), () => (node: Scrollable | null) =>
mergeRefs(outerRef ? [ref, outerRef] : [ref])(node),
[ref, outerRef], [ref, outerRef],
) )
+9
View File
@@ -0,0 +1,9 @@
import {getInfoAsync} from 'expo-file-system/legacy'
export async function getUriSize(uri: string): Promise<number> {
const info = await getInfoAsync(uri)
if (!info.exists) {
throw new Error('Failed to read image size')
}
return info.size
}
+5
View File
@@ -0,0 +1,5 @@
export async function getUriSize(uri: string): Promise<number> {
const response = await fetch(uri)
const blob = await response.blob()
return blob.size
}
-2
View File
@@ -44,8 +44,6 @@ export async function compressVideo(
} }
// Pre-check the threshold ourselves so we can label the skip in telemetry. // Pre-check the threshold ourselves so we can label the skip in telemetry.
// rnc would do the same skip internally via minimumFileSizeForCompress, but
// that path is invisible to us.
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes( const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes, file.mimeType as SupportedMimeTypes,
) )
+1 -1
View File
@@ -31,7 +31,7 @@ function assignRef<T>(
* @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and * @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and
* returns a ref callback function that can be used to merge multiple refs into a single ref. * returns a ref callback function that can be used to merge multiple refs into a single ref.
*/ */
export function mergeRefs<T>(refs: (Ref<T> | undefined)[]): Ref<T> { export function mergeRefs<T>(refs: (Ref<T> | undefined)[]): RefCallback<T> {
return (value: T | null) => { return (value: T | null) => {
const cleanups: (() => void)[] = [] const cleanups: (() => void)[] = []
+3 -3
View File
@@ -1,4 +1,4 @@
import {useEffect, useRef, useState} from 'react' import {useEffect, useState} from 'react'
import {AppState, type AppStateStatus} from 'react-native' import {AppState, type AppStateStatus} from 'react-native'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import { import {
@@ -168,8 +168,8 @@ function QueryProviderInner({
children: React.ReactNode children: React.ReactNode
currentDid: string | undefined currentDid: string | undefined
}) { }) {
const initialDid = useRef(currentDid) const [initialDid] = useState(currentDid)
if (currentDid !== initialDid.current) { if (currentDid !== initialDid) {
throw Error( throw Error(
'Something is very wrong. Expected did to be stable due to key above.', 'Something is very wrong. Expected did to be stable due to key above.',
) )
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -95,9 +95,8 @@ export function Deactivated() {
logger.error(e, { logger.error(e, {
message: 'Failed to activate account', message: 'Failed to activate account',
}) })
} finally {
setPending(false)
} }
setPending(false)
}, [_, pdsClient, refreshSession, setPending, setError, queryClient]) }, [_, pdsClient, refreshSession, setPending, setError, queryClient])
return ( return (
+1 -2
View File
@@ -103,9 +103,8 @@ export function ListHiddenScreen({
msg`There was an issue. Please check your internet connection and try again.`, msg`There was an issue. Please check your internet connection and try again.`,
), ),
) )
} finally {
setIsProcessing(false)
} }
setIsProcessing(false)
} }
return ( return (
+1 -2
View File
@@ -58,9 +58,8 @@ export const ChooseAccountForm = ({
}) })
// Move to login form. // Move to login form.
onSelectAccount(account) onSelectAccount(account)
} finally {
setPendingDid(null)
} }
setPendingDid(null)
}, },
[ [
currentAccount, currentAccount,
@@ -1,4 +1,4 @@
import {useRef, useState} from 'react' import {useEffect, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import PagerView from 'react-native-pager-view' import PagerView from 'react-native-pager-view'
import {type PagerViewOnPageSelectedEvent} from 'react-native-pager-view' import {type PagerViewOnPageSelectedEvent} from 'react-native-pager-view'
@@ -26,9 +26,17 @@ export function ValuePropositionPager({
if (step !== activePage) { if (step !== activePage) {
setActivePage(step) setActivePage(step)
ref.current?.setPage(step)
} }
/*
* In an effect rather than inline above: driving the pager is a side effect,
* and reading a ref during render is a Rules of React violation. `initialPage`
* already covers the first render, so the mount run is a no-op.
*/
useEffect(() => {
ref.current?.setPage(step)
}, [step])
const images = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]] const images = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]]
return ( return (
+2 -2
View File
@@ -22,7 +22,7 @@ import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
import {compressIfNeeded} from '#/lib/media/manip' import {compressIfNeeded} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker' import {openCropper} from '#/lib/media/picker'
import {getDataUriSize} from '#/lib/media/util' import {getUriSize} from '#/lib/media/uriSize'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {isCancelledError} from '#/lib/strings/errors' import {isCancelledError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -133,7 +133,7 @@ export function StepProfile() {
height: rendered.height, height: rendered.height,
width: rendered.width, width: rendered.width,
path: result.uri, path: result.uri,
size: getDataUriSize(result.uri), size: await getUriSize(result.uri),
}, },
] ]
} catch { } catch {
@@ -102,9 +102,8 @@ function Inner() {
logger.error('Failed to request password reset', {safeMessage: e}) logger.error('Failed to request password reset', {safeMessage: e})
setError(cleanError(e)) setError(cleanError(e))
} }
} finally {
setIsProcessing(false)
} }
setIsProcessing(false)
} }
const onChangePassword = async () => { const onChangePassword = async () => {
@@ -151,9 +150,8 @@ function Inner() {
logger.error('Failed to set new password', {safeMessage: e}) logger.error('Failed to set new password', {safeMessage: e})
setError(cleanError(e)) setError(cleanError(e))
} }
} finally {
setIsProcessing(false)
} }
setIsProcessing(false)
} }
const onBlur = () => { const onBlur = () => {
@@ -64,9 +64,8 @@ function DeactivateAccountDialogInner({
logger.error(e, { logger.error(e, {
message: 'Failed to deactivate account', message: 'Failed to deactivate account',
}) })
} finally {
setPending(false)
} }
setPending(false)
}, [client, control, logoutCurrentAccount, _, setPending]) }, [client, control, logoutCurrentAccount, _, setPending])
return ( return (
@@ -49,9 +49,8 @@ export function DisableEmail2FADialog({
setStage(Stages.ConfirmCode) setStage(Stages.ConfirmCode)
} catch (e) { } catch (e) {
setError(cleanError(String(e))) setError(cleanError(String(e)))
} finally {
setIsProcessing(false)
} }
setIsProcessing(false)
} }
const onConfirmDisable = async () => { const onConfirmDisable = async () => {
@@ -80,9 +79,8 @@ export function DisableEmail2FADialog({
} else { } else {
setError(cleanError(e)) setError(cleanError(e))
} }
} finally {
setIsProcessing(false)
} }
setIsProcessing(false)
} }
return ( return (
@@ -53,9 +53,8 @@ export function ExportCarDialog({
} catch (e) { } catch (e) {
logger.error('Error occurred while downloading CAR file', {message: e}) logger.error('Error occurred while downloading CAR file', {message: e})
Toast.show(l`Error occurred while saving file`, {type: 'error'}) Toast.show(l`Error occurred while saving file`, {type: 'error'})
} finally {
setLoading(false)
} }
setLoading(false)
}, [l, currentAccount, pdsClient]) }, [l, currentAccount, pdsClient])
const downloadChatData = useCallback(async () => { const downloadChatData = useCallback(async () => {
@@ -84,9 +83,8 @@ export function ExportCarDialog({
} catch (e) { } catch (e) {
logger.error('Error occurred while downloading chat data', {message: e}) logger.error('Error occurred while downloading chat data', {message: e})
Toast.show(l`Error occurred while saving file`, {type: 'error'}) Toast.show(l`Error occurred while saving file`, {type: 'error'})
} finally {
setLoading(false)
} }
setLoading(false)
}, [l, currentAccount, chatClient]) }, [l, currentAccount, chatClient])
return ( return (
+1 -2
View File
@@ -51,9 +51,8 @@ export function StepCaptchaNative() {
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
logger.error(e) logger.error(e)
} finally {
setReady(true)
} }
setReady(true)
})() })()
}, []) }, [])
+2 -1
View File
@@ -219,6 +219,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
a.w_full, a.w_full,
a.py_lg, a.py_lg,
a.flex_row, a.flex_row,
a.flex_wrap,
a.gap_md, a.gap_md,
a.align_center, a.align_center,
]}> ]}>
@@ -227,7 +228,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
style={ style={
gtMobile gtMobile
? [a.flex_1, a.flex, a.flex_row, a.justify_end] ? [a.flex_1, a.flex, a.flex_row, a.justify_end]
: [] : [a.flex_shrink]
}> }>
<Text <Text
style={[ style={[
+1 -2
View File
@@ -60,9 +60,8 @@ export function SignupQueued() {
} }
} catch (e: any) { } catch (e: any) {
logger.error('Failed to check signup queue', {err: e.toString()}) logger.error('Failed to check signup queue', {err: e.toString()})
} finally {
setProcessing(false)
} }
setProcessing(false)
}, [ }, [
setProcessing, setProcessing,
setEstimatedTime, setEstimatedTime,
+3 -4
View File
@@ -2,9 +2,9 @@ import {
createContext, createContext,
useContext, useContext,
useEffect, useEffect,
useEffectEvent,
useMemo, useMemo,
useReducer, useReducer,
useRef,
} from 'react' } from 'react'
import {useCurrentConvoId} from './current-convo-id' import {useCurrentConvoId} from './current-convo-id'
@@ -44,8 +44,7 @@ export function useMessageDraft() {
export function useSaveMessageDraft(message: string) { export function useSaveMessageDraft(message: string) {
const {currentConvoId} = useCurrentConvoId() const {currentConvoId} = useCurrentConvoId()
const {dispatch} = useMessageDraftsContext() const {dispatch} = useMessageDraftsContext()
const messageRef = useRef(message) const getMessage = useEffectEvent(() => message)
messageRef.current = message
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -53,7 +52,7 @@ export function useSaveMessageDraft(message: string) {
dispatch({ dispatch({
type: 'set', type: 'set',
convoId: currentConvoId, convoId: currentConvoId,
draft: messageRef.current, draft: getMessage(),
}) })
} }
} }
+5 -49
View File
@@ -16,7 +16,7 @@
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
*/ */
import {useCallback, useEffect, useMemo, useRef} from 'react' import {useCallback, useMemo, useRef} from 'react'
import {AtUri} from '@atproto/syntax' import {AtUri} from '@atproto/syntax'
import {moderatePost} from '@bsky/sdk/moderation' import {moderatePost} from '@bsky/sdk/moderation'
import { import {
@@ -37,6 +37,7 @@ import {
didOrHandleUriMatches, didOrHandleUriMatches,
embedViewRecordToPostView, embedViewRecordToPostView,
getEmbeddedPost, getEmbeddedPost,
useAutoPagination,
} from '../util' } from '../util'
import {type FeedPage} from './types' import {type FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread' import {useUnreadNotificationsApi} from './unread'
@@ -220,54 +221,9 @@ export function useNotificationFeedQuery(opts: {
), ),
}) })
// The server may end up returning an empty page, a page with too few items, const itemCount =
// or a page with items that end up getting filtered out. When we fetch pages, query.data?.pages.reduce((count, page) => count + page.items.length, 0) ?? 0
// we'll keep track of how many items we actually hope to see. If the server useAutoPagination(query, itemCount, PAGE_SIZE)
// doesn't return enough items, we're going to continue asking for more items.
const lastItemCount = useRef(0)
const wantedItemCount = useRef(0)
const autoPaginationAttemptCount = useRef(0)
useEffect(() => {
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
query
// Count the items that we already have.
let itemCount = 0
for (const page of data?.pages || []) {
itemCount += page.items.length
}
// If items got truncated, reset the state we're tracking below.
if (itemCount !== lastItemCount.current) {
if (itemCount < lastItemCount.current) {
wantedItemCount.current = itemCount
}
lastItemCount.current = itemCount
}
// Now track how many items we really want, and fetch more if needed.
if (isLoading || isRefetching) {
// During the initial fetch, we want to get an entire page's worth of items.
wantedItemCount.current = PAGE_SIZE
} else if (isFetchingNextPage) {
if (itemCount > wantedItemCount.current) {
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
// Some other code must have called fetchNextPage(), for example, from onEndReached.
// Adjust the wantedItemCount to reflect that we want one more full page of items.
wantedItemCount.current = itemCount + PAGE_SIZE
}
} else if (hasNextPage) {
// At this point we're not fetching anymore, so it's time to make a decision.
// If we didn't receive enough items from the server, paginate again until we do.
if (itemCount < wantedItemCount.current) {
autoPaginationAttemptCount.current++
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
query.fetchNextPage()
}
} else {
autoPaginationAttemptCount.current = 0
}
}
}, [query])
return query return query
} }
+4 -46
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useMemo, useRef} from 'react' import {useCallback, useMemo, useRef} from 'react'
import {AppState} from 'react-native' import {AppState} from 'react-native'
import {type Client} from '@atproto/lex' import {type Client} from '@atproto/lex'
import {type AtIdentifierString, AtUri, type AtUriString} from '@atproto/syntax' import {type AtIdentifierString, AtUri, type AtUriString} from '@atproto/syntax'
@@ -46,6 +46,7 @@ import {
didOrHandleUriMatches, didOrHandleUriMatches,
embedViewRecordToPostView, embedViewRecordToPostView,
getEmbeddedPost, getEmbeddedPost,
useAutoPagination,
} from './util' } from './util'
type ActorDid = string type ActorDid = string
@@ -368,56 +369,13 @@ export function usePostFeedQuery(
), ),
}) })
// The server may end up returning an empty page, a page with too few items,
// or a page with items that end up getting filtered out. When we fetch pages,
// we'll keep track of how many items we actually hope to see. If the server
// doesn't return enough items, we're going to continue asking for more items.
const lastItemCount = useRef(0)
const wantedItemCount = useRef(0)
const autoPaginationAttemptCount = useRef(0)
useEffect(() => {
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
query
// Count the items that we already have.
let itemCount = 0 let itemCount = 0
for (const page of data?.pages || []) { for (const page of query.data?.pages || []) {
for (const slice of page.slices) { for (const slice of page.slices) {
itemCount += slice.items.length itemCount += slice.items.length
} }
} }
useAutoPagination(query, itemCount, MIN_POSTS)
// If items got truncated, reset the state we're tracking below.
if (itemCount !== lastItemCount.current) {
if (itemCount < lastItemCount.current) {
wantedItemCount.current = itemCount
}
lastItemCount.current = itemCount
}
// Now track how many items we really want, and fetch more if needed.
if (isLoading || isRefetching) {
// During the initial fetch, we want to get an entire page's worth of items.
wantedItemCount.current = MIN_POSTS
} else if (isFetchingNextPage) {
if (itemCount > wantedItemCount.current) {
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
// Some other code must have called fetchNextPage(), for example, from onEndReached.
// Adjust the wantedItemCount to reflect that we want one more full page of items.
wantedItemCount.current = itemCount + MIN_POSTS
}
} else if (hasNextPage) {
// At this point we're not fetching anymore, so it's time to make a decision.
// If we didn't receive enough items from the server, paginate again until we do.
if (itemCount < wantedItemCount.current) {
autoPaginationAttemptCount.current++
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
query.fetchNextPage()
}
} else {
autoPaginationAttemptCount.current = 0
}
}
}, [query])
return query return query
} }
+6 -5
View File
@@ -38,7 +38,7 @@ import {
type UsePreferencesQueryResponse, type UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types' } from '#/state/queries/preferences/types'
import {createQueryKey} from '#/state/queries/util' import {createQueryKey} from '#/state/queries/util'
import {useAppviewClient, usePdsClient} from '#/state/session' import {useAppviewClient, useChatClient, usePdsClient} from '#/state/session'
import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation' import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
@@ -58,6 +58,7 @@ export const preferencesQueryKey = createQueryKey(
export function usePreferencesQuery() { export function usePreferencesQuery() {
const client = usePdsClient() const client = usePdsClient()
const appviewClient = useAppviewClient() const appviewClient = useAppviewClient()
const chatClient = useChatClient()
const aa = useAgeAssurance() const aa = useAgeAssurance()
const query = useQuery({ const query = useQuery({
@@ -85,12 +86,12 @@ export function usePreferencesQuery() {
* from a labeler would not affect server-attached labels until the * from a labeler would not affect server-attached labels until the
* session bundle was rebuilt. * session bundle was rebuilt.
* *
* The subscriptions go on the appview client, which is what stamps * The subscriptions go on both services that hydrate moderated content.
* `atproto-accept-labelers` on its own requests. The Bluesky moderation * The Bluesky moderation DID is dropped so the globally redacted
* DID is dropped so the globally redacted authority is not also listed * authority is not also listed unredacted.
* unredacted.
*/ */
applyLabelersToClient(appviewClient, labelerDids) applyLabelersToClient(appviewClient, labelerDids)
applyLabelersToClient(chatClient, labelerDids)
/* /*
* `BskyPreferences` is now the sdk's own type, so the assembled * `BskyPreferences` is now the sdk's own type, so the assembled
+100
View File
@@ -0,0 +1,100 @@
import {renderHook} from '@testing-library/react-native'
import {useAutoPagination} from './util'
function query(overrides: Record<string, unknown> = {}) {
return {
data: {pageParams: [undefined]},
isLoading: false,
isRefetching: false,
isFetchingNextPage: false,
hasNextPage: true,
fetchNextPage: jest.fn().mockResolvedValue(undefined),
...overrides,
}
}
describe('useAutoPagination', () => {
it('fetches another page when visible items are missing', () => {
const value = query()
renderHook(() => useAutoPagination(value, 0, 10))
expect(value.fetchNextPage).toHaveBeenCalledTimes(1)
})
it('stops when the requested number of items is visible', () => {
const value = query()
renderHook(() => useAutoPagination(value, 10, 10))
expect(value.fetchNextPage).not.toHaveBeenCalled()
})
it('stops when the server repeats a cursor', () => {
const value = query({data: {pageParams: [undefined, 'a', 'a']}})
renderHook(() => useAutoPagination(value, 0, 10))
expect(value.fetchNextPage).not.toHaveBeenCalled()
})
it('stops when structured page params repeat a cursor', () => {
const value = query({
data: {pageParams: [undefined, {cursor: 'a'}, {cursor: 'a'}]},
})
renderHook(() => useAutoPagination(value, 0, 10))
expect(value.fetchNextPage).not.toHaveBeenCalled()
})
it('stops when a cursor repeats non-adjacently', () => {
const value = query({data: {pageParams: [undefined, 'a', 'b', 'a']}})
renderHook(() => useAutoPagination(value, 0, 10))
expect(value.fetchNextPage).not.toHaveBeenCalled()
})
it('fills one page after switching to a smaller cached query', () => {
const first = query({hasNextPage: false})
const second = query()
let value = first
let itemCount = 10
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
value = second
itemCount = 5
rerender(undefined)
expect(second.fetchNextPage).toHaveBeenCalledTimes(1)
})
it('resets the attempt limit after switching to cached data with the same item count', () => {
const fetchNextPage = jest.fn().mockResolvedValue(undefined)
const data = {pageParams: [undefined]}
let value = query({fetchNextPage, data})
const itemCount = 0
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
for (let i = 1; i < 50; i++) {
value = query({
fetchNextPage,
data,
})
rerender(undefined)
}
expect(fetchNextPage).toHaveBeenCalledTimes(49)
const second = query({
data: {
pageParams: Array.from({length: 51}, (_, i) => `new-cursor-${i}`),
},
})
value = second
rerender(undefined)
expect(second.fetchNextPage).toHaveBeenCalledTimes(1)
})
})
+77
View File
@@ -1,3 +1,4 @@
import {useEffect, useRef} from 'react'
import {type AtUri} from '@atproto/syntax' import {type AtUri} from '@atproto/syntax'
import { import {
type InfiniteData, type InfiniteData,
@@ -8,6 +9,82 @@ import {
import {app} from '#/lexicons' import {app} from '#/lexicons'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
type AutoPaginationQuery = {
data?: {pageParams: unknown[]}
isLoading: boolean
isRefetching: boolean
isFetchingNextPage: boolean
hasNextPage: boolean
fetchNextPage: () => Promise<unknown>
}
export function useAutoPagination(
query: AutoPaginationQuery,
itemCount: number,
pageSize: number,
) {
const lastItemCount = useRef(0)
const lastPageParams = useRef(query.data?.pageParams)
const wantedItemCount = useRef(pageSize)
const attemptCount = useRef(0)
useEffect(() => {
const cursorOf = (param: unknown) =>
param && typeof param === 'object' && 'cursor' in param
? param.cursor
: param
const pageParams = query.data?.pageParams
const previousPageParams = lastPageParams.current
const continuedPagination =
pageParams &&
previousPageParams &&
pageParams.length > previousPageParams.length &&
previousPageParams.every((param, index) =>
Object.is(cursorOf(param), cursorOf(pageParams[index])),
)
if (
pageParams !== previousPageParams &&
previousPageParams &&
!continuedPagination
) {
wantedItemCount.current = pageSize
attemptCount.current = 0
}
lastPageParams.current = pageParams
if (itemCount !== lastItemCount.current) {
attemptCount.current = 0
if (itemCount < lastItemCount.current) {
wantedItemCount.current = Math.max(itemCount, pageSize)
}
lastItemCount.current = itemCount
}
if (query.isLoading || query.isRefetching) {
wantedItemCount.current = pageSize
attemptCount.current = 0
} else if (query.isFetchingNextPage) {
if (itemCount > wantedItemCount.current) {
wantedItemCount.current = itemCount + pageSize
}
} else if (query.hasNextPage) {
if (itemCount < wantedItemCount.current) {
const currentCursor = cursorOf(pageParams?.at(-1))
const repeatedCursor = pageParams
?.slice(0, -1)
.some(param => Object.is(cursorOf(param), currentCursor))
if (repeatedCursor) return
attemptCount.current++
if (attemptCount.current < 50) {
void query.fetchNextPage()
}
} else {
attemptCount.current = 0
}
}
}, [itemCount, pageSize, query])
}
export type StructuredQueryKey<T extends Record<string, unknown>> = readonly [ export type StructuredQueryKey<T extends Record<string, unknown>> = readonly [
string, string,
T, T,
+25 -9
View File
@@ -251,19 +251,35 @@ describe('buildChatClient', () => {
expect(headers.get('authorization')).toBe('Bearer access-jwt') expect(headers.get('authorization')).toBe('Bearer access-jwt')
}) })
it('emits no labeler header', async () => { it('emits a global app labeler once, redacted', async () => {
/* the global authorities do not apply: a chat call is not an appview read */ const client = buildChatClient(makeSession(fetchMock))
configureGlobalAppLabelers(['did:plc:global-labeler']) configureGlobalAppLabelers(['did:plc:global-labeler'])
await buildChatClient(makeSession(fetchMock)) await client.call(chat.bsky.convo.listConvos, {}).catch(() => {})
.call(chat.bsky.convo.listConvos, {})
.catch(() => {})
expect( const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
'atproto-accept-labelers', 'atproto-accept-labelers',
), )
).toBeNull() const entries = labelers!
.split(',')
.map(l => l.trim())
.filter(l => l.includes('did:plc:global-labeler'))
expect(entries).toEqual(['did:plc:global-labeler;redact'])
})
it('emits an account subscription exactly once', async () => {
const client = buildChatClient(makeSession(fetchMock))
client.setLabelers(['did:plc:labeler'])
await client.call(chat.bsky.convo.listConvos, {}).catch(() => {})
const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
'atproto-accept-labelers',
)
const entries = labelers!
.split(',')
.filter(l => l.includes('did:plc:labeler'))
expect(entries).toHaveLength(1)
}) })
}) })
@@ -0,0 +1,45 @@
import {type Client} from '@atproto/lex'
import {api} from '@bsky/sdk'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
jest.mock('#/storage', () => ({
account: {
get: jest.fn(),
set: jest.fn(),
},
device: {
get: jest.fn(),
},
}))
import {account} from '#/storage'
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
import {configureModerationForAccount} from '../moderation'
import {makeAccount} from './mock-fetch'
describe('configureModerationForAccount', () => {
beforeEach(() => {
jest.clearAllMocks()
configureGlobalAppLabelers([])
})
it('applies cached account labelers to appview and chat', () => {
const appviewClient = {setLabelers: jest.fn()} as unknown as Client
const chatClient = {setLabelers: jest.fn()} as unknown as Client
jest
.mocked(account.get)
.mockReturnValue(['did:plc:account-labeler', api.moderation.did])
configureModerationForAccount(
{appviewClient, chatClient},
makeAccount({handle: 'alice.example.com'}),
)
expect(appviewClient.setLabelers).toHaveBeenCalledWith([
'did:plc:account-labeler',
])
expect(chatClient.setLabelers).toHaveBeenCalledWith([
'did:plc:account-labeler',
])
})
})
@@ -98,10 +98,10 @@ export function configureAdditionalModerationAuthorities() {
* reads, so a request carries the same `;redact` authorities whether or not * reads, so a request carries the same `;redact` authorities whether or not
* there is a session behind it. * there is a session behind it.
* *
* It is a single global producer by design. The PDS and chat clients opt out * It is a single global producer by design. The PDS client opts out with
* with `appLabelers: null` (see `clients.ts`) because those services take no * `appLabelers: null` (see `clients.ts`) because that service takes no
* moderation authorities, leaving exactly one producer on an appview request and * moderation authorities. Appview and chat requests each carry one copy from
* none elsewhere. * their respective clients.
*/ */
export function configureGlobalAppLabelers(dids: string[]) { export function configureGlobalAppLabelers(dids: string[]) {
Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
+4 -3
View File
@@ -54,12 +54,13 @@ export function buildPdsClient(agent: Agent): Client {
* env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can * env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can
* be retargeted per environment. * be retargeted per environment.
* *
* `appLabelers: null` for the same reason as the PDS client: the chat service * Unlike the PDS client, chat carries moderation authorities. The service uses
* takes no moderation authorities. * them to hydrate labels on profiles embedded in conversation responses, so
* this client reads the global `Client.appLabelers` and receives the account's
* subscriptions through `configureModerationForAccount`.
*/ */
export function buildChatClient(agent: Agent): Client { export function buildChatClient(agent: Agent): Client {
return createLexClient(agent, { return createLexClient(agent, {
appLabelers: null,
service: CHAT_PROXY_SERVICE, service: CHAT_PROXY_SERVICE,
}) })
} }
+4 -3
View File
@@ -12,7 +12,7 @@ import {
import {type SessionAccount} from './types' import {type SessionAccount} from './types'
/** The moderation surface of a session bundle. */ /** The moderation surface of a session bundle. */
type ModerationSession = {appviewClient: Client} type ModerationSession = {appviewClient: Client; chatClient: Client}
/** /**
* Cache an account's subscribed labeler DIDs. Called on every preferences * Cache an account's subscribed labeler DIDs. Called on every preferences
@@ -45,8 +45,8 @@ export function readLabelers(did: string): string[] | undefined {
* lex collects the two lists into a `Set` keyed on the suffixed string, so * lex collects the two lists into a `Set` keyed on the suffixed string, so
* neither dedupes against the other. * neither dedupes against the other.
* *
* Only the appview client takes subscriptions - the PDS and chat clients suppress * Appview and chat both take subscriptions. The PDS suppresses labelers because
* labelers entirely (see clients.ts). * repo and identity requests do not hydrate moderated content (see clients.ts).
*/ */
export function applyLabelersToClient( export function applyLabelersToClient(
client: Client, client: Client,
@@ -85,6 +85,7 @@ export function configureModerationForAccount(
const labelerDids = readLabelers(account.did) const labelerDids = readLabelers(account.did)
if (labelerDids) { if (labelerDids) {
applyLabelersToClient(bundle.appviewClient, labelerDids) applyLabelersToClient(bundle.appviewClient, labelerDids)
applyLabelersToClient(bundle.chatClient, labelerDids)
} else { } else {
// If there are no headers in the storage, we'll not send them on the initial requests. // If there are no headers in the storage, we'll not send them on the initial requests.
// If we wanted to fix this, we could block on the preferences query here. // If we wanted to fix this, we could block on the preferences query here.
+8 -6
View File
@@ -408,7 +408,7 @@ export const ComposePost = ({
asset.mimeType !== 'image/gif' asset.mimeType !== 'image/gif'
) { ) {
try { try {
const probed = await getVideoMetadata(asset.uri) const probed = await getVideoMetadata(asset.uri, asset.mimeType)
asset = { asset = {
...asset, ...asset,
mimeType: probed.mimeType ?? asset.mimeType, mimeType: probed.mimeType ?? asset.mimeType,
@@ -538,8 +538,8 @@ export const ComposePost = ({
let uri = videoInfo.uri let uri = videoInfo.uri
if (IS_ANDROID) { if (IS_ANDROID) {
// Android: expo-file-system double-encodes filenames with special chars. // Android: expo-file-system double-encodes filenames with special chars.
// The file exists, but react-native-compressor's MediaMetadataRetriever // The native metadata probe can't handle the double-encoded URI, so
// can't handle the double-encoded URI. Copy to a temp file with a simple name. // copy it to a temp file with a simple name.
const sourceFile = new FileSystem.File(videoInfo.uri) const sourceFile = new FileSystem.File(videoInfo.uri)
const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}` const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}`
const tempFile = new FileSystem.File( const tempFile = new FileSystem.File(
@@ -553,7 +553,7 @@ export const ComposePost = ({
}) })
uri = tempFile.uri uri = tempFile.uri
} }
asset = await getVideoMetadata(uri) asset = await getVideoMetadata(uri, videoInfo.mimeType)
} }
// Start video processing using existing flow // Start video processing using existing flow
@@ -1213,7 +1213,7 @@ export const ComposePost = ({
if (initQuote) { if (initQuote) {
// We want to wait for the quote count to update before we call `onPost`, which will refetch data // We want to wait for the quote count to update before we call `onPost`, which will refetch data
void whenAppViewReady(client, initQuote.uri, res => { void whenAppViewReady(client, initQuote.uri, res => {
const anchor = res.thread.at(0) const anchor = res?.thread.at(0)
if ( if (
bsky.isType(app.bsky.unspecced.defs.threadItemPost, anchor?.value) && bsky.isType(app.bsky.unspecced.defs.threadItemPost, anchor?.value) &&
anchor.value.post.quoteCount !== initQuote.quoteCount anchor.value.post.quoteCount !== initQuote.quoteCount
@@ -2478,7 +2478,9 @@ function useKeyboardVerticalOffset() {
async function whenAppViewReady( async function whenAppViewReady(
client: Client, client: Client,
uri: string, uri: string,
fn: (res: app.bsky.unspecced.getPostThreadV2.$OutputBody) => boolean, fn: (
res: app.bsky.unspecced.getPostThreadV2.$OutputBody | undefined,
) => boolean,
) { ) {
await until( await until(
5, // 5 tries 5, // 5 tries
@@ -1,6 +1,10 @@
import {clearCache, createVideoThumbnail} from 'react-native-compressor'
import Animated, {FadeIn} from 'react-native-reanimated' import Animated, {FadeIn} from 'react-native-reanimated'
import {File} from 'expo-file-system'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {
getThumbnailAsync,
type VideoThumbnailsResult,
} from 'expo-video-thumbnails'
import {type QueryClient, useQuery} from '@tanstack/react-query' import {type QueryClient, useQuery} from '@tanstack/react-query'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -8,15 +12,32 @@ import {atoms as a} from '#/alf'
export const RQKEY = 'video-thumbnail' export const RQKEY = 'video-thumbnail'
export function clearThumbnailCache(queryClient: QueryClient) { export function clearThumbnailCache(queryClient: QueryClient) {
clearCache().catch(() => {}) for (const [, thumbnail] of queryClient.getQueriesData<VideoThumbnailsResult>(
void queryClient.resetQueries({queryKey: [RQKEY]}) {queryKey: [RQKEY]},
)) {
if (thumbnail) {
deleteThumbnail(thumbnail)
}
}
queryClient.removeQueries({queryKey: [RQKEY]})
}
function deleteThumbnail(thumbnail: VideoThumbnailsResult) {
try {
new File(thumbnail.uri).delete()
} catch {}
} }
export function VideoTranscodeBackdrop({uri}: {uri: string}) { export function VideoTranscodeBackdrop({uri}: {uri: string}) {
const {data: thumbnail} = useQuery({ const {data: thumbnail} = useQuery({
queryKey: [RQKEY, uri], queryKey: [RQKEY, uri],
queryFn: async () => { queryFn: async ({signal}) => {
return await createVideoThumbnail(uri) const result = await getThumbnailAsync(uri)
if (signal.aborted) {
deleteThumbnail(result)
throw new Error('Thumbnail generation canceled')
}
return result
}, },
}) })
@@ -25,7 +46,7 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) {
<Animated.View style={a.flex_1} entering={FadeIn}> <Animated.View style={a.flex_1} entering={FadeIn}>
<Image <Image
style={a.flex_1} style={a.flex_1}
source={thumbnail.path} source={thumbnail.uri}
cachePolicy="none" cachePolicy="none"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
blurRadius={15} blurRadius={15}
+17 -5
View File
@@ -1,29 +1,41 @@
import {getVideoMetaData} from 'react-native-compressor'
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import {probe} from '@bsky.app/video-compressor'
import {extToMime} from '#/lib/media/video/util' import {extToMime} from '#/lib/media/video/util'
export async function getVideoMetadata( export async function getVideoMetadata(
file: File | string, file: File | string,
fallbackMimeType?: string,
): Promise<ImagePickerAsset> { ): Promise<ImagePickerAsset> {
if (typeof file !== 'string') if (typeof file !== 'string')
throw new Error( throw new Error(
'getVideoMetadata was passed a File, when on native it should be a uri', 'getVideoMetadata was passed a File, when on native it should be a uri',
) )
const metadata = await getVideoMetaData(file) const metadata = await probe(file)
return { return {
uri: file, uri: file,
mimeType: extToMime(metadata.extension), mimeType: getMimeTypeFromUri(file) ?? fallbackMimeType ?? metadata.mimeType,
fileSize: metadata.fileSize,
width: metadata.width, width: metadata.width,
height: metadata.height, height: metadata.height,
/* /*
* react-native-compressor reports seconds; the rest of the app treats * The probe reports seconds; `ImagePickerAsset.duration` uses milliseconds.
* `ImagePickerAsset.duration` as milliseconds (matching expo-image-picker).
*/ */
duration: metadata.duration * 1000, duration: metadata.duration * 1000,
} }
} }
function getMimeTypeFromUri(uri: string): string | undefined {
const extension = uri.match(/\.([^.?#/]+)(?:[?#]|$)/)?.[1]
if (!extension) return
try {
return extToMime(extension)
} catch {
return
}
}
export function hasWebCodecs(): boolean { export function hasWebCodecs(): boolean {
return false return false
} }
@@ -11,6 +11,7 @@ export function hasWebCodecs(): boolean {
export async function getVideoMetadata( export async function getVideoMetadata(
file: File | string, file: File | string,
_fallbackMimeType?: string,
): Promise<ImagePickerAsset> { ): Promise<ImagePickerAsset> {
if (typeof file === 'string') if (typeof file === 'string')
throw new Error( throw new Error(
+1 -1
View File
@@ -79,7 +79,7 @@ export function ProfileFeedgens({
error, error,
refetch, refetch,
} = useProfileFeedgensQuery(did, opts) } = useProfileFeedgensQuery(did, opts)
const isEmpty = !isPending && !data?.pages[0]?.feeds.length const isEmpty = !isPending && !data?.pages.some(page => page.feeds.length)
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const navigation = useNavigation() const navigation = useNavigation()
const {currentAccount} = useSession() const {currentAccount} = useSession()
+1 -1
View File
@@ -90,7 +90,7 @@ export function ListMembers({
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
} = useListMembersQuery(list) } = useListMembersQuery(list)
const isEmpty = !isFetching && !data?.pages[0].items.length const isEmpty = !isFetching && !data?.pages.some(page => page.items.length)
const isOwner = const isOwner =
currentAccount && data?.pages[0].list.creator.did === currentAccount.did currentAccount && data?.pages[0].list.creator.did === currentAccount.did
+1 -1
View File
@@ -79,7 +79,7 @@ export function ProfileLists({
error, error,
refetch, refetch,
} = useProfileListsQuery(did, opts) } = useProfileListsQuery(did, opts)
const isEmpty = !isPending && !data?.pages[0]?.lists.length const isEmpty = !isPending && !data?.pages.some(page => page.lists.length)
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const navigation = useNavigation() const navigation = useNavigation()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -103,9 +103,8 @@ export function NotificationFeed({
logger.error('Failed to refresh notifications feed', { logger.error('Failed to refresh notifications feed', {
message: err, message: err,
}) })
} finally {
setIsPTRing(false)
} }
setIsPTRing(false)
}, [refreshNotifications, setIsPTRing]) }, [refreshNotifications, setIsPTRing])
const onEndReached = useCallback(async () => { const onEndReached = useCallback(async () => {
@@ -920,9 +920,8 @@ function SayHelloBtn({profile}: {profile: app.bsky.actor.defs.ProfileView}) {
}) })
} catch (e) { } catch (e) {
logger.error('Failed to get conversation', {safeMessage: e}) logger.error('Failed to get conversation', {safeMessage: e})
} finally {
setIsLoading(false)
} }
setIsLoading(false)
} }
if ( if (
@@ -36,7 +36,7 @@ export function ModerationBlockedAccounts({}: Props) {
fetchNextPage, fetchNextPage,
isFetchingNextPage, isFetchingNextPage,
} = useMyBlockedAccountsQuery() } = useMyBlockedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.blocks.length const isEmpty = !isFetching && !data?.pages.some(page => page.blocks.length)
const profiles = useMemo(() => { const profiles = useMemo(() => {
if (data?.pages) { if (data?.pages) {
return data.pages.flatMap(page => page.blocks) return data.pages.flatMap(page => page.blocks)
+1 -1
View File
@@ -36,7 +36,7 @@ export function ModerationMutedAccounts({}: Props) {
fetchNextPage, fetchNextPage,
isFetchingNextPage, isFetchingNextPage,
} = useMyMutedAccountsQuery() } = useMyMutedAccountsQuery()
const isEmpty = !isFetching && !data?.pages[0]?.mutes.length const isEmpty = !isFetching && !data?.pages.some(page => page.mutes.length)
const profiles = useMemo(() => { const profiles = useMemo(() => {
if (data?.pages) { if (data?.pages) {
return data.pages.flatMap(page => page.mutes) return data.pages.flatMap(page => page.mutes)
+1 -2
View File
@@ -559,9 +559,8 @@ function ComposeBtn({minimal}: {minimal: boolean}) {
handle = await fetchHandle(handle) handle = await fetchHandle(handle)
} catch (e) { } catch (e) {
handle = undefined handle = undefined
} finally {
setIsFetchingHandle(false)
} }
setIsFetchingHandle(false)
} }
if ( if (