perf: reduce startup and feed rendering work

This commit is contained in:
Barry Deen
2026-05-04 08:08:44 -04:00
parent a0ec123e0c
commit e3373889e0
10 changed files with 149 additions and 80 deletions
+15 -13
View File
@@ -41,21 +41,23 @@ object Nip05 {
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
if (!response.isSuccessful) return@withContext Nip05Result.ERROR
response.use {
if (!it.isSuccessful) return@withContext Nip05Result.ERROR
val body = response.body?.string() ?: return@withContext Nip05Result.ERROR
val root = json.parseToJsonElement(body).jsonObject
val names = root["names"]?.jsonObject ?: return@withContext Nip05Result.MISMATCH
// Case-insensitive lookup per NIP-05 spec (servers may return capitalized keys)
val registeredPubkey = (names[local] ?: names.entries.firstOrNull {
it.key.equals(local, ignoreCase = true)
}?.value)?.jsonPrimitive?.content
?: return@withContext Nip05Result.MISMATCH
val body = it.body?.string() ?: return@withContext Nip05Result.ERROR
val root = json.parseToJsonElement(body).jsonObject
val names = root["names"]?.jsonObject ?: return@withContext Nip05Result.MISMATCH
// Case-insensitive lookup per NIP-05 spec (servers may return capitalized keys)
val registeredPubkey = (names[local] ?: names.entries.firstOrNull { entry ->
entry.key.equals(local, ignoreCase = true)
}?.value)?.jsonPrimitive?.content
?: return@withContext Nip05Result.MISMATCH
if (registeredPubkey.equals(pubkeyHex, ignoreCase = true))
Nip05Result.VERIFIED
else
Nip05Result.MISMATCH
if (registeredPubkey.equals(pubkeyHex, ignoreCase = true))
Nip05Result.VERIFIED
else
Nip05Result.MISMATCH
}
} catch (_: Exception) {
Nip05Result.ERROR
}
@@ -36,10 +36,7 @@ data class RelayInfo(
object Nip11 {
private val json = Json { ignoreUnknownKeys = true }
private val httpClient
get() = com.wisp.app.relay.HttpClientFactory.createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 10
)
get() = com.wisp.app.relay.HttpClientFactory.getGeneralClient()
suspend fun fetchRelayInfo(url: String, httpClient: OkHttpClient? = null): RelayInfo? {
val client = httpClient ?: this.httpClient
@@ -9,6 +9,10 @@ import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
object HttpClientFactory {
@Volatile private var imageClient: OkHttpClient? = null
@Volatile private var generalClient: OkHttpClient? = null
@Volatile private var shortTimeoutClient: OkHttpClient? = null
@Volatile private var mediaClient: OkHttpClient? = null
fun createRelayClient(): OkHttpClient {
// OkHttp's default Dispatcher.maxRequests is 64, which caps concurrent
@@ -38,18 +42,52 @@ object HttpClientFactory {
.build()
}
private var imageClient: OkHttpClient? = null
fun getImageClient(): OkHttpClient {
imageClient?.let { return it }
return createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 30
).also { imageClient = it }
return synchronized(this) {
imageClient ?: createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 30
).also { imageClient = it }
}
}
fun getGeneralClient(): OkHttpClient {
generalClient?.let { return it }
return synchronized(this) {
generalClient ?: createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 15
).also { generalClient = it }
}
}
fun getShortTimeoutClient(): OkHttpClient {
shortTimeoutClient?.let { return it }
return synchronized(this) {
shortTimeoutClient ?: createHttpClient(
connectTimeoutSeconds = 5,
readTimeoutSeconds = 5
).also { shortTimeoutClient = it }
}
}
fun getNip05Client(): OkHttpClient {
return getGeneralClient()
}
fun getMediaClient(): OkHttpClient {
mediaClient?.let { return it }
return synchronized(this) {
mediaClient ?: createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 30
).also { mediaClient = it }
}
}
fun createExoPlayer(context: Context): ExoPlayer {
val client = createHttpClient(connectTimeoutSeconds = 10, readTimeoutSeconds = 30)
val client = getMediaClient()
val dataSourceFactory = OkHttpDataSource.Factory(client)
return ExoPlayer.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
@@ -83,10 +83,7 @@ object ExchangeRateRepository {
scope.launch {
try {
val url = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=$vsCurrencies"
val client = HttpClientFactory.createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 15
)
val client = HttpClientFactory.getGeneralClient()
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
@@ -30,10 +30,7 @@ class Nip05Repository {
val version: StateFlow<Int> = _version
private val httpClient
get() = com.wisp.app.relay.HttpClientFactory.createHttpClient(
connectTimeoutSeconds = 5,
readTimeoutSeconds = 10
)
get() = com.wisp.app.relay.HttpClientFactory.getNip05Client()
fun clear() {
statusCache.clear()
@@ -14,6 +14,12 @@ import com.wisp.app.nostr.NotificationGroup
import com.wisp.app.nostr.NotificationSummary
import com.wisp.app.nostr.NotificationType
import com.wisp.app.nostr.ZapEntry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -48,6 +54,8 @@ class NotificationRepository(
private val lock = Any()
private val groupMap = mutableMapOf<String, NotificationGroup>()
private val zapEventIdsByGroup = mutableMapOf<String, MutableSet<String>>()
private val rebuildScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val rebuildSignals = Channel<Unit>(Channel.CONFLATED)
private val _notifications = MutableStateFlow<List<NotificationGroup>>(emptyList())
val notifications: StateFlow<List<NotificationGroup>> = _notifications
@@ -82,6 +90,18 @@ class NotificationRepository(
private val _notifReceived = MutableSharedFlow<Int>(extraBufferCapacity = 1)
val notifReceived: SharedFlow<Int> = _notifReceived
init {
rebuildScope.launch {
for (signal in rebuildSignals) {
delay(50)
while (rebuildSignals.tryReceive().isSuccess) Unit
synchronized(lock) {
rebuildSortedList()
}
}
}
}
fun getLatestNotifTimestamp(): Long? = if (latestNotifTs > 0) latestNotifTs else null
/**
@@ -133,7 +153,7 @@ class NotificationRepository(
_replyReceived.tryEmit(Unit)
}
}
rebuildSortedList()
scheduleRebuildSortedList()
}
}
@@ -277,7 +297,7 @@ class NotificationRepository(
}
}
}
rebuildSortedList()
scheduleRebuildSortedList()
}
}
@@ -357,7 +377,7 @@ class NotificationRepository(
if (item.actorPubkey == pubkey) { flatItemIds.remove(item.id); true } else false
}
if (toRemove.isNotEmpty() || toUpdate.isNotEmpty()) {
rebuildSortedList()
scheduleRebuildSortedList()
}
}
@@ -365,6 +385,10 @@ class NotificationRepository(
rebuildSortedList()
}
private fun scheduleRebuildSortedList() {
rebuildSignals.trySend(Unit)
}
private fun rebuildSortedList() {
val now = System.currentTimeMillis() / 1000
val recentCutoff = now - RECENT_WINDOW_SECONDS
@@ -873,7 +897,7 @@ class NotificationRepository(
val itemRoot = if (ref != null) Nip10.getRootId(ref) ?: ref.id else item.referencedEventId
if (itemRoot == rootEventId) { flatItemIds.remove(item.id); true } else false
}
rebuildSortedList()
scheduleRebuildSortedList()
}
}
@@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -93,13 +94,13 @@ import java.util.Locale
private val mediaExtensions = setOf("mp4", "mov", "webm", "mp3", "wav", "ogg", "m4a", "flac", "aac", "jpg", "jpeg", "png", "gif", "webp")
private val mediaMimePrefixes = listOf("video/", "audio/", "image/")
private val contentUrlRegex = Regex("""https?://\S+""")
private fun contentHasMedia(content: String, imetaMap: Map<String, MediaMeta>): Boolean {
// Check imeta tags for video/audio
if (imetaMap.values.any { meta -> meta.mime?.let { m -> mediaMimePrefixes.any { m.startsWith(it) } } == true }) return true
// Check URLs in content for media extensions
val urlRegex = Regex("""https?://\S+""")
return urlRegex.findAll(content).any { match ->
return contentUrlRegex.findAll(content).any { match ->
val url = match.value.trimEnd('.', ',', ')', ']')
val ext = url.substringAfterLast('.').substringBefore('?').lowercase()
ext in mediaExtensions
@@ -1275,7 +1276,9 @@ internal fun Nip05Badge(
modifier: Modifier = Modifier
) {
if (nip05.isBlank()) return
nip05Repo?.checkOrFetch(pubkey, nip05)
LaunchedEffect(nip05Repo, pubkey, nip05) {
nip05Repo?.checkOrFetch(pubkey, nip05)
}
val version = nip05Repo?.version?.collectAsState()
// Read .value to ensure Compose tracks this state
val v = version?.value ?: 0
@@ -54,6 +54,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -627,27 +628,29 @@ fun RichContent(
)
}
// Group segments into inline runs vs block-level items
val groups = mutableListOf<Any>() // Either MutableList<ContentSegment> (inline run) or ContentSegment (block)
fun isInline(s: ContentSegment) = s is ContentSegment.TextSegment ||
s is ContentSegment.HashtagSegment ||
s is ContentSegment.NostrProfileSegment ||
s is ContentSegment.CustomEmojiSegment ||
s is ContentSegment.InlineLinkSegment ||
(plainLinks && s is ContentSegment.LinkSegment)
val groups = remember(segments, plainLinks) {
val built = mutableListOf<Any>() // Either List<ContentSegment> (inline run) or ContentSegment (block)
fun isInline(s: ContentSegment) = s is ContentSegment.TextSegment ||
s is ContentSegment.HashtagSegment ||
s is ContentSegment.NostrProfileSegment ||
s is ContentSegment.CustomEmojiSegment ||
s is ContentSegment.InlineLinkSegment ||
(plainLinks && s is ContentSegment.LinkSegment)
for (segment in segments) {
if (isInline(segment)) {
val last = groups.lastOrNull()
if (last is MutableList<*>) {
@Suppress("UNCHECKED_CAST")
(last as MutableList<ContentSegment>).add(segment)
for (segment in segments) {
if (isInline(segment)) {
val last = built.lastOrNull()
if (last is MutableList<*>) {
@Suppress("UNCHECKED_CAST")
(last as MutableList<ContentSegment>).add(segment)
} else {
built.add(mutableListOf(segment))
}
} else {
groups.add(mutableListOf(segment))
built.add(segment)
}
} else {
groups.add(segment)
}
built
}
val defaultLinkColor = MaterialTheme.colorScheme.primary
@@ -1835,10 +1838,7 @@ private fun UnknownMediaContent(
val type = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).head().build()
val client = HttpClientFactory.createHttpClient(
connectTimeoutSeconds = 5,
readTimeoutSeconds = 5
)
val client = HttpClientFactory.getShortTimeoutClient()
client.newCall(request).execute().use { response ->
response.header("Content-Type")
}
@@ -2651,20 +2651,31 @@ private fun parseAspectRatio(dim: String?): Float? {
return if (h > 0) w / h else null
}
private val mediaPlaceholderCache = LruCache<String, BitmapPainter>(200)
@Composable
internal fun rememberMediaPlaceholderPainter(
thumbhash: String?,
blurhash: String?,
dimension: String?
): BitmapPainter? {
return remember(thumbhash, blurhash, dimension) {
val painter by produceState<BitmapPainter?>(null, thumbhash, blurhash, dimension) {
val key = listOf(thumbhash.orEmpty(), blurhash.orEmpty(), dimension.orEmpty()).joinToString("|")
mediaPlaceholderCache.get(key)?.let {
value = it
return@produceState
}
val dims = dimension?.split('x')
val width = dims?.getOrNull(0)?.toIntOrNull()?.coerceAtMost(100) ?: 32
val height = dims?.getOrNull(1)?.toIntOrNull()?.coerceAtMost(100) ?: 32
MediaHashDecoder.decode(thumbhash, blurhash, width, height)
?.asImageBitmap()
?.let { BitmapPainter(it) }
value = withContext(Dispatchers.Default) {
MediaHashDecoder.decode(thumbhash, blurhash, width, height)
?.asImageBitmap()
?.let { BitmapPainter(it) }
}
value?.let { mediaPlaceholderCache.put(key, it) }
}
return painter
}
// --- Link Preview (OG tags) ---
@@ -2679,10 +2690,7 @@ private data class OgData(
private val ogCache = LruCache<String, OgData>(200)
private val httpClient
get() = com.wisp.app.relay.HttpClientFactory.createHttpClient(
connectTimeoutSeconds = 5,
readTimeoutSeconds = 5
)
get() = com.wisp.app.relay.HttpClientFactory.getShortTimeoutClient()
private val ogTagRegex = Regex(
"""<meta[^>]+property\s*=\s*["']og:(\w+)["'][^>]+content\s*=\s*["']([^"']*)["'][^>]*/?>|<meta[^>]+content\s*=\s*["']([^"']*)["'][^>]+property\s*=\s*["']og:(\w+)["'][^>]*/?>""",
@@ -26,10 +26,7 @@ object MediaDownloader {
)
private val httpClient
get() = com.wisp.app.relay.HttpClientFactory.createHttpClient(
connectTimeoutSeconds = 30,
readTimeoutSeconds = 60
)
get() = com.wisp.app.relay.HttpClientFactory.getMediaClient()
suspend fun downloadMedia(context: Context, url: String) {
try {
@@ -225,21 +225,27 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
)
val safetyPrefs = SafetyPreferences(app, pubkeyHex)
val spamAuthorCache = com.wisp.app.repo.SpamAuthorCache()
val nspamClassifier: com.wisp.app.ml.NSpamClassifier? = try {
val weights = com.wisp.app.ml.NSpamWeights.loadFromAssets(app)
com.wisp.app.ml.NSpamClassifier(weights)
} catch (e: Exception) {
Log.e("FeedVM", "Failed to load nspam weights", e)
null
}
@Volatile
var nspamClassifier: com.wisp.app.ml.NSpamClassifier? = null
private set
init {
eventRepo.safetyPrefs = safetyPrefs
eventRepo.extendedNetworkRepo = extendedNetworkRepo
notifRepo.spamClassifier = nspamClassifier
notifRepo.spamAuthorCache = spamAuthorCache
notifRepo.safetyPrefs = safetyPrefs
notifRepo.contactRepo = contactRepo
notifRepo.extendedNetworkRepo = extendedNetworkRepo
viewModelScope.launch(Dispatchers.Default) {
nspamClassifier = try {
val weights = com.wisp.app.ml.NSpamWeights.loadFromAssets(app)
com.wisp.app.ml.NSpamClassifier(weights)
} catch (e: Exception) {
Log.e("FeedVM", "Failed to load nspam weights", e)
null
}
notifRepo.spamClassifier = nspamClassifier
}
}
val customEmojiRepo = CustomEmojiRepository(app, pubkeyHex)
val translationRepo = TranslationRepository()