Queue synchronization of local profile

This commit is contained in:
Kgothatso Ngako
2026-03-29 23:17:12 +02:00
parent daa6cb11a7
commit 3f889fa02b
13 changed files with 269 additions and 50 deletions

View File

@@ -80,7 +80,6 @@ abstract class NostrDao(
@Transaction
open suspend fun storeNostrEvent(
nostrEvent: NostrEvent,
synchronizeNostrEventRequest: SynchronizeNostrEventRequest,
synchronizationRelayURLs: List<String>
) {
logger.i("Store Nostr Event: $nostrEvent")
@@ -121,14 +120,6 @@ abstract class NostrDao(
nostrEvent = nostrEvent,
synchronizationRelayURLs = synchronizationRelayURLs
)
// Save synchronizationResult
database.synchronizeNostrEventResultDao().upsert(
SynchronizeNostrEventResult(
nostrEventId = nostrEvent.id,
relayURL = synchronizeNostrEventRequest.relayURL
)
)
}
private suspend fun indexNostrEvent(

View File

@@ -13,4 +13,7 @@ interface SynchronizeNostrEventRequestDao {
@Upsert
fun upsert(synchronizeNostrEventRequest: SynchronizeNostrEventRequest)
@Upsert
fun insert(synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>)
}

View File

@@ -4,6 +4,7 @@ import ac.aux.compose.database.model.BroadcastNostrEventReceipt
import ac.aux.compose.database.model.BroadcastNostrEventRequest
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.UnsignedNostrEvent
import androidx.room.Embedded
import androidx.room.Relation
@@ -33,5 +34,12 @@ data class LocalProfile(
parentColumn = "id",
entityColumn = "unsignedNostrEventId"
)
val broadcastNostrEventReceipt: BroadcastNostrEventReceipt?
)
val broadcastNostrEventReceipt: BroadcastNostrEventReceipt?,
@Relation(
parentColumn = "id",
entityColumn = "unsignedNostrEventId"
)
val synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>,
)

View File

@@ -49,7 +49,8 @@ class DatabaseNostrRepository(
name: String?,
biography: String?,
onError: () -> Unit,
onProfileReady: (Profile) -> Unit
onProfileReady: (Profile) -> Unit,
synchronizationRelayURLs: List<String>
) {
val profileEventTemplate = MetadataEvent.createNew(
name = name,
@@ -82,6 +83,15 @@ class DatabaseNostrRepository(
if (unsignedNostrEvent.signedAt != null) {
// Submit a SyncNostrEventRequest
synchronizationRelayURLs.forEach { relayUrl ->
database.synchronizeNostrEventRequestDao().upsert(
SynchronizeNostrEventRequest(
authorPublicKeys = unsignedNostrEvent.pubKey,
status = "pending",
relayURL = relayUrl
)
)
}
}
}
}
@@ -171,11 +181,27 @@ class DatabaseNostrRepository(
synchronizeNostrEventRequest: SynchronizeNostrEventRequest,
synchronizationRelayURLs: List<String>
) {
database.nostrDao().storeNostrEvent(
nostrEvent,
synchronizeNostrEventRequest,
synchronizationRelayURLs = synchronizationRelayURLs
)
database.synchronizeNostrEventRequestDao().upsert(
synchronizeNostrEventRequest.copy(
nostrEventId = nostrEvent.id
)
)
}
override suspend fun queueSynchronizeNostrEvent(
synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>,
) {
// Save synchronizationResult
database.synchronizeNostrEventRequestDao().insert(
synchronizeNostrEventRequests
)
}
}

View File

