diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrEventDao.kt index f634157f..fd3c337f 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/dao/NostrEventDao.kt @@ -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> + @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, + eventId: HexKey, + since: Instant + ): Flow> + @Transaction @RawQuery fun observePublicKeyFollowingNostrEvents( diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt index 39cf8b6f..290ffa1d 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/database/repository/DatabaseNostrRepository.kt @@ -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()!! ) } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt index b538c4cc..0a8d327e 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt @@ -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() + } } } } diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/InReplyToViewModel.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/InReplyToViewModel.kt new file mode 100755 index 00000000..faa34e03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/view/model/InReplyToViewModel.kt @@ -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 = 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 + ) + } + } + + } +} \ No newline at end of file