Show the feed for who we are following

This commit is contained in:
Kgothatso Ngako
2026-04-24 02:42:50 +02:00
parent 4515bfa375
commit b33af464c3
8 changed files with 397 additions and 274 deletions

View File

@@ -6,6 +6,8 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.IGNORE
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.RoomRawQuery
import androidx.room.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
@@ -29,12 +31,23 @@ interface NostrEventDao {
ids: Array<HexKey>,
): Flow<List<LocalNostrEvent>>
@Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND pubKey in (:authors) ORDER BY createdAt DESC")
@Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND pubKey in (:authors) ORDER BY createdAt DESC LIMIT 50")
fun observeAuthoredNostrEvents(
kinds: Array<Kind>,
authors: Array<HexKey>,
): Flow<List<LocalNostrEvent>>
@Query("SELECT * FROM NostrEvent WHERE tags LIKE '%' || :publicKey || '%' AND kind in (:kinds) ORDER BY createdAt DESC")
fun observePublicKeyMentionedNostrEvents(
kinds: Array<Kind>,
publicKey: HexKey
): Flow<List<LocalNostrEvent>>
@RawQuery
fun observePublicKeyFollowingNostrEvents(
query: RoomRawQuery
): Flow<List<LocalNostrEvent>>
@Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) ORDER BY savedAt DESC")
fun getAllNostrEvents(kinds: Array<Kind>): List<NostrEvent>

View File

