Show replies to notes.

This commit is contained in:
Kgothatso Ngako
2026-05-03 21:41:50 +02:00
parent 479e5bd67c
commit 4c5e1d5f1b
4 changed files with 296 additions and 20 deletions

View File

@@ -14,6 +14,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import fr.acinq.bitcoin.PublicKey
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
@Dao
interface NostrEventDao {
@@ -49,6 +50,14 @@ interface NostrEventDao {
publicKey: HexKey
): Flow<List<LocalNostrEvent>>
@Transaction
@Query("SELECT * FROM NostrEvent WHERE tags LIKE '%' || :eventId || '%reply%' AND kind in (:kinds) AND createdAt > :since ORDER BY createdAt DESC")
fun observeNostrEventReplies(
kinds: Array<Kind>,
eventId: HexKey,
since: Instant
): Flow<List<LocalNostrEvent>>
@Transaction
@RawQuery
fun observePublicKeyFollowingNostrEvents(

View File

@@ -491,12 +491,18 @@ class DatabaseNostrRepository(
authors = synchronizationFilter.authors
)
}
synchronizationFilter.kinds != null && synchronizationFilter.since != null && synchronizationFilter.tags?.contains("e") == true && synchronizationFilter.tags["e"]?.first()?.isNotEmpty() == true -> {
logger.d("observePublicKeyMentionedNostrEvents: $synchronizationFilter")
database.nostrEventDao().observeNostrEventReplies(
kinds = synchronizationFilter.kinds,
eventId = synchronizationFilter.tags["e"]?.first()!!,
since = synchronizationFilter.since
)
}
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,
),
kinds = synchronizationFilter.kinds,
publicKey = synchronizationFilter.tags["p"]?.first()!!
)
}

View File

@@ -11,6 +11,7 @@ import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileAvatar
import ac.cord.auxiliary.compose.ui.composable.widgets.profile.ProfileColor
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.InReplyToViewModel
import ac.cord.auxiliary.compose.ui.view.state.FeedListUIState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddReaction
@@ -33,26 +35,35 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.BottomAppBarScrollBehavior
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FlexibleBottomAppBar
import androidx.compose.material3.HorizontalDivider
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.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
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.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -63,20 +74,20 @@ fun TextNoteEventDetail(
onNavigateToWriteAReply: (String) -> Unit,
onNavigateToNostrEvent: (HexKey) -> Unit,
) {
val inReplyToNostrEventFeedListViewModel: FeedListViewModel = viewModel(
key = "replies",
factory = FeedListViewModel.factory(
val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior()
val inReplyToNostrEventFeedListViewModel: InReplyToViewModel = viewModel(
key = "replies-${localNostrEvent.nostrEvent.id}",
factory = InReplyToViewModel.factory(
nostrEventId = localNostrEvent.nostrEvent.id,
initialFeedListUIState = FeedListUIState.Loading,
synchronizationFilter = SynchronizationFilter(
ids = arrayOf(localNostrEvent.nostrEvent.id)
// TODO: Marked Root In Reply...
),
nostrRepository = nostrRepository
nostrRepository = nostrRepository,
createdAt = localNostrEvent.nostrEvent.createdAt
),
)
Scaffold(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
navigationIcon = {
@@ -100,7 +111,8 @@ fun TextNoteEventDetail(
contentPadding = PaddingValues(
start = 10.dp,
end = 10.dp
)
),
scrollBehavior = scrollBehavior
) {
Surface(
@@ -189,7 +201,8 @@ fun TextNoteEventDetail(
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// TODO: get all the notes inReplyToRoot before this note
item {
@@ -338,15 +351,41 @@ fun TextNoteEventDetail(
}
}
item {
inReplyToNostrEventFeedListViewModel.RenderFeed(
onNavigateToEvent = {
// TODO: NavigateToEvent...
when (val inReplyToFeedListUIState = inReplyToNostrEventFeedListViewModel.inReplyToFeedListUIState) {
is FeedListUIState.Loaded -> {
if (inReplyToFeedListUIState.localNostrEvents.isEmpty()) {
item {
Text(
"Be the first to comment."
)
}
} else {
items(
items = inReplyToFeedListUIState.localNostrEvents,
key = { e -> "reply-${e.nostrEvent.id}" }
) { localNostrEvent ->
localNostrEvent.RenderNotePreview()
}
}
)
}
FeedListUIState.Error -> {
item {
Text(
text = "Something went wrong"
)
}
}
FeedListUIState.Loading -> {
item {
LoadingIndicator()
}
}
}
}
LaunchedEffect(true) {
inReplyToNostrEventFeedListViewModel.initiate()
}
}
}
}

View File

@@ -0,0 +1,222 @@
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.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
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlin.time.Instant
class InReplyToViewModel(
nostrEventId: String,
initialFeedListUIState: FeedListUIState,
val synchronizationFilter: SynchronizationFilter,
val nostrRepository: NostrRepository
): ViewModel() {
val logger = Logger.withTag(TAG)
var inReplyToFeedListUIState: FeedListUIState by mutableStateOf(initialFeedListUIState)
private set
val isSynchronizationPending: MutableState<Boolean> = mutableStateOf(false)
fun initiate() {
logger.d("init")
scheduleSynchronization()
observeLocalNostrFeed()
observeSynchronizeNostrEventRequestByPurposeAndStatusCount()
}
fun observeLocalNostrFeed() {
logger.d("observeLocalNostrFeed")
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.observeNostrFeed(
synchronizationFilter
).distinctUntilChanged().collect { localNostrEvents ->
inReplyToFeedListUIState = FeedListUIState.Loaded(
localNostrEvents = localNostrEvents
)
}
}
}
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,
level = 0,
)
}
)
}
}
fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount() {
logger.d("observeSynchronizeNostrEventRequestByPurposeAndStatusCount")
viewModelScope.launch(Dispatchers.IO) {
nostrRepository.observeSynchronizeNostrEventRequestByPurposeAndStatusCount("feed").distinctUntilChanged().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 = inReplyToFeedListUIState) {
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 = "InReplyToViewModel"
fun factory(
nostrEventId: String,
createdAt: Instant,
initialFeedListUIState: FeedListUIState = FeedListUIState.Loading,
synchronizationFilter: SynchronizationFilter = SynchronizationFilter(
kinds = arrayOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
),
tags = mapOf(
"e" to listOf(
nostrEventId
)
),
since = createdAt
),
nostrRepository: NostrRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
InReplyToViewModel(
initialFeedListUIState = initialFeedListUIState,
nostrEventId = nostrEventId,
synchronizationFilter = synchronizationFilter,
nostrRepository = nostrRepository
)
}
}
}
}