Improve feed logic on profile detail

This commit is contained in:
Kgothatso Ngako
2026-04-22 03:25:19 +02:00
parent 2b693b832a
commit 810a690776
8 changed files with 365 additions and 267 deletions

View File

@@ -22,6 +22,9 @@ interface ConnectionDao {
@Query("SELECT * FROM Connection WHERE destinationPublicKey = :destinationPublicKey")
fun getPublicKeyFollowers(destinationPublicKey: String): LocalFollowers?
@Query("SELECT * FROM Connection WHERE destinationPublicKey = :destinationPublicKey")
fun observe(destinationPublicKey: String): LocalFollowers?
@Insert
suspend fun insertPlaceholderProfile(connection: Connection)

View File

@@ -2,6 +2,8 @@ package ac.cord.auxiliary.compose.database.dao
import ac.cord.auxiliary.compose.database.model.Profile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfile
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowers
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
@@ -18,6 +20,12 @@ interface ProfileDao {
@Query("SELECT * FROM Profile WHERE publicKey = :publicKey")
fun getProfileByPublicKey(publicKey: String): Profile?
@Query("SELECT * FROM NostrEvent WHERE kind = 0 AND pubKey = :publicKey")
fun observeProfileWithFollowersByPublicKey(publicKey: String): LocalProfileWithFollowers?
@Query("SELECT * FROM NostrEvent WHERE kind = 0 AND pubKey = :publicKey")
fun observeProfileWithFollowingByPublicKey(publicKey: String): LocalProfileWithFollowing?
@Insert
suspend fun insertPlaceholderProfile(profile: Profile)

View File

@@ -0,0 +1,29 @@
package ac.cord.auxiliary.compose.database.model.intermdiate
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 androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
data class LocalProfileWithFollowers(
@Embedded val nostrEvent: NostrEvent,
@Relation(
parentColumn = "pubKey",
entityColumn = "publicKey"
)
val profile: Profile,
@Relation(
parentColumn = "pubKey",
entityColumn = "publicKey",
associateBy = Junction(
value = Connection::class,
parentColumn = "destinationPublicKey",
entityColumn = "sourcePublicKey"
)
)
val followers: List<Profile>
)

View File

@@ -0,0 +1,29 @@
package ac.cord.auxiliary.compose.database.model.intermdiate
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 androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
data class LocalProfileWithFollowing(
@Embedded val nostrEvent: NostrEvent,
@Relation(
parentColumn = "pubKey",
entityColumn = "publicKey"
)
val profile: Profile,
@Relation(
parentColumn = "pubKey",
entityColumn = "publicKey",
associateBy = Junction(
value = Connection::class,
parentColumn = "sourcePublicKey",
entityColumn = "destinationPublicKey"
)
)
val followers: List<Profile>
)

View File

@@ -1,5 +1,7 @@
package ac.cord.auxiliary.compose.ui.composable
import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter
import ac.cord.auxiliary.compose.managers.SeedManager
import ac.cord.auxiliary.compose.repository.NostrRepository
import ac.cord.auxiliary.compose.ui.composable.widgets.LoadingDataIndicator
import ac.cord.auxiliary.compose.ui.composable.widgets.feed.FeedListItem
@@ -43,6 +45,16 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -56,6 +68,23 @@ fun FeedScreen(
val feedListViewModel: FeedListViewModel = viewModel(
factory = FeedListViewModel.factory(
initialFeedListUIState = initialFeedListUIState,
synchronizationFilter = SynchronizationFilter(
kinds = arrayOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
ReactionEvent.KIND,
LnZapEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
PrivateDmEvent.KIND,
BlossomServersEvent.KIND,
FileServersEvent.KIND
),
tags = mapOf(
Pair("p", listOf(SeedManager.activePublicKey().toHexKey()))
),
limit = 50
),
nostrRepository = nostrRepository
),
)

View File

@@ -8,6 +8,7 @@ import ac.cord.auxiliary.compose.ui.composable.navigation.routes.NostrEventDetai
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.MetadataEventDetailType
import ac.cord.auxiliary.compose.ui.view.model.MetadataEventDetailViewModel
import ac.cord.auxiliary.compose.ui.view.model.SearchResultType
@@ -216,81 +217,40 @@ fun MetadataEventDetail(
HorizontalPager(
state = metadataEventDetailViewModel.pagerState,
) { pageIndex ->
when (MetadataEventDetailType.entries[pageIndex]) {
MetadataEventDetailType.Posts, MetadataEventDetailType.Replies,
MetadataEventDetailType.Articles, MetadataEventDetailType.Zaps,
MetadataEventDetailType.Photos, MetadataEventDetailType.Videos,
MetadataEventDetailType.Bookmarks, MetadataEventDetailType.Reports, MetadataEventDetailType.Relays -> {
val feedListViewModel: FeedListViewModel = viewModel(
factory = FeedListViewModel.factory(
initialFeedListUIState = FeedListUIState.Loading,
synchronizationFilter = metadataEventDetailViewModel.getSynchronizationFilter(),
nostrRepository = nostrRepository
),
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (val feedListUIState = metadataEventDetailViewModel.feedListUIState) {
FeedListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = "Something went wrong",
)
}
}
is FeedListUIState.Loaded -> {
if (feedListUIState.localNostrEvents.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.localNostrEvents,
key = { localNostrEvent -> localNostrEvent.nostrEvent.id}
) { localNostrEvent ->
localNostrEvent.ListView(
onNavigateToEvent = { nostrEventId ->
onNavigateToEvent.invoke(
NostrEventDetailRoute(
nostrEventId = nostrEventId
)
)
}
)
}
}
}
feedListViewModel.RenderFeed(
onNavigateToEvent = onNavigateToEvent
)
}
}
FeedListUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = "Loading ${MetadataEventDetailType.entries[pageIndex].name.lowercase()}",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
)
MetadataEventDetailType.Following -> {
Text("Implementing logic to see following")
}
MetadataEventDetailType.Followers -> {
Text("Implementing logic to see followers")
}
}
LaunchedEffect(true) {
metadataEventDetailViewModel.updateMetadataEventDetailType(
MetadataEventDetailType.Posts
) {
LoadingIndicator()
}
}
}
}
}

