From 45a7a18ea78e2dbcf3c66397bdf2527eec5f8263 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 12 Jan 2023 12:47:31 -0500 Subject: [PATCH] Thread View --- .../com/vitorpamplona/amethyst/model/Note.kt | 30 +++ .../service/NostrSingleEventDataSource.kt | 18 +- .../amethyst/service/NostrThreadDataSource.kt | 82 +++++++ .../vitorpamplona/amethyst/ui/MainActivity.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 3 +- .../amethyst/ui/navigation/Routes.kt | 36 ++- .../amethyst/ui/note/BlankNote.kt | 2 +- .../amethyst/ui/note/BoostSetCompose.kt | 5 +- .../amethyst/ui/note/LikeSetCompose.kt | 5 +- .../vitorpamplona/amethyst/ui/note/Note.kt | 19 +- .../ui/screen/AccountStateViewModel.kt | 2 + .../amethyst/ui/screen/CardFeedView.kt | 9 +- .../amethyst/ui/screen/FeedView.kt | 5 +- .../amethyst/ui/screen/FeedViewModel.kt | 1 - .../amethyst/ui/screen/ThreadFeedView.kt | 227 ++++++++++++++++++ .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 5 +- .../ui/screen/loggedIn/NotificationScreen.kt | 5 +- .../ui/screen/loggedIn/SearchScreen.kt | 5 +- .../ui/screen/loggedIn/ThreadScreen.kt | 33 +++ 19 files changed, 455 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 0ac85eeff..74fdc435c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.ui.note.toDisplayHex import fr.acinq.secp256k1.Hex +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Collections import nostr.postr.events.Event @@ -29,6 +32,33 @@ class Note(val idHex: String) { refreshObservers() } + fun formattedDateTime(timestamp: Long): String { + return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss")) + } + + fun replyLevelSignature(): String { + val replyTo = replyTo + if (replyTo == null || replyTo.isEmpty()) { + return "/" + formattedDateTime(event?.createdAt ?: 0) + ";" + } + + return replyTo + .map { it.replyLevelSignature() } + .maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";" + } + + fun replyLevel(): Int { + val replyTo = replyTo + if (replyTo == null || replyTo.isEmpty()) { + return 0 + } + + return replyTo.maxOf { + it.replyLevel() + } + 1 + } + fun addReply(note: Note) { if (replies.add(note)) refreshObservers() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 5faeeef53..d3811a1db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -21,18 +21,26 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { } fun createLoadEventsIfNotLoadedFilter(): JsonFilter? { - val eventsToLoad = eventsToWatch - .map { LocalCache.notes[it] } - .filterNotNull() + val directEventsToLoad = eventsToWatch + .mapNotNull { LocalCache.notes[it] } .filter { it.event == null } + + val threadingEventsToLoad = eventsToWatch + .mapNotNull { LocalCache.notes[it] } + .mapNotNull { it.replyTo } + .flatten() + .filter { it.event == null } + + val interestedEvents = + (directEventsToLoad + threadingEventsToLoad) .map { it.idHex.substring(0, 8) } - if (eventsToLoad.isEmpty()) { + if (interestedEvents.isEmpty()) { return null } return JsonFilter( - ids = eventsToLoad + ids = interestedEvents ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt new file mode 100644 index 000000000..3172d2d1d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -0,0 +1,82 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import java.util.Collections +import nostr.postr.JsonFilter + +object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") { + val eventsToWatch = Collections.synchronizedList(mutableListOf()) + + fun createRepliesAndReactionsFilter(): JsonFilter? { + val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) } + + if (reactionsToWatch.isEmpty()) { + return null + } + + return JsonFilter( + tags = mapOf("e" to reactionsToWatch) + ) + } + + fun createLoadEventsIfNotLoadedFilter(): JsonFilter? { + val eventsToLoad = eventsToWatch + .map { LocalCache.notes[it] } + .filterNotNull() + .filter { it.event == null } + .map { it.idHex.substring(0, 8) } + + if (eventsToLoad.isEmpty()) { + return null + } + + return JsonFilter( + ids = eventsToLoad + ) + } + + val repliesAndReactionsChannel = requestNewChannel() + val loadEventsChannel = requestNewChannel() + + override fun feed(): List { + return eventsToWatch.map { + LocalCache.notes[it] + }.filterNotNull() + } + + override fun updateChannelFilters() { + repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter() + loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter() + } + + fun loadThread(noteId: String) { + val note = LocalCache.notes[noteId] ?: return + + val thread = mutableListOf() + val threadSet = mutableSetOf() + + val threadRoot = note.replyTo?.firstOrNull() ?: note + + loadDown(threadRoot, thread, threadSet) + + // Currently orders by date of each event, descending, at each level of the reply stack + val order = compareByDescending { it.replyLevelSignature() } + + eventsToWatch.clear() + eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex }) + + resetFilters() + } + + fun loadDown(note: Note, thread: MutableList, threadSet: MutableSet) { + if (note !in threadSet) { + thread.add(note) + threadSet.add(note) + + note.replies.forEach { + loadDown(it, thread, threadSet) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index c873ec925..cd2f697c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -15,6 +15,7 @@ import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrNotificationDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource +import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel @@ -54,6 +55,7 @@ class MainActivity : ComponentActivity() { NostrNotificationDataSource.stop() NostrSingleEventDataSource.stop() NostrSingleUserDataSource.stop() + NostrThreadDataSource.stop() Client.disconnect() super.onPause() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 1728df7e6..c2568df22 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -13,7 +14,7 @@ fun AppNavigation( ) { NavHost(navController, startDestination = Route.Home.route) { Routes.forEach { - composable(it.route, content = it.buildScreen(accountViewModel)) + composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, navController)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index a347d2efb..23d7ff735 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -2,12 +2,17 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.navArgument import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.HomeScreen import com.vitorpamplona.amethyst.ui.screen.MessageScreen +import com.vitorpamplona.amethyst.ui.screen.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.SearchScreen @@ -17,17 +22,23 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel sealed class Route( val route: String, val icon: Int, - val buildScreen: (AccountViewModel) -> @Composable (NavBackStackEntry) -> Unit + val arguments: List = emptyList(), + val buildScreen: (AccountViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit ) { - object Home : Route("Home", R.drawable.ic_home, { acc -> { _ -> HomeScreen(acc) } }) - object Search : Route("Search", R.drawable.ic_search, { acc -> { _ -> SearchScreen(acc) }}) - object Notification : Route("Notification", R.drawable.ic_notifications, { acc -> { _ -> NotificationScreen(acc) }}) - object Message : Route("Message", R.drawable.ic_dm, { acc -> { _ -> MessageScreen(acc) }}) - object Profile : Route("Profile", R.drawable.ic_profile, { acc -> { _ -> ProfileScreen(acc) }}) - object Lists : Route("Lists", R.drawable.ic_lists, { acc -> { _ -> ProfileScreen(acc) }}) - object Topics : Route("Topics", R.drawable.ic_topics, { acc -> { _ -> ProfileScreen(acc) }}) - object Bookmarks : Route("Bookmarks", R.drawable.ic_bookmarks, { acc -> { _ -> ProfileScreen(acc) }}) - object Moments : Route("Moments", R.drawable.ic_moments, { acc -> { _ -> ProfileScreen(acc) }}) + object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, nav -> { _ -> HomeScreen(acc, nav) } }) + object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, nav -> { _ -> SearchScreen(acc, nav) }}) + object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, nav -> { _ -> NotificationScreen(acc, nav) }}) + object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, nav -> { _ -> MessageScreen(acc) }}) + object Profile : Route("Profile", R.drawable.ic_profile, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }}) + object Lists : Route("Lists", R.drawable.ic_lists, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }}) + object Topics : Route("Topics", R.drawable.ic_topics, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }}) + object Bookmarks : Route("Bookmarks", R.drawable.ic_bookmarks, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }}) + object Moments : Route("Moments", R.drawable.ic_moments, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }}) + + object Note : Route("Note/{id}", R.drawable.ic_moments, + arguments = listOf(navArgument("id") { type = NavType.StringType } ), + buildScreen = { acc, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) }} + ) } val Routes = listOf( @@ -42,7 +53,10 @@ val Routes = listOf( Route.Lists, Route.Topics, Route.Bookmarks, - Route.Moments + Route.Moments, + + //inner + Route.Note ) @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 053ed1499..f5bb3678f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable -fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean) { +fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { Column(modifier = modifier) { Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt index dea521c0c..cc56f2706 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R @@ -39,7 +40,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import nostr.postr.events.TextNoteEvent @Composable -fun BoostSetCompose(likeSetCard: BoostSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) { +fun BoostSetCompose(likeSetCard: BoostSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) { val noteState by likeSetCard.note.live.observeAsState() val note = noteState?.note @@ -84,7 +85,7 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, modifier: Modifier = Modifier, is } } - NoteCompose(note, modifier = Modifier.padding(top = 5.dp), isInnerNote = true, accountViewModel = accountViewModel) + NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt index 870821df5..b54cca4b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R @@ -38,7 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import nostr.postr.events.TextNoteEvent @Composable -fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) { +fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) { val noteState by likeSetCard.note.live.observeAsState() val note = noteState?.note @@ -83,7 +84,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn } } - NoteCompose(note, modifier = Modifier.padding(top = 5.dp), isInnerNote = true, accountViewModel = accountViewModel) + NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt index 301b157a9..b629984e8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt @@ -4,6 +4,7 @@ import android.text.format.DateUtils import android.text.format.DateUtils.getRelativeTimeSpanString import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -21,6 +22,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ReactionEvent @@ -30,7 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import nostr.postr.events.TextNoteEvent @Composable -fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel) { +fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) { val noteState by baseNote.live.observeAsState() val note = noteState?.note @@ -41,7 +44,14 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool val author = authorState?.user Column(modifier = modifier) { - Row(modifier = Modifier.padding(horizontal = if (!isInnerNote) 12.dp else 0.dp)) { + Row( + modifier = Modifier + .padding( + start = if (!isInnerNote) 12.dp else 0.dp, + end = if (!isInnerNote) 12.dp else 0.dp, + top = 10.dp) + .clickable ( onClick = { navController.navigate("Note/${note.idHex}") } ) + ) { // Draws the boosted picture outside the boosted card. if (!isInnerNote) { @@ -100,7 +110,8 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool note, modifier = Modifier.padding(top = 5.dp), isInnerNote = true, - accountViewModel = accountViewModel + accountViewModel = accountViewModel, + navController = navController ) } @@ -121,7 +132,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool ReactionsRowState(note, accountViewModel) Divider( - modifier = Modifier.padding(vertical = 10.dp), + modifier = Modifier.padding(top = 10.dp), thickness = 0.25.dp ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index e09385e02..f7ed8a302 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrNotificationDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource +import com.vitorpamplona.amethyst.service.NostrThreadDataSource import fr.acinq.secp256k1.Hex import java.util.regex.Pattern import kotlinx.coroutines.flow.MutableStateFlow @@ -65,6 +66,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre NostrNotificationDataSource.start() NostrSingleEventDataSource.start() NostrSingleUserDataSource.start() + NostrThreadDataSource.start() } fun newKey() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index b4d2eaa1b..c84070817 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.ui.note.BoostSetCompose @@ -32,7 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalLifecycleComposeApi::class) @Composable -fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel) { +fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() var isRefreshing by remember { mutableStateOf(false) } @@ -76,9 +77,9 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode ) { itemsIndexed(state.feed) { index, item -> when (item) { - is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel) - is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel) - is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel) + is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel, navController = navController) + is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController) + is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 87fdf99b7..e00c17d6b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -30,7 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalLifecycleComposeApi::class) @Composable -fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel) { +fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() var isRefreshing by remember { mutableStateOf(false) } @@ -73,7 +74,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel) { state = listState ) { itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item -> - NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel) + NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index db3cb174d..c072818e1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCacheState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.NostrDataSource -import com.vitorpamplona.amethyst.service.model.ReactionEvent import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt new file mode 100644 index 000000000..4d00dd8dd --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -0,0 +1,227 @@ +package com.vitorpamplona.amethyst.ui.screen + +import android.content.res.Resources.Theme +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.RichTextViewer +import com.vitorpamplona.amethyst.ui.note.BlankNote +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.ReactionsRowState +import com.vitorpamplona.amethyst.ui.note.UserDisplay +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLifecycleComposeApi::class) +@Composable +fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + + var isRefreshing by remember { mutableStateOf(false) } + val swipeRefreshState = rememberSwipeRefreshState(isRefreshing) + + val listState = rememberLazyListState() + + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + viewModel.refresh() + isRefreshing = false + } + } + + SwipeRefresh( + state = swipeRefreshState, + onRefresh = { + isRefreshing = true + }, + ) { + Column() { + Crossfade(targetState = feedState) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { + isRefreshing = true + } + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { + isRefreshing = true + } + } + is FeedState.Loaded -> { + var noteIdPositionInThread by remember { mutableStateOf(0) } + // only in the first transition + LaunchedEffect(noteIdPositionInThread) { + listState.animateScrollToItem(noteIdPositionInThread, 0) + } + + val notePosition = state.feed.filter { it.idHex == noteId}.firstOrNull() + if (notePosition != null) { + noteIdPositionInThread = state.feed.indexOf(notePosition) + } + + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ), + state = listState + ) { + itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item -> + if (index == 0) + NoteMaster(item, accountViewModel = accountViewModel, navController = navController) + else { + Column() { + Row() { + NoteCompose( + item, + Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)), + isInnerNote = false, + accountViewModel = accountViewModel, + navController = navController, + ) + } + } + } + } + } + } + FeedState.Loading -> { + LoadingFeed() + } + } + } + } + } +} + +// Creates a Zebra pattern where each bar is a reply level. +fun Modifier.drawReplyLevel(level: Int, color: Color): Modifier = this + .drawBehind { + val paddingDp = 2 + val strokeWidthDp = 2 + val levelWidthDp = strokeWidthDp + 1 + + val padding = paddingDp.dp.toPx() + val strokeWidth = strokeWidthDp.dp.toPx() + val levelWidth = levelWidthDp.dp.toPx() + + repeat(level) { + this.drawLine( + color, + Offset(padding + it * levelWidth, 0f), + Offset(padding + it * levelWidth, size.height), + strokeWidth = strokeWidth + ) + } + + return@drawBehind + } + .padding(start = (2 + (level * 3)).dp) + +@Composable +fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { + val noteState by baseNote.live.observeAsState() + val note = noteState?.note + + if (note?.event == null) { + BlankNote() + } else { + val authorState by note.author!!.live.observeAsState() + val author = authorState?.user + + Column( + Modifier + .fillMaxWidth() + .padding(top = 10.dp)) { + Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp)) { + // Draws the boosted picture outside the boosted card. + AsyncImage( + model = author?.profilePicture(), + contentDescription = "Profile Image", + modifier = Modifier + .width(55.dp) + .clip(shape = CircleShape) + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (author != null) + UserDisplay(author) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + timeAgo(note.event?.createdAt), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } + + Row(modifier = Modifier.padding(horizontal = 12.dp)) { + Column() { + val eventContent = note.event?.content + if (eventContent != null) + RichTextViewer(eventContent, note.event?.tags) + + ReactionsRowState(note, accountViewModel) + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 6f97b1a0e..1eeb741d2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -10,12 +10,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalLifecycleComposeApi::class) @Composable -fun HomeScreen(accountViewModel: AccountViewModel) { +fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) { val account by accountViewModel.accountLiveData.observeAsState() if (account != null) { @@ -25,7 +26,7 @@ fun HomeScreen(accountViewModel: AccountViewModel) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - FeedView(feedViewModel, accountViewModel) + FeedView(feedViewModel, accountViewModel, navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 61cd681f6..795b9a932 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -10,12 +10,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import com.vitorpamplona.amethyst.service.NostrNotificationDataSource import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalLifecycleComposeApi::class) @Composable -fun NotificationScreen(accountViewModel: AccountViewModel) { +fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavController) { val account by accountViewModel.accountLiveData.observeAsState() if (account != null) { @@ -26,7 +27,7 @@ fun NotificationScreen(accountViewModel: AccountViewModel) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - CardFeedView(feedViewModel, accountViewModel = accountViewModel) + CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index c9cbc7ae4..2f84a2d43 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -8,19 +8,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import com.vitorpamplona.amethyst.service.NostrGlobalDataSource import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @OptIn(ExperimentalLifecycleComposeApi::class) @Composable -fun SearchScreen(accountViewModel: AccountViewModel) { +fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) { val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrGlobalDataSource ) } Column(Modifier.fillMaxHeight()) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - FeedView(feedViewModel, accountViewModel) + FeedView(feedViewModel, accountViewModel, navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt new file mode 100644 index 000000000..bcc8acab5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt @@ -0,0 +1,33 @@ +package com.vitorpamplona.amethyst.ui.screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.service.NostrThreadDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navController: NavController) { + val account by accountViewModel.accountLiveData.observeAsState() + + if (account != null && noteId != null) { + NostrThreadDataSource.loadThread(noteId) + + val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrThreadDataSource ) } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = Modifier.padding(vertical = 0.dp) + ) { + ThreadFeedView(noteId, feedViewModel, accountViewModel, navController) + } + } + } +}