Add list following logic

This commit is contained in:
Kgothatso Ngako committed 2026-04-22 04:04:41 +02:00
1 parent 1f193af805
commit c7a8f17dd4
6 files changed
+269 -15

No files matched your search

@@ -1,15 +1,13 @@
package ac.cord.auxiliary.compose.database.dao package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.Connection 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.LocalFollowers
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalFollowing 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.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import fr.acinq.bitcoin.PublicKey import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ConnectionDao { interface ConnectionDao {
@@ -17,13 +15,11 @@ interface ConnectionDao {
fun getAllConnections(limit: Int = 21): List<Connection> fun getAllConnections(limit: Int = 21): List<Connection>
@Query("SELECT * FROM Connection WHERE sourcePublicKey = :sourcePublicKey") @Query("SELECT * FROM Connection WHERE sourcePublicKey = :sourcePublicKey")
fun getPublicKeyFollowing(sourcePublicKey: String): LocalFollowing? fun observePublicKeyFollowing(sourcePublicKey: String): Flow<LocalFollowing?>
@Query("SELECT * FROM Connection WHERE destinationPublicKey = :destinationPublicKey") @Query("SELECT * FROM Connection WHERE destinationPublicKey = :destinationPublicKey")
fun getPublicKeyFollowers(destinationPublicKey: String): LocalFollowers? fun observePublicKeyFollowers(destinationPublicKey: String): Flow<LocalFollowers?>
@Query("SELECT * FROM Connection WHERE destinationPublicKey = :destinationPublicKey")
fun observe(destinationPublicKey: String): LocalFollowers?
@Insert @Insert
suspend fun insertPlaceholderProfile(connection: Connection) suspend fun insertPlaceholderProfile(connection: Connection)
@@ -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.LocalBroadcastNostrEventRequest
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent 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.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.intermdiate.LocalProfile
import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilterArray import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilterArray
import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter
@@ -372,6 +374,13 @@ class DatabaseNostrRepository(
return database.profileDao().filterProfiles(searchTerm = searchTerm) return database.profileDao().filterProfiles(searchTerm = searchTerm)
} }
override suspend fun observeProfileFollowers(publicKey: String): Flow<LocalFollowers?> {
return database.connectionDao().observePublicKeyFollowers(publicKey)
}
override suspend fun observeProfileFollowing(publicKey: String): Flow<LocalFollowing?> {
return database.connectionDao().observePublicKeyFollowing(publicKey)
}
override suspend fun getRecentSearches(): List<RecentSearch> { override suspend fun getRecentSearches(): List<RecentSearch> {
return database.recentSearchDao().getAllRecentSearches() return database.recentSearchDao().getAllRecentSearches()
} }
@@ -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.LocalBroadcastNostrEventRequest
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent 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.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.intermdiate.LocalProfile
import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilterArray import ac.cord.auxiliary.compose.database.model.typealiases.SynchronizationFilterArray
import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter
@@ -81,6 +83,10 @@ interface NostrRepository {
suspend fun filterProfiles(searchTerm: String): List<LocalProfile> suspend fun filterProfiles(searchTerm: String): List<LocalProfile>
suspend fun observeProfileFollowing(publicKey: String): Flow<LocalFollowing?>
suspend fun observeProfileFollowers(publicKey: String): Flow<LocalFollowers?>
suspend fun getRecentSearches(): List<RecentSearch> suspend fun getRecentSearches(): List<RecentSearch>
suspend fun removeRecentSearch(recentSearch: RecentSearch) suspend fun removeRecentSearch(recentSearch: RecentSearch)
@@ -190,6 +196,14 @@ interface NostrRepository {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
override suspend fun observeProfileFollowing(publicKey: String): Flow<LocalFollowing?> {
TODO("Not yet implemented")
}
override suspend fun observeProfileFollowers(publicKey: String): Flow<LocalFollowers?> {
TODO("Not yet implemented")
}
override suspend fun getRecentSearches(): List<RecentSearch> { override suspend fun getRecentSearches(): List<RecentSearch> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
@@ -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.Profile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent
import ac.cord.auxiliary.compose.repository.NostrRepository 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.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.theme.AuxTheme
import ac.cord.auxiliary.compose.ui.view.model.FeedListViewModel 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.MetadataEventDetailType
import ac.cord.auxiliary.compose.ui.view.model.MetadataEventDetailViewModel 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.FeedListUIState
import ac.cord.auxiliary.compose.ui.view.state.FollowingListUIState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -36,7 +33,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -46,7 +42,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -238,8 +233,22 @@ fun MetadataEventDetail(
} }
MetadataEventDetailType.Following -> { 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 -> { MetadataEventDetailType.Followers -> {
@@ -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<Boolean> = 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
)
}
}
}
}
@@ -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<Profile>
): FollowingListUIState
data object Error: FollowingListUIState
data object Loading: FollowingListUIState
}