perf: reduce startup and feed rendering work
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user