View File

@@ -5,11 +5,29 @@ import ac.cord.auxiliary.compose.database.model.types.SynchronizationFilter
import ac.cord.auxiliary.compose.managers.SeedManager
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.FeedListUIState
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
@@ -33,6 +51,7 @@ import kotlinx.coroutines.launch
class FeedListViewModel(
initialFeedListUIState: FeedListUIState,
val synchronizationFilter: SynchronizationFilter,
val nostrRepository: NostrRepository
): ViewModel() {
val logger = Logger.withTag(TAG)
@@ -58,7 +77,9 @@ class FeedListViewModel(
if (localProfile?.profile == null) {
feedListUIState = FeedListUIState.Error
} else {
nostrRepository.observeNostrFeed().collect { localNostrEvents ->
nostrRepository.observeNostrFeed(
synchronizationFilter
).collect { localNostrEvents ->
feedListUIState = FeedListUIState.Loaded(
localNostrEvents = localNostrEvents
)
@@ -77,23 +98,7 @@ class FeedListViewModel(
SynchronizeNostrEventRequest(
purpose = "feed",
synchronizationFilters = arrayOf(
SynchronizationFilter(
kinds = arrayOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
ReactionEvent.KIND,
LnZapEvent.KIND,
ContactListEvent.KIND,
AdvertisedRelayListEvent.KIND,
PrivateDmEvent.KIND,
BlossomServersEvent.KIND,
FileServersEvent.KIND
),
tags = mapOf(
Pair("p", listOf(SeedManager.activePublicKey().toHexKey()))
),
limit = 50
)
synchronizationFilter
),
relayURL = normalizedRelay.url
)
@@ -112,16 +117,101 @@ class FeedListViewModel(
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RenderFeed(
onNavigateToEvent: (Route) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (val feedListUIState = feedListUIState) {
FeedListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = "Something went wrong",
)
}
}
is FeedListUIState.Loaded -> {
if (feedListUIState.localNostrEvents.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.localNostrEvents,
key = { localNostrEvent -> localNostrEvent.nostrEvent.id}
) { localNostrEvent ->
localNostrEvent.ListView(
onNavigateToEvent = { nostrEventId ->
onNavigateToEvent.invoke(
NostrEventDetailRoute(
nostrEventId = nostrEventId
)
)
}
)
}
}
}
}
FeedListUIState.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: FeedListUIState = FeedListUIState.Loading,
synchronizationFilter: SynchronizationFilter,
nostrRepository: NostrRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
FeedListViewModel(
initialFeedListUIState = initialFeedListUIState,
synchronizationFilter = synchronizationFilter,
nostrRepository = nostrRepository
)
}

View File

@@ -42,198 +42,148 @@ class MetadataEventDetailViewModel(
): ViewModel() {
val logger = Logger.withTag(TAG)
var feedJob: Job? = null
var metadataEventDetailType: MetadataEventDetailType by mutableStateOf(initialMetadataEventDetailType)
private set
var feedListUIState: FeedListUIState by mutableStateOf(FeedListUIState.Loading)
private set
fun updateMetadataEventDetailType (metadataEventDetailType: MetadataEventDetailType, navigateToPage: () -> Unit) {
this.metadataEventDetailType = metadataEventDetailType
initiateMetadataFeed(this.metadataEventDetailType)
navigateToPage.invoke()
}
fun initiateMetadataFeed(metadataEventDetailType: MetadataEventDetailType = this.metadataEventDetailType) {
feedListUIState = FeedListUIState.Loading
logger.d("initiateMetadataFeed")
val synchronizationFilter = when (metadataEventDetailType) {
MetadataEventDetailType.Posts -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Replies -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
),
// TODO: replyTo is set...
limit = 50
)
}
MetadataEventDetailType.Articles -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
LongTextNoteEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Followers -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
ContactListEvent.KIND,
),
// TODO: Check users with hexKey
limit = 50
)
}
MetadataEventDetailType.Following -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
ContactListEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Zaps -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
LnZapEvent.KIND,
),
// TODO: Zaps user has received...
limit = 50
)
}
MetadataEventDetailType.Photos -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
),
// TODO: filter for tags of pictures...
limit = 50
)
}
MetadataEventDetailType.Videos -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
),
// TODO: filter for tags of videos...
limit = 50
)
}
MetadataEventDetailType.Bookmarks -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
BookmarkListEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Reports -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
ReportEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Relays -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
RelayFeedsListEvent.KIND,
),
limit = 50
)
}
fun getSynchronizationFilter() = when (metadataEventDetailType) {
MetadataEventDetailType.Posts -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
),
limit = 50
)
}
syncFeed(synchronizationFilter)
loadFeed(synchronizationFilter)
}
private fun syncFeed(synchronizationFilter: SynchronizationFilter) {
logger.d("syncFeed")
viewModelScope.launch(Dispatchers.IO) {
// Sync Notifications... might want to also run this in the background
nostrRepository.queueSynchronizeNostrEvent(
Relays.eventFinderRelaySet.take(1).map { normalizedRelay -> // TODO: Sync from all relays instead of 1
SynchronizeNostrEventRequest(
purpose = "search",
synchronizationFilters = arrayOf(
synchronizationFilter
),
relayURL = normalizedRelay.url
)
}
MetadataEventDetailType.Replies -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
),
// TODO: replyTo is set...
limit = 50
)
}
MetadataEventDetailType.Articles -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
LongTextNoteEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Followers -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
ContactListEvent.KIND,
),
// TODO: Check users with hexKey
limit = 50
)
}
MetadataEventDetailType.Following -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
ContactListEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Zaps -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
LnZapEvent.KIND,
),
// TODO: Zaps user has received...
limit = 50
)
}
MetadataEventDetailType.Photos -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
),
// TODO: filter for tags of pictures...
limit = 50
)
}
MetadataEventDetailType.Videos -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
TextNoteEvent.KIND,
),
// TODO: filter for tags of videos...
limit = 50
)
}
MetadataEventDetailType.Bookmarks -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
BookmarkListEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Reports -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
ReportEvent.KIND,
),
limit = 50
)
}
MetadataEventDetailType.Relays -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
RelayFeedsListEvent.KIND,
),
limit = 50
)
}
}
private fun loadFeed(synchronizationFilter: SynchronizationFilter) {
logger.d("loadFeed")
feedJob?.cancel()
feedJob = viewModelScope.launch(Dispatchers.IO) {
// This is something not taking into consideration the results we are actually looking for...
nostrRepository.observeNostrFeed(synchronizationFilter).collect { localNostrEvents ->
feedListUIState = FeedListUIState.Loaded(
localNostrEvents = localNostrEvents
)
}
}
}
companion object {
const val TAG = "SearchViewModel"
const val TAG = "MetadataEventDetailViewModel"
fun factory(
initialMetadataEventDetailType: MetadataEventDetailType = MetadataEventDetailType.Posts,