Add profile details

This commit is contained in:
Kgothatso Ngako
2026-04-20 17:17:43 +02:00
parent 703d7329db
commit 302d6c620a
5 changed files with 463 additions and 110 deletions

View File

@@ -4,6 +4,7 @@ import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.intermdiate.LocalNostrEvent
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.composable.navigation.routes.Route
import ac.aux.compose.ui.composable.widgets.LoadingDataIndicator
import ac.aux.compose.ui.composable.widgets.detail.MetadataEventDetail
import ac.aux.compose.ui.composable.widgets.detail.TextNoteEventDetail
@@ -37,6 +38,7 @@ fun NostrEventDetailScreen(
nostrEventId: HexKey,
nostrRepository: NostrRepository,
onNavigateBack: () -> Unit,
onNavigateToEvent: (Route) -> Unit,
onNavigateToWriteAReply: (String) -> Unit
) {
val nostrEventDetailViewModel: NostrEventDetailViewModel = viewModel(
@@ -64,6 +66,8 @@ fun NostrEventDetailScreen(
MetadataEventDetail(
localNostrEvent = feedListUIState.localNostrEvent,
onNavigateBack = onNavigateBack,
onNavigateToEvent = onNavigateToEvent,
nostrRepository = nostrRepository
)
}
TextNoteEvent.KIND -> {
@@ -155,6 +159,7 @@ It has survived not only five centuries, but also the leap into electronic types
nostrEventId = "",
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
onNavigateBack = {},
onNavigateToEvent = {},
onNavigateToWriteAReply = {}
)
}

View File

@@ -206,37 +206,72 @@ fun SearchResultScreen(
Text("Posts")
}
SearchResultType.Articles -> {
Text("Posts")
Text("Articles")
}
SearchResultType.Profiles -> {
Text("Posts")
Text("Profiles")
}
SearchResultType.Photos -> {
Text("Posts")
Text("Photos")
}
SearchResultType.Videos -> {
Text("Posts")
Text("Videos")
}
}
when (val searchResultListUIState = searchViewModel.searchResultListUIState) {
SearchResultListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = "Something went wrong",
)
SearchResultListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = "Something went wrong",
)
}
}
}
is SearchResultListUIState.Loaded -> {
if (searchResultListUIState.localNostrEvents.isEmpty()) {
is SearchResultListUIState.Loaded -> {
if (searchResultListUIState.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 = searchResultListUIState.localNostrEvents,
key = { localNostrEvent -> localNostrEvent.nostrEvent.id}
) { localNostrEvent ->
localNostrEvent.ListView(
onNavigateToEvent = { nostrEventId ->
onNavigateToEvent.invoke(
NostrEventDetailRoute(
nostrEventId = nostrEventId
)
)
}
)
}
}
}
}
SearchResultListUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
@@ -245,66 +280,19 @@ fun SearchResultScreen(
modifier = Modifier.height(50.dp)
)
Text(
text = "No events were found",
text = "Searching for ${searchViewModel.searchResultType.name.lowercase()} on \"${searchQuery.lowercase()}\"",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
)
LoadingIndicator()
}
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(
items = searchResultListUIState.localNostrEvents,
key = { localNostrEvent -> localNostrEvent.nostrEvent.id}
) { localNostrEvent ->
localNostrEvent.ListView(
onNavigateToEvent = { nostrEventId ->
onNavigateToEvent.invoke(
NostrEventDetailRoute(
nostrEventId = nostrEventId
)
)
}
)
}
}
}
}
SearchResultListUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = "Searching for ${searchViewModel.searchResultType.name.lowercase()} on \"${searchQuery.lowercase()}\"",
textAlign = TextAlign.Center
)
Spacer(
modifier = Modifier.height(50.dp)
)
LoadingIndicator()
}
}
}
}
}
}
}
}

View File

