chore: improve handling of loading states #26
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user