From d57c03a01a4b5edc81f3b0fb91d645b40c359074 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 04:10:27 +0000 Subject: [PATCH 1/2] feat: add Instagram-style picture feed for kind 20 events Implements a dedicated picture feed screen with a custom card layout: - Full-width image display with user avatar and name header - Title and 3-line content preview below the image - Reaction row (reply, boost, like, zap, share) at the bottom - Top bar with follow list filter (same pattern as polls feed) - Complete relay subscription pipeline for picture events - Accessible from the navigation drawer https://claude.ai/code/session_01KtuzFmDZXk67tW8QxPqmMy --- .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 7 + .../loggedIn/pictures/PictureCardCompose.kt | 218 ++++++++++++++++++ .../loggedIn/pictures/PictureFeedLoaded.kt | 74 ++++++ .../loggedIn/pictures/PicturesScreen.kt | 118 ++++++++++ .../loggedIn/pictures/PicturesTopBar.kt | 70 ++++++ .../pictures/dal/PictureFeedFilter.kt | 77 +++++++ .../datasource/PicturesFilterAssembler.kt | 50 ++++ .../PicturesFilterAssemblerSubscription.kt | 48 ++++ .../datasource/PicturesSubAssembler.kt | 97 ++++++++ .../pictures/datasource/SubAssemblyHelper.kt | 49 ++++ .../subassemblies/FilterPicturesByAuthors.kt | 92 ++++++++ .../subassemblies/FilterPicturesByFollows.kt | 44 ++++ .../subassemblies/FilterPicturesByHashtag.kt | 67 ++++++ .../subassemblies/FilterPicturesGlobal.kt | 49 ++++ amethyst/src/main/res/drawable/ic_picture.xml | 9 + amethyst/src/main/res/values/strings.xml | 2 + 21 files changed, 1088 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt create mode 100644 amethyst/src/main/res/drawable/ic_picture.xml diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 3c1363a1fe..8811717b66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler @@ -64,6 +65,7 @@ class RelaySubscriptionsCoordinator( val video = VideoFilterAssembler(client) val discovery = DiscoveryFilterAssembler(client) val polls = PollsFilterAssembler(client) + val pictures = PicturesFilterAssembler(client) // loaders of content that is not yet in the device. // they are active when looking at events, users, channels. @@ -98,6 +100,7 @@ class RelaySubscriptionsCoordinator( video, discovery, polls, + pictures, channelFinder, eventFinder, userFinder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 4e5dabc4f9..6d77437fde 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -56,6 +56,7 @@ object ScrollStateKeys { const val DISCOVER_CHATS = "DiscoverChatsFeed" const val POLLS_SCREEN = "PollsFeed" + const val PICTURES_SCREEN = "PicturesFeed" const val SEARCH_SCREEN = "SearchFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 8df8f6c712..be2d6de4ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -101,6 +101,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPic import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen @@ -175,6 +176,7 @@ fun BuildNavigation( composable { DiscoverScreen(accountViewModel, nav) } composable { NotificationScreen(accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { PicturesScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { WalletScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 8dcb5f6c5e..51b3b34cf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -581,6 +581,15 @@ fun ListContent( route = Route.Polls, ) + NavigationRow( + title = R.string.pictures, + icon = R.drawable.ic_picture, + iconReference = 1, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Pictures, + ) + NavigationRow( title = R.string.wallet, icon = Icons.Outlined.AccountBalanceWallet, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b7e953bc51..7fab3f7db6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -43,6 +43,8 @@ sealed class Route { @Serializable object Polls : Route() + @Serializable object Pictures : Route() + @Serializable object Chess : Route() @Serializable object Wallet : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index a3c03ee087..03395bd30f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedConte import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.OpenPollsState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal.PictureFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.dal.PollsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter @@ -72,6 +73,8 @@ class AccountFeedContentStates( val pollsFeed = FeedContentState(PollsFeedFilter(account), scope, LocalCache) + val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) + val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notificationsOpenPolls = OpenPollsState(account, scope) val notificationSummary = NotificationSummaryState(account) @@ -108,6 +111,8 @@ class AccountFeedContentStates( pollsFeed.updateFeedWith(newNotes) + picturesFeed.updateFeedWith(newNotes) + notifications.updateFeedWith(newNotes) notificationSummary.invalidateInsertData(newNotes) @@ -138,6 +143,8 @@ class AccountFeedContentStates( pollsFeed.deleteFromFeed(newNotes) + picturesFeed.deleteFromFeed(newNotes) + notifications.deleteFromFeed(newNotes) notificationSummary.invalidateInsertData(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt new file mode 100644 index 0000000000..5e5b66d58b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures + +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ReactionsRow +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.observeEdits +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun PictureCardCompose( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (baseNote.event as? PictureEvent) ?: return + val backgroundColor = remember { mutableStateOf(Color.Transparent) } + val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) + + Column( + modifier = Modifier.fillMaxWidth(), + ) { + // Author header row + PictureCardHeader(baseNote, accountViewModel, nav) + + // Image content + PictureCardImage(baseNote, event, backgroundColor, accountViewModel) + + // Reactions row + ReactionsRow( + baseNote = baseNote, + showReactionDetail = true, + addPadding = true, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) + + // Title and content + PictureCardCaption(event) + } +} + +@Composable +private fun PictureCardHeader( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + val route = routeFor(baseNote, accountViewModel.account) + if (route != null) { + nav.nav(route) + } + }.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + NoteAuthorPicture( + baseNote = baseNote, + size = Size35dp, + accountViewModel = accountViewModel, + nav = nav, + ) + + Column(modifier = Modifier.weight(1f)) { + NoteUsernameDisplay( + baseNote = baseNote, + weight = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + ) + } + + TimeAgo(baseNote) + } +} + +@Composable +private fun PictureCardImage( + note: Note, + event: PictureEvent, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, +) { + val uri = note.toNostrUri() + + val images by + remember(note) { + mutableStateOf( + event + .imetaTags() + .map { + MediaUrlImage( + url = it.url, + description = it.alt, + hash = it.hash, + blurhash = it.blurhash, + dim = it.dimension, + uri = uri, + mimeType = it.mimeType, + ) + }.toImmutableList(), + ) + } + + if (images.isNotEmpty()) { + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + if (images.size == 1) { + ZoomableContentView( + content = images.first(), + images = images, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } else { + AutoNonlazyGrid(images.size) { + ZoomableContentView( + content = images[it], + images = images, + roundedCorner = false, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } +} + +@Composable +private fun PictureCardCaption(event: PictureEvent) { + val title = event.title() + val content = event.content + + if (title != null || content.isNotBlank()) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) { + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + + if (content.isNotBlank()) { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt new file mode 100644 index 0000000000..f6c61424a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureFeedLoaded.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +@Composable +fun PictureFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + if (item.event is PictureEvent) { + PictureCardCompose( + baseNote = item, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider( + thickness = DividerThickness, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt new file mode 100644 index 0000000000..0f791265e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesScreen.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssemblerSubscription + +@Composable +fun PicturesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + PicturesScreen( + picturesFeedContentState = accountViewModel.feedStates.picturesFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun PicturesScreen( + picturesFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(picturesFeedContentState) + WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel) + PicturesFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + PicturesTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.Pictures, accountViewModel) { route -> + if (route == Route.Pictures) { + picturesFeedContentState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + accountViewModel = accountViewModel, + ) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + RefresheableBox(picturesFeedContentState, true) { + SaveableFeedContentState(picturesFeedContentState, scrollStateKey = ScrollStateKeys.PICTURES_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = picturesFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "PicturesFeed", + onLoaded = { loaded -> + PictureFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } + } +} + +@Composable +fun WatchAccountForPicturesScreen( + picturesFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveDiscoveryFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + picturesFeedContentState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt new file mode 100644 index 0000000000..0f78fb3e89 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PicturesTopBar.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun PicturesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultDiscoveryFollowList + .collectAsStateWithLifecycle() + + PicturesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultDiscoveryFollowList, + ) + } +} + +@Composable +private fun PicturesTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt new file mode 100644 index 0000000000..568692c9fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/dal/PictureFeedFilter.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +class PictureFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + + override fun limit() = 200 + + fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() + + override fun feed(): List { + val params = buildFilterParams(account) + val notes = + LocalCache.notes.filterIntoSet { _, it -> + val noteEvent = it.event + noteEvent is PictureEvent && params.match(noteEvent, it.relays) + } + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveDiscoveryFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is PictureEvent && params.match(noteEvent, it.relays) + } + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt new file mode 100644 index 0000000000..11afee9e15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class PicturesQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class PicturesFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + PicturesSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..90c0c0fc94 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PicturesFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + PicturesFilterAssemblerSubscription( + accountViewModel.dataSources().pictures, + accountViewModel, + ) +} + +@Composable +fun PicturesFilterAssemblerSubscription( + dataSource: PicturesFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + PicturesQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesSubAssembler.kt new file mode 100644 index 0000000000..7a6475abd3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/PicturesSubAssembler.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class PicturesSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: PicturesQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + + return makePicturesFilter(feedSettings, since, key.feedStates.picturesFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: PicturesQueryState) = key.account.userProfile() + + override fun list(key: PicturesQueryState) = key.listName() + + fun PicturesQueryState.listNameFlow() = account.settings.defaultDiscoveryFollowList + + fun PicturesQueryState.listName() = listNameFlow().value + + fun PicturesQueryState.followsPerRelayFlow() = account.liveDiscoveryFollowListsPerRelay + + fun PicturesQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: PicturesQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.picturesFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..bca3b4be9b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/SubAssemblyHelper.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByFollows +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByHashtag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesByMutedAuthors +import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies.filterPicturesGlobal +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun makePicturesFilter( + feedSettings: IFeedTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List = + when (feedSettings) { + is AllFollowsTopNavPerRelayFilterSet -> filterPicturesByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterPicturesByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPicturesGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterPicturesByHashtag(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPicturesByMutedAuthors(feedSettings, since, defaultSince) + else -> emptyList() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt new file mode 100644 index 0000000000..7535759e72 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByAuthors.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +fun filterPicturesByAuthors( + relay: NormalizedRelayUrl, + authors: Set, + since: Long? = null, +): List { + val authorList = authors.sorted() + return listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authorList, + kinds = listOf(PictureEvent.KIND), + limit = 200, + since = since, + ), + ), + ) +} + +fun filterPicturesByAuthors( + authorSet: AuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPicturesByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} + +fun filterPicturesByMutedAuthors( + authorSet: MutedAuthorsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set + .mapNotNull { + if (it.value.authors.isEmpty()) { + null + } else { + filterPicturesByAuthors( + relay = it.key, + authors = it.value.authors, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt new file mode 100644 index 0000000000..f630c6bca4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByFollows.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +fun filterPicturesByFollows( + followsSet: AllFollowsTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + val since = since?.get(it.key)?.time ?: defaultSince + val relay = it.key + + listOfNotNull( + it.value.authors?.let { + filterPicturesByAuthors(relay, it, since) + }, + ).flatten() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt new file mode 100644 index 0000000000..4a35bc0836 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesByHashtag.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip68Picture.PictureEvent + +fun filterPicturesByHashtag( + relay: NormalizedRelayUrl, + hashtags: Set, + since: Long? = null, +): List = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(PictureEvent.KIND), + tags = mapOf("t" to hashtags.toList()), + limit = 200, + since = since, + ), + ), + ) + +fun filterPicturesByHashtag( + hashtagSet: HashtagTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (hashtagSet.set.isEmpty()) return emptyList() + + return hashtagSet.set + .mapNotNull { relayHashSet -> + if (relayHashSet.value.hashtags.isEmpty()) { + null + } else { + filterPicturesByHashtag( + relay = relayHashSet.key, + hashtags = relayHashSet.value.hashtags, + since = since?.get(relayHashSet.key)?.time ?: defaultSince, + ) + } + }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt new file mode 100644 index 0000000000..ebba2de347 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/datasource/subassemblies/FilterPicturesGlobal.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.subassemblies + +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +fun filterPicturesGlobal( + relays: GlobalTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + defaultSince: Long? = null, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo() + RelayBasedFilter( + relay = it.key, + filter = + Filter( + kinds = listOf(PictureEvent.KIND), + limit = 200, + since = since, + ), + ) + } +} diff --git a/amethyst/src/main/res/drawable/ic_picture.xml b/amethyst/src/main/res/drawable/ic_picture.xml new file mode 100644 index 0000000000..59e0b862a8 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_picture.xml @@ -0,0 +1,9 @@ + + + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c11be62630..748c0e05b2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -412,6 +412,7 @@ Your default Bookmarks that many clients support Drafts Polls + Pictures Private Bookmarks Public Bookmarks Add to Private Bookmarks @@ -1283,6 +1284,7 @@ Notifications Global Shorts + Pictures Chess Wallet Balance From 20425a7678017eb8e30fc9d27757bce5f4ae4de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 13:00:29 +0000 Subject: [PATCH 2/2] feat: add @Preview composables for picture feed UI components Adds 6 preview functions covering: - PictureCardCompose with title, without title, and multi-image - PictureCardCaption with title, without title, and long content - Uses ThemeComparisonColumn for light/dark theme comparison - Uses mock PictureEvent JSON data consumed via LocalCache https://claude.ai/code/session_01KtuzFmDZXk67tW8QxPqmMy --- .../loggedIn/pictures/PictureCardCompose.kt | 2 +- .../pictures/PictureCardComposePreview.kt | 218 ++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 5e5b66d58b..227d4dad56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -185,7 +185,7 @@ private fun PictureCardImage( } @Composable -private fun PictureCardCaption(event: PictureEvent) { +internal fun PictureCardCaption(event: PictureEvent) { val title = event.title() val content = event.content diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt new file mode 100644 index 0000000000..35784afd4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardComposePreview.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures + +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.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private const val SAMPLE_PICTURE_EVENT_JSON = + "{\"id\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"," + + "\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," + + "\"created_at\":1708695717," + + "\"kind\":20," + + "\"tags\":[[\"title\",\"Sunset at the Beach\"]," + + "[\"imeta\",\"url https://image.nostr.build/sample-sunset.jpg\"," + + "\"m image/jpeg\",\"dim 1200x800\",\"alt A beautiful sunset over the ocean\"," + + "\"blurhash LKO2:N%2Tw=w]~RBVZRi};RPxuwH\"]]," + + "\"content\":\"Caught this amazing sunset while walking along the shore. The colors were absolutely breathtaking, painting the sky in shades of orange and purple.\"," + + "\"sig\":\"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\"}" + +private const val SAMPLE_PICTURE_EVENT_NO_TITLE_JSON = + "{\"id\":\"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3\"," + + "\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," + + "\"created_at\":1708695000," + + "\"kind\":20," + + "\"tags\":[[\"imeta\",\"url https://image.nostr.build/sample-mountain.jpg\"," + + "\"m image/jpeg\",\"dim 1080x1080\",\"alt Mountain landscape\"]]," + + "\"content\":\"Mountain vibes today. Fresh air and clear skies.\"," + + "\"sig\":\"b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3\"}" + +private const val SAMPLE_MULTI_IMAGE_EVENT_JSON = + "{\"id\":\"c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\"," + + "\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\"," + + "\"created_at\":1708694000," + + "\"kind\":20," + + "\"tags\":[[\"title\",\"Travel Photos\"]," + + "[\"imeta\",\"url https://image.nostr.build/sample-travel1.jpg\"," + + "\"m image/jpeg\",\"dim 800x600\",\"alt City street\"]," + + "[\"imeta\",\"url https://image.nostr.build/sample-travel2.jpg\"," + + "\"m image/jpeg\",\"dim 800x600\",\"alt Market square\"]]," + + "\"content\":\"Exploring the old town district. Every corner has a story to tell. The architecture here is incredible and the food is even better.\"," + + "\"sig\":\"c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4\"}" + +@Preview +@Composable +private fun PictureCardComposePreview() { + val event = Event.fromJson(SAMPLE_PICTURE_EVENT_JSON) as PictureEvent + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav() + + runBlocking { + withContext(Dispatchers.IO) { + LocalCache.justConsume(event, null, false) + } + } + + LoadNote( + baseNoteHex = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + accountViewModel = accountViewModel, + ) { baseNote -> + ThemeComparisonColumn { + if (baseNote != null) { + PictureCardCompose( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Preview +@Composable +private fun PictureCardComposeNoTitlePreview() { + val event = Event.fromJson(SAMPLE_PICTURE_EVENT_NO_TITLE_JSON) as PictureEvent + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav() + + runBlocking { + withContext(Dispatchers.IO) { + LocalCache.justConsume(event, null, false) + } + } + + LoadNote( + baseNoteHex = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", + accountViewModel = accountViewModel, + ) { baseNote -> + ThemeComparisonColumn { + if (baseNote != null) { + PictureCardCompose( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Preview +@Composable +private fun PictureCardComposeMultiImagePreview() { + val event = Event.fromJson(SAMPLE_MULTI_IMAGE_EVENT_JSON) as PictureEvent + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav() + + runBlocking { + withContext(Dispatchers.IO) { + LocalCache.justConsume(event, null, false) + } + } + + LoadNote( + baseNoteHex = "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + accountViewModel = accountViewModel, + ) { baseNote -> + ThemeComparisonColumn { + if (baseNote != null) { + PictureCardCompose( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Preview +@Composable +private fun PictureCardCaptionWithTitlePreview() { + val event = Event.fromJson(SAMPLE_PICTURE_EVENT_JSON) as PictureEvent + + ThemeComparisonColumn { + PictureCardCaption(event) + } +} + +@Preview +@Composable +private fun PictureCardCaptionNoTitlePreview() { + val event = Event.fromJson(SAMPLE_PICTURE_EVENT_NO_TITLE_JSON) as PictureEvent + + ThemeComparisonColumn { + PictureCardCaption(event) + } +} + +@Preview +@Composable +private fun PictureCardCaptionLongContentPreview() { + ThemeComparisonColumn { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + text = "A Very Long Title That Should Be Truncated When It Exceeds The Available Width", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = + "This is a long description that spans multiple lines to test the three-line " + + "truncation behavior. The text should be cut off after three lines with an " + + "ellipsis. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do " + + "eiusmod tempor incididunt ut labore et dolore magna aliqua.", + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) + } + } +}