From 5cd9cc09d6568b4341a4d1c2fd3f0a9dd9ce29a6 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 24 Apr 2026 00:18:36 +0200 Subject: [PATCH] Setup UI for follow/unfollow logic --- .../compose/database/dao/ConnectionDao.kt | 5 ++ .../repository/DatabaseNostrRepository.kt | 11 +++ .../compose/repository/NostrRepository.kt | 10 +++ .../ui/composable/NostrEventDetailScreen.kt | 5 +- .../ui/composable/navigation/AuxNavHost.kt | 5 ++ .../widgets/detail/MetadataEventDetail.kt | 76 +++++++++++++++++-- .../ui/view/model/CreateProfileViewModel.kt | 3 +- .../model/MetadataEventDetailViewModel.kt | 56 ++++++++++++-- .../view/state/MetadataEventDetailUIState.kt | 16 ++++ 9 files changed, 171 insertions(+), 16 deletions(-) create mode 100755 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/MetadataEventDetailUIState.kt diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ConnectionDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ConnectionDao.kt index f7781f33..a84d8d0e 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ConnectionDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/ConnectionDao.kt @@ -5,6 +5,8 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import androidx.room.Upsert +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.Flow @Dao interface ConnectionDao { @@ -12,6 +14,9 @@ interface ConnectionDao { fun getAllConnections(limit: Int = 21): List + @Query("SELECT * FROM Connection WHERE (sourcePublicKey = :alicePublicKey AND destinationPublicKey = :bobPublicKey) OR (sourcePublicKey = :bobPublicKey AND destinationPublicKey = :alicePublicKey)") + fun observeRelation(alicePublicKey: HexKey, bobPublicKey: HexKey): Flow> + @Insert suspend fun insertPlaceholderProfile(connection: Connection) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt index 51d76eee..4d1ebb3e 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt @@ -4,6 +4,7 @@ import ac.cord.auxiliary.compose.database.AuxDatabase import ac.cord.auxiliary.compose.database.GENESIS_AT import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventReceipt import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventRequest +import ac.cord.auxiliary.compose.database.model.Connection import ac.cord.auxiliary.compose.database.model.NostrEvent import ac.cord.auxiliary.compose.database.model.Profile import ac.cord.auxiliary.compose.database.model.RecentSearch @@ -59,6 +60,16 @@ class DatabaseNostrRepository( return database.unsignedNostrEventDao().observeProfile(publicKey) } + override suspend fun observeRelation( + publicKey: HexKey, + activePublicKey: HexKey + ): Flow> { + return database.connectionDao().observeRelation( + alicePublicKey = publicKey, + bobPublicKey = activePublicKey + ) + } + override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow { return database.unsignedNostrEventDao().observeUnsignedNostrEvents(publicKey) } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt index ee7512a4..0845286b 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/repository/NostrRepository.kt @@ -2,6 +2,7 @@ package ac.cord.auxiliary.compose.repository import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventReceipt import ac.cord.auxiliary.compose.database.model.BroadcastNostrEventRequest +import ac.cord.auxiliary.compose.database.model.Connection import ac.cord.auxiliary.compose.database.model.NostrEvent import ac.cord.auxiliary.compose.database.model.Profile import ac.cord.auxiliary.compose.database.model.RecentSearch @@ -21,6 +22,8 @@ import kotlinx.coroutines.flow.Flow interface NostrRepository { suspend fun observeProfile(publicKey: HexKey): Flow + suspend fun observeRelation(publicKey: HexKey, activePublicKey: HexKey): Flow> + suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow suspend fun observePendingBroadcastNostrEventRequests(): Flow> @@ -99,6 +102,13 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun observeRelation( + publicKey: HexKey, + activePublicKey: HexKey + ): Flow> { + TODO("Not yet implemented") + } + override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/NostrEventDetailScreen.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/NostrEventDetailScreen.kt index 4d180a3d..edaf4c13 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/NostrEventDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/NostrEventDetailScreen.kt @@ -39,6 +39,7 @@ fun NostrEventDetailScreen( nostrRepository: NostrRepository, onNavigateBack: () -> Unit, onNavigateToEvent: (Route) -> Unit, + onNavigateToEditProfile: () -> Unit, onNavigateToWriteAReply: (String) -> Unit ) { val nostrEventDetailViewModel: NostrEventDetailViewModel = viewModel( @@ -67,6 +68,7 @@ fun NostrEventDetailScreen( localNostrEvent = feedListUIState.localNostrEvent, onNavigateBack = onNavigateBack, onNavigateToEvent = onNavigateToEvent, + onNavigateToEditProfile = onNavigateToEditProfile, nostrRepository = nostrRepository ) } @@ -160,7 +162,8 @@ It has survived not only five centuries, but also the leap into electronic types nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, onNavigateBack = {}, onNavigateToEvent = {}, - onNavigateToWriteAReply = {} + onNavigateToWriteAReply = {}, + onNavigateToEditProfile = {} ) } } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt index edab2295..2eb2107e 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/navigation/AuxNavHost.kt @@ -387,6 +387,11 @@ fun AuxNavHost( inReplyToEventId = nostrEventId ) ) + }, + onNavigateToEditProfile = { + navController.navigate( + route = ImplementationPendingRoute("Edit Profile") + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index 575c7c93..b31d233e 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/MetadataEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/MetadataEventDetail.kt @@ -15,6 +15,7 @@ import ac.cord.auxiliary.compose.ui.view.model.MetadataEventDetailViewModel import ac.cord.auxiliary.compose.ui.view.state.FeedListUIState import ac.cord.auxiliary.compose.ui.view.state.FollowersListUIState import ac.cord.auxiliary.compose.ui.view.state.FollowingListUIState +import ac.cord.auxiliary.compose.ui.view.state.MetadataEventDetailUIState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,13 +31,14 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Error import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold @@ -66,6 +68,7 @@ fun MetadataEventDetail( localNostrEvent: LocalNostrEvent, onNavigateBack: () -> Unit, onNavigateToEvent: (Route) -> Unit, + onNavigateToEditProfile: () -> Unit, nostrRepository: NostrRepository ) { val scope = rememberCoroutineScope() @@ -160,14 +163,64 @@ fun MetadataEventDetail( ) - Button( - onClick = { + + when(val metadataEventDetailUIState = metadataEventDetailViewModel.metadataEventDetailUIState) { + MetadataEventDetailUIState.Error -> { + Icon( + Icons.Default.Error, + contentDescription = "Something went wrong" + ) + } + is MetadataEventDetailUIState.Loaded -> { + + if (metadataEventDetailViewModel.isActionPending.value) { + LoadingIndicator() + } else { + if (metadataEventDetailUIState.isActiveUser) { + Button( + onClick = onNavigateToEditProfile + ) { + Text( + "Edit Profile" + ) + } + + } else if (metadataEventDetailUIState.followedByActiveUserConnection != null) { + Button( + onClick = { + // Unfollow + metadataEventDetailViewModel.unfollow( + metadataEventDetailUIState.followedByActiveUserConnection + ) + } + ) { + Text( + "Unfollow" + ) + } + } else { + // flow + Button( + onClick = { + metadataEventDetailViewModel.follow() + } + ) { + Text( + text = if (metadataEventDetailUIState.followingActiveUserConnection == null) { + "follow back" + } else { + "follow" + } + ) + } + } + } + } - ) { - Text( - "Follow" - ) + MetadataEventDetailUIState.Loading -> { + LoadingIndicator() + } } } Text( @@ -182,7 +235,7 @@ fun MetadataEventDetail( } } - item { + stickyHeader { PrimaryScrollableTabRow( modifier = Modifier.padding(10.dp), edgePadding = 10.dp, @@ -206,7 +259,9 @@ fun MetadataEventDetail( ) } } + } + item { Column( modifier = Modifier.fillParentMaxSize() ) { @@ -289,6 +344,10 @@ fun MetadataEventDetail( } } } + + key(true) { + metadataEventDetailViewModel.initiate() + } } } } @@ -321,6 +380,7 @@ private fun MetadataEventEventDetailPreview() { ), onNavigateBack = {}, onNavigateToEvent = {}, + onNavigateToEditProfile = {}, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY ) } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt index 0bb5f89d..74136356 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/CreateProfileViewModel.kt @@ -73,9 +73,10 @@ class CreateProfileViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { + val publicKey = SeedManager.activePublicKey().toHexKey() nostrRepository.createNewProfile( - SeedManager.activePublicKey().toHexKey(), + publicKey, name = createProfileFormState.nameField.text.value, biography = createProfileFormState.biographyField.text.value, onError = { diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/MetadataEventDetailViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/MetadataEventDetailViewModel.kt index 240bb030..c6826859 100755 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/MetadataEventDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/MetadataEventDetailViewModel.kt @@ -1,11 +1,12 @@ package ac.cord.auxiliary.compose.ui.view.model -import ac.cord.auxiliary.compose.database.model.SynchronizeNostrEventRequest +import ac.cord.auxiliary.compose.database.model.Connection import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter -import ac.cord.auxiliary.compose.nostr.Relays +import ac.cord.auxiliary.compose.managers.SeedManager import ac.cord.auxiliary.compose.repository.NostrRepository -import ac.cord.auxiliary.compose.ui.view.state.FeedListUIState +import ac.cord.auxiliary.compose.ui.view.state.MetadataEventDetailUIState import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -16,6 +17,7 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -27,7 +29,6 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.Job import kotlinx.coroutines.launch enum class MetadataEventDetailType { @@ -35,11 +36,41 @@ enum class MetadataEventDetailType { } class MetadataEventDetailViewModel( + initialMetadataEventDetailUIState: MetadataEventDetailUIState = MetadataEventDetailUIState.Loading, val publicKey: HexKey, val nostrRepository: NostrRepository, val pagerState: PagerState ): ViewModel() { - val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + var metadataEventDetailUIState: MetadataEventDetailUIState by mutableStateOf(initialMetadataEventDetailUIState) + private set + + val logger = Logger.withTag("MetadataEventDetailViewModel") + + fun initiate() { + observeMetadataEventRelationWithActiveUser() + } + + fun observeMetadataEventRelationWithActiveUser() { + logger.d("observeLocalNostrFeed") + viewModelScope.launch(Dispatchers.IO) { + val activePublicKey = SeedManager.activePublicKey().toHexKey() + + nostrRepository.observeRelation( + publicKey, + activePublicKey = activePublicKey + ).collect { relations -> + logger.d("We found relations: $relations") + metadataEventDetailUIState = MetadataEventDetailUIState.Loaded( + isActiveUser = activePublicKey == publicKey, + followingActiveUserConnection = relations.find { connection -> connection.sourcePublicKey == publicKey && connection.destinationPublicKey == activePublicKey }, + followedByActiveUserConnection = relations.find { connection -> connection.sourcePublicKey == activePublicKey && connection.destinationPublicKey == publicKey } + ) + } + } + } fun getSynchronizationFilter( metadataEventDetailType: MetadataEventDetailType @@ -174,7 +205,20 @@ class MetadataEventDetailViewModel( } } - // TODO: Queue profile synchronization request... + fun follow() { + isActionPending.value = true + + val activePublicKey = SeedManager.activePublicKey().toHexKey() + + + } + + fun unfollow(connection: Connection) { + isActionPending.value = true + + val activePublicKey = SeedManager.activePublicKey().toHexKey() + + } companion object { const val TAG = "MetadataEventDetailViewModel" diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/MetadataEventDetailUIState.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/MetadataEventDetailUIState.kt new file mode 100755 index 00000000..456dfb88 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/MetadataEventDetailUIState.kt @@ -0,0 +1,16 @@ +package ac.cord.auxiliary.compose.ui.view.state + +import ac.cord.auxiliary.compose.database.model.Connection + + +sealed interface MetadataEventDetailUIState { + data class Loaded( + val isActiveUser: Boolean, + val followingActiveUserConnection: Connection?, + val followedByActiveUserConnection: Connection?, + ): MetadataEventDetailUIState + + data object Error: MetadataEventDetailUIState + data object Loading: MetadataEventDetailUIState +} +