From f50b39fcc946a2dd62874ba5ce7fb09a0a1cb3ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:27:28 +0000 Subject: [PATCH 1/3] refactor: split the smallest AccountViewModel-bound composables into app entries and shared displays First pass of the Render/Display split (commons/plans/2026-07-16-note-ui- commons-extraction.md): the app keeps the entry that talks to the account, commonsUI gets the rendering, which takes values and callbacks. - Channel header actions. The NIP-28 and ephemeral-chat Join/Leave buttons were four copies of the same FilledTonalButton; they become JoinChannelButton/LeaveChannelButton(onClick) in chats/ui and the headers pass `accountViewModel.follow/unfollow(channel)`. MuteChannelButton(isMuted, onToggle) is the display half of MuteChatButton, which keeps collecting the muted set. EditChannelButton (nip28PublicChat/ui) replaces the app's EditButton wrapper. - ProofOfWorkPill(pow, onClick) in ui/note; DisplayPoW stays the entry that explains the number in a toast. - FollowedHashtagLabel(tag, onClick) in ui/note; the follow-list lookup stays in DisplayFollowingHashtagsInPost. - HashtagExternalIdCard(topic, onClick) in nip73ExternalIds/ui, the display overload of DisplayHashtagExternalId. - rememberFoundCacheIds(FeedState) in nipCCGeocaching/ui; rememberMyFoundCacheIds only picks the account's Finds feed. - NewProductButton, NewMusicTrackButton and RelaysTabHeader took accountViewModel (and the header a User) without reading them. The parameters go and the files move whole to nip99Classifieds/ui, music/ui and profile/ui. Duplicates removed on the way: DiscoverScreen carried its own copies of NewProductButton and of NewArticleButton (as NewLongFormMarkdownButton), and ephemChat/ui/JoinButton had no callers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../ui/note/elements/DisplayHashtags.kt | 31 +----- .../amethyst/ui/note/elements/DisplayPoW.kt | 40 ++------ .../nip22Comments/DisplayHashtagExternalId.kt | 58 +----------- .../header/ShortEphemeralChatChannelHeader.kt | 8 +- .../header/LongPublicChatChannelHeader.kt | 9 +- .../header/ShortPublicChatChannelHeader.kt | 4 +- .../header/actions/JoinChatButton.kt | 48 ---------- .../header/actions/LeaveChatButton.kt | 48 ---------- .../header/actions/MuteChatButton.kt | 34 +------ .../loggedIn/discover/DiscoverScreen.kt | 58 +----------- .../loggedIn/geocaches/GeocacheFinds.kt | 30 +----- .../loggedIn/music/MusicTracksScreen.kt | 3 +- .../loggedIn/products/ProductsScreen.kt | 3 +- .../screen/loggedIn/profile/ProfileScreen.kt | 4 +- commons/ARCHITECTURE.md | 2 +- .../commons/chats/ui/ChannelActionButtons.kt | 94 +++++++++++++++++++ .../commons/ephemChat/ui/JoinButton.kt | 44 --------- .../commons/music/ui}/NewMusicTrackButton.kt | 11 +-- .../nip28PublicChat/ui/EditChannelButton.kt | 15 +-- .../ui/HashtagExternalIdCard.kt | 79 ++++++++++++++++ .../nip99Classifieds/ui}/NewProductButton.kt | 8 +- .../nipCCGeocaching/ui/FoundCacheIds.kt | 56 +++++++++++ .../commons/profile/ui}/RelaysTabHeader.kt | 9 +- .../commons/ui/note/FollowedHashtagLabel.kt | 47 +++++----- .../commons/ui/note/ProofOfWorkPill.kt | 49 +++++----- 25 files changed, 335 insertions(+), 457 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt delete mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui}/NewMusicTrackButton.kt (87%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt (78%) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui}/NewProductButton.kt (91%) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui}/RelaysTabHeader.kt (83%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt (51%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt => commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt (56%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index b60072001c..a695bb79e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -20,24 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.navigation.Route -import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.theme.lessImportantLink +import com.vitorpamplona.amethyst.commons.ui.note.FollowedHashtagLabel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -68,25 +61,7 @@ fun DisplayFollowingHashtagsInPost( } } - firstTag?.let { - Column(verticalArrangement = Arrangement.Center) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayTagList(it, accountViewModel, nav) } - } - } -} - -@Composable -private fun DisplayTagList( - firstTag: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - ClickableTextColor( - "#$firstTag", - linkColor = MaterialTheme.colorScheme.lessImportantLink, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) { - nav.nav(Route.Hashtag(firstTag.lowercase())) + firstTag?.let { tag -> + FollowedHashtagLabel(tag) { nav.nav(Route.Hashtag(tag.lowercase())) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 0da6aa11eb..25c0870a11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -21,45 +21,23 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.pow_info_description import com.vitorpamplona.amethyst.commons.resources.pow_settings_title -import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.commons.ui.note.ProofOfWorkPill import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -@Composable -@Preview -fun DisplayPoWPreview() { - ThemeComparisonColumn( - toPreview = { DisplayPoW(pow = 24, accountViewModel = mockAccountViewModel()) }, - ) -} - -/** - * Compact pill showing the proof of work a received note carries: a gear plus - * the difficulty in leading zero bits. Tapping it explains what the number - * means. Sits inline in note headers, so it stays at text height. - */ +/** Entry: the shared [ProofOfWorkPill], explaining the number in a toast when tapped. */ @Composable fun DisplayPoW( pow: Int, accountViewModel: AccountViewModel, ) { - HeaderPill( - symbol = MaterialSymbols.Manufacturing, - text = pow.toString(), - contentDescription = stringRes(Res.string.pow_settings_title), - onClick = { - accountViewModel.toastManager.toast( - Res.string.pow_settings_title, - Res.string.pow_info_description, - pow.toString(), - ) - }, - ) + ProofOfWorkPill(pow) { + accountViewModel.toastManager.toast( + Res.string.pow_settings_title, + Res.string.pow_info_description, + pow.toString(), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt index 1848479637..1166fa439b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/DisplayHashtagExternalId.kt @@ -20,31 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.note.nip22Comments -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.LinkInteractionListener -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withLink -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.Route -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.hashtag_exclusive +import com.vitorpamplona.amethyst.commons.nip73ExternalIds.ui.HashtagExternalIdCard import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @@ -54,40 +33,7 @@ fun DisplayHashtagExternalId( accountViewModel: AccountViewModel, nav: INav, ) { - DisplayHashtagExternalId(externalId.topic) { + HashtagExternalIdCard(externalId.topic) { nav.nav(Route.Hashtag(externalId.topic.lowercase())) } } - -@Composable -fun DisplayHashtagExternalId( - topic: String, - linkInteractionListener: LinkInteractionListener, -) { - Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - Icon( - symbol = MaterialSymbols.Tag, - contentDescription = stringRes(id = Res.string.hashtag_exclusive), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - - Spacer(StdHorzSpacer) - - Text( - text = - buildAnnotatedString { - withLink( - LinkAnnotation.Clickable("hashtag", null, linkInteractionListener), - ) { - append(topic) - } - }, - style = - LocalTextStyle.current.copy( - fontWeight = FontWeight.Bold, - ), - maxLines = 1, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 035ea330f3..b63dd2940e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -35,6 +35,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.chats.ui.JoinChannelButton +import com.vitorpamplona.amethyst.commons.chats.ui.LeaveChannelButton import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.profile_image @@ -47,8 +49,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.JoinChatButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.actions.LeaveChatButton @Composable fun ShortEphemeralChatChannelHeader( @@ -130,8 +130,8 @@ fun JoinEphemeralChatButtonIfNotAlreadyJoined( val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) if (!isFollowing) { - JoinChatButton(channel, accountViewModel, nav) + JoinChannelButton { accountViewModel.follow(channel) } } else { - LeaveChatButton(channel, accountViewModel, nav) + LeaveChannelButton { accountViewModel.unfollow(channel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 8dfec8f0cb..cb74de4208 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -41,7 +41,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.chats.ui.LeaveChannelButton +import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.nip28PublicChat.ui.EditChannelButton import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.channel_image import com.vitorpamplona.amethyst.commons.resources.created_at @@ -64,8 +67,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.EditButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LeaveChatButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LinkChatButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.MuteChatButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.OpenChatButton @@ -212,7 +213,7 @@ fun EditButtonIfIamCreator( } if (isMe) { - EditButton(channel, accountViewModel, nav) + EditChannelButton { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) } } } @@ -225,6 +226,6 @@ fun LeaveButtonIfFollowing( val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) if (isFollowing) { - LeaveChatButton(channel, accountViewModel, nav) + LeaveChannelButton { accountViewModel.unfollow(channel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index b90ff26e63..be5aa768b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.chats.ui.JoinChannelButton import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.profile_image @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.JoinChatButton @Composable fun ShortPublicChatChannelHeader( @@ -147,6 +147,6 @@ fun JoinChatButtonIfNotAlreadyJoined( val isFollowing by observeUserIsFollowingChannel(accountViewModel.account, channel, accountViewModel) if (!isFollowing) { - JoinChatButton(channel, accountViewModel, nav) + JoinChannelButton { accountViewModel.follow(channel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt deleted file mode 100644 index 1a14b7a52d..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.chats.publicChannels.nip28PublicChat.header.actions - -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.join -import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding -import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel - -@Composable -fun JoinChatButton( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: INav, -) { - FilledTonalButton( - modifier = HalfHalfHorzModifier, - onClick = { accountViewModel.follow(channel) }, - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(Res.string.join)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt deleted file mode 100644 index 8a3cfcd30e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.chats.publicChannels.nip28PublicChat.header.actions - -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.leave -import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding -import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel - -@Composable -fun LeaveChatButton( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: INav, -) { - FilledTonalButton( - modifier = HalfHalfHorzModifier, - onClick = { accountViewModel.unfollow(channel) }, - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(Res.string.leave)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt index e5864ed985..47418b63c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/MuteChatButton.kt @@ -20,23 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.FilledTonalButton 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.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.chats.ui.MuteChannelButton import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.mute_notifications -import com.vitorpamplona.amethyst.commons.resources.unmute_notifications -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -47,23 +35,5 @@ fun MuteChatButton( val mutedChats by accountViewModel.mutedPublicChatsFlow().collectAsStateWithLifecycle() val isMuted = channel.idHex in mutedChats - val label = - stringRes( - if (isMuted) Res.string.unmute_notifications else Res.string.mute_notifications, - ) - - FilledTonalButton( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { accountViewModel.toggleMutedPublicChat(channel.idHex) }, - contentPadding = ZeroPadding, - ) { - Icon( - symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications, - contentDescription = label, - modifier = Size20Modifier, - ) - } + MuteChannelButton(isMuted) { accountViewModel.toggleMutedPublicChat(channel.idHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index 0e355a01aa..6ded9230bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -34,8 +34,6 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SecondaryScrollableTabRow @@ -52,10 +50,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.navigation.DiscoverTab import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.nip23LongContent.ui.NewArticleButton +import com.vitorpamplona.amethyst.commons.nip99Classifieds.ui.NewProductButton import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.discover_chat import com.vitorpamplona.amethyst.commons.resources.discover_community_v2 @@ -64,8 +62,6 @@ import com.vitorpamplona.amethyst.commons.resources.discover_follows import com.vitorpamplona.amethyst.commons.resources.discover_live_v2 import com.vitorpamplona.amethyst.commons.resources.discover_marketplace import com.vitorpamplona.amethyst.commons.resources.discover_reads -import com.vitorpamplona.amethyst.commons.resources.new_long_form_post -import com.vitorpamplona.amethyst.commons.resources.new_product import com.vitorpamplona.amethyst.commons.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed @@ -79,8 +75,6 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier -import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.PrefetchLoadedFeedMedia @@ -273,11 +267,11 @@ private fun DiscoverPages( if (currentPage >= 0 && currentPage < feedTabs.size) { FabBottomBarPadded(nav) { if (feedTabs[currentPage].resource == Res.string.discover_marketplace) { - NewProductButton(accountViewModel, nav) + NewProductButton(nav) } if (feedTabs[currentPage].resource == Res.string.discover_reads) { - NewLongFormMarkdownButton(accountViewModel, nav) + NewArticleButton(nav) } } } @@ -372,50 +366,6 @@ private fun RenderDiscoverFeed( } } -@Composable -fun NewProductButton( - accountViewModel: AccountViewModel, - nav: INav, -) { - FloatingActionButton( - onClick = { - nav.nav(Route.NewProduct()) - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = stringRes(id = Res.string.new_product), - modifier = Size26Modifier, - tint = MaterialTheme.colorScheme.onPrimary, - ) - } -} - -@Composable -fun NewLongFormMarkdownButton( - accountViewModel: AccountViewModel, - nav: INav, -) { - FloatingActionButton( - onClick = { - nav.nav(Route.NewLongFormPost()) - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = stringRes(id = Res.string.new_long_form_post), - modifier = Size26Modifier, - tint = MaterialTheme.colorScheme.onPrimary, - ) - } -} - @Composable private fun RenderDiscoverFeed( feedContentState: FeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt index f6cdc5e142..6c90d45d6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocacheFinds.kt @@ -22,39 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.feeds.FeedState +import com.vitorpamplona.amethyst.commons.nipCCGeocaching.ui.rememberFoundCacheIds import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent -/** - * The addresses the signed-in user has already logged a find on, as `37516:pubkey:d` strings. - * - * Derived from the Finds feed rather than scanning the cache directly, so it tracks a new log - * the moment the feed does — which is what makes a hunt's progress bar move as you walk it. - */ +/** The addresses the signed-in user has already logged a find on, from their Finds feed. */ @Composable fun rememberMyFoundCacheIds(accountViewModel: AccountViewModel): Set { val finds by accountViewModel.feedStates.geocacheFindsFeed.feedContent .collectAsStateWithLifecycle() - return when (val state = finds) { - is FeedState.Loaded -> rememberFoundCacheIds(state) - else -> emptySet() - } -} - -/** - * Collects the loaded feed's own flow. [FeedState.Loaded] is handed out once and then mutated - * through its inner flow, so reading `.value` here would freeze the set at whatever the feed held - * when the hunt screen first composed — the progress bar would never move. - */ -@Composable -private fun rememberFoundCacheIds(state: FeedState.Loaded): Set { - val loaded by state.feed.collectAsStateWithLifecycle() - - return remember(loaded) { - loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() } - } + return rememberFoundCacheIds(finds) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt index 49b0cafccd..7330473598 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/MusicTracksScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.music.ui.NewMusicTrackButton import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel @@ -87,7 +88,7 @@ fun MusicTracksScreen( }, floatingButton = { FabBottomBarPadded(nav) { - NewMusicTrackButton(accountViewModel, nav) + NewMusicTrackButton(nav) } }, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt index 7cee0b536d..99c72dfa2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/ProductsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.nip99Classifieds.ui.NewProductButton import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.commons.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel @@ -75,7 +76,7 @@ fun ProductsScreen( }, floatingButton = { FabBottomBarPadded(nav) { - NewProductButton(accountViewModel, nav) + NewProductButton(nav) } }, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index dd0cf3d0ca..0bc9808c07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -67,6 +67,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.profile.ui.RelaysTabHeader import com.vitorpamplona.amethyst.commons.relayClient.profile.UserProfileFilterAssemblerSubscription import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.gallery @@ -109,7 +110,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProf import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.dal.UserProfileNewThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.pinnedNotes.dal.UserProfilePinnedNotesFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.RelaysTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays.TabRelays import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.ReportsTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.TabReports @@ -697,7 +697,7 @@ private fun CreateAndRenderTabs( ProfileTab.Bookmarks -> BookmarkTabHeader(baseUser, accountViewModel) ProfileTab.FollowedTags -> FollowedTagsTabHeader(baseUser, accountViewModel) ProfileTab.Reports -> ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) - ProfileTab.Relays -> RelaysTabHeader(baseUser, accountViewModel) + ProfileTab.Relays -> RelaysTabHeader() } }, ) diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index 473cad938c..fa90c43d92 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -140,7 +140,7 @@ type (`LazyListState`, `TextFieldValue`, `TextFieldState`) goes to `commonsUI`. | `napplet` | mixed | Protocol/permission logic here; `NappletWebContract` (serves the shell/shim from `composeResources`) in `commonsUI`. | | `favorites`, `nip30CustomEmojis`, `nip34Git`, `nip85TrustedAssertions`, `nip53LiveActivities` | mixed | Logic here; each feature's `ui/` (or the flat `FavoriteAppIcon`, `EmojiSuggestionState`) in `commonsUI`. | | `chats` | yes | Composables shared by every chat kind (DMs, public chats, relay groups, concord): unread badge, divisors, system messages, author line, send button, reply toggle, new-conversation screen. Chat *models* are in `model/chats`. | -| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `buzz`, `cashu`, `concord`, `ephemChat`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. | +| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nip73ExternalIds`, `nip99Classifieds`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `buzz`, `cashu`, `concord`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. | ### Mixed (documented debt — see §4) | Package | UI? | Purpose | diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt new file mode 100644 index 0000000000..da23e6ccd3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chats/ui/ChannelActionButtons.kt @@ -0,0 +1,94 @@ +/* + * 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.commons.chats.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.join +import com.vitorpamplona.amethyst.commons.resources.leave +import com.vitorpamplona.amethyst.commons.resources.mute_notifications +import com.vitorpamplona.amethyst.commons.resources.unmute_notifications +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier +import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding + +// Membership actions shared by every channel header (NIP-28 public chats and ephemeral chats). +// They take the action as a callback so the header that knows the account decides what +// joining, leaving or muting means. + +@Composable +fun JoinChannelButton(onClick: () -> Unit) { + FilledTonalButton( + modifier = HalfHalfHorzModifier, + onClick = onClick, + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(Res.string.join)) + } +} + +@Composable +fun LeaveChannelButton(onClick: () -> Unit) { + FilledTonalButton( + modifier = HalfHalfHorzModifier, + onClick = onClick, + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(Res.string.leave)) + } +} + +/** Bell toggle for a channel's notifications; [isMuted] picks the icon and label. */ +@Composable +fun MuteChannelButton( + isMuted: Boolean, + onToggle: () -> Unit, +) { + val label = + stringRes( + if (isMuted) Res.string.unmute_notifications else Res.string.mute_notifications, + ) + + FilledTonalButton( + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), + onClick = onToggle, + contentPadding = ZeroPadding, + ) { + Icon( + symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications, + contentDescription = label, + modifier = Size20Modifier, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt deleted file mode 100644 index c304893c25..0000000000 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ephemChat/ui/JoinButton.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.commons.ephemChat.ui - -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.join -import com.vitorpamplona.amethyst.commons.ui.stringRes - -@Composable -fun JoinButton( - onPost: () -> Unit = {}, - isActive: Boolean, - modifier: Modifier = Modifier, -) { - Button( - enabled = isActive, - modifier = modifier, - onClick = onPost, - ) { - Text(text = stringRes(Res.string.join)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui/NewMusicTrackButton.kt similarity index 87% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui/NewMusicTrackButton.kt index c886c60594..76d070bd85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/music/NewMusicTrackButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/music/ui/NewMusicTrackButton.kt @@ -18,7 +18,7 @@ * 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.music +package com.vitorpamplona.amethyst.commons.music.ui import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton @@ -36,14 +36,9 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @Composable -fun NewMusicTrackButton( - accountViewModel: AccountViewModel, - nav: INav, -) { +fun NewMusicTrackButton(nav: INav) { FloatingActionButton( onClick = { nav.nav(Route.NewMusicTrack()) }, modifier = Size55Modifier, @@ -63,6 +58,6 @@ fun NewMusicTrackButton( @Composable private fun NewMusicTrackButtonPreview() { ThemeComparisonRow { - NewMusicTrackButton(accountViewModel = mockAccountViewModel(), nav = EmptyNav()) + NewMusicTrackButton(nav = EmptyNav()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt similarity index 78% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt index 159f0faec6..320e656b4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip28PublicChat/ui/EditChannelButton.kt @@ -18,7 +18,7 @@ * 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.chats.publicChannels.nip28PublicChat.header.actions +package com.vitorpamplona.amethyst.commons.nip28PublicChat.ui import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -28,27 +28,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.model.navigation.Route -import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.edits_the_channel_metadata -import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ZeroPadding -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +/** Opens the channel-metadata editor; shown to the channel's owner in the long header. */ @Composable -fun EditButton( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: INav, -) { +fun EditChannelButton(onClick: () -> Unit) { FilledTonalButton( modifier = Modifier .padding(horizontal = 3.dp) .width(50.dp), - onClick = { nav.nav(Route.ChannelMetadataEdit(channel.idHex)) }, + onClick = onClick, contentPadding = ZeroPadding, ) { Icon( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt new file mode 100644 index 0000000000..a0dbc0c74c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip73ExternalIds/ui/HashtagExternalIdCard.kt @@ -0,0 +1,79 @@ +/* + * 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.commons.nip73ExternalIds.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.LinkInteractionListener +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.hashtag_exclusive +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier + +/** Header card for a NIP-22 comment scoped to a hashtag (NIP-73 `#t` external id): tag icon + the tagged topic as a link. */ +@Composable +fun HashtagExternalIdCard( + topic: String, + linkInteractionListener: LinkInteractionListener, +) { + Row(modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Tag, + contentDescription = stringRes(id = Res.string.hashtag_exclusive), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(StdHorzSpacer) + + Text( + text = + buildAnnotatedString { + withLink( + LinkAnnotation.Clickable("hashtag", null, linkInteractionListener), + ) { + append(topic) + } + }, + style = + LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/NewProductButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui/NewProductButton.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/NewProductButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui/NewProductButton.kt index 2c410dd1b3..ca5ec5a69b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/products/NewProductButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip99Classifieds/ui/NewProductButton.kt @@ -18,7 +18,7 @@ * 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.products +package com.vitorpamplona.amethyst.commons.nip99Classifieds.ui import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FloatingActionButton @@ -33,13 +33,9 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size26Modifier import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun NewProductButton( - accountViewModel: AccountViewModel, - nav: INav, -) { +fun NewProductButton(nav: INav) { FloatingActionButton( onClick = { nav.nav(Route.NewProduct()) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt new file mode 100644 index 0000000000..aa0a738cb7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipCCGeocaching/ui/FoundCacheIds.kt @@ -0,0 +1,56 @@ +/* + * 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.commons.nipCCGeocaching.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.feeds.FeedState +import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent + +/** + * The addresses logged as found in a Finds feed ([feedState]), as `37516:pubkey:d` strings. + * Empty until the feed has loaded. + * + * Deriving it from the feed rather than scanning the cache directly means it tracks a new log + * the moment the feed does, which is what makes a hunt's progress bar move as you walk it. + */ +@Composable +fun rememberFoundCacheIds(feedState: FeedState): Set = + when (feedState) { + is FeedState.Loaded -> rememberLoadedFoundCacheIds(feedState) + else -> emptySet() + } + +/** + * Collects the loaded feed's own flow. [FeedState.Loaded] is handed out once and then mutated + * through its inner flow, so reading `.value` here would freeze the set at whatever the feed held + * when the hunt screen first composed — the progress bar would never move. + */ +@Composable +private fun rememberLoadedFoundCacheIds(state: FeedState.Loaded): Set { + val loaded by state.feed.collectAsStateWithLifecycle() + + return remember(loaded) { + loaded.list.mapNotNullTo(mutableSetOf()) { (it.event as? GeocacheFoundLogEvent)?.geocacheId() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/RelaysTabHeader.kt similarity index 83% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/RelaysTabHeader.kt index 3a2a316e7b..7ce01d0b1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelaysTabHeader.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ui/RelaysTabHeader.kt @@ -18,20 +18,15 @@ * 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.profile.relays +package com.vitorpamplona.amethyst.commons.profile.ui import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.relays import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun RelaysTabHeader( - baseUser: User, - accountViewModel: AccountViewModel, -) { +fun RelaysTabHeader() { Text(text = stringRes(Res.string.relays)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt similarity index 51% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt index 760b69a194..a390dbcc71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/LeaveChatButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/FollowedHashtagLabel.kt @@ -18,31 +18,36 @@ * 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.chats.publicChannels.ephemChat.header.actions +package com.vitorpamplona.amethyst.commons.ui.note -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.leave -import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding -import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor +import com.vitorpamplona.amethyst.commons.ui.theme.lessImportantLink +/** + * The `#tag` link a note header shows when the note carries a hashtag the viewer follows, + * explaining why it is in their feed. Single line, ellipsized. + */ @Composable -fun LeaveChatButton( - channel: EphemeralChatChannel, - accountViewModel: AccountViewModel, - nav: INav, +fun FollowedHashtagLabel( + tag: String, + onClick: () -> Unit, ) { - FilledTonalButton( - modifier = HalfHalfHorzModifier, - onClick = { accountViewModel.unfollow(channel) }, - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(Res.string.leave)) + Column(verticalArrangement = Arrangement.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { + ClickableTextColor( + "#$tag", + linkColor = MaterialTheme.colorScheme.lessImportantLink, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + onClick = onClick, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt rename to commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt index 5f48f50ff0..9ae434afab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/actions/JoinChatButton.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/ProofOfWorkPill.kt @@ -18,31 +18,38 @@ * 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.chats.publicChannels.ephemChat.header.actions +package com.vitorpamplona.amethyst.commons.ui.note -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.join -import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.resources.pow_settings_title import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding -import com.vitorpamplona.amethyst.commons.ui.theme.HalfHalfHorzModifier -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn @Composable -fun JoinChatButton( - channel: EphemeralChatChannel, - accountViewModel: AccountViewModel, - nav: INav, -) { - FilledTonalButton( - modifier = HalfHalfHorzModifier, - onClick = { accountViewModel.follow(channel) }, - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(Res.string.join)) - } +@Preview +fun ProofOfWorkPillPreview() { + ThemeComparisonColumn( + toPreview = { ProofOfWorkPill(pow = 24, onClick = {}) }, + ) +} + +/** + * Compact pill showing the proof of work a received note carries: a gear plus + * the difficulty in leading zero bits. Sits inline in note headers, so it stays + * at text height. [onClick] is where the host explains what the number means. + */ +@Composable +fun ProofOfWorkPill( + pow: Int, + onClick: () -> Unit, +) { + HeaderPill( + symbol = MaterialSymbols.Manufacturing, + text = pow.toString(), + contentDescription = stringRes(Res.string.pow_settings_title), + onClick = onClick, + ) } From 230f645eba96da8c4dcf51a6c872d68f4c0785c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:57:05 +0000 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20second=20Render/Display=20batch?= =?UTF-8?q?=20=E2=80=94=20NIP-43=20cards,=20NIP=20card,=20two=20dialogs,?= =?UTF-8?q?=20bookmark=20toggle,=20crossfade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NIP-43 relay membership: the five renderers drew only from the event (nav and relayPubKey were passed to the card and never read). The cards move to commonsUI nip43RelayMembers/ui as RelayMembershipListCard, RelayAddMemberCard, RelayRemoveMemberCard, RelayJoinRequestCard, RelayLeaveRequestCard over a public RelayMemberEventCard; the app keeps the dispatcher entries that decode the note. - NIPs on Nostr: NipNoteHeader only read the event; it is NipCard(event) in nipsOnNostr/ui. Its preview no longer seeds LocalCache to build a Note the card never used. - RelayGroupInviteCodeDialog(onJoin, onDismiss) in nip29RelayGroups/ui; JoinRelayGroupDialog stays the entry that sends the kind-9021 request. - HashtagLabelDialog(onConfirm, onDismiss) in nip32Labeling/ui, keeping the hashtag normalization; AddHashtagLabelDialog publishes the label. - BookmarkToggleButton(isBookmarked, onToggle) in ui/note; PodcastBookmarkButton keeps reading the public bookmark sets. - CrossfadeIfEnabled: the crossfade machinery (DeferredCrossfade, MyCrossfade, and a CrossfadeIfEnabled taking `enabled: Boolean`) moves to ui/components. The app's CrossfadeIfEnabled(accountViewModel) is now a one-line overload passing "not performance mode", so its ~40 callers are unchanged. DeferredCrossfade becomes public for the androidTest that exercises it. No behaviour changes: every display is the entry's former body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../amethyst/ui/note/DeferredAnimationTest.kt | 2 +- .../amethyst/ui/actions/CrossfadeIfEnabled.kt | 137 +---------- .../ui/note/elements/AddHashtagLabelDialog.kt | 73 +----- .../amethyst/ui/note/types/Nip.kt | 160 +------------ .../ui/note/types/PodcastBookmarkButton.kt | 58 +---- .../amethyst/ui/note/types/RelayMembers.kt | 200 +--------------- .../relayGroup/JoinRelayGroupDialog.kt | 57 +---- commons/ARCHITECTURE.md | 2 +- .../ui/RelayGroupInviteCodeDialog.kt | 86 +++++++ .../nip32Labeling/ui/HashtagLabelDialog.kt | 105 ++++++++ .../nip43RelayMembers/ui/RelayMemberCards.kt | 224 ++++++++++++++++++ .../commons/nipsOnNostr/ui/NipCard.kt | 164 +++++++++++++ .../commons/ui/components/Crossfade.kt | 169 +++++++++++++ .../commons/ui/note/BookmarkToggleButton.kt | 77 ++++++ 14 files changed, 875 insertions(+), 639 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt index a2cbb273e7..086b3e42d4 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/note/DeferredAnimationTest.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade +import com.vitorpamplona.amethyst.commons.ui.components.DeferredCrossfade import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt index 4c5014141f..9470b4e89d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/CrossfadeIfEnabled.kt @@ -20,25 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.actions -import androidx.collection.mutableScatterMapOf -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.util.fastForEach +import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -50,116 +37,12 @@ fun CrossfadeIfEnabled( label: String = "Crossfade", accountViewModel: AccountViewModel, content: @Composable (T) -> Unit, -) { - if (accountViewModel.settings.isPerformanceMode()) { - Box(modifier, contentAlignment) { - content(targetState) - } - } else { - DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content) - } -} - -/** Latches the first time a crossfade's target moves off the value it was composed with. */ -private class ChangeLatch { - var changed = false -} - -/** - * A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there - * is something to animate. - * - * `updateTransition` allocates a transition, its animation list and its seeking state on *first - * composition*, even though first composition has nothing to cross-fade — target and initial state - * are the same value. In a feed that is waste: every card scrolled in builds a transition per - * animated element, and during a scroll essentially none of them run, because the underlying counts - * and icons do not change in the second a card is on screen. - * - * So the plain content renders until the target actually moves. At that point the transition is - * built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at - * the new one, so the first real change still animates exactly as before; every later change - * animates through the now-live transition normally. - */ -@OptIn(ExperimentalAnimationApi::class) -@Composable -internal fun DeferredCrossfade( - targetState: T, - modifier: Modifier, - contentAlignment: Alignment, - animationSpec: FiniteAnimationSpec, - label: String, - content: @Composable (T) -> Unit, -) { - val initial = remember { targetState } - val latch = remember { ChangeLatch() } - if (targetState != initial) latch.changed = true - - if (!latch.changed) { - Box(modifier, contentAlignment) { - content(targetState) - } - } else { - val transitionState = remember { MutableTransitionState(initial) } - transitionState.targetState = targetState - val transition = rememberTransition(transitionState, label) - transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) - } -} - -@OptIn(ExperimentalAnimationApi::class) -@Composable -fun MyCrossfade( - targetState: T, - modifier: Modifier = Modifier, - contentAlignment: Alignment = Alignment.TopStart, - animationSpec: FiniteAnimationSpec = tween(), - label: String = "Crossfade", - content: @Composable (T) -> Unit, -) { - val transition = updateTransition(targetState, label) - transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) -} - -@ExperimentalAnimationApi -@Composable -fun Transition.MyCrossfade( - modifier: Modifier = Modifier, - contentAlignment: Alignment = Alignment.TopStart, - animationSpec: FiniteAnimationSpec = tween(), - contentKey: (targetState: T) -> Any? = { it }, - content: @Composable (targetState: T) -> Unit, -) { - val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } } - val contentMap = remember { mutableScatterMapOf Unit>() } - // If not animating, just display the current state - if (currentState == targetState && (currentlyVisible.size != 1 || currentlyVisible[0] != targetState)) { - // Remove all the intermediate items from the list once the animation is finished. - currentlyVisible.removeAll { it != targetState } - contentMap.clear() - } - - if (targetState !in contentMap) { - // Replace target with the same key if any - val replacementId = - currentlyVisible.indexOfFirst { contentKey(it) == contentKey(targetState) } - if (replacementId == -1) { - currentlyVisible.add(targetState) - } else { - currentlyVisible[replacementId] = targetState - } - contentMap.clear() - currentlyVisible.fastForEach { stateForContent -> - contentMap[stateForContent] = { - val alpha by - animateFloat(transitionSpec = { animationSpec }) { - if (it == stateForContent) 1f else 0f - } - Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { content(stateForContent) } - } - } - } - - Box(modifier, contentAlignment) { - currentlyVisible.fastForEach { key(contentKey(it)) { contentMap[it]?.invoke() } } - } -} +) = CrossfadeIfEnabled( + targetState = targetState, + enabled = !accountViewModel.settings.isPerformanceMode(), + modifier = modifier, + contentAlignment = contentAlignment, + animationSpec = animationSpec, + label = label, + content = content, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt index 977181c7f1..4200433286 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt @@ -20,29 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_confirm -import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_explainer -import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_field -import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_title -import com.vitorpamplona.amethyst.commons.resources.cancel -import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.nip32Labeling.ui.HashtagLabelDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** @@ -56,53 +36,8 @@ fun AddHashtagLabelDialog( accountViewModel: AccountViewModel, onDismiss: () -> Unit, ) { - var hashtag by remember { mutableStateOf("") } - - // Strip the leading '#', drop whitespace and lowercase so the stored label matches the - // hashtag-feed convention. A blank result disables the confirm button. - val sanitized = - hashtag - .trim() - .removePrefix("#") - .filterNot { it.isWhitespace() } - .lowercase() - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringRes(Res.string.add_hashtag_label_title)) }, - text = { - Column { - Text(stringRes(Res.string.add_hashtag_label_explainer)) - OutlinedTextField( - value = hashtag, - onValueChange = { hashtag = it }, - singleLine = true, - label = { Text(stringRes(Res.string.add_hashtag_label_field)) }, - prefix = { Text("#") }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - enabled = sanitized.isNotEmpty(), - onClick = { - accountViewModel.labelWithHashtag(note, sanitized) - onDismiss() - }, - ) { - Text(stringRes(Res.string.add_hashtag_label_confirm)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringRes(Res.string.cancel)) - } - }, + HashtagLabelDialog( + onConfirm = { hashtag -> accountViewModel.labelWithHashtag(note, hashtag) }, + onDismiss = onDismiss, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt index 0be95db94d..d606a31bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Nip.kt @@ -20,40 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.kinds -import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.commons.nipsOnNostr.ui.NipCard import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp -import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy5dp -import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds @Composable fun RenderNipContent( @@ -63,133 +35,5 @@ fun RenderNipContent( ) { val noteEvent = note.event as? NipTextEvent ?: return - NipNoteHeader(noteEvent, note, accountViewModel, nav) -} - -@Composable -private fun NipNoteHeader( - noteEvent: NipTextEvent, - note: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val title = remember(noteEvent) { noteEvent.title() } - val kinds = remember(noteEvent) { noteEvent.kinds() } - - Column( - modifier = - Modifier - .padding(top = Size5dp) - .clip(shape = QuoteBorder) - .border( - 1.dp, - MaterialTheme.colorScheme.subtleBorder, - QuoteBorder, - ), - verticalArrangement = SpacedBy5dp, - ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp), - ) - } - Text( - text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content }, - style = MaterialTheme.typography.bodySmall, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - color = Color.Gray, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - if (kinds.isNotEmpty()) { - FlowRow( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), - horizontalArrangement = SpacedBy5dp, - verticalArrangement = SpacedBy5dp, - itemVerticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(Res.string.kinds), - ) - kinds.forEach { - NoPaddingSuggestionChip( - label = it.toString(), - ) - } - } - } - } -} - -@Preview -@Composable -fun NipNoteHeaderPreview() { - val event = - NipTextEvent( - id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0", - pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", - createdAt = 1767978398, - content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.", - sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21", - tags = - arrayOf( - arrayOf("d", "trusted-translations"), - arrayOf("title", "Trusted Translations"), - arrayOf("k", "1011"), - arrayOf("k", "10041"), - arrayOf("k", "1011"), - arrayOf("k", "10041"), - arrayOf("k", "1011"), - arrayOf("k", "10041"), - arrayOf("k", "1011"), - arrayOf("k", "10041"), - arrayOf("client", "nostrhub.io"), - ), - ) - - LocalCache.justConsume(event, null, true) - val note = LocalCache.getOrCreateNote(event.id) - - ThemeComparisonColumn( - toPreview = { - NipNoteHeader( - noteEvent = event, - note = note, - accountViewModel = mockAccountViewModel(), - nav = EmptyNav(), - ) - }, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NoPaddingSuggestionChip( - label: String, - modifier: Modifier = Modifier, -) { - Surface( - shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look - color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color - modifier = modifier, - ) { - Text( - text = label, - // Apply desired internal padding to the Text itself - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } + NipCard(noteEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt index daf6d81628..d282f524b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt @@ -20,30 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.add_to_public_bookmarks -import com.vitorpamplona.amethyst.commons.resources.remove_from_public_bookmarks -import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.note.BookmarkToggleButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** @@ -56,10 +42,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel * Bookmarked state is read from the public bookmark id/address **sets** (not `List` * containment) so it reflects reliably for addressable notes and updates the moment the list * changes — the icon flipping filled/outline is the feedback, no toast needed. - * - * Rendered as a compact clickable glyph (not a Material [androidx.compose.material3.IconButton], - * whose 48dp minimum touch target would stand taller than the title it sits beside). [iconSize] - * defaults to a single title line so it aligns when placed next to a show/episode title. */ @Composable fun PodcastBookmarkButton( @@ -82,30 +64,16 @@ fun PodcastBookmarkButton( } } - Box( - modifier = - modifier - .clip(CircleShape) - .clickable( - role = Role.Button, - onClick = { - if (isBookmarked) { - accountViewModel.removePublicBookmark(note) - } else { - accountViewModel.addPublicBookmark(note) - } - }, - ).padding(4.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd, - contentDescription = - stringRes( - if (isBookmarked) Res.string.remove_from_public_bookmarks else Res.string.add_to_public_bookmarks, - ), - tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(iconSize), - ) - } + BookmarkToggleButton( + isBookmarked = isBookmarked, + onToggle = { + if (isBookmarked) { + accountViewModel.removePublicBookmark(note) + } else { + accountViewModel.addPublicBookmark(note) + } + }, + modifier = modifier, + iconSize = iconSize, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt index bf6c079f3c..22c1fc34c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayMembers.kt @@ -20,42 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.relay_join_request -import com.vitorpamplona.amethyst.commons.resources.relay_leave_request -import com.vitorpamplona.amethyst.commons.resources.relay_member_added -import com.vitorpamplona.amethyst.commons.resources.relay_member_removed -import com.vitorpamplona.amethyst.commons.resources.relay_members_added -import com.vitorpamplona.amethyst.commons.resources.relay_members_count -import com.vitorpamplona.amethyst.commons.resources.relay_members_removed -import com.vitorpamplona.amethyst.commons.resources.relay_membership_list +import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayAddMemberCard +import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayJoinRequestCard +import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayLeaveRequestCard +import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayMembershipListCard +import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRemoveMemberCard import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent +// Dispatcher entries for the NIP-43 relay-membership kinds. The cards draw only from the event, +// so the entries just decode the note; the rendering is in commonsUI nip43RelayMembers/ui. + @Composable fun RenderRelayMembershipList( baseNote: Note, @@ -63,15 +43,7 @@ fun RenderRelayMembershipList( nav: INav, ) { val noteEvent = baseNote.event as? RelayMembershipListEvent ?: return - val memberCount = remember(noteEvent) { noteEvent.members().size } - - RelayMemberEventCard( - icon = MaterialSymbols.People, - title = stringRes(Res.string.relay_membership_list), - subtitle = stringRes(Res.string.relay_members_count, memberCount), - nav = nav, - relayPubKey = noteEvent.pubKey, - ) + RelayMembershipListCard(noteEvent) } @Composable @@ -81,21 +53,7 @@ fun RenderRelayAddMember( nav: INav, ) { val noteEvent = baseNote.event as? RelayAddMemberEvent ?: return - val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() } - val title = - if (memberKeys.size == 1) { - stringRes(Res.string.relay_member_added) - } else { - stringRes(Res.string.relay_members_added, memberKeys.size) - } - val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } } - - RelayMemberEventCard( - icon = MaterialSymbols.PersonAdd, - title = title, - subtitle = subtitle, - relayPubKey = noteEvent.pubKey, - ) + RelayAddMemberCard(noteEvent) } @Composable @@ -105,21 +63,7 @@ fun RenderRelayRemoveMember( nav: INav, ) { val noteEvent = baseNote.event as? RelayRemoveMemberEvent ?: return - val memberKeys = remember(noteEvent) { noteEvent.memberPubKeys() } - val title = - if (memberKeys.size == 1) { - stringRes(Res.string.relay_member_removed) - } else { - stringRes(Res.string.relay_members_removed, memberKeys.size) - } - val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } } - - RelayMemberEventCard( - icon = MaterialSymbols.PersonRemove, - title = title, - subtitle = subtitle, - relayPubKey = noteEvent.pubKey, - ) + RelayRemoveMemberCard(noteEvent) } @Composable @@ -127,129 +71,11 @@ fun RenderRelayJoinRequest( baseNote: Note, accountViewModel: AccountViewModel, nav: INav, -) { - RelayMemberEventCard( - icon = MaterialSymbols.PersonAdd, - title = stringRes(Res.string.relay_join_request), - subtitle = null, - nav = nav, - relayPubKey = null, - ) -} +) = RelayJoinRequestCard() @Composable fun RenderRelayLeaveRequest( baseNote: Note, accountViewModel: AccountViewModel, nav: INav, -) { - RelayMemberEventCard( - icon = MaterialSymbols.AutoMirrored.ExitToApp, - title = stringRes(Res.string.relay_leave_request), - subtitle = null, - nav = nav, - relayPubKey = null, - ) -} - -@Composable -private fun RelayMemberEventCard( - icon: MaterialSymbol, - title: String, - subtitle: String?, - nav: INav? = null, - relayPubKey: String? = null, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - symbol = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Column { - Text( - text = title, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyLarge, - ) - subtitle?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -@Preview -@Composable -private fun RelayMembershipListCardPreview() { - ThemeComparisonColumn { - RelayMemberEventCard( - icon = MaterialSymbols.People, - title = "Relay membership list", - subtitle = "42 members", - ) - } -} - -@Preview -@Composable -private fun RelayAddMemberCardPreview() { - ThemeComparisonColumn { - RelayMemberEventCard( - icon = MaterialSymbols.PersonAdd, - title = "Member added to relay", - subtitle = "a1b2c3d4e5f6a7b8...", - ) - } -} - -@Preview -@Composable -private fun RelayRemoveMemberCardPreview() { - ThemeComparisonColumn { - RelayMemberEventCard( - icon = MaterialSymbols.PersonRemove, - title = "Member removed from relay", - subtitle = "a1b2c3d4e5f6a7b8...", - ) - } -} - -@Preview -@Composable -private fun RelayJoinRequestCardPreview() { - ThemeComparisonColumn { - RelayMemberEventCard( - icon = MaterialSymbols.PersonAdd, - title = "Relay join request", - subtitle = null, - ) - } -} - -@Preview -@Composable -private fun RelayLeaveRequestCardPreview() { - ThemeComparisonColumn { - RelayMemberEventCard( - icon = MaterialSymbols.AutoMirrored.ExitToApp, - title = "Relay leave request", - subtitle = null, - ) - } -} +) = RelayLeaveRequestCard() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt index e1edd7eb42..197323b319 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/JoinRelayGroupDialog.kt @@ -20,28 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.cancel -import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_hint -import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_label -import com.vitorpamplona.amethyst.commons.resources.relay_group_join_confirm -import com.vitorpamplona.amethyst.commons.resources.relay_group_join_title -import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayGroupInviteCodeDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** @@ -56,37 +37,11 @@ fun JoinRelayGroupDialog( onJoined: () -> Unit, onDismiss: () -> Unit, ) { - var code by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringRes(Res.string.relay_group_join_title)) }, - text = { - Column { - Text(stringRes(Res.string.relay_group_join_code_hint)) - OutlinedTextField( - value = code, - onValueChange = { code = it }, - singleLine = true, - label = { Text(stringRes(Res.string.relay_group_join_code_label)) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - } - }, - confirmButton = { - TextButton( - enabled = code.isNotBlank(), - onClick = { - accountViewModel.joinRelayGroup(channel, code.trim()) - onJoined() - onDismiss() - }, - ) { - Text(stringRes(Res.string.relay_group_join_confirm)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + RelayGroupInviteCodeDialog( + onJoin = { code -> + accountViewModel.joinRelayGroup(channel, code) + onJoined() }, + onDismiss = onDismiss, ) } diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index fa90c43d92..00bc47da2e 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -140,7 +140,7 @@ type (`LazyListState`, `TextFieldValue`, `TextFieldState`) goes to `commonsUI`. | `napplet` | mixed | Protocol/permission logic here; `NappletWebContract` (serves the shell/shim from `composeResources`) in `commonsUI`. | | `favorites`, `nip30CustomEmojis`, `nip34Git`, `nip85TrustedAssertions`, `nip53LiveActivities` | mixed | Logic here; each feature's `ui/` (or the flat `FavoriteAppIcon`, `EmojiSuggestionState`) in `commonsUI`. | | `chats` | yes | Composables shared by every chat kind (DMs, public chats, relay groups, concord): unread badge, divisors, system messages, author line, send button, reply toggle, new-conversation screen. Chat *models* are in `model/chats`. | -| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nip73ExternalIds`, `nip99Classifieds`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `buzz`, `cashu`, `concord`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. | +| `nip17Dm`, `nip23LongContent`, `nip28PublicChat`, `nip29RelayGroups`, `nip32Labeling`, `nip43RelayMembers`, `nip51Lists`, `nip52Calendar`, `nip56Reports`, `nip72ModCommunities`, `nip73ExternalIds`, `nip99Classifieds`, `nipC0CodeSnippets`, `nipCCGeocaching`, `birdstar`, `nipsOnNostr`, `buzz`, `cashu`, `concord`, `music`, `mediaServers`, `browser`, `profile`, `napplet` | yes / mixed | Single-feature UI under `/ui` (named after the `quartz` package, or its concern name when `quartz` has none). Where the package also has logic (`cashu`, `browser`, `napplet`, `profile`) that part stays here; the `buzz` and `concord` models are in `model/buzz` and `model/concord`. | ### Mixed (documented debt — see §4) | Package | UI? | Purpose | diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt new file mode 100644 index 0000000000..39132b97c9 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupInviteCodeDialog.kt @@ -0,0 +1,86 @@ +/* + * 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.commons.nip29RelayGroups.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_hint +import com.vitorpamplona.amethyst.commons.resources.relay_group_join_code_label +import com.vitorpamplona.amethyst.commons.resources.relay_group_join_confirm +import com.vitorpamplona.amethyst.commons.resources.relay_group_join_title +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Invite-code prompt for joining a closed (invite-only) NIP-29 group. Confirm hands the + * trimmed code to [onJoin] and then closes the dialog through [onDismiss]. + */ +@Composable +fun RelayGroupInviteCodeDialog( + onJoin: (code: String) -> Unit, + onDismiss: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.relay_group_join_title)) }, + text = { + Column { + Text(stringRes(Res.string.relay_group_join_code_hint)) + OutlinedTextField( + value = code, + onValueChange = { code = it }, + singleLine = true, + label = { Text(stringRes(Res.string.relay_group_join_code_label)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton( + enabled = code.isNotBlank(), + onClick = { + onJoin(code.trim()) + onDismiss() + }, + ) { + Text(stringRes(Res.string.relay_group_join_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt new file mode 100644 index 0000000000..507902ea28 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip32Labeling/ui/HashtagLabelDialog.kt @@ -0,0 +1,105 @@ +/* + * 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.commons.nip32Labeling.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_confirm +import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_explainer +import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_field +import com.vitorpamplona.amethyst.commons.resources.add_hashtag_label_title +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Asks for a hashtag to label a post with (NIP-32, `#t` namespace). The typed text is + * normalized the way hashtag feeds store tags: leading `#` and whitespace stripped, + * lowercased. Confirm hands that value to [onConfirm] and closes through [onDismiss]. + */ +@Composable +fun HashtagLabelDialog( + onConfirm: (hashtag: String) -> Unit, + onDismiss: () -> Unit, +) { + var hashtag by remember { mutableStateOf("") } + + // Strip the leading '#', drop whitespace and lowercase so the stored label matches the + // hashtag-feed convention. A blank result disables the confirm button. + val sanitized = + hashtag + .trim() + .removePrefix("#") + .filterNot { it.isWhitespace() } + .lowercase() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.add_hashtag_label_title)) }, + text = { + Column { + Text(stringRes(Res.string.add_hashtag_label_explainer)) + OutlinedTextField( + value = hashtag, + onValueChange = { hashtag = it }, + singleLine = true, + label = { Text(stringRes(Res.string.add_hashtag_label_field)) }, + prefix = { Text("#") }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + enabled = sanitized.isNotEmpty(), + onClick = { + onConfirm(sanitized) + onDismiss() + }, + ) { + Text(stringRes(Res.string.add_hashtag_label_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(Res.string.cancel)) + } + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt new file mode 100644 index 0000000000..65e4e7765f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip43RelayMembers/ui/RelayMemberCards.kt @@ -0,0 +1,224 @@ +/* + * 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.commons.nip43RelayMembers.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.relay_join_request +import com.vitorpamplona.amethyst.commons.resources.relay_leave_request +import com.vitorpamplona.amethyst.commons.resources.relay_member_added +import com.vitorpamplona.amethyst.commons.resources.relay_member_removed +import com.vitorpamplona.amethyst.commons.resources.relay_members_added +import com.vitorpamplona.amethyst.commons.resources.relay_members_count +import com.vitorpamplona.amethyst.commons.resources.relay_members_removed +import com.vitorpamplona.amethyst.commons.resources.relay_membership_list +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent +import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent +import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent + +/** NIP-43 kind 13534: the relay's current member list, shown as a count. */ +@Composable +fun RelayMembershipListCard(event: RelayMembershipListEvent) { + val memberCount = remember(event) { event.members().size } + + RelayMemberEventCard( + icon = MaterialSymbols.People, + title = stringRes(Res.string.relay_membership_list), + subtitle = stringRes(Res.string.relay_members_count, memberCount), + ) +} + +/** NIP-43 kind 8000: members the relay added. */ +@Composable +fun RelayAddMemberCard(event: RelayAddMemberEvent) { + val memberKeys = remember(event) { event.memberPubKeys() } + val title = + if (memberKeys.size == 1) { + stringRes(Res.string.relay_member_added) + } else { + stringRes(Res.string.relay_members_added, memberKeys.size) + } + val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } } + + RelayMemberEventCard( + icon = MaterialSymbols.PersonAdd, + title = title, + subtitle = subtitle, + ) +} + +/** NIP-43 kind 8001: members the relay removed. */ +@Composable +fun RelayRemoveMemberCard(event: RelayRemoveMemberEvent) { + val memberKeys = remember(event) { event.memberPubKeys() } + val title = + if (memberKeys.size == 1) { + stringRes(Res.string.relay_member_removed) + } else { + stringRes(Res.string.relay_members_removed, memberKeys.size) + } + val subtitle = remember(memberKeys) { memberKeys.joinToString(", ") { it.take(16) + "..." } } + + RelayMemberEventCard( + icon = MaterialSymbols.PersonRemove, + title = title, + subtitle = subtitle, + ) +} + +/** NIP-43 kind 28934: a request to join the relay. */ +@Composable +fun RelayJoinRequestCard() { + RelayMemberEventCard( + icon = MaterialSymbols.PersonAdd, + title = stringRes(Res.string.relay_join_request), + subtitle = null, + ) +} + +/** NIP-43 kind 28936: a request to leave the relay. */ +@Composable +fun RelayLeaveRequestCard() { + RelayMemberEventCard( + icon = MaterialSymbols.AutoMirrored.ExitToApp, + title = stringRes(Res.string.relay_leave_request), + subtitle = null, + ) +} + +@Composable +fun RelayMemberEventCard( + icon: MaterialSymbol, + title: String, + subtitle: String?, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column { + Text( + text = title, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge, + ) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Preview +@Composable +private fun RelayMembershipListCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = MaterialSymbols.People, + title = "Relay membership list", + subtitle = "42 members", + ) + } +} + +@Preview +@Composable +private fun RelayAddMemberCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = MaterialSymbols.PersonAdd, + title = "Member added to relay", + subtitle = "a1b2c3d4e5f6a7b8...", + ) + } +} + +@Preview +@Composable +private fun RelayRemoveMemberCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = MaterialSymbols.PersonRemove, + title = "Member removed from relay", + subtitle = "a1b2c3d4e5f6a7b8...", + ) + } +} + +@Preview +@Composable +private fun RelayJoinRequestCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = MaterialSymbols.PersonAdd, + title = "Relay join request", + subtitle = null, + ) + } +} + +@Preview +@Composable +private fun RelayLeaveRequestCardPreview() { + ThemeComparisonColumn { + RelayMemberEventCard( + icon = MaterialSymbols.AutoMirrored.ExitToApp, + title = "Relay leave request", + subtitle = null, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt new file mode 100644 index 0000000000..52a2f10eb4 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nipsOnNostr/ui/NipCard.kt @@ -0,0 +1,164 @@ +/* + * 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.commons.nipsOnNostr.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.kinds +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp +import com.vitorpamplona.amethyst.commons.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds + +/** Card for a NIP published on Nostr ("NIPs on Nostr"): title, summary or opening text, and the kinds it defines. */ +@Composable +fun NipCard(noteEvent: NipTextEvent) { + val title = remember(noteEvent) { noteEvent.title() } + val kinds = remember(noteEvent) { noteEvent.kinds() } + + Column( + modifier = + Modifier + .padding(top = Size5dp) + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + verticalArrangement = SpacedBy5dp, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp), + ) + } + Text( + text = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content }, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + color = Color.Gray, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + if (kinds.isNotEmpty()) { + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp), + horizontalArrangement = SpacedBy5dp, + verticalArrangement = SpacedBy5dp, + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(Res.string.kinds), + ) + kinds.forEach { + NoPaddingSuggestionChip( + label = it.toString(), + ) + } + } + } + } +} + +@Preview +@Composable +fun NipCardPreview() { + val event = + NipTextEvent( + id = "eb2b05394ff0014bb6a79c2eacfd1c80696821592f4dbf86c950c4bf16614aa0", + pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + createdAt = 1767978398, + content = "Trusted Translations\n--------------------\n\n`draft` `optional`\n\nThis NIP allows anyone to post a translation for any event on a `kind:76`.\n\n```js\n{\n \"kind\": 76,\n \"tags\": [\n [\"e\", \"\u003coriginal_event_id\u003e\", \"\u003crelay\u003e\"]\n [\"k\", \"\u003coriginal_event_kind\u003e\"]\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n [\"s\", \"title\", \"translated title tag\"]\n [\"s\", \"summary\", \"translated summary tag\"]\n ],\n \"content\": \"this is a translated version of the original content\",\n // ...other fields\n}\n```\n\n`e` tag points to the event being translated, `k` tag points to the kind of that event.\n\n`l` tags define the language this was translated to in lowercase codes as defined by ISO 639-1. \n\n`s` tags are the translations for tags in the original event. \n\n`.content` contains the translation of the original `.content`\n\nClients SHOULD request translations by `e` and `l` tags spoken by their user. For every tag being rendered, Clients SHOULD look for their translated versions.\n\nProviders SHOULD use the event id in the filter to know which events need translations.\n\nProviders MAY store their translations behind a paid relay with NIP-42 auth.\n\n## Declaring Translation Providers\n\nKind `10041` lists the user's authorized translation providers. Each `p` tag is followed by the `pubkey` of the service publishing kind 76s, and the relay translations can be found. Users can specify these publicly or privately by JSON-stringifying and encrypting the tag list in the `.content` using NIP-44. \n\n```js\n{\n \"kind\": 10041,\n \"tags\": [\n [\"p\", \"4fd5e210530e4f6b2cb083795834bfe5108324f1ed9f00ab73b9e8fcfe5f12fe\", \"wss://translations.nostr.com\"],\n [\"l\", \"\u003ctranslated_to_language_country\u003e\"], // ISO 639-1: \"en\", \"es\", ...\n [\"l\", \"\u003ctranslated_to_language_code\u003e\"], // ISO 639-1: \"en-us\", \"en-br\"\n //...\n}\n```\n\n`l` tags in this event are the languages the user understands and wants translations to.\n\nProviders SHOULD create the `10041` event and post to the user's outbox relay.", + sig = "7fa9f1d49c41c7bfbdad6d089d1c6685777c86de8fbda7662a630888658839f0444cb1398385f759461c09191b287fa222eec0ffe22b3fa8cf740f71aab9dc21", + tags = + arrayOf( + arrayOf("d", "trusted-translations"), + arrayOf("title", "Trusted Translations"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("k", "1011"), + arrayOf("k", "10041"), + arrayOf("client", "nostrhub.io"), + ), + ) + + ThemeComparisonColumn( + toPreview = { NipCard(event) }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoPaddingSuggestionChip( + label: String, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.extraSmall, // Use a small shape for chip look + color = MaterialTheme.colorScheme.secondaryContainer, // Default chip color + modifier = modifier, + ) { + Text( + text = label, + // Apply desired internal padding to the Text itself + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt new file mode 100644 index 0000000000..38c8ce3edc --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/Crossfade.kt @@ -0,0 +1,169 @@ +/* + * 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.commons.ui.components + +import androidx.collection.mutableScatterMapOf +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.util.fastForEach + +/** + * [Crossfade][MyCrossfade] between [targetState]s when [enabled], a plain swap otherwise (the app + * turns it off in performance mode). The animated path defers building its transition until the + * target first changes; see [DeferredCrossfade]. + */ +@Composable +fun CrossfadeIfEnabled( + targetState: T, + enabled: Boolean, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = tween(), + label: String = "Crossfade", + content: @Composable (T) -> Unit, +) { + if (enabled) { + DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content) + } else { + Box(modifier, contentAlignment) { + content(targetState) + } + } +} + +/** Latches the first time a crossfade's target moves off the value it was composed with. */ +private class ChangeLatch { + var changed = false +} + +/** + * A [MyCrossfade] that does not build its [androidx.compose.animation.core.Transition] until there + * is something to animate. + * + * `updateTransition` allocates a transition, its animation list and its seeking state on *first + * composition*, even though first composition has nothing to cross-fade — target and initial state + * are the same value. In a feed that is waste: every card scrolled in builds a transition per + * animated element, and during a scroll essentially none of them run, because the underlying counts + * and icons do not change in the second a card is on screen. + * + * So the plain content renders until the target actually moves. At that point the transition is + * built seeded at the *original* value via [MutableTransitionState] and immediately re-targeted at + * the new one, so the first real change still animates exactly as before; every later change + * animates through the now-live transition normally. + */ +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun DeferredCrossfade( + targetState: T, + modifier: Modifier, + contentAlignment: Alignment, + animationSpec: FiniteAnimationSpec, + label: String, + content: @Composable (T) -> Unit, +) { + val initial = remember { targetState } + val latch = remember { ChangeLatch() } + if (targetState != initial) latch.changed = true + + if (!latch.changed) { + Box(modifier, contentAlignment) { + content(targetState) + } + } else { + val transitionState = remember { MutableTransitionState(initial) } + transitionState.targetState = targetState + val transition = rememberTransition(transitionState, label) + transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) + } +} + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun MyCrossfade( + targetState: T, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = tween(), + label: String = "Crossfade", + content: @Composable (T) -> Unit, +) { + val transition = updateTransition(targetState, label) + transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content) +} + +@ExperimentalAnimationApi +@Composable +fun Transition.MyCrossfade( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopStart, + animationSpec: FiniteAnimationSpec = tween(), + contentKey: (targetState: T) -> Any? = { it }, + content: @Composable (targetState: T) -> Unit, +) { + val currentlyVisible = remember { mutableStateListOf().apply { add(currentState) } } + val contentMap = remember { mutableScatterMapOf Unit>() } + // If not animating, just display the current state + if (currentState == targetState && (currentlyVisible.size != 1 || currentlyVisible[0] != targetState)) { + // Remove all the intermediate items from the list once the animation is finished. + currentlyVisible.removeAll { it != targetState } + contentMap.clear() + } + + if (targetState !in contentMap) { + // Replace target with the same key if any + val replacementId = + currentlyVisible.indexOfFirst { contentKey(it) == contentKey(targetState) } + if (replacementId == -1) { + currentlyVisible.add(targetState) + } else { + currentlyVisible[replacementId] = targetState + } + contentMap.clear() + currentlyVisible.fastForEach { stateForContent -> + contentMap[stateForContent] = { + val alpha by + animateFloat(transitionSpec = { animationSpec }) { + if (it == stateForContent) 1f else 0f + } + Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) { content(stateForContent) } + } + } + } + + Box(modifier, contentAlignment) { + currentlyVisible.fastForEach { key(contentKey(it)) { contentMap[it]?.invoke() } } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.kt new file mode 100644 index 0000000000..8e72fb9ee8 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/BookmarkToggleButton.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.commons.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.add_to_public_bookmarks +import com.vitorpamplona.amethyst.commons.resources.remove_from_public_bookmarks +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Round bookmark toggle: a filled, primary-tinted bookmark when [isBookmarked], an outlined + * "add" bookmark otherwise. [onToggle] adds or removes the bookmark. + * + * A compact clickable glyph rather than a Material IconButton, whose 48dp minimum touch target + * would stand taller than a title it sits beside; [iconSize] defaults to one title line. + */ +@Composable +fun BookmarkToggleButton( + isBookmarked: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + iconSize: Dp = 20.dp, +) { + Box( + modifier = + modifier + .clip(CircleShape) + .clickable( + role = Role.Button, + onClick = onToggle, + ).padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd, + contentDescription = + stringRes( + if (isBookmarked) Res.string.remove_from_public_bookmarks else Res.string.add_to_public_bookmarks, + ), + tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(iconSize), + ) + } +} From 027463a3e99ac9e56271fbf0d5d8c0a9105ac6d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 21:31:47 +0000 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20third=20Render/Display=20batch?= =?UTF-8?q?=20=E2=80=94=20report=20dialog,=20relay-group=20menu=20items,?= =?UTF-8?q?=20git=20status=20buttons,=20RSVP=20row,=20chat=20subject=20dia?= =?UTF-8?q?log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each keeps its app entry (account reads, signing, publishing) and moves the rendering to commonsUI, taking values and callbacks: - ReportDialog(onBlock, onReport(reason, text), onDismiss) in nip56Reports/ui; ReportNoteDialog hides the author and files the report. - BuzzPinDropdownItem(isPinned, closeMenu, onToggle) in buzz/ui and RelayGroupMessagesDropdownItem(onMyList, closeMenu, onToggle) in nip29RelayGroups/ui; the app overloads read the starred set and the kind-10009 list. - GitStatusButtons(closedOrApplied, isPatchOrPr, onReopen, onMarkMerged, onClose) in nip34Git/ui, with CompactButtonHeight/Padding (now public; GitPullRequestChanges imports them). GitStatusActions keeps the moderation check, the status index read and the signing. - CalendarRsvpButtons(currentStatus, onSelect) in nip52Calendar/ui; CalendarRsvpRow keeps the RSVP lookup and publishing. - ChatroomSubjectDialog(initialSubject, onPost(subject, message), onClose) in nip17Dm/ui; NewChatroomSubjectDialog builds and sends the NIP-17 message. An unused rememberCoroutineScope is dropped. No behaviour changes: every display is the entry's former body. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6 --- .../amethyst/ui/note/types/CalendarRsvpRow.kt | 101 +------- .../ui/note/types/GitPullRequestChanges.kt | 2 + .../ui/note/types/GitStatusActions.kt | 68 +----- .../header/NewChatroomSubjectDialog.kt | 151 ++---------- .../relayGroup/BuzzChannelMenuItems.kt | 53 +--- .../loggedIn/report/ReportNoteDialog.kt | 203 +--------------- .../commons/buzz/ui/BuzzPinDropdownItem.kt | 62 +++++ .../nip17Dm/ui/ChatroomSubjectDialog.kt | 147 ++++++++++++ .../ui/RelayGroupMessagesDropdownItem.kt | 62 +++++ .../commons/nip34Git/ui/GitStatusButtons.kt | 98 ++++++++ .../nip52Calendar/ui/CalendarRsvpButtons.kt | 135 +++++++++++ .../commons/nip56Reports/ui/ReportDialog.kt | 227 ++++++++++++++++++ 12 files changed, 783 insertions(+), 526 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt index 7ba0e0a4bb..b3b9a80dd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRow.kt @@ -20,30 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going -import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe -import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going -import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarRsvpButtons import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -90,88 +72,9 @@ fun CalendarRsvpRow( ) } - Row( - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - RsvpButton( - label = stringRes(Res.string.calendar_rsvp_going), - status = RSVPStatusTag.STATUS.ACCEPTED, - currentStatus = currentStatus, - modifier = Modifier.weight(1f), - onClick = onTap, - ) - RsvpButton( - label = stringRes(Res.string.calendar_rsvp_maybe), - status = RSVPStatusTag.STATUS.TENTATIVE, - currentStatus = currentStatus, - modifier = Modifier.weight(1f), - onClick = onTap, - ) - RsvpButton( - label = stringRes(Res.string.calendar_rsvp_not_going), - status = RSVPStatusTag.STATUS.DECLINED, - currentStatus = currentStatus, - modifier = Modifier.weight(1f), - onClick = onTap, - ) - } + CalendarRsvpButtons(currentStatus, onTap) } -@Composable -private fun RsvpButton( - label: String, - status: RSVPStatusTag.STATUS, - currentStatus: RSVPStatusTag.STATUS?, - modifier: Modifier, - onClick: (RSVPStatusTag.STATUS) -> Unit, -) { - val selected = status == currentStatus - if (selected) { - FilledTonalButton( - onClick = { onClick(status) }, - modifier = modifier, - contentPadding = RsvpButtonPadding, - colors = - ButtonDefaults.filledTonalButtonColors( - containerColor = colorFor(status), - contentColor = Color.White, - ), - ) { - RsvpButtonLabel(label) - } - } else { - OutlinedButton( - onClick = { onClick(status) }, - modifier = modifier, - contentPadding = RsvpButtonPadding, - ) { - RsvpButtonLabel(label) - } - } -} - -private val RsvpButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) - -@Composable -private fun RsvpButtonLabel(label: String) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - ) -} - -@Composable -private fun colorFor(status: RSVPStatusTag.STATUS) = - when (status) { - RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary - RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary - RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error - } - /** * Deterministic per-target d-tag so each user's RSVP for a given event is a single addressable. * The format mirrors the a-tag coordinate so it's debuggable (`rsvp:31923::`). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt index eeec970d64..04f1c257aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonHeight +import com.vitorpamplona.amethyst.commons.nip34Git.ui.CompactButtonPadding import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.git_pr_changes_retry import com.vitorpamplona.amethyst.commons.resources.git_pr_loading_changes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt index eeb66e0737..79be00fbe5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -20,33 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.GitStatusIndex import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.git_status_close -import com.vitorpamplona.amethyst.commons.resources.git_status_mark_merged -import com.vitorpamplona.amethyst.commons.resources.git_status_reopen -import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.nip34Git.ui.GitStatusButtons import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event @@ -60,10 +41,6 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent private enum class StatusTarget { OPEN, CLOSED, APPLIED } -/** Compact sizing shared by the small action buttons on git cards. */ -internal val CompactButtonHeight = Modifier.height(32.dp) -internal val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp) - /** * NIP-34 status controls for an issue, patch or pull request. Visible only to the * people allowed to moderate the thread — the item's author and the repository @@ -88,42 +65,13 @@ fun GitStatusActions( val current = index?.get(note.idHex) val closedOrApplied = current is GitStatusClosedEvent || current is GitStatusAppliedEvent - FlowRow( - modifier = Modifier.padding(top = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (closedOrApplied) { - FilledTonalButton( - onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }, - modifier = CompactButtonHeight, - contentPadding = CompactButtonPadding, - ) { - Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp)) - Text(stringRes(Res.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) - } - } else { - if (isPatchOrPr) { - FilledTonalButton( - onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }, - modifier = CompactButtonHeight, - contentPadding = CompactButtonPadding, - ) { - Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) - Text(stringRes(Res.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) - } - } - OutlinedButton( - onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) }, - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - modifier = CompactButtonHeight, - contentPadding = CompactButtonPadding, - ) { - Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp)) - Text(stringRes(Res.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) - } - } - } + GitStatusButtons( + closedOrApplied = closedOrApplied, + isPatchOrPr = isPatchOrPr, + onReopen = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }, + onMarkMerged = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }, + onClose = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) }, + ) } private fun repositoryAddressOf(event: Event): Address? = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt index 437a9d7fd1..890d697cce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/NewChatroomSubjectDialog.kt @@ -20,42 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header -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.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.model.cache.LocalCache -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject -import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject_caption -import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message -import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message_placeholder -import com.vitorpamplona.amethyst.commons.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.commons.ui.note.buttons.PostButton -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.nip17Dm.ui.ChatroomSubjectDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent @@ -67,103 +34,25 @@ fun NewChatroomSubjectDialog( accountViewModel: AccountViewModel, room: ChatroomKey, ) { - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - val groupName = - remember { - mutableStateOf( - accountViewModel.account.chatroomList.rooms - .get(room) - ?.subject - ?.value ?: "", - ) - } - val message = remember { mutableStateOf("") } - val scope = rememberCoroutineScope() + ChatroomSubjectDialog( + initialSubject = + accountViewModel.account.chatroomList.rooms + .get(room) + ?.subject + ?.value ?: "", + onPost = { subject, message -> + accountViewModel.launchSigner { + val template = + ChatMessageEvent.build( + message, + room.users.map { LocalCache.getOrCreateUser(it).toPTag() }, + ) { + subject.ifBlank { null }?.let { changeSubject(it) } + } - Column( - modifier = - Modifier - .padding(10.dp) - .verticalScroll(rememberScrollState()), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = { onClose() }) - - PostButton( - onPost = { - accountViewModel.launchSigner { - val template = - ChatMessageEvent.build( - message.value, - room.users.map { LocalCache.getOrCreateUser(it).toPTag() }, - ) { - groupName.value.ifBlank { null }?.let { changeSubject(it) } - } - - accountViewModel.account.sendNip17PrivateMessage(template) - } - - onClose() - }, - true, - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(Res.string.messages_new_message_subject)) }, - modifier = Modifier.fillMaxWidth(), - value = groupName.value, - onValueChange = { groupName.value = it }, - placeholder = { - Text( - text = stringRes(Res.string.messages_new_message_subject_caption), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(Res.string.messages_new_subject_message)) }, - modifier = - Modifier - .fillMaxWidth() - .height(100.dp), - value = message.value, - onValueChange = { message.value = it }, - placeholder = { - Text( - text = stringRes(Res.string.messages_new_subject_message_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 10, - ) + accountViewModel.account.sendNip17PrivateMessage(template) } - } - } + }, + onClose = onClose, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt index 61db159967..81836641f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/BuzzChannelMenuItems.kt @@ -20,25 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup -import androidx.compose.foundation.layout.size -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text 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.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.buzz.ui.BuzzPinDropdownItem import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.add_to_messages -import com.vitorpamplona.amethyst.commons.resources.buzz_pin -import com.vitorpamplona.amethyst.commons.resources.buzz_unpin -import com.vitorpamplona.amethyst.commons.resources.remove_from_messages -import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.nip29RelayGroups.ui.RelayGroupMessagesDropdownItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip29RelayGroups.GroupId @@ -55,21 +43,11 @@ fun BuzzPinDropdownItem( ) { val stars = accountViewModel.account.buzzChannelStars val starred by stars.flow.collectAsStateWithLifecycle() - val isStarred = groupId.id in starred - DropdownMenuItem( - leadingIcon = { - Icon( - symbol = MaterialSymbols.PushPin, - contentDescription = null, - tint = if (isStarred) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (isStarred) Res.string.buzz_unpin else Res.string.buzz_pin)) }, - onClick = { - closeMenu() - stars.toggle(groupId.id) - }, + + BuzzPinDropdownItem( + isPinned = groupId.id in starred, + closeMenu = closeMenu, + onToggle = { stars.toggle(groupId.id) }, ) } @@ -88,18 +66,11 @@ fun RelayGroupMessagesDropdownItem( val joinedGroupIds by accountViewModel.account.relayGroupList.liveRelayGroupIds .collectAsStateWithLifecycle() val onMyList = channel.groupId in joinedGroupIds - DropdownMenuItem( - leadingIcon = { - Icon( - symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - }, - text = { Text(stringRes(if (onMyList) Res.string.remove_from_messages else Res.string.add_to_messages)) }, - onClick = { - closeMenu() + + RelayGroupMessagesDropdownItem( + onMyList = onMyList, + closeMenu = closeMenu, + onToggle = { if (onMyList) { accountViewModel.removeRelayGroupFromMessages(channel) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt index 4344908a75..23017a308e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/report/ReportNoteDialog.kt @@ -20,210 +20,23 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.report -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.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.block_only -import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_label -import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_placeholder -import com.vitorpamplona.amethyst.commons.resources.report_dialog_block_hide_user_btn -import com.vitorpamplona.amethyst.commons.resources.report_dialog_blocking_a_user -import com.vitorpamplona.amethyst.commons.resources.report_dialog_illegal -import com.vitorpamplona.amethyst.commons.resources.report_dialog_impersonation -import com.vitorpamplona.amethyst.commons.resources.report_dialog_nudity -import com.vitorpamplona.amethyst.commons.resources.report_dialog_post_report_btn -import com.vitorpamplona.amethyst.commons.resources.report_dialog_profanity -import com.vitorpamplona.amethyst.commons.resources.report_dialog_reminder_public -import com.vitorpamplona.amethyst.commons.resources.report_dialog_report_btn -import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_label -import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_placeholder -import com.vitorpamplona.amethyst.commons.resources.report_dialog_spam -import com.vitorpamplona.amethyst.commons.resources.report_dialog_title -import com.vitorpamplona.amethyst.commons.resources.report_malware -import com.vitorpamplona.amethyst.commons.resources.violence -import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner -import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer -import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.commons.ui.theme.LightRedColor +import com.vitorpamplona.amethyst.commons.nip56Reports.ui.ReportDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip56Reports.ReportType -import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportNoteDialog( note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, ) { - val reportTypes = - listOf( - Pair(ReportType.SPAM, stringRes(Res.string.report_dialog_spam)), - Pair(ReportType.PROFANITY, stringRes(Res.string.report_dialog_profanity)), - Pair(ReportType.IMPERSONATION, stringRes(Res.string.report_dialog_impersonation)), - Pair(ReportType.NUDITY, stringRes(Res.string.report_dialog_nudity)), - Pair(ReportType.ILLEGAL, stringRes(Res.string.report_dialog_illegal)), - Pair(ReportType.MALWARE, stringRes(Res.string.report_malware)), - Pair(ReportType.VIOLENCE, stringRes(Res.string.violence)), - ) - - val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() } - var additionalReason by remember { mutableStateOf("") } - var selectedReason by remember { mutableIntStateOf(-1) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(text = stringRes(id = Res.string.report_dialog_title)) }, - navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { pad -> - Column( - modifier = - Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()), - verticalArrangement = Arrangement.SpaceAround, - ) { - SpacerH16() - SectionHeader(text = stringRes(id = Res.string.block_only)) - SpacerH16() - Text( - text = stringRes(Res.string.report_dialog_blocking_a_user), - ) - SpacerH16() - ActionButton( - text = stringRes(Res.string.report_dialog_block_hide_user_btn), - icon = MaterialSymbols.Block, - onClick = { - note.author?.let { accountViewModel.hide(it) } - onDismiss() - }, - ) - SpacerH16() - - HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness) - - SpacerH16() - SectionHeader(text = stringRes(Res.string.report_dialog_report_btn)) - SpacerH16() - Text(stringRes(Res.string.report_dialog_reminder_public)) - SpacerH16() - TextSpinner( - label = stringRes(Res.string.report_dialog_select_reason_label), - placeholder = stringRes(Res.string.report_dialog_select_reason_placeholder), - options = reasonOptions, - onSelect = { selectedReason = it }, - modifier = Modifier.fillMaxWidth(), - ) - SpacerH16() - OutlinedTextField( - value = additionalReason, - onValueChange = { additionalReason = it }, - placeholder = { - Text(text = stringRes(Res.string.report_dialog_additional_reason_placeholder)) - }, - label = { Text(stringRes(Res.string.report_dialog_additional_reason_label)) }, - modifier = Modifier.fillMaxWidth(), - ) - SpacerH16() - - ActionButton( - text = stringRes(Res.string.report_dialog_post_report_btn), - icon = MaterialSymbols.Report, - enabled = selectedReason in 0..reportTypes.lastIndex, - onClick = { - accountViewModel.report( - note, - reportTypes[selectedReason].first, - additionalReason, - ) - note.author?.let { accountViewModel.hide(it) } - onDismiss() - }, - ) - } - } - } -} - -@Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp)) - -@Composable -private fun SectionHeader(text: String) = - Text( - text = text, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 18.sp, + ReportDialog( + onBlock = { note.author?.let { accountViewModel.hide(it) } }, + onReport = { reason, additionalReason -> + accountViewModel.report(note, reason, additionalReason) + note.author?.let { accountViewModel.hide(it) } + }, + onDismiss = onDismiss, ) - -@Composable -private fun ActionButton( - text: String, - icon: MaterialSymbol, - enabled: Boolean = true, - onClick: () -> Unit, -) = Button( - onClick = onClick, - enabled = enabled, - colors = ButtonDefaults.buttonColors(containerColor = LightRedColor), - modifier = Modifier.fillMaxWidth(), -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - symbol = icon, - contentDescription = null, - tint = Color.White, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = text, color = Color.White) - } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt new file mode 100644 index 0000000000..f88494e0a6 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/buzz/ui/BuzzPinDropdownItem.kt @@ -0,0 +1,62 @@ +/* + * 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.commons.buzz.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.buzz_pin +import com.vitorpamplona.amethyst.commons.resources.buzz_unpin +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Pin/Unpin overflow-menu item for a Buzz channel. [isPinned] picks the label and tints the pin; + * clicking closes the menu, then calls [onToggle]. + */ +@Composable +fun BuzzPinDropdownItem( + isPinned: Boolean, + closeMenu: () -> Unit, + onToggle: () -> Unit, +) { + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (isPinned) Res.string.buzz_unpin else Res.string.buzz_pin)) }, + onClick = { + closeMenu() + onToggle() + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt new file mode 100644 index 0000000000..e3cb9e8c87 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip17Dm/ui/ChatroomSubjectDialog.kt @@ -0,0 +1,147 @@ +/* + * 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.commons.nip17Dm.ui + +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.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject +import com.vitorpamplona.amethyst.commons.resources.messages_new_message_subject_caption +import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message +import com.vitorpamplona.amethyst.commons.resources.messages_new_subject_message_placeholder +import com.vitorpamplona.amethyst.commons.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.commons.ui.note.buttons.PostButton +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText + +/** + * Rename a private group chat (NIP-17): edit the subject and write the message that carries the + * change. [onPost] receives the subject (possibly blank) and the message, then the dialog closes. + */ +@Composable +fun ChatroomSubjectDialog( + initialSubject: String, + onPost: (subject: String, message: String) -> Unit, + onClose: () -> Unit, +) { + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + Surface { + val groupName = remember { mutableStateOf(initialSubject) } + val message = remember { mutableStateOf("") } + + Column( + modifier = + Modifier + .padding(10.dp) + .verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton(onPress = { onClose() }) + + PostButton( + onPost = { + onPost(groupName.value, message.value) + onClose() + }, + true, + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(Res.string.messages_new_message_subject)) }, + modifier = Modifier.fillMaxWidth(), + value = groupName.value, + onValueChange = { groupName.value = it }, + placeholder = { + Text( + text = stringRes(Res.string.messages_new_message_subject_caption), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(Res.string.messages_new_subject_message)) }, + modifier = + Modifier + .fillMaxWidth() + .height(100.dp), + value = message.value, + onValueChange = { message.value = it }, + placeholder = { + Text( + text = stringRes(Res.string.messages_new_subject_message_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 10, + ) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt new file mode 100644 index 0000000000..797b767231 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip29RelayGroups/ui/RelayGroupMessagesDropdownItem.kt @@ -0,0 +1,62 @@ +/* + * 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.commons.nip29RelayGroups.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.add_to_messages +import com.vitorpamplona.amethyst.commons.resources.remove_from_messages +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Add/Remove overflow-menu item for whether a relay group shows in Messages. [onMyList] picks + * the label and icon; clicking closes the menu, then calls [onToggle]. + */ +@Composable +fun RelayGroupMessagesDropdownItem( + onMyList: Boolean, + closeMenu: () -> Unit, + onToggle: () -> Unit, +) { + DropdownMenuItem( + leadingIcon = { + Icon( + symbol = if (onMyList) MaterialSymbols.VisibilityOff else MaterialSymbols.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + }, + text = { Text(stringRes(if (onMyList) Res.string.remove_from_messages else Res.string.add_to_messages)) }, + onClick = { + closeMenu() + onToggle() + }, + ) +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt new file mode 100644 index 0000000000..59e1d5c36f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/GitStatusButtons.kt @@ -0,0 +1,98 @@ +/* + * 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.commons.nip34Git.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.git_status_close +import com.vitorpamplona.amethyst.commons.resources.git_status_mark_merged +import com.vitorpamplona.amethyst.commons.resources.git_status_reopen +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** Compact sizing shared by the small action buttons on git cards. */ +val CompactButtonHeight = Modifier.height(32.dp) +val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp) + +/** + * NIP-34 status buttons for an issue, patch or pull request: "Reopen" when it is + * [closedOrApplied], otherwise "Mark merged" (patches and PRs only, [isPatchOrPr]) and "Close". + * The host decides who may see them and publishes the status event. + */ +@Composable +fun GitStatusButtons( + closedOrApplied: Boolean, + isPatchOrPr: Boolean, + onReopen: () -> Unit, + onMarkMerged: () -> Unit, + onClose: () -> Unit, +) { + FlowRow( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (closedOrApplied) { + FilledTonalButton( + onClick = onReopen, + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(Res.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) + } + } else { + if (isPatchOrPr) { + FilledTonalButton( + onClick = onMarkMerged, + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(Res.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) + } + } + OutlinedButton( + onClick = onClose, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(Res.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt new file mode 100644 index 0000000000..33f47db5ca --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarRsvpButtons.kt @@ -0,0 +1,135 @@ +/* + * 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.commons.nip52Calendar.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going +import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe +import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag + +/** + * Going / Maybe / Not going buttons for a NIP-52 calendar event. The one matching + * [currentStatus] is filled in its status colour; the others are outlined. [onSelect] publishes + * the chosen status. + */ +@Composable +fun CalendarRsvpButtons( + currentStatus: RSVPStatusTag.STATUS?, + onSelect: (RSVPStatusTag.STATUS) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RsvpButton( + label = stringRes(Res.string.calendar_rsvp_going), + status = RSVPStatusTag.STATUS.ACCEPTED, + currentStatus = currentStatus, + modifier = Modifier.weight(1f), + onClick = onSelect, + ) + RsvpButton( + label = stringRes(Res.string.calendar_rsvp_maybe), + status = RSVPStatusTag.STATUS.TENTATIVE, + currentStatus = currentStatus, + modifier = Modifier.weight(1f), + onClick = onSelect, + ) + RsvpButton( + label = stringRes(Res.string.calendar_rsvp_not_going), + status = RSVPStatusTag.STATUS.DECLINED, + currentStatus = currentStatus, + modifier = Modifier.weight(1f), + onClick = onSelect, + ) + } +} + +@Composable +private fun RsvpButton( + label: String, + status: RSVPStatusTag.STATUS, + currentStatus: RSVPStatusTag.STATUS?, + modifier: Modifier, + onClick: (RSVPStatusTag.STATUS) -> Unit, +) { + val selected = status == currentStatus + if (selected) { + FilledTonalButton( + onClick = { onClick(status) }, + modifier = modifier, + contentPadding = RsvpButtonPadding, + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = colorFor(status), + contentColor = Color.White, + ), + ) { + RsvpButtonLabel(label) + } + } else { + OutlinedButton( + onClick = { onClick(status) }, + modifier = modifier, + contentPadding = RsvpButtonPadding, + ) { + RsvpButtonLabel(label) + } + } +} + +private val RsvpButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) + +@Composable +private fun RsvpButtonLabel(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun colorFor(status: RSVPStatusTag.STATUS) = + when (status) { + RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary + RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary + RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error + } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt new file mode 100644 index 0000000000..a8cd7086c1 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip56Reports/ui/ReportDialog.kt @@ -0,0 +1,227 @@ +/* + * 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.commons.nip56Reports.ui + +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.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.block_only +import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_label +import com.vitorpamplona.amethyst.commons.resources.report_dialog_additional_reason_placeholder +import com.vitorpamplona.amethyst.commons.resources.report_dialog_block_hide_user_btn +import com.vitorpamplona.amethyst.commons.resources.report_dialog_blocking_a_user +import com.vitorpamplona.amethyst.commons.resources.report_dialog_illegal +import com.vitorpamplona.amethyst.commons.resources.report_dialog_impersonation +import com.vitorpamplona.amethyst.commons.resources.report_dialog_nudity +import com.vitorpamplona.amethyst.commons.resources.report_dialog_post_report_btn +import com.vitorpamplona.amethyst.commons.resources.report_dialog_profanity +import com.vitorpamplona.amethyst.commons.resources.report_dialog_reminder_public +import com.vitorpamplona.amethyst.commons.resources.report_dialog_report_btn +import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_label +import com.vitorpamplona.amethyst.commons.resources.report_dialog_select_reason_placeholder +import com.vitorpamplona.amethyst.commons.resources.report_dialog_spam +import com.vitorpamplona.amethyst.commons.resources.report_dialog_title +import com.vitorpamplona.amethyst.commons.resources.report_malware +import com.vitorpamplona.amethyst.commons.resources.violence +import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner +import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.commons.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.commons.ui.theme.LightRedColor +import com.vitorpamplona.quartz.nip56Reports.ReportType +import kotlinx.collections.immutable.toImmutableList + +/** + * Block-or-report dialog for a post. [onBlock] hides the author; [onReport] files a public + * NIP-56 report with the chosen reason and optional text (the host also hides the author). + * Both close the dialog through [onDismiss] afterwards. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportDialog( + onBlock: () -> Unit, + onReport: (reason: ReportType, additionalReason: String) -> Unit, + onDismiss: () -> Unit, +) { + val reportTypes = + listOf( + Pair(ReportType.SPAM, stringRes(Res.string.report_dialog_spam)), + Pair(ReportType.PROFANITY, stringRes(Res.string.report_dialog_profanity)), + Pair(ReportType.IMPERSONATION, stringRes(Res.string.report_dialog_impersonation)), + Pair(ReportType.NUDITY, stringRes(Res.string.report_dialog_nudity)), + Pair(ReportType.ILLEGAL, stringRes(Res.string.report_dialog_illegal)), + Pair(ReportType.MALWARE, stringRes(Res.string.report_malware)), + Pair(ReportType.VIOLENCE, stringRes(Res.string.violence)), + ) + + val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() } + var additionalReason by remember { mutableStateOf("") } + var selectedReason by remember { mutableIntStateOf(-1) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringRes(id = Res.string.report_dialog_title)) }, + navigationIcon = { IconButton(onClick = onDismiss) { ArrowBackIcon() } }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Column( + modifier = + Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()), + verticalArrangement = Arrangement.SpaceAround, + ) { + SpacerH16() + SectionHeader(text = stringRes(id = Res.string.block_only)) + SpacerH16() + Text( + text = stringRes(Res.string.report_dialog_blocking_a_user), + ) + SpacerH16() + ActionButton( + text = stringRes(Res.string.report_dialog_block_hide_user_btn), + icon = MaterialSymbols.Block, + onClick = { + onBlock() + onDismiss() + }, + ) + SpacerH16() + + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface, thickness = DividerThickness) + + SpacerH16() + SectionHeader(text = stringRes(Res.string.report_dialog_report_btn)) + SpacerH16() + Text(stringRes(Res.string.report_dialog_reminder_public)) + SpacerH16() + TextSpinner( + label = stringRes(Res.string.report_dialog_select_reason_label), + placeholder = stringRes(Res.string.report_dialog_select_reason_placeholder), + options = reasonOptions, + onSelect = { selectedReason = it }, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH16() + OutlinedTextField( + value = additionalReason, + onValueChange = { additionalReason = it }, + placeholder = { + Text(text = stringRes(Res.string.report_dialog_additional_reason_placeholder)) + }, + label = { Text(stringRes(Res.string.report_dialog_additional_reason_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + SpacerH16() + + ActionButton( + text = stringRes(Res.string.report_dialog_post_report_btn), + icon = MaterialSymbols.Report, + enabled = selectedReason in 0..reportTypes.lastIndex, + onClick = { + onReport(reportTypes[selectedReason].first, additionalReason) + onDismiss() + }, + ) + } + } + } +} + +@Composable private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp)) + +@Composable +private fun SectionHeader(text: String) = + Text( + text = text, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 18.sp, + ) + +@Composable +private fun ActionButton( + text: String, + icon: MaterialSymbol, + enabled: Boolean = true, + onClick: () -> Unit, +) = Button( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = LightRedColor), + modifier = Modifier.fillMaxWidth(), +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + symbol = icon, + contentDescription = null, + tint = Color.White, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, color = Color.White) + } +}