chore: improve handling of loading states #26

Merged
reya merged 2 commits from refine-ui into master 2026-06-26 09:23:05 +00:00
5 changed files with 119 additions and 86 deletions

View File

@@ -65,8 +65,10 @@ class NostrForegroundService : Service() {
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e) throw IllegalStateException("Failed to initialize Nostr Client", e)
} }
// Connect to bootstrap relays // Connect to bootstrap relays
nostr.connectBootstrapRelays() nostr.connectBootstrapRelays()
// Handle notifications // Handle notifications
nostr.handleNotifications( nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata -> onMetadataUpdate = { pubkey, metadata ->
@@ -75,9 +77,6 @@ class NostrForegroundService : Service() {
onContactListUpdate = { contacts -> onContactListUpdate = { contacts ->
serviceScope.launch { nostr.emitContactListUpdate(contacts) } serviceScope.launch { nostr.emitContactListUpdate(contacts) }
}, },
onSubscriptionClose = {
serviceScope.launch { nostr.emitSubscriptionClosed() }
},
onNewMessage = { event -> onNewMessage = { event ->
serviceScope.launch { serviceScope.launch {
if (!isUserInApp()) { if (!isUserInApp()) {

View File

@@ -4,15 +4,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -526,9 +522,7 @@ fun ChatInput(
) { ) {
Surface(modifier = Modifier.fillMaxWidth()) { Surface(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
TextField( TextField(
@@ -545,9 +539,7 @@ fun ChatInput(
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton( FilledTonalIconButton(
onClick = onSend, onClick = onSend,
modifier = Modifier modifier = Modifier.size(56.dp),
.fillMaxHeight()
.aspectRatio(1f),
colors = IconButtonDefaults.filledTonalIconButtonColors( colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant contentColor = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -127,7 +127,8 @@ fun HomeScreen() {
val userProfile by currentUserProfile.collectAsStateWithLifecycle() val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
@@ -185,10 +186,18 @@ fun HomeScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
title = { title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text( Text(
text = "Coop", text = "Coop",
style = MaterialTheme.typography.titleMediumEmphasized style = MaterialTheme.typography.titleMediumEmphasized
) )
if (isSyncing) {
LoadingIndicator(modifier = Modifier.size(24.dp))
}
}
}, },
actions = { actions = {
// QR Scanner // QR Scanner

View File

@@ -4,12 +4,15 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
@@ -56,7 +59,6 @@ import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync import rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
@@ -95,18 +97,20 @@ class Nostr {
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100) private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
val contactListUpdates = _contactListUpdates.asSharedFlow() val contactListUpdates = _contactListUpdates.asSharedFlow()
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10) private val _messageSyncState = MutableStateFlow(MessageSyncState())
val subscriptionClosed = _subscriptionClosed.asSharedFlow() val messageSyncState = _messageSyncState.asStateFlow()
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event) suspend fun emitNewEvent(event: UnsignedEvent) {
_newEvents.emit(event)
}
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit) suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
_metadataUpdates.emit(pubkey to metadata) _metadataUpdates.emit(pubkey to metadata)
}
suspend fun emitContactListUpdate(contacts: List<PublicKey>) = suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
_contactListUpdates.emit(contacts) _contactListUpdates.emit(contacts)
}
suspend fun init( suspend fun init(
dbPath: String, dbPath: String,
@@ -266,17 +270,39 @@ class Nostr {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun handleNotifications( suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit, onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit, onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit, onNewMessage: (UnsignedEvent) -> Unit,
onSubscriptionClose: () -> Unit,
) = supervisorScope { ) = supervisorScope {
val now = Timestamp.now() val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@supervisorScope val notifications = client?.notifications() ?: return@supervisorScope
var eoseTrackerJob: Job? = null val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
var processedCount = 0
var eoseReceived = false
launch(Dispatchers.Default) {
for (event in giftWrapQueue) {
val rumor = extractRumor(event) ?: continue
processedCount++
// Trigger new message notification
if (rumor.createdAt().asSecs() >= now.asSecs()) {
onNewMessage(rumor)
}
// Update sync state
_messageSyncState.update {
it.copy(
processedCount = processedCount,
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
)
}
}
}
while (true) { while (true) {
val notification = notifications.next() ?: continue val notification = notifications.next() ?: continue
@@ -293,7 +319,8 @@ class Nostr {
if (processedEvent.contains(event.id())) continue if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id()) processedEvent.add(event.id())
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) { when (event.kind().asStd()) {
KindStandard.METADATA -> {
try { try {
val metadata = Metadata.fromJson(event.content()) val metadata = Metadata.fromJson(event.content())
onMetadataUpdate(event.author(), metadata) onMetadataUpdate(event.author(), metadata)
@@ -302,7 +329,7 @@ class Nostr {
} }
} }
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) { KindStandard.CONTACT_LIST -> {
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
val pubkeys = event.tags().publicKeys() val pubkeys = event.tags().publicKeys()
// Get mutual contacts // Get mutual contacts
@@ -312,40 +339,27 @@ class Nostr {
} }
} }
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { KindStandard.INBOX_RELAYS -> {
// Get all gift wrap events for the current user // Get all gift wrap events for the current user
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event) getUserMessages(msgRelayList = event)
} }
} }
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { KindStandard.GIFT_WRAP -> {
val rumor = extractRumor(event) giftWrapQueue.send(event)
// Logic to notify UI after processing
// Cancel previous tracker if it exists
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
} }
// Handle new message else -> {}
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
}
} }
} }
is RelayMessageEnum.EndOfStoredEvents -> { is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId if (message.subscriptionId == "gift-wraps") {
eoseReceived = true
if (subscriptionId == "gift-wraps") { if (giftWrapQueue.isEmpty) {
onSubscriptionClose() _messageSyncState.update { it.copy(isSyncing = false) }
}
} }
} }
@@ -1036,3 +1050,8 @@ class Nostr {
} }
} }
} }
data class MessageSyncState(
val processedCount: Int = 0,
val isSyncing: Boolean = false
)

View File

@@ -13,10 +13,13 @@ 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.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
@@ -79,9 +82,14 @@ class NostrViewModel(
val errorEvents = _errorEvents.receiveAsFlow() val errorEvents = _errorEvents.receiveAsFlow()
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>()
val isSyncing = nostr.messageSyncState.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init { init {
// Skip the splash screen if a user is already logged in // Skip the splash screen if a user is already logged in
if (nostr.signer.currentUser != null) { if (nostr.signer.currentUser != null) {
@@ -108,7 +116,6 @@ class NostrViewModel(
viewModelScope.launch { viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
coroutineScope { coroutineScope {
launch { refreshChatRooms() }
launch { runObserver() } launch { runObserver() }
launch { runMetadataBatching() } launch { runMetadataBatching() }
} }
@@ -148,6 +155,21 @@ class NostrViewModel(
} }
private suspend fun runObserver() = coroutineScope { private suspend fun runObserver() = coroutineScope {
// Observe message sync progress
launch {
nostr.messageSyncState.collect { state ->
// When at least some messages are processed, allow UI to show the list
if (state.processedCount > 0) {
_isPartialProcessedGiftWrap.value = true
}
// Refresh UI every 10 messages OR when sync is fully done
if (state.processedCount % 10 == 0 || !state.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages // Observe new messages
launch { launch {
nostr.newEvents.collect { event -> nostr.newEvents.collect { event ->
@@ -181,14 +203,6 @@ class NostrViewModel(
updateMetadata(pubkey, metadata) updateMetadata(pubkey, metadata)
} }
} }
// Observes subscription close
launch {
nostr.subscriptionClosed.collect {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
}
}
} }
private suspend fun runMetadataBatching() = coroutineScope { private suspend fun runMetadataBatching() = coroutineScope {