@@ -22,6 +22,7 @@ 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.repository.RelayRepository
import androidx.room.RoomRawQuery
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
@@ -376,6 +377,15 @@ class DatabaseNostrRepository(
authors = synchronizationFilter.authors
)
}
synchronizationFilter.kinds != null && synchronizationFilter.tags?.contains("p") == true && synchronizationFilter.tags["p"]?.first()?.isNotEmpty() == true -> {
logger.d("observePublicKeyMentionedNostrEvents: $synchronizationFilter")
database.nostrEventDao().observePublicKeyMentionedNostrEvents(
kinds = arrayOf(
TextNoteEvent.KIND,
),
publicKey = synchronizationFilter.tags["p"]?.first()!!
)
}
else -> {
logger.d("observeNostrEvents: $synchronizationFilter")
database.nostrEventDao().observeNostrEvents(

View File

@@ -1,266 +0,0 @@
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
import ac.cord.auxiliary.compose.ui.theme.AuxTheme
import ac.cord.auxiliary.compose.ui.view.model.FeedListViewModel
import ac.cord.auxiliary.compose.ui.view.state.FeedListUIState
import androidx.compose.foundation.horizontalScroll
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
fun FeedScreen(
initialFeedListUIState: FeedListUIState,
onNavigateToEvent: (HexKey) -> Unit,
onNavigateToWriteNewNote: () -> Unit,
onNavigateToSearch: () -> Unit,
nostrRepository: NostrRepository,
) {
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
),
)
Scaffold(
modifier = Modifier,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary
),
title = {
Text(
"Aux"
)
},
navigationIcon = {
IconButton(
onClick = {
}
) {
Icon(
Icons.Default.Person,
contentDescription = "Account"
)
}
},
actions = {
IconButton(
onClick = {
onNavigateToSearch.invoke()
}
) {
Icon(
Icons.Default.Search,
contentDescription = "Search"
)
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNavigateToWriteNewNote,
icon = {
Icon(
Icons.Default.Create,
contentDescription = "Write"
)
},
text = {
Text("Write")
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
when(val feedListUIState = feedListViewModel.feedListUIState) {
is FeedListUIState.Loading -> {
LoadingDataIndicator()
}
FeedListUIState.Error -> {
Text("Something went wrong")
}
is FeedListUIState.Loaded -> {
LazyColumn(
modifier = Modifier.fillMaxWidth().fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(5.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
stickyHeader {
Surface() {
Row(
modifier = Modifier.fillMaxWidth().padding(10.dp)
.horizontalScroll(
rememberScrollState()
),
horizontalArrangement = Arrangement.spacedBy(10.dp)
){
FilterChip(
selected = true,
onClick = {},
label = {
Text("All")
}
)
FilterChip(
selected = false,
onClick = {},
label = {
Text("Kind")
Icon(
Icons.Default.ArrowDropDown,
contentDescription = "Author Filter"
)
}
)
FilterChip(
selected = false,
onClick = {},
label = {
Text("Author")
Icon(
Icons.Default.ArrowDropDown,
contentDescription = "Author Filter"
)
}
)
FilterChip(
selected = false,
onClick = {},
label = {
Text("Unread")
Icon(
Icons.Default.ArrowDropDown,
contentDescription = "Author Filter"
)
}
)
}
}
}
if (feedListViewModel.isSynchronizationPending.value) {
item {
Spacer(
modifier = Modifier.height(30.dp)
)
LoadingIndicator()
}
}
items(
items = feedListUIState.localNostrEvents,
key = { localNostrEvent -> localNostrEvent.nostrEvent.id }
) { localNostrEvent ->
FeedListItem(
localNostrEvent = localNostrEvent,
onNavigateToEvent = onNavigateToEvent
)
}
}
}
}
}
key(true) {
feedListViewModel.initiate()
}
}
}
@Preview
@Composable
private fun FeedScreenPreview() {
AuxTheme {
Surface(
modifier = Modifier.padding(20.dp)
) {
FeedScreen(
initialFeedListUIState = FeedListUIState.Loaded(emptyList()),
onNavigateToEvent = {},
onNavigateToWriteNewNote = {},
onNavigateToSearch = {},
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY
)
}
}
}

View File

@@ -0,0 +1,216 @@
package ac.cord.auxiliary.compose.ui.composable
import ac.cord.auxiliary.compose.repository.NostrRepository
import ac.cord.auxiliary.compose.ui.composable.navigation.routes.Route
import ac.cord.auxiliary.compose.ui.composable.widgets.LoadingDataIndicator
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.HomeScreenType
import ac.cord.auxiliary.compose.ui.view.model.HomeViewModel
import ac.cord.auxiliary.compose.ui.view.state.FeedListUIState
import ac.cord.auxiliary.compose.ui.view.state.HomeScreenUIState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun HomeScreen(
onNavigateToEvent: (Route) -> Unit,
onNavigateToWriteNewNote: () -> Unit,
onNavigateToSearch: () -> Unit,
nostrRepository: NostrRepository,
) {
val scope = rememberCoroutineScope()
val homeScreenViewModel: HomeViewModel = viewModel(
factory = HomeViewModel.factory(
nostrRepository = nostrRepository,
pagerState = rememberPagerState { HomeScreenType.entries.size }
),
)
Scaffold(
modifier = Modifier,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary
),
title = {
Text(
"Aux"
)
},
navigationIcon = {
IconButton(
onClick = {
}
) {
Icon(
Icons.Default.Person,
contentDescription = "Account"
)
}
},
actions = {
IconButton(
onClick = {
onNavigateToSearch.invoke()
}
) {
Icon(
Icons.Default.Search,
contentDescription = "Search"
)
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNavigateToWriteNewNote,
icon = {
Icon(
Icons.Default.Create,
contentDescription = "Write"
)
},
text = {
Text("Write")
}
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
when(val homeScreenUIState = homeScreenViewModel.homeScreenUIState) {
HomeScreenUIState.Error -> {
Text("Something went wrong")
}
is HomeScreenUIState.Loaded -> {
PrimaryScrollableTabRow(
modifier = Modifier.padding(10.dp),
edgePadding = 10.dp,
selectedTabIndex = homeScreenViewModel.pagerState.currentPage,
) {
HomeScreenType.entries.forEachIndexed { index, destination ->
Tab(
selected = homeScreenViewModel.pagerState.currentPage == index,
onClick = {
scope.launch {
homeScreenViewModel.pagerState.animateScrollToPage(destination.ordinal)
}
},
text = {
Text(
text = destination.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
Column(
modifier = Modifier.weight(1f)
) {
HorizontalPager(
state = homeScreenViewModel.pagerState,
) { pageIndex ->
when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) {
HomeScreenType.Following, HomeScreenType.Mentions -> {
val feedListViewModel: FeedListViewModel = viewModel(
key = homeScreenTypeType.name,
factory = FeedListViewModel.factory(
initialFeedListUIState = FeedListUIState.Loading,
synchronizationFilter = homeScreenViewModel.getSynchronizationFilter(
homeScreenTypeType,
profileWithFollowing = homeScreenUIState.profileWithFollowing
),
nostrRepository = nostrRepository
),
)
key(feedListViewModel.feedListUIState) {
feedListViewModel.RenderFeed(
onNavigateToEvent = onNavigateToEvent
)
}
key(true) {
feedListViewModel.initiate()
}
}
HomeScreenType.Messages -> {
Text("Messaging coming soon.")
}
}
}
}
}
HomeScreenUIState.Loading -> {
LoadingDataIndicator()
}
}
}
key(true) {
homeScreenViewModel.initiate()
}
}
}
@Preview
@Composable
private fun HomeScreenPreview() {
AuxTheme {
Surface(
modifier = Modifier.padding(20.dp)
) {
HomeScreen(
onNavigateToEvent = {},
onNavigateToWriteNewNote = {},
onNavigateToSearch = {},
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY
)
}
}
}

View File

@@ -5,7 +5,7 @@ import ac.cord.auxiliary.compose.database.repository.DatabaseNostrRepository
import ac.cord.auxiliary.compose.database.repository.DatabaseSearchRepository
import ac.cord.auxiliary.compose.managers.DatabaseManager
import ac.cord.auxiliary.compose.ui.composable.CreateProfileScreen
import ac.cord.auxiliary.compose.ui.composable.FeedScreen
import ac.cord.auxiliary.compose.ui.composable.HomeScreen
import ac.cord.auxiliary.compose.ui.composable.ImplementationPendingScreen
import ac.cord.auxiliary.compose.ui.composable.LandingScreen
import ac.cord.auxiliary.compose.ui.composable.LoadingScreen
@@ -302,13 +302,10 @@ fun AuxNavHost(
)
}
composable<FeedRoute> {
FeedScreen(
initialFeedListUIState = FeedListUIState.Loading,
onNavigateToEvent = { nostrEventId ->
HomeScreen(
onNavigateToEvent = { eventRoute ->
navController.navigate(
route = NostrEventDetailRoute(
nostrEventId = nostrEventId
)
route = eventRoute
)
},
onNavigateToWriteNewNote = {

View File

@@ -208,5 +208,6 @@ class FeedListViewModel(
)
}
}
}
}

View File

@@ -0,0 +1,137 @@
package ac.cord.auxiliary.compose.ui.view.model
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
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.view.state.HomeScreenUIState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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 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.nip23LongContent.LongTextNoteEvent
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
enum class HomeScreenType {
Following, Mentions, Messages
}
class HomeViewModel(
val initialHomeScreenUIState: HomeScreenUIState = HomeScreenUIState.Loading,
val nostrRepository: NostrRepository,
val pagerState: PagerState
): ViewModel() {
val logger = Logger.withTag(TAG)
var homeScreenUIState: HomeScreenUIState by mutableStateOf(initialHomeScreenUIState)
private set
fun initiate() {
observeActiveUserProfileWithFollowing()
}
fun observeActiveUserProfileWithFollowing() {
logger.d("observeActiveUserProfileWithFollowing")
viewModelScope.launch(Dispatchers.IO) {
val activePublicKey = SeedManager.activePublicKey().toHexKey()
nostrRepository.observeProfileWithFollowing(
activePublicKey
).collect { profileWithFollowing ->
homeScreenUIState = if (profileWithFollowing != null) {
HomeScreenUIState.Loaded(
profileWithFollowing = profileWithFollowing
)
} else {
HomeScreenUIState.Error
}
}
}
}
fun getSynchronizationFilter(
homeScreenType: HomeScreenType,
profileWithFollowing: LocalProfileWithFollowing,
): SynchronizationFilter {
val publicKey = SeedManager.activePublicKey().toHexKey()
return when (homeScreenType) {
HomeScreenType.Following -> {
SynchronizationFilter(
authors = profileWithFollowing.following.map { it.publicKey }.toTypedArray(),
kinds = arrayOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
),
limit = 50
)
}
HomeScreenType.Mentions -> {
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(publicKey))
),
limit = 50
)
}
HomeScreenType.Messages -> {
SynchronizationFilter(
authors = arrayOf(
publicKey
),
kinds = arrayOf(
LongTextNoteEvent.KIND,
),
limit = 50
)
}
}
}
companion object {
const val TAG = "HomeViewModel"
fun factory(
nostrRepository: NostrRepository,
pagerState: PagerState
): ViewModelProvider.Factory = viewModelFactory {
initializer {
HomeViewModel(
nostrRepository = nostrRepository,
pagerState = pagerState,
)
}
}
}
}

View File

@@ -0,0 +1,15 @@
package ac.cord.auxiliary.compose.ui.view.state
import ac.cord.auxiliary.compose.database.model.Connection
import ac.cord.auxiliary.compose.database.model.intermdiate.LocalProfileWithFollowing
sealed interface HomeScreenUIState {
data class Loaded(
val profileWithFollowing: LocalProfileWithFollowing
): HomeScreenUIState
data object Error: HomeScreenUIState
data object Loading: HomeScreenUIState
}