@@ -376,6 +376,11 @@ fun AuxNavHost(
onNavigateBack = {
navController.popBackStack()
},
onNavigateToEvent = { route ->
navController.navigate(
route = route
)
},
onNavigateToWriteAReply = { nostrEventId ->
navController.navigate(
route = WriteNewNoteRoute(

View File

@@ -3,49 +3,53 @@ package ac.aux.compose.ui.composable.widgets.detail
import ac.aux.compose.database.model.NostrEvent
import ac.aux.compose.database.model.Profile
import ac.aux.compose.database.model.intermdiate.LocalNostrEvent
import ac.aux.compose.extensions.toFormattedTimeAndDateString
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import ac.aux.compose.ui.composable.navigation.routes.Route
import ac.aux.compose.ui.composable.widgets.feed.ListView
import ac.aux.compose.ui.theme.AuxTheme
import ac.aux.compose.ui.view.model.MetadataEventDetailType
import ac.aux.compose.ui.view.model.MetadataEventDetailViewModel
import ac.aux.compose.ui.view.model.SearchResultType
import ac.aux.compose.ui.view.state.FeedListUIState
import ac.aux.compose.ui.view.state.SearchResultListUIState
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
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
import androidx.compose.material.icons.filled.AddReaction
import androidx.compose.material.icons.filled.ArrowBackIosNew
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
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.PrimaryTabRow
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.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -55,21 +59,32 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.DialogNavigator
import androidx.navigation.compose.rememberNavController
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun MetadataEventDetail(
localNostrEvent: LocalNostrEvent,
onNavigateBack: () -> Unit,
onNavigateToEvent: (Route) -> Unit,
nostrRepository: NostrRepository
) {
val scope = rememberCoroutineScope()
val profile = localNostrEvent.profile
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val metadataEventDetailViewModel: MetadataEventDetailViewModel = viewModel(
factory = MetadataEventDetailViewModel.factory(
initialMetadataEventDetailType = MetadataEventDetailType.Posts,
publicKey = localNostrEvent.nostrEvent.pubKey,
nostrRepository = nostrRepository,
pagerState = rememberPagerState { SearchResultType.entries.size }
),
)
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -173,27 +188,25 @@ fun MetadataEventDetail(
}
item {
val destinations = arrayOf("Notes", "Replies", "Articles", "Follows", "Following", "Photos", "Videos")
val startDestination = 0
var selectedDestination by rememberSaveable {
mutableIntStateOf(startDestination)
}
PrimaryScrollableTabRow(
modifier = Modifier.padding(10.dp),
edgePadding = 10.dp,
selectedTabIndex = selectedDestination,
selectedTabIndex = metadataEventDetailViewModel.pagerState.currentPage,
) {
destinations.forEachIndexed { index, destination ->
MetadataEventDetailType.entries.forEachIndexed { index, destination ->
Tab(
selected = selectedDestination == index,
selected = metadataEventDetailViewModel.pagerState.currentPage == index,
onClick = {
selectedDestination = index
metadataEventDetailViewModel.updateMetadataEventDetailType(destination) {
scope.launch {
metadataEventDetailViewModel.pagerState.animateScrollToPage(destination.ordinal)
}
}
},
text = {
Text(
text = destination,
text = destination.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -203,15 +216,99 @@ fun MetadataEventDetail(
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
modifier = Modifier.fillParentMaxSize()
) {
Spacer(
modifier = Modifier.height(30.dp)
)
Text("Profile Feed Nav Host Goes here...")
HorizontalPager(
state = metadataEventDetailViewModel.pagerState,
) { pageIndex ->
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
)
)
}
)
}
}
}
}
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)
)
LoadingIndicator()
}
}
}
}
}
}
LaunchedEffect(true) {
metadataEventDetailViewModel.updateMetadataEventDetailType(
MetadataEventDetailType.Posts
) {
}
}
}
}
}
@@ -245,6 +342,8 @@ private fun MetadataEventEventDetailPreview() {
)
),
onNavigateBack = {},
onNavigateToEvent = {},
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY
)
}
}

View File

@@ -0,0 +1,256 @@
package ac.aux.compose.ui.view.model
import ac.aux.compose.database.model.SynchronizeNostrEventRequest
import ac.aux.compose.database.model.types.SynchronizationFilter
import ac.aux.compose.nostr.Relays
import ac.aux.compose.repository.NostrRepository
import ac.aux.compose.ui.view.state.FeedListUIState
import ac.aux.compose.ui.view.state.SearchResultListUIState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
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.HexKey
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
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.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
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 {
Posts, Replies, Articles, Followers, Following, Zaps, Photos, Videos, Bookmarks, Reports, Relays
}
class MetadataEventDetailViewModel(
initialMetadataEventDetailType: MetadataEventDetailType,
val publicKey: HexKey,
val nostrRepository: NostrRepository,
val pagerState: PagerState
): 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
)
}
}
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.map { normalizedRelay ->
SynchronizeNostrEventRequest(
purpose = "search",
synchronizationFilters = arrayOf(
synchronizationFilter
),
relayURL = normalizedRelay.url
)
}
)
}
}
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"
fun factory(
initialMetadataEventDetailType: MetadataEventDetailType = MetadataEventDetailType.Posts,
publicKey: HexKey,
nostrRepository: NostrRepository,
pagerState: PagerState
): ViewModelProvider.Factory = viewModelFactory {
initializer {
MetadataEventDetailViewModel(
initialMetadataEventDetailType = initialMetadataEventDetailType,
nostrRepository = nostrRepository,
pagerState = pagerState,
publicKey = publicKey
)
}
}
}
}