optimize viewmodel
This commit is contained in:
@@ -2,6 +2,7 @@ package su.reya.coop
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -31,7 +32,6 @@ import androidx.compose.material3.rememberModalBottomSheetState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.util.Consumer
|
import androidx.core.util.Consumer
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
@@ -93,8 +94,8 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
val navigator = remember(backStack) { Navigator(backStack) }
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
|
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
|
||||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -105,7 +106,7 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
// Enabled the dynamic color scheme
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
// Enable the dynamic color scheme for Android 12+
|
// Enable the dynamic color scheme for Android 12+
|
||||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||||
context
|
context
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
@@ -50,18 +50,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||||
|
startForegroundService(serviceIntent)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
startForegroundService(serviceIntent)
|
|
||||||
} else {
|
|
||||||
startService(serviceIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
viewModel.signerRequired.value == null
|
viewModel.signerRequired.value == null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
|
||||||
|
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(viewModel = viewModel)
|
App(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ object NostrManager {
|
|||||||
|
|
||||||
val BOOTSTRAP_RELAYS = listOf(
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://user.kindpag.es",
|
|
||||||
"wss://purplepag.es"
|
"wss://purplepag.es"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -612,7 +611,6 @@ class Nostr {
|
|||||||
ReqTarget.manual(
|
ReqTarget.manual(
|
||||||
mapOf(
|
mapOf(
|
||||||
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
|
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
|
||||||
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
|
|
||||||
RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
|
RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -50,12 +57,6 @@ class NostrViewModel(
|
|||||||
private val _isLoggedIn = MutableStateFlow(false)
|
private val _isLoggedIn = MutableStateFlow(false)
|
||||||
val isLoggedIn = _isLoggedIn.asStateFlow()
|
val isLoggedIn = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
|
||||||
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||||
|
|
||||||
@@ -74,6 +75,28 @@ class NostrViewModel(
|
|||||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
private val manualRoomUpdates = MutableSharedFlow<Set<Room>>()
|
||||||
|
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||||
|
|
||||||
|
val chatRooms: StateFlow<Set<Room>> = merge(
|
||||||
|
nostr.newEvents.map { event ->
|
||||||
|
processIncomingEvent(event)
|
||||||
|
_chatRooms.value
|
||||||
|
},
|
||||||
|
manualRoomUpdates
|
||||||
|
).stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
val contactList: StateFlow<Set<PublicKey>> = nostr.contactListUpdates
|
||||||
|
.map { it.toSet() }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Skip the splash screen if a user is already logged in
|
// Skip the splash screen if a user is already logged in
|
||||||
@@ -95,12 +118,18 @@ class NostrViewModel(
|
|||||||
|
|
||||||
// Get all local stored metadata
|
// Get all local stored metadata
|
||||||
getCacheMetadata()
|
getCacheMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
// Observe new events from the Nostr client
|
fun bindLifecycle(lifecycle: Lifecycle) {
|
||||||
runObserver()
|
viewModelScope.launch {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
// Wait and merge metadata requests into a single batch
|
coroutineScope {
|
||||||
runMetadataBatching()
|
launch { refreshChatRooms() }
|
||||||
|
launch { runObserver() }
|
||||||
|
launch { runMetadataBatching() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
@@ -134,81 +163,66 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runObserver() {
|
private fun processIncomingEvent(event: UnsignedEvent) {
|
||||||
viewModelScope.launch {
|
val roomId = event.roomId()
|
||||||
// Observe new messages
|
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||||
launch {
|
|
||||||
nostr.newEvents.collect { event ->
|
|
||||||
val roomId = event.roomId()
|
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
|
||||||
|
|
||||||
if (existingRoom == null) {
|
if (existingRoom == null) {
|
||||||
val currentUser = nostr.signer.currentUser
|
nostr.signer.currentUser?.let { user ->
|
||||||
if (currentUser != null) {
|
val newRoom = Room.new(event, user)
|
||||||
val newRoom = Room.new(event, currentUser)
|
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEvents.emit(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observe metadata updates
|
private suspend fun runObserver() = coroutineScope {
|
||||||
launch {
|
// Observe metadata updates
|
||||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
launch {
|
||||||
updateMetadata(pubkey, metadata)
|
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
}
|
updateMetadata(pubkey, metadata)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observe contact list updates
|
// Observes subscription close
|
||||||
launch {
|
launch {
|
||||||
nostr.contactListUpdates.collect { contacts ->
|
nostr.subscriptionClosed.collect {
|
||||||
_contactList.value = contacts.toSet()
|
getChatRooms()
|
||||||
}
|
_isPartialProcessedGiftWrap.value = true
|
||||||
}
|
|
||||||
|
|
||||||
// Observes subscription close
|
|
||||||
launch {
|
|
||||||
nostr.subscriptionClosed.collect {
|
|
||||||
getChatRooms()
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runMetadataBatching() {
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
viewModelScope.launch {
|
// Wait until the client is ready
|
||||||
// Wait until the client is ready
|
nostr.waitUntilInitialized()
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
val batch = mutableSetOf<PublicKey>()
|
val batch = mutableSetOf<PublicKey>()
|
||||||
val timeout = 500L // 500ms timeout for batching
|
val timeout = 500L // 500ms timeout for batching
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val firstKey = metadataRequestChannel.receive()
|
val firstKey = metadataRequestChannel.receive()
|
||||||
batch.add(firstKey)
|
batch.add(firstKey)
|
||||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
while (batch.isNotEmpty()) {
|
while (batch.isNotEmpty()) {
|
||||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||||
metadataRequestChannel.receive()
|
metadataRequestChannel.receive()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextKey != null) {
|
// Only add the key if it's not null
|
||||||
batch.add(nextKey)
|
if (nextKey != null) batch.add(nextKey)
|
||||||
}
|
|
||||||
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
// Get current time
|
||||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
val keysToRequest = batch.toList()
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
nostr.fetchMetadataBatch(keysToRequest)
|
// Check if the batch is full or timeout has passed
|
||||||
}
|
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||||
|
val keysToRequest = batch.toList()
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
nostr.fetchMetadataBatch(keysToRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user