@@ -27,7 +27,8 @@ interface NostrRepository {
onError: () -> Unit,
// onUnsignProfile: (UnsignedNostrEvent) -> Unit,
// onUnbroadcastedProfile: (NostrEvent) -> Unit,
onProfileReady: (Profile) -> Unit
onProfileReady: (Profile) -> Unit,
synchronizationRelayURLs: List<String>
)
suspend fun createNewTextNote(
@@ -59,6 +60,10 @@ interface NostrRepository {
synchronizationRelayURLs: List<String>
)
suspend fun queueSynchronizeNostrEvent(
synchronizeNostrEventRequests: List<SynchronizeNostrEventRequest>,
)
companion object {
val NO_OP_NOSTR_REPOSITORY = object : NostrRepository {
override suspend fun observeProfile(publicKey: HexKey): Flow<LocalProfile?> {
@@ -82,7 +87,8 @@ interface NostrRepository {
name: String?,
biography: String?,
onError: () -> Unit,
onProfileReady: (Profile) -> Unit
onProfileReady: (Profile) -> Unit,
synchronizationRelayURLs: List<String>
) {
}

View File

@@ -0,0 +1,97 @@
package ac.aux.compose.ui.composable
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.composable.widgets.LoadingDataIndicator
import ac.aux.compose.ui.theme.AuxTheme
import ac.aux.compose.ui.view.model.UnqueuedProfileSynchronizationViewModel
import ac.aux.compose.ui.view.model.WriteNewNoteViewModel
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UnqueuedProfileSynchronizationScreen(
profilePublicKey: String,
nostrRepository: NostrRepository
) {
val unqueuedProfileSynchronizationViewModel: UnqueuedProfileSynchronizationViewModel = viewModel(
factory = UnqueuedProfileSynchronizationViewModel.factory(
profilePublicKey = profilePublicKey,
nostrRepository = nostrRepository
)
)
Scaffold { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(
10.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "We are searching the internet to find your profile and complete sign in.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = "We are looking for your profile on as many relays as possible. Nostr aims to be decentralized by distributing data to multiple nodse.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
LaunchedEffect(true) {
unqueuedProfileSynchronizationViewModel.queueSynchronization()
}
}
@Preview
@Composable
private fun UnqueuedProfileSynchronizationScreenPreview() {
AuxTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
UnqueuedProfileSynchronizationScreen(
profilePublicKey = "npub is for living."
)
}
}
}

View File

@@ -32,6 +32,7 @@ import ac.aux.compose.ui.composable.navigation.routes.SocialPreconditionRoute
import ac.aux.compose.ui.composable.navigation.routes.UnannouncedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnindexedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnqueuedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnqueuedProfileSynchronizationRoute
import ac.aux.compose.ui.composable.navigation.routes.UnsignedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.UnsyncedProfileRoute
import ac.aux.compose.ui.composable.navigation.routes.WriteNewNoteRoute
@@ -42,7 +43,6 @@ import ac.aux.compose.ui.view.state.NavigationUIState
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -53,7 +53,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
@@ -129,6 +128,15 @@ fun AuxNavHost(
popUpTo(0)
}
}
is NavigationUIState.UnqueuedProfileSynchronization -> {
navController.navigate(
route = UnqueuedProfileSynchronizationRoute(
state.unsignedNostrEvent.pubKey
)
) {
popUpTo(0)
}
}
is NavigationUIState.UnindexedProfile -> {
navController.navigate(
route = UnindexedProfileRoute(
@@ -243,6 +251,12 @@ fun AuxNavHost(
unsyncedProfilePublicKey = route.publicKey
)
}
composable<UnqueuedProfileSynchronizationRoute> { backStackEntry ->
val route = backStackEntry.toRoute<UnqueuedProfileSynchronizationRoute>()
UnqueuedProfileSynchronizationRoute(
publicKey = route.publicKey
)
}
composable<UnindexedProfileRoute> {
UnindexedProfileScreen()
}

View File

@@ -0,0 +1,9 @@
package ac.aux.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class UnqueuedProfileSynchronizationRoute(
val publicKey: String,
): Route() {
}

View File

@@ -1,6 +1,7 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.CreateProfileUIState
import androidx.compose.runtime.MutableState
@@ -80,25 +81,13 @@ class CreateProfileViewModel(
onError = {
createProfileUIState.value = CreateProfileUIState.Error
},
// onUnsignProfile = { unsignedNostrEvent ->
// onNavigateToUnsignedProfile.invoke(
// UnsignedProfileRoute(
// unsignedNostrEvent.id
// )
// )
// },
// onUnbroadcastedProfile = { unannouncedNostrEvent ->
// onNavigateToUnannouncedProfile.invoke(
// UnannouncedProfileRoute(
// nostrEventId = unannouncedNostrEvent.id
// )
// )
// }
) { profile ->
createProfileUIState.value = CreateProfileUIState.ProfileReady(
profile = profile
)
}
{ profile ->
createProfileUIState.value = CreateProfileUIState.ProfileReady(
profile = profile
)
},
synchronizationRelayURLs = Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url }
)
}
}

View File

@@ -193,16 +193,18 @@ class NavigationViewModel(
jsonText
)
val nostrEvent = NostrEvent(
id = event.id,
pubKey = event.pubKey,
kind = event.kind,
content = event.content,
tags = event.tags,
createdAt = Instant.fromEpochMilliseconds(event.createdAt),
sig = event.sig
)
nostrRepository.saveNostrEvent(
nostrEvent = NostrEvent(
id = event.id,
pubKey = event.pubKey,
kind = event.kind,
content = event.content,
tags = event.tags,
createdAt = Instant.fromEpochMilliseconds(event.createdAt),
sig = event.sig
),
nostrEvent = nostrEvent,
synchronizeNostrEventRequest,
synchronizationRelayURLs = Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url }
)
@@ -315,7 +317,12 @@ class NavigationViewModel(
NavigationUIState.UnindexedProfile(
nostrEvent = localProfile.nostrEvent
)
} else if (localProfile.unsignedNostrEvent != null && localProfile.unsignedNostrEvent.signedAt != null) {
} else if (localProfile.unsignedNostrEvent.signedAt != null && localProfile.synchronizeNostrEventRequests.isEmpty()) {
logger.i("We should request to sync profile: ${localProfile.unsignedNostrEvent}")
NavigationUIState.UnqueuedProfileSynchronization(
unsignedNostrEvent = localProfile.unsignedNostrEvent
)
} else if (localProfile.unsignedNostrEvent.signedAt != null) {
logger.i("We should be syncing the profile: ${localProfile.unsignedNostrEvent}")
NavigationUIState.UnsyncedProfile(
unsignedNostrEvent = localProfile.unsignedNostrEvent

View File

@@ -1,6 +1,6 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.managers.SeedManager
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.SignInToProfileUIState
import ac.aux.compose.ui.view.state.form.SignInToProfileFormState
@@ -12,7 +12,6 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
@@ -78,7 +77,8 @@ class SignInToProfileViewModel(
name = null,
biography = null,
onError = onError,
onProfileReady = {}
onProfileReady = {},
synchronizationRelayURLs = Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url }
)
}
is NSec -> {
@@ -87,7 +87,8 @@ class SignInToProfileViewModel(
name = null,
biography = null,
onError = onError,
onProfileReady = {}
onProfileReady = {},
synchronizationRelayURLs = Relays.eventPublishRelaySet.map { normalizedRelayUrl -> normalizedRelayUrl.url }
)
}
else -> {

View File

@@ -0,0 +1,65 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.SignInToProfileUIState
import ac.aux.compose.ui.view.state.form.SignInToProfileFormState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
class UnqueuedProfileSynchronizationViewModel(
val profilePublicKey: String,
val nostrRepository: NostrRepository
): ViewModel() {
companion object {
private const val TAG = "UnqueuedProfileSynchronizationViewModel"
fun factory(
profilePublicKey: String,
nostrRepository: NostrRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
UnqueuedProfileSynchronizationViewModel(
profilePublicKey = profilePublicKey,
nostrRepository = nostrRepository
)
}
}
}
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
fun queueSynchronization() {
logger.d { "queueSynchronization" }
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.queueSynchronizeNostrEvent(
Relays.eventPublishRelaySet.map { normalizedRelayUrl ->
SynchronizeNostrEventRequest(
authorPublicKeys = profilePublicKey,
kinds = "0",
relayURL = normalizedRelayUrl.url
)
}
)
}
}
}

View File

@@ -5,7 +5,6 @@ import ac.aux.compose.database.model.BroadcastNostrEventRequest
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.UnsignedNostrEvent
import ac.aux.compose.database.model.intermdiate.LocalProfile
abstract class NavigationUIState {
data object Error: NavigationUIState()
@@ -22,6 +21,10 @@ abstract class NavigationUIState {
val unsignedNostrEvent: UnsignedNostrEvent,
) : NavigationUIState()
data class UnqueuedProfileSynchronization(
val unsignedNostrEvent: UnsignedNostrEvent,
) : NavigationUIState()
data class UnindexedProfile(
val nostrEvent: NostrEvent,
) : NavigationUIState()