From c7a8f17dd49f7c20faaab781f58e7ced07ab7e58 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 22 Apr 2026 04:04:41 +0200 Subject: [PATCH] Add list following logic --- .../compose/database/dao/ConnectionDao.kt | 10 +- .../repository/DatabaseNostrRepository.kt | 9 + .../compose/repository/NostrRepository.kt | 14 ++ .../widgets/detail/MetadataEventDetail.kt | 25 +- .../ui/view/model/FollowingListViewModel.kt | 213 ++++++++++++++++++ .../ui/view/state/FollowingListUIState.kt | 13 ++ 6 files changed, 269 insertions(+), 15 deletions(-) create mode 100755 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/FollowingListViewModel.kt create mode 100755 composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/FollowingListUIState.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 26bc08c6..b1a76d5b 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 @@ -1,15 +1,13 @@ package ac.cord.auxiliary.compose.database.dao import ac.cord.auxiliary.compose.database.model.Connection -import ac.cord.auxiliary.compose.database.model.Profile import ac.cord.auxiliary.compose.database.model.intermdiate.LocalFollowers import ac.cord.auxiliary.compose.database.model.intermdiate.LocalFollowing -import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import androidx.room.Upsert -import fr.acinq.bitcoin.PublicKey +import kotlinx.coroutines.flow.Flow @Dao interface ConnectionDao { @@ -17,13 +15,11 @@ interface ConnectionDao { fun getAllConnections(limit: Int = 21): List @Query("SELECT * FROM Connection WHERE sourcePublicKey = :sourcePublicKey") - fun getPublicKeyFollowing(sourcePublicKey: String): LocalFollowing? + fun observePublicKeyFollowing(sourcePublicKey: String): Flow @Query("SELECT * FROM Connection WHERE destinationPublicKey = :destinationPublicKey") - fun getPublicKeyFollowers(destinationPublicKey: String): LocalFollowers? + fun observePublicKeyFollowers(destinationPublicKey: String): Flow - @Query("SELECT * FROM Connection WHERE destinationPublicKey = :destinationPublicKey") - fun observe(destinationPublicKey: String): LocalFollowers? @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 fefe015a..44496bf4 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 @@ -13,6 +13,8 @@ import ac.cord.auxiliary.compose.database.model.UnsignedNostrEvent import ac.cord.auxiliary.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent import ac.cord.auxiliary.compose.database.model.intermdiate.LocalAccount +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalFollowers +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalFollowing import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilterArray import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter @@ -372,6 +374,13 @@ class DatabaseNostrRepository( return database.profileDao().filterProfiles(searchTerm = searchTerm) } + override suspend fun observeProfileFollowers(publicKey: String): Flow { + return database.connectionDao().observePublicKeyFollowers(publicKey) + } + + override suspend fun observeProfileFollowing(publicKey: String): Flow { + return database.connectionDao().observePublicKeyFollowing(publicKey) + } override suspend fun getRecentSearches(): List { return database.recentSearchDao().getAllRecentSearches() } 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 51dc2d5d..7631cf1a 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 @@ -10,6 +10,8 @@ import ac.cord.auxiliary.compose.database.model.UnsignedNostrEvent import ac.cord.auxiliary.compose.database.model.intermdiate.LocalBroadcastNostrEventRequest import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent import ac.cord.auxiliary.compose.database.model.intermdiate.LocalAccount +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalFollowers +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalFollowing import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilterArray import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter @@ -81,6 +83,10 @@ interface NostrRepository { suspend fun filterProfiles(searchTerm: String): List + suspend fun observeProfileFollowing(publicKey: String): Flow + + suspend fun observeProfileFollowers(publicKey: String): Flow + suspend fun getRecentSearches(): List suspend fun removeRecentSearch(recentSearch: RecentSearch) @@ -190,6 +196,14 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun observeProfileFollowing(publicKey: String): Flow { + TODO("Not yet implemented") + } + + override suspend fun observeProfileFollowers(publicKey: String): Flow { + TODO("Not yet implemented") + } + override suspend fun getRecentSearches(): List { TODO("Not yet implemented") } 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 ea4f30cb..597ee599 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 @@ -4,26 +4,23 @@ import ac.cord.auxiliary.compose.database.model.NostrEvent import ac.cord.auxiliary.compose.database.model.Profile import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent import ac.cord.auxiliary.compose.repository.NostrRepository -import ac.cord.auxiliary.compose.ui.composable.navigation.routes.NostrEventDetailRoute import ac.cord.auxiliary.compose.ui.composable.navigation.routes.Route -import ac.cord.auxiliary.compose.ui.composable.widgets.feed.ListView import ac.cord.auxiliary.compose.ui.theme.AuxTheme import ac.cord.auxiliary.compose.ui.view.model.FeedListViewModel +import ac.cord.auxiliary.compose.ui.view.model.FollowingListViewModel import ac.cord.auxiliary.compose.ui.view.model.MetadataEventDetailType import ac.cord.auxiliary.compose.ui.view.model.MetadataEventDetailViewModel -import ac.cord.auxiliary.compose.ui.view.model.SearchResultType import ac.cord.auxiliary.compose.ui.view.state.FeedListUIState +import ac.cord.auxiliary.compose.ui.view.state.FollowingListUIState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons @@ -36,7 +33,6 @@ 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 @@ -46,7 +42,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -238,8 +233,22 @@ fun MetadataEventDetail( } MetadataEventDetailType.Following -> { + val followingListViewModel: FollowingListViewModel = viewModel( + key = metadataEventDetailType.name, + factory = FollowingListViewModel.factory( + initialFeedListUIState = FollowingListUIState.Loading, + publicKey = localNostrEvent.nostrEvent.pubKey, + synchronizationFilter = metadataEventDetailViewModel.getSynchronizationFilter(metadataEventDetailType), + nostrRepository = nostrRepository + ), + ) - Text("Implementing logic to see following") + + key(followingListViewModel.followingListUIState) { + followingListViewModel.RenderFeed( + onNavigateToEvent = onNavigateToEvent + ) + } } MetadataEventDetailType.Followers -> { diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/FollowingListViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/FollowingListViewModel.kt new file mode 100755 index 00000000..bbe3e610 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/FollowingListViewModel.kt @@ -0,0 +1,213 @@ +package ac.cord.auxiliary.compose.ui.view.model + +import ac.cord.auxiliary.compose.database.model.SynchronizeNostrEventRequest +import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter +import ac.cord.auxiliary.compose.nostr.Relays +import ac.cord.auxiliary.compose.repository.NostrRepository +import ac.cord.auxiliary.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import ac.cord.auxiliary.compose.ui.composable.navigation.routes.Route +import ac.cord.auxiliary.compose.ui.composable.widgets.feed.ListView +import ac.cord.auxiliary.compose.ui.view.state.FollowingListUIState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +class FollowingListViewModel( + initialFollowingListUIState: FollowingListUIState, + val publicKey: String, + val synchronizationFilter: SynchronizationFilter, + val nostrRepository: NostrRepository +): ViewModel() { + val logger = Logger.withTag(TAG) + var followingListUIState: FollowingListUIState by mutableStateOf(initialFollowingListUIState) + private set + + val isSynchronizationPending: MutableState = mutableStateOf(false) + + init { + logger.d("init") + scheduleSynchronization() + observeLocalNostrFeed() + observeSynchronizeNostrEventRequestByPurposeAndStatusCount() + } + + fun observeLocalNostrFeed() { + logger.d("observeLocalNostrFeed") + viewModelScope.launch(Dispatchers.IO) { + nostrRepository.observeProfileFollowing( + publicKey + ).collect { localFollowing -> + followingListUIState = FollowingListUIState.Loaded( + profilesBeingFollowed = localFollowing?.following ?: emptyList() + ) + } + } + } + + fun scheduleSynchronization() { + logger.d("scheduleSynchronization") + viewModelScope.launch(Dispatchers.IO) { + // Sync Notifications... might want to also run this in the background + nostrRepository.queueSynchronizeNostrEvent( + Relays.eventPublishRelaySet.take(1).map { normalizedRelay -> // TODO: Sync from all the publish relays... + SynchronizeNostrEventRequest( + purpose = "feed", + synchronizationFilters = arrayOf( + synchronizationFilter + ), + relayURL = normalizedRelay.url + ) + } + ) + } + } + + fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount() { + logger.d("observeSynchronizeNostrEventRequestByPurposeAndStatusCount") + viewModelScope.launch(Dispatchers.IO) { + nostrRepository.observeSynchronizeNostrEventRequestByPurposeAndStatusCount("feed").collect { count -> + logger.d("Pending/Processing Synchronization Request: $count") + isSynchronizationPending.value = count > 0 + } + } + } + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + @Composable + fun RenderFeed( + onNavigateToEvent: (Route) -> Unit + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (val feedListUIState = followingListUIState) { + FollowingListUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(50.dp) + ) + Text( + text = "Something went wrong", + ) + } + } + is FollowingListUIState.Loaded -> { + if (feedListUIState.profilesBeingFollowed.isEmpty()) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(50.dp) + ) + Text( + text = "No events were found", + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items( + items = feedListUIState.profilesBeingFollowed, + key = { profile -> profile.publicKey } + ) { profile -> + Column( + modifier = Modifier.clickable { + onNavigateToEvent.invoke( + NostrEventDetailRoute( + profile.nostrEventId + ) + ) + } + ) { + Text( + text = profile.displayName ?: profile.userName ?: profile.userName ?: profile.publicKey + ) + + profile.nip05?.let { + Text( + text = profile.nip05 + ) + } + } + } + } + } + + } + FollowingListUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(50.dp) + ) + Text( + text = "Loading", + textAlign = TextAlign.Center + ) + Spacer( + modifier = Modifier.height(50.dp) + ) + + LoadingIndicator() + } + } + } + } + } + + companion object { + const val TAG = "FeedListViewModel" + + fun factory( + initialFeedListUIState: FollowingListUIState = FollowingListUIState.Loading, + publicKey: String, + synchronizationFilter: SynchronizationFilter, + nostrRepository: NostrRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + FollowingListViewModel( + initialFollowingListUIState = initialFeedListUIState, + publicKey = publicKey, + synchronizationFilter = synchronizationFilter, + nostrRepository = nostrRepository + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/FollowingListUIState.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/FollowingListUIState.kt new file mode 100755 index 00000000..01032a54 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/state/FollowingListUIState.kt @@ -0,0 +1,13 @@ +package ac.cord.auxiliary.compose.ui.view.state + +import ac.cord.auxiliary.compose.database.model.Profile + +sealed interface FollowingListUIState { + data class Loaded( + val profilesBeingFollowed: List + ): FollowingListUIState + + data object Error: FollowingListUIState + data object Loading: FollowingListUIState +} +