diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md new file mode 100644 index 000000000..36bf02c91 --- /dev/null +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -0,0 +1,77 @@ +--- +name: find-missing-translations +description: Use when comparing Android strings.xml locale files to find untranslated string resources, missing translation keys, or preparing translation work for a specific language +--- + +# Find Missing Translations + +## Overview + +Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation. + +## When to Use + +- Need to find untranslated strings for a specific locale +- Preparing a batch of strings for a translator +- Checking translation coverage after adding new features + +## Technique + +### 1. Identify files + +``` +Default: amethyst/src/main/res/values/strings.xml +Target: amethyst/src/main/res/values-/strings.xml +``` + +Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`). + +### 2. Extract and diff keys + +Use a single bash pipeline to extract translatable keys from both files and diff them: + +```bash +# Extract translatable keys from default (exclude translatable="false") +comm -23 \ + <(grep 'Valid + Valid from %1$s + Lists +``` + +Also check `` and `` tags using the same approach if the project uses them. + +## Common Mistakes + +- **Forgetting `translatable="false"`** — these should never appear in locale files +- **Not checking string-arrays/plurals** — only checking `` misses other resource types +- **Modifying files** — this is a read-only research task unless the user asks to add entries \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 51195f4cb..26ef7ac12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ + +# [Release v1.06.0: Polls, Relay Feeds, Wallets and much more](https://github.com/vitorpamplona/amethyst/releases/tag/v1.06.0) - 2025-03-21 + Adds support for creating and rendering NIP-85 Polls +- Redesign of the poll and zap poll cards +- Adds Special notification card that stays while the poll is running Adds support for Relay Feeds - Adds support for NIP-51 favorite relay feeds @@ -15,7 +20,10 @@ Redesigns Media Player - Turn video controller creation into a flow to fix playback lifecycle issues - Adds support for uploading audio -Adds support for NIP-47 Wallets +Adds support for NIP-47 Wallets and compete NWC spec +- Adds views for Balance and Transactions +- Add transaction filtering and pagination to wallet screen +- Added several test cases from other repos to guarantee interoperability Adds support for NIP-52 Calendar appointments @@ -29,30 +37,32 @@ Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel00 Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents. -Adds support for NIP-40 Expirations in any new post. +Adds support for custom NIP-40 Expirations in any new post. +- Displays expirations on posts and DMs Adds support for NIP-66 Relay Monitor and discovery support to Quartz -Adds support for Namecoin .bit urls to NIP-05 -- Adds choice of ElectrumX server to resolve namecoins. +Adds support for Attestations (https://attestr.xyz/) Adds basic support for Chess with Jester protocol Adds NIP-46 Bunker support to Quartz and Amethyst Desktop -Adds a Broadcasting feedback pop-up in the Complete UI mode +Adds support for inline reply, mark as read from Push Notifications -Adds support for rendering Zap events when quoted inside of posts. +Removes NIP-04 DMs and blocks DM sending if the receiver doesn't have NIP-17 relay lists. -Removes support for NIP-96 and updates Blossom recommendations - -Adds support to upload Documents to all new post screens. +Uploads: +- Adds support to upload Documents to all new post screens. +- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Adds encrypted file upload fallback option for NIP-17 chats +- Removes support for NIP-96 and updates Blossom recommendations Content warning improvements: -- Adds optional description field for sensitive content warnings in new posts. +- Adds an optional description field for sensitive content warnings in new posts. - Displays additional information on warning composables -Redesigns and reorganizes Setting pages +Settings redesign - Consolidate drawer settings into a single Settings hub screen - Redesigns Zap Amount and NWC setup screens - Redesigns Custom zap amount screens @@ -67,7 +77,14 @@ URL/URI parser rewrite in Kotlin multiplatform (KMP) - Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd - Adds a parser for blossom: uris -Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx +UI Improvements: +- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx +- New UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- New UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Draft Screen requests confirmation before deleting drafts on swipe +- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef +- Adds support for rendering Zap events when quoted inside of posts. +- Adds a Broadcasting feedback pop-up in the Complete UI mode Relay Management: - Adds relay search tooltip when adding relays @@ -75,7 +92,9 @@ Relay Management: - Adds active subscriptions and outbox event in the queue to relay information - Adds a complete list of event kind names to the subscription card to relay information - Tracks and displays connection success rate on relay settings -- Add relay settings export functionality +- Adds relay settings export functionality +- Adds NIP-45 count queries to show how many events each relay has. +- Adds Relay sync utility to help users move posts between relays. Search fixes - Breaks the search filter into two subscriptions to prioritize Metadata without punishing content. @@ -88,11 +107,14 @@ Search fixes Profiles: - Adds a profile picture upload button when the user has no picture +- Adds last seen to the user profile +- Adds nprofile and npub copy options to the profile - Groups received zap amounts by sending the user in the profile tab - Increases the limit of Zap downloads for profiles to 1000 - Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx - Migrates profile galleries to display a thumbnail for videos - Fixes profile galleries' aspect ratios +- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins. Bulk Follow onboarding - Adds screens to search for a user and to copy his/her follow list @@ -126,6 +148,7 @@ Fixes: - Fixes bug on Show More calculations for very long texts without spaces - Fixing IO Dispatchers and coroutine scopes of choice - Fixes anySync parallel operation that was returning the first result, not the first positive "any". +- Fixes Req onCannotConnect listeners to the relays that actually sent the req AI: - Add SKILL.md for AI agent customization @@ -137,6 +160,14 @@ Defaults: - Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays - Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers +Quartz: +- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support +- Adds support for dynamic policies to the relay implementation. +- Migrates Quartz EventStore from Android-only to KMP +- Adds a reqUntilEoseAsFlow extension to the Nostr Client +- Adds comprehensive NIP-46 Bunker support +- Adds comprehensive support for NIP-47 non-payment methods. + Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k - Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge. - Provide implementation for LargeCache, using a CacheMap @@ -149,7 +180,6 @@ Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53 - Provide implementation for AESGCM - Provide implementation for DigestInstance - Provide implementation for LibSodium -- Adds a reqUntilEoseAsFlow extension to the Nostr Client Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c - Adds NIP-46 Bunker Login @@ -159,6 +189,8 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7 - Adds encrypted DMs (NIP-04/NIP-17) - Adds proper empty states with EOSE tracking - Adds multi-column deck layout +- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox +- Adds advanced search with NIP-50, collapsible sections, and nav state preservation - Clear stored credentials on logout - Adds bunker heartbeat indicator - Adds QR-based signer pairing @@ -176,6 +208,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7 Code Quality - Migrates to AGP 9.0 +- Adds Amethyst Desktop to CI/CD and Release builds - Removes the in-app memory counter methods - Refactors the old NIP-05 code on Quartz - Migrates contact list management to addressable notes @@ -7008,4 +7041,4 @@ First public version with: [v0.4]: https://github.com/vitorpamplona/amethyst/compare/v0.3...v0.4 [v0.3]: https://github.com/vitorpamplona/amethyst/compare/v0.2...v0.3 [v0.2]: https://github.com/vitorpamplona/amethyst/compare/v0.1...v0.2 -[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1 \ No newline at end of file +[v0.1]: https://github.com/vitorpamplona/amethyst/tree/v0.1 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6ddfc6fd9..d1c8d7e2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -619,6 +623,30 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( + event: AttestationEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: AttestationRequestEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: AttestorRecommendationEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: AttestorProficiencyEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consumeRegularEvent( event: Event, relay: NormalizedRelayUrl?, @@ -3051,6 +3079,10 @@ object LocalCache : ILocalCache, ICacheProvider { is AppDefinitionEvent -> consume(event, relay, wasVerified) is AppRecommendationEvent -> consume(event, relay, wasVerified) is AppSpecificDataEvent -> consume(event, relay, wasVerified) + is AttestationEvent -> consume(event, relay, wasVerified) + is AttestationRequestEvent -> consume(event, relay, wasVerified) + is AttestorRecommendationEvent -> consume(event, relay, wasVerified) + is AttestorProficiencyEvent -> consume(event, relay, wasVerified) is AudioHeaderEvent -> consume(event, relay, wasVerified) is AudioTrackEvent -> consume(event, relay, wasVerified) is BadgeAwardEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index ab54dcc70..194b8f499 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -32,12 +32,9 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -45,9 +42,11 @@ 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.tooling.preview.Preview import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @@ -134,61 +133,41 @@ fun OverflowMenuButton( modifier = Size20Modifier, ) } + } - DropdownMenu( - expanded = menuExpanded.value, - onDismissRequest = { menuExpanded.value = false }, - containerColor = Color.Black.copy(alpha = 0.85f), + if (menuExpanded.value) { + M3ActionDialog( + title = stringRes(R.string.playback_actions_dialog_title), + onDismiss = { menuExpanded.value = false }, ) { - if (showShare) { - DropdownMenuItem( - text = { Text(stringRes(R.string.share_or_save), color = Color.White) }, - onClick = { + M3ActionSection { + if (showShare) { + M3ActionRow( + icon = Icons.Default.Share, + text = stringRes(R.string.share_or_save), + ) { menuExpanded.value = false onShareClick() - }, - leadingIcon = { - Icon( - Icons.Default.Share, - contentDescription = null, - tint = Color.White, - ) - }, - ) - } - - if (showSave) { - DropdownMenuItem( - text = { Text(stringRes(R.string.download_to_phone), color = Color.White) }, - onClick = { + } + } + if (showSave) { + M3ActionRow( + icon = Icons.Default.SaveAlt, + text = stringRes(R.string.download_to_phone), + ) { menuExpanded.value = false onSaveClick() - }, - leadingIcon = { - Icon( - Icons.Default.SaveAlt, - contentDescription = null, - tint = Color.White, - ) - }, - ) - } - - if (showPip) { - DropdownMenuItem( - text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) }, - onClick = { + } + } + if (showPip) { + M3ActionRow( + icon = Icons.Default.PictureInPicture, + text = stringRes(R.string.picture_in_picture), + ) { menuExpanded.value = false onPipClick() - }, - leadingIcon = { - Icon( - Icons.Default.PictureInPicture, - contentDescription = null, - tint = Color.White, - ) - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index aa90e787f..0d22198a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent @@ -85,6 +87,12 @@ val NotificationsPerKeyKinds2 = InteractiveStorySceneEvent.KIND, ) +val NotificationsPerKeyKinds3 = + listOf( + AttestationRequestEvent.KIND, + AttestorRecommendationEvent.KIND, + ) + fun filterSummaryNotificationsToPubkey( relay: NormalizedRelayUrl, pubkey: HexKey?, @@ -130,7 +138,17 @@ fun filterNotificationsToPubkey( Filter( kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), - limit = 500, + limit = 200, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds3, + tags = mapOf("p" to listOf(pubkey)), + limit = 10, since = since, ), ), @@ -171,7 +189,17 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( Filter( kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), - limit = 20, + limit = 10, + since = since, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = NotificationsPerKeyKinds3, + tags = mapOf("p" to listOf(pubkey)), + limit = 2, since = since, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 53b0f12cb..dc2c08557 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -47,6 +48,7 @@ val RepliesAndReactionsToAddressesKinds1 = LnZapEvent.KIND, ZapPollEvent.KIND, CommentEvent.KIND, + AttestationEvent.KIND, ) val PostsAndChatMessagesToAddresses = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 46f6331d4..55220b18f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -53,6 +54,7 @@ val RepliesAndReactionsKinds = OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, + AttestationEvent.KIND, ) val RepliesAndReactionsKinds2 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/M3ActionDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/M3ActionDialog.kt new file mode 100644 index 000000000..38d58b2da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/M3ActionDialog.kt @@ -0,0 +1,140 @@ +/* + * 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.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier + +@Composable +fun M3ActionDialog( + title: String, + onDismiss: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = + Modifier + .padding(vertical = 20.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) + content() + } + } + } +} + +@Composable +fun M3ActionSection(content: @Composable ColumnScope.() -> Unit) { + Surface( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column { + content() + } + } +} + +@Composable +fun M3ActionRow( + icon: ImageVector, + text: String, + isDestructive: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val tint = + if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + val textColor = + if (isDestructive) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + val alpha = if (enabled) 1f else 0.38f + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .alpha(alpha) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Size20Modifier, + tint = tint, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + fontSize = Font14SP, + color = textColor, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt index 79b3ce8ed..bbd56206c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt @@ -24,12 +24,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color @@ -47,27 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier -@Composable -private fun CopyToClipboard( - popupExpanded: MutableState, - content: String, - onDismiss: () -> Unit, -) { - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = onDismiss, - ) { - val clipboardManager = LocalClipboardManager.current - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_url_to_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(content)) - onDismiss() - }, - ) - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable fun UrlPreviewCard( @@ -81,11 +59,20 @@ fun UrlPreviewCard( } if (popupExpanded.value) { - CopyToClipboard( - popupExpanded = popupExpanded, - content = url, + val clipboardManager = LocalClipboardManager.current + M3ActionDialog( + title = stringRes(R.string.link_actions_dialog_title), + onDismiss = { popupExpanded.value = false }, ) { - popupExpanded.value = false + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.ContentCopy, + text = stringRes(R.string.copy_url_to_clipboard), + ) { + clipboardManager.setText(AnnotatedString(url)) + popupExpanded.value = false + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e9f22e2ab..357f640ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -32,15 +32,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Report -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -771,119 +772,101 @@ fun ShareMediaAction( // Track if video is downloading - hoisted here to block menu dismiss during download val isDownloadingVideo = remember { mutableStateOf(false) } - DropdownMenu( - expanded = popupExpanded.value, - onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() }, - ) { - val clipboardManager = LocalClipboardManager.current + if (popupExpanded.value) { + M3ActionDialog( + title = stringRes(R.string.media_actions_dialog_title), + onDismiss = { if (!isDownloadingVideo.value) onDismiss() }, + ) { + val clipboardManager = LocalClipboardManager.current - if (videoUri != null && !videoUri.startsWith("file")) { - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_url_to_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(videoUri)) - onDismiss() - }, - ) - } - - postNostrUri?.let { - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_the_note_id_to_the_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(it)) - onDismiss() - }, - ) - } - - postNostrUri?.let { - DropdownMenuItem( - text = { Text(stringRes(R.string.add_media_to_gallery)) }, - onClick = { - if (videoUri != null) { - val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent - if (n19 != null) { - accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first? - accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery) + // Copy & Gallery section + if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) { + M3ActionSection { + if (videoUri != null && !videoUri.startsWith("file")) { + M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) { + clipboardManager.setText(AnnotatedString(videoUri)) + onDismiss() } } - - onDismiss() - }, - ) - } - - content?.let { - val context = LocalContext.current - - when (content) { - is MediaUrlImage -> { - videoUri?.let { - if (videoUri.isNotEmpty()) { - DropdownMenuItem( - text = { Text(stringRes(R.string.share_image)) }, - onClick = { - scope.launch { shareImageFile(context, videoUri, mimeType) } - onDismiss() - }, - ) + postNostrUri?.let { + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) { + clipboardManager.setText(AnnotatedString(it)) + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.Collections, text = stringRes(R.string.add_media_to_gallery)) { + if (videoUri != null) { + val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent + if (n19 != null) { + accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) + accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery) + } + } + onDismiss() } } } + } - is MediaUrlVideo -> { - videoUri?.let { - if (videoUri.isNotEmpty()) { - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringRes(R.string.share_video)) - if (isDownloadingVideo.value) { - Spacer(modifier = Modifier.width(8.dp)) - LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp) + // Share section + content?.let { + val context = LocalContext.current + + M3ActionSection { + when (content) { + is MediaUrlImage -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_image)) { + scope.launch { shareImageFile(context, videoUri, mimeType) } + onDismiss() + } + } + } + } + + is MediaUrlVideo -> { + videoUri?.let { + if (videoUri.isNotEmpty()) { + M3ActionRow( + icon = Icons.Outlined.Share, + text = stringRes(R.string.share_video), + enabled = !isDownloadingVideo.value, + ) { + isDownloadingVideo.value = true + scope.launch { + shareVideoFile( + context = context, + videoUrl = videoUri, + mimeType = mimeType, + okHttpClient = { url -> + accountViewModel.httpClientBuilder.okHttpClientForVideo(url) + }, + onComplete = { + isDownloadingVideo.value = false + onDismiss() + }, + onError = { + isDownloadingVideo.value = false + }, + ) } } - }, - enabled = !isDownloadingVideo.value, - onClick = { - isDownloadingVideo.value = true - scope.launch { - shareVideoFile( - context = context, - videoUrl = videoUri, - mimeType = mimeType, - okHttpClient = { url -> - accountViewModel.httpClientBuilder.okHttpClientForVideo(url) - }, - onComplete = { - isDownloadingVideo.value = false - onDismiss() - }, - onError = { - isDownloadingVideo.value = false - }, - ) - } - }, - ) + } + } } + + is MediaLocalVideo -> { + content.localFile?.let { localFile -> + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_video)) { + scope.launch { shareLocalVideoFile(context, localFile, mimeType) } + onDismiss() + } + } + } + + else -> { /* No share option for other types */ } } } - - is MediaLocalVideo -> { - content.localFile?.let { localFile -> - DropdownMenuItem( - text = { Text(stringRes(R.string.share_video)) }, - onClick = { - scope.launch { shareLocalVideoFile(context, localFile, mimeType) } - onDismiss() - }, - ) - } - } - - else -> { /* No share option for other types */ } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 077683c28..92763735b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -102,6 +102,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward @@ -174,6 +178,10 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -767,6 +775,22 @@ private fun RenderNoteRow( RenderAppDefinition(baseNote, accountViewModel, nav) } + is AttestationEvent -> { + RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + + is AttestationRequestEvent -> { + RenderAttestationRequest(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + + is AttestorRecommendationEvent -> { + RenderAttestorRecommendation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + + is AttestorProficiencyEvent -> { + RenderAttestorProficiency(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) + } + is AudioTrackEvent -> { RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index aec0c4a00..242bf2f46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -21,10 +21,22 @@ package com.vitorpamplona.amethyst.ui.note.elements import android.content.Intent -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bookmark +import androidx.compose.material.icons.outlined.BookmarkAdd +import androidx.compose.material.icons.outlined.BookmarkRemove +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material.icons.outlined.PersonAdd +import androidx.compose.material.icons.outlined.PersonRemove +import androidx.compose.material.icons.outlined.PlaylistAdd +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.State @@ -44,6 +56,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo @@ -53,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -78,16 +92,16 @@ fun MoreOptionsButton( onClick = { popupExpanded.value = true }, ) { VerticalDotsIcon() + } - if (popupExpanded.value) { - NoteDropDownMenu( - note = baseNote, - onDismiss = { popupExpanded.value = false }, - editState = editState, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (popupExpanded.value) { + NoteDropDownMenu( + note = baseNote, + onDismiss = { popupExpanded.value = false }, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -146,213 +160,150 @@ fun NoteDropDownMenu( ) } - DropdownMenu( - expanded = true, - onDismissRequest = onDismiss, + M3ActionDialog( + title = stringRes(R.string.note_actions_dialog_title), + onDismiss = onDismiss, ) { val clipboardManager = LocalClipboardManager.current val actContext = LocalContext.current - val scope = rememberCoroutineScope() - if (!state.isFollowingAuthor) { - DropdownMenuItem( - text = { Text(stringRes(R.string.follow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + // Follow section + M3ActionSection { + if (!state.isFollowingAuthor) { + M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.follow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.unfollow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + } + } else { + M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.unfollow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } - DropdownMenuItem( - text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) }, - onClick = { - val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem + } + } + M3ActionRow(icon = Icons.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) { + val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow nav.nav(Route.PeopleListManagement(authorHexKey)) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_text)) }, - onClick = { + } + } + + // Copy & Share section + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) { val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - clipboardManager.setText(AnnotatedString(it)) - } + accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_user_pubkey)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) onDismiss() } } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_note_id)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString(note.toNostrUri())) onDismiss() } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), - ) - putExtra( - Intent.EXTRA_TITLE, - stringRes(actContext, R.string.quick_action_share_browser_link), - ) + putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) + putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) } - - val shareIntent = - Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) ContextCompat.startActivity(actContext, shareIntent, null) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser && note.isDraft()) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_draft)) }, - onClick = { - nav.nav { - routeEditDraftTo(note, accountViewModel.account) - } - }, - ) - } - if (note.event is TextNoteEvent && !note.isDraft()) { - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_post)) }, - onClick = { - wantsToEditPost.value = true - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.propose_an_edit)) }, - onClick = { - wantsToEditPost.value = true - }, - ) } } - DropdownMenuItem( - text = { Text(stringRes(R.string.broadcast)) }, - onClick = { + + // Edit & Broadcast section + M3ActionSection { + if (state.isLoggedUser && note.isDraft()) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) { + nav.nav { routeEditDraftTo(note, accountViewModel.account) } + } + } + if (note.event is TextNoteEvent && !note.isDraft()) { + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) { + wantsToEditPost.value = true + } + } else { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) { + wantsToEditPost.value = true + } + } + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) { accountViewModel.broadcast(note) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.otsState.hasPendingAttestations(note)) { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_pending)) }, - onClick = { - onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_it)) }, - onClick = { + } + } + + // Timestamp & Bookmarks section + M3ActionSection { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } + } else { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) { accountViewModel.timestamp(note) onDismiss() - }, - ) - } - HorizontalDivider(thickness = DividerThickness) - note.let { + } + } val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) - DropdownMenuItem( - text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) }, - onClick = { - if (note.event is LongTextNoteEvent) { - val noteAddress = (note as AddressableNote).address - nav.nav(Route.ArticleBookmarkManagement(noteAddress)) - } else { - nav.nav(Route.PostBookmarkManagement(note.idHex)) - } - onDismiss() - }, - ) - } - if (state.isPrivateBookmarkNote) { - DropdownMenuItem( - text = { Text(stringRes(R.string.remove_from_private_bookmarks)) }, - onClick = { + M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) { + if (note.event is LongTextNoteEvent) { + nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address)) + } else { + nav.nav(Route.PostBookmarkManagement(note.idHex)) + } + onDismiss() + } + if (state.isPrivateBookmarkNote) { + M3ActionRow(icon = Icons.Outlined.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) { accountViewModel.removePrivateBookmark(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.add_to_private_bookmarks)) }, - onClick = { + } + } else { + M3ActionRow(icon = Icons.Outlined.Lock, text = stringRes(R.string.add_to_private_bookmarks)) { accountViewModel.addPrivateBookmark(note) onDismiss() - }, - ) - } - if (state.isPublicBookmarkNote) { - DropdownMenuItem( - text = { Text(stringRes(R.string.remove_from_public_bookmarks)) }, - onClick = { + } + } + if (state.isPublicBookmarkNote) { + M3ActionRow(icon = Icons.Outlined.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) { accountViewModel.removePublicBookmark(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.add_to_public_bookmarks)) }, - onClick = { + } + } else { + M3ActionRow(icon = Icons.Outlined.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) { accountViewModel.addPublicBookmark(note) onDismiss() - }, - ) + } + } } - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.request_deletion)) }, - onClick = { + + // Moderation section + M3ActionSection { + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.block_report)) }, - onClick = { reportDialogShowing = true }, - ) + } + } else { + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) { + reportDialogShowing = true + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt new file mode 100644 index 000000000..6eb4dae2e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -0,0 +1,638 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.Recommend +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.UserCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.KindChip +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Preview +@Composable +fun RenderAttestationPreview() { + val event = + AttestationEvent( + id = "f8e05e4fa964d9dfbeb339ae0450d0b3424aaf8f6083d95f38c780335c3dbd56", + pubKey = "c4f5e7a75a8ce3683d529cff06368439c529e5243c6b125ba68789198856cac7", + createdAt = 1773941524, + content = "This is Frank's new npub.", + sig = "61dde5dc5738bfa637aa04451eec63d45f649902a0772abbaf55711aad5b7ce376bc7712c937f9ffc504655c4fd7e39f78139dc3f1a90953d150b43f3e2428fb", + tags = + arrayOf( + arrayOf("d", "af5aa898:fe108febb997:1773941524"), + arrayOf("e", "fe108febb99796c4091775e00aa1fc3ffc489ad22fdf1f8c559b2472815c09c7"), + arrayOf("s", "verified"), + arrayOf("v", "valid"), + arrayOf("client", "attestr.xyz"), + ), + ) + + LocalCache.justConsume(event, null, true) + val note = LocalCache.getOrCreateNote(event.id) + + ThemeComparisonColumn( + toPreview = { + RenderAttestation( + baseNote = note, + quotesLeft = 3, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + }, + ) +} + +@Composable +fun RenderAttestation( + baseNote: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by baseNote + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestationEvent ?: return + + RenderAttestation( + baseNote, + noteEvent, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun RenderAttestation( + note: Note, + noteEvent: AttestationEvent, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val validity = remember(noteEvent) { noteEvent.validity() } + val status = remember(noteEvent) { noteEvent.status() } + val validFrom = remember(noteEvent) { noteEvent.validFrom() } + val validTo = remember(noteEvent) { noteEvent.validTo() } + val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } + + val statusColor = remember(status, validity) { attestationColor(status, validity) } + val statusIcon = remember(status, validity) { attestationIcon(status, validity) } + val statusLabel = attestationStatusLabel(status, validity) + + val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } + val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } + val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, statusColor.copy(alpha = 0.3f), RoundedCornerShape(12.dp)) + .background(statusColor.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = statusIcon, + contentDescription = stringRes(R.string.attestation), + tint = statusColor, + modifier = Modifier.size(24.dp), + ) + Text( + text = statusLabel, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = statusColor, + ) + } + + if (validFrom != null || validTo != null) { + Spacer(modifier = DoubleVertSpacer) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + validFrom?.let { + Text( + text = stringRes(R.string.attestation_valid_from, formatTimestamp(it)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + validTo?.let { + Text( + text = stringRes(R.string.attestation_valid_to, formatTimestamp(it)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (quotesLeft > 0) { + if (aboutAddress != null) { + LoadAddressableNote(aboutAddress, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutEvent != null) { + LoadNote(aboutEvent, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutPubkey != null) { + LoadUser(aboutPubkey, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + UserCompose(it, accountViewModel = accountViewModel, nav = nav) + } + } + } + } + + content?.let { + Spacer(modifier = DoubleVertSpacer) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun RenderAttestationRequest( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestationRequestEvent ?: return + + val content = remember(noteEvent) { noteEvent.content.ifBlank { null } } + + val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } + val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } + val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + RoundedCornerShape(12.dp), + ).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = stringRes(R.string.attestation_request), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.attestation_request), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + + content?.let { + Spacer(modifier = DoubleVertSpacer) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (quotesLeft > 0) { + if (aboutAddress != null) { + LoadAddressableNote(aboutAddress, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = stringRes(R.string.attestation_requests_attestation_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutEvent != null) { + LoadNote(aboutEvent, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = stringRes(R.string.attestation_requests_attestation_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NoteCompose( + baseNote = it, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = ReplyRenderType.NONE, + makeItShort = true, + quotesLeft = quotesLeft - 1, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } else if (aboutPubkey != null) { + LoadUser(aboutPubkey, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + Text( + text = stringRes(R.string.attestation_requests_attestation_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + UserCompose(it, accountViewModel = accountViewModel, nav = nav) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderAttestorRecommendation( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestorRecommendationEvent ?: return + + val kinds = remember(noteEvent) { noteEvent.kinds() } + val description = remember(noteEvent) { noteEvent.description() } + val aboutPubKey = remember(noteEvent) { noteEvent.dTag() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f), + RoundedCornerShape(12.dp), + ).background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Recommend, + contentDescription = stringRes(R.string.attestor_recommendation), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.attestor_recommendation), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + LoadUser(aboutPubKey, accountViewModel) { + if (it != null) { + Spacer(modifier = DoubleVertSpacer) + UserCompose(it, accountViewModel = accountViewModel, nav = nav) + } + } + + if (kinds.isNotEmpty()) { + Spacer(modifier = DoubleVertSpacer) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringRes(R.string.attestor_recommendation_for_kinds), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Kinds + kinds.forEach { kind -> + KindChip(kind) + } + } + } + + description?.let { + Spacer(modifier = DoubleVertSpacer) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RenderAttestorProficiency( + note: Note, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteState by note + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val noteEvent = noteState.note.event as? AttestorProficiencyEvent ?: return + + val kinds = remember(noteEvent) { noteEvent.kinds() } + val description = remember(noteEvent) { noteEvent.description() } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border( + 1.dp, + MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f), + RoundedCornerShape(12.dp), + ).background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.06f)) + .padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = stringRes(R.string.attestor_proficiency), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringRes(R.string.attestor_proficiency), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.secondary, + ) + } + + if (kinds.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + kinds.forEach { kind -> + Text( + text = "Kind $kind", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + + description?.let { + Spacer(modifier = Modifier.height(8.dp)) + TranslatableRichTextViewer( + content = it, + canPreview = true, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +private fun attestationColor( + status: AttestationStatus?, + validity: Validity?, +): Color = + when { + status == AttestationStatus.REVOKED -> Color(0xFFB71C1C) + status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) + validity == Validity.INVALID -> Color(0xFFB71C1C) + status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) + validity == Validity.VALID -> Color(0xFF2E7D32) + status == AttestationStatus.VERIFYING -> Color(0xFFF57F17) + status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0) + else -> Color(0xFF757575) + } + +private fun attestationIcon( + status: AttestationStatus?, + validity: Validity?, +): ImageVector = + when { + status == AttestationStatus.REVOKED -> Icons.Default.Close + status == AttestationStatus.REJECTED -> Icons.Default.Close + validity == Validity.INVALID -> Icons.Default.Close + status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser + validity == Validity.VALID -> Icons.Default.CheckCircle + status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop + status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle + else -> Icons.Default.VerifiedUser + } + +@Composable +private fun attestationStatusLabel( + status: AttestationStatus?, + validity: Validity?, +): String = + when { + status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked) + status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected) + validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) + status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified) + validity == Validity.VALID -> stringRes(R.string.attestation_valid) + status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) + status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted) + else -> stringRes(R.string.attestation) + } + +private fun formatTimestamp(timestamp: Long): String { + val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + return sdf.format(Date(timestamp * 1000)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index c2411a8c9..717499410 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -21,10 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display import android.content.Intent -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BookmarkRemove +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material.icons.outlined.PersonAdd +import androidx.compose.material.icons.outlined.PersonRemove +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.getValue @@ -42,6 +51,9 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon @@ -52,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.Dispatchers @@ -76,20 +87,20 @@ fun BookmarkGroupItemOptions( onClick = { popupExpanded.value = true }, ) { VerticalDotsIcon() + } - if (popupExpanded.value) { - BookmarkGroupItemOptionsMenu( - note = baseNote, - isBookmarkItemPrivate = isBookmarkItemPrivate, - onDismiss = { popupExpanded.value = false }, - onMoveBookmarkToPublic = onMoveBookmarkToPublic, - onMoveBookmarkToPrivate = onMoveBookmarkToPrivate, - onDeleteBookmarkItem = onDeleteBookmarkItem, - editState = editState, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (popupExpanded.value) { + BookmarkGroupItemOptionsMenu( + note = baseNote, + isBookmarkItemPrivate = isBookmarkItemPrivate, + onDismiss = { popupExpanded.value = false }, + onMoveBookmarkToPublic = onMoveBookmarkToPublic, + onMoveBookmarkToPrivate = onMoveBookmarkToPrivate, + onDeleteBookmarkItem = onDeleteBookmarkItem, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -142,89 +153,71 @@ fun BookmarkGroupItemOptionsMenu( ) } - DropdownMenu( - expanded = true, - onDismissRequest = onDismiss, + M3ActionDialog( + title = stringRes(R.string.bookmark_item_actions_dialog_title), + onDismiss = onDismiss, ) { val clipboardManager = LocalClipboardManager.current val actContext = LocalContext.current - val scope = rememberCoroutineScope() - DropdownMenuItem( - text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) }, - onClick = if (isBookmarkItemPrivate) onMoveBookmarkToPublic else onMoveBookmarkToPrivate, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.bookmark_remove_action_label)) }, - onClick = { + + // Bookmark Management section + M3ActionSection { + M3ActionRow( + icon = if (isBookmarkItemPrivate) Icons.Outlined.LockOpen else Icons.Outlined.Lock, + text = stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label), + ) { + if (isBookmarkItemPrivate) onMoveBookmarkToPublic() else onMoveBookmarkToPrivate() + } + M3ActionRow( + icon = Icons.Outlined.BookmarkRemove, + text = stringRes(R.string.bookmark_remove_action_label), + isDestructive = true, + ) { onDeleteBookmarkItem() onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) + } + } - if (!state.isFollowingAuthor) { - DropdownMenuItem( - text = { Text(stringRes(R.string.follow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + // Follow section + M3ActionSection { + if (!state.isFollowingAuthor) { + M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.follow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.unfollow)) }, - onClick = { - val author = note.author ?: return@DropdownMenuItem + } + } else { + M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) { + val author = note.author ?: return@M3ActionRow accountViewModel.unfollow(author) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - } -// DropdownMenuItem( -// text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) }, -// onClick = { -// val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem -// nav.nav(Route.PeopleListManagement(authorHexKey)) -// onDismiss() -// }, -// ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_text)) }, - onClick = { - val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - clipboardManager.setText(AnnotatedString(it)) } + } + } + + // Copy & Share section + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) { + val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note + accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) } onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_user_pubkey)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { note.author?.let { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) onDismiss() } } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_note_id)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) { scope.launch(Dispatchers.IO) { clipboardManager.setText(AnnotatedString(note.toNostrUri())) onDismiss() } - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -243,74 +236,57 @@ fun BookmarkGroupItemOptionsMenu( Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) ContextCompat.startActivity(actContext, shareIntent, null) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser && note.isDraft()) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_draft)) }, - onClick = { + } + } + + // Edit & Broadcast section + M3ActionSection { + if (state.isLoggedUser && note.isDraft()) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) { nav.nav { routeEditDraftTo(note, accountViewModel.account) } - }, - ) - } - if (note.event is TextNoteEvent && !note.isDraft()) { - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.edit_post)) }, - onClick = { - wantsToEditPost.value = true - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.propose_an_edit)) }, - onClick = { - wantsToEditPost.value = true - }, - ) + } } - } - DropdownMenuItem( - text = { Text(stringRes(R.string.broadcast)) }, - onClick = { + if (note.event is TextNoteEvent && !note.isDraft()) { + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) { + wantsToEditPost.value = true + } + } else { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) { + wantsToEditPost.value = true + } + } + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) { accountViewModel.broadcast(note) onDismiss() - }, - ) - HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.otsState.hasPendingAttestations(note)) { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_pending)) }, - onClick = { + } + } + + // Timestamp & Moderation section + M3ActionSection { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.timestamp_it)) }, - onClick = { + } + } else { + M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) { accountViewModel.timestamp(note) onDismiss() - }, - ) - } - HorizontalDivider(thickness = DividerThickness) - if (state.isLoggedUser) { - DropdownMenuItem( - text = { Text(stringRes(R.string.request_deletion)) }, - onClick = { + } + } + if (state.isLoggedUser) { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(R.string.block_report)) }, - onClick = { reportDialogShowing = true }, - ) + } + } else { + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) { + reportDialogShowing = true + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt index 1a7605f9b..943a262ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -30,11 +30,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme @@ -57,6 +57,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -65,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -304,6 +306,31 @@ fun BookmarkGroupActionsMenuButton( ) { val isActionListOpen = remember { mutableStateOf(false) } + if (isActionListOpen.value) { + M3ActionDialog( + title = stringRes(R.string.list_actions_dialog_title), + onDismiss = { isActionListOpen.value = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.CellTower, + text = stringRes(R.string.bookmark_list_broadcast_btn_label), + ) { + onBroadcastList() + isActionListOpen.value = false + } + M3ActionRow( + icon = Icons.Outlined.Delete, + text = stringRes(R.string.bookmark_list_delete_btn_label), + isDestructive = true, + ) { + onDeleteList() + isActionListOpen.value = false + } + } + } + } + ClickableBox( modifier = StdPadding @@ -319,44 +346,5 @@ fun BookmarkGroupActionsMenuButton( onClick = { isActionListOpen.value = true }, ) { VerticalDotsIcon() - BookmarkGroupActionsMenu( - onCloseMenu = { isActionListOpen.value = false }, - isOpen = isActionListOpen.value, - onBroadcastList = onBroadcastList, - onDeleteList = onDeleteList, - ) - } -} - -@Composable -fun BookmarkGroupActionsMenu( - onCloseMenu: () -> Unit, - isOpen: Boolean, - onBroadcastList: () -> Unit, - onDeleteList: () -> Unit, -) { - DropdownMenu( - expanded = isOpen, - onDismissRequest = onCloseMenu, - ) { - DropdownMenuItem( - text = { - Text(stringRes(R.string.bookmark_list_broadcast_btn_label)) - }, - onClick = { - onBroadcastList() - onCloseMenu() - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.bookmark_list_delete_btn_label)) - }, - onClick = { - onDeleteList() - onCloseMenu() - }, - ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt index 2393d385b..eeb80529f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -32,10 +32,12 @@ import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -57,6 +59,9 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.stringRes @@ -258,18 +263,18 @@ private fun BookmarkGroupOptionsButton( onClick = { isMenuOpen.value = true }, ) { VerticalDotsIcon() - - GroupOptionsMenu( - groupName = bookmarkGroupName, - groupDescription = bookmarkGroupDescription, - isExpanded = isMenuOpen.value, - onDismiss = { isMenuOpen.value = false }, - onGroupRename = onGroupRename, - onGroupDescriptionChange = onGroupDescriptionChange, - onGroupClone = onGroupCloneCreate, - onDelete = onGroupDelete, - ) } + + GroupOptionsMenu( + groupName = bookmarkGroupName, + groupDescription = bookmarkGroupDescription, + isExpanded = isMenuOpen.value, + onDismiss = { isMenuOpen.value = false }, + onGroupRename = onGroupRename, + onGroupDescriptionChange = onGroupDescriptionChange, + onGroupClone = onGroupCloneCreate, + onDelete = onGroupDelete, + ) } @Composable @@ -288,45 +293,32 @@ private fun GroupOptionsMenu( val optionalCloneName = remember { mutableStateOf(null) } val optionalCloneDescription = remember { mutableStateOf(null) } - DropdownMenu( - expanded = isExpanded, - onDismissRequest = onDismiss, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_rename_btn_label)) - }, - onClick = { - onGroupRename() - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_desc_modify_label)) - }, - onClick = { - onGroupDescriptionChange() - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_copy_action_btn_label)) - }, - onClick = { - isCopyDialogOpen.value = true - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.quick_action_delete)) - }, - onClick = { - onDelete() - }, - ) + if (isExpanded) { + M3ActionDialog( + title = stringRes(R.string.group_actions_dialog_title), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_rename_btn_label)) { + onGroupRename() + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.Description, text = stringRes(R.string.follow_set_desc_modify_label)) { + onGroupDescriptionChange() + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) { + isCopyDialogOpen.value = true + onDismiss() + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + onDismiss() + } + } + } } if (isCopyDialogOpen.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt index 756b8bc8e..a66a30be7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt @@ -30,12 +30,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BookmarkAdd import androidx.compose.material.icons.filled.BookmarkRemove +import androidx.compose.material.icons.outlined.BookmarkAdd import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.RemoveCircleOutline -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -48,6 +47,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkMembershipStatusAndNumberDisplay import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding @@ -158,6 +160,30 @@ fun BookmarkManagementOptions( ) { val isBookmarkAddTapped = remember { mutableStateOf(false) } + if (isBookmarkAddTapped.value) { + M3ActionDialog( + title = stringRes(R.string.add_bookmark_dialog_title), + onDismiss = { isBookmarkAddTapped.value = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.BookmarkAdd, + text = stringRes(R.string.public_bookmark_add_action_label), + ) { + onAddBookmark(false) + isBookmarkAddTapped.value = false + } + M3ActionRow( + icon = Icons.Outlined.Lock, + text = stringRes(R.string.private_bookmark_add_action_label), + ) { + onAddBookmark(true) + isBookmarkAddTapped.value = false + } + } + } + } + Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -196,29 +222,5 @@ fun BookmarkManagementOptions( ) } } - - DropdownMenu( - expanded = isBookmarkAddTapped.value, - onDismissRequest = { isBookmarkAddTapped.value = false }, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.public_bookmark_add_action_label)) - }, - onClick = { - onAddBookmark(false) - isBookmarkAddTapped.value = false - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.private_bookmark_add_action_label)) - }, - onClick = { - onAddBookmark(true) - isBookmarkAddTapped.value = false - }, - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 5bf51f508..8b5961be5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -28,8 +28,9 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.MoveToInbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -48,6 +49,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -103,12 +107,13 @@ fun MessagesTabHeader( contentDescription = stringRes(id = R.string.more_options), tint = MaterialTheme.colorScheme.placeholderText, ) + } - MessagesTabMenu( - moreActionsExpanded, - { moreActionsExpanded = false }, - onMarkKnownAsRead, - onMarkNewAsRead, + if (moreActionsExpanded) { + MessagesMarkAsReadDialog( + onDismiss = { moreActionsExpanded = false }, + onMarkKnownAsRead = onMarkKnownAsRead, + onMarkNewAsRead = onMarkNewAsRead, ) } } @@ -142,34 +147,38 @@ fun MessagesPager( } @Composable -fun MessagesTabMenu( - expanded: Boolean, +fun MessagesMarkAsReadDialog( onDismiss: () -> Unit, onMarkKnownAsRead: () -> Unit, onMarkNewAsRead: () -> Unit, ) { - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_known_as_read)) }, - onClick = { + M3ActionDialog( + title = stringRes(R.string.mark_as_read_dialog_title), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.Groups, + text = stringRes(R.string.mark_all_known_as_read), + ) { onMarkKnownAsRead() onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_new_as_read)) }, - onClick = { + } + M3ActionRow( + icon = Icons.Outlined.MoveToInbox, + text = stringRes(R.string.mark_all_new_as_read), + ) { onMarkNewAsRead() onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.mark_all_as_read)) }, - onClick = { + } + M3ActionRow( + icon = Icons.Outlined.DoneAll, + text = stringRes(R.string.mark_all_as_read), + ) { onMarkKnownAsRead() onMarkNewAsRead() onDismiss() - }, - ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt index dafcd7530..e0561e6ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/FilterCommunityPosts.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -42,6 +43,7 @@ val CommunityPostKinds = ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, CommunityPostApprovalEvent.KIND, + AttestationEvent.KIND, ) fun filterCommunityPosts( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 66f61eca9..d5c134ba4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -29,6 +29,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -60,6 +64,7 @@ class HomeNewThreadFeedFilter( LongTextNoteEvent.KIND, LiveChessGameChallengeEvent.KIND, LiveChessGameEndEvent.KIND, + AttestationEvent.KIND, ) } @@ -126,7 +131,11 @@ class HomeNewThreadFeedFilter( noteEvent is AudioHeaderEvent || noteEvent is ChessGameEvent || noteEvent is LiveChessGameChallengeEvent || - noteEvent is LiveChessGameEndEvent + noteEvent is LiveChessGameEndEvent || + noteEvent is AttestationEvent || + noteEvent is AttestationRequestEvent || + noteEvent is AttestorRecommendationEvent || + noteEvent is AttestorProficiencyEvent ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt index 4f52a0000..4c19d4939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGeohashes.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -36,7 +37,6 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent val HomePostsByGeohashKinds = listOf( @@ -46,11 +46,11 @@ val HomePostsByGeohashKinds = LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, - InteractiveStoryPrologueEvent.KIND, WikiNoteEvent.KIND, CommentEvent.KIND, VoiceEvent.KIND, - VoiceReplyEvent.KIND, + AttestationEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) fun filterHomePostsByGeohashes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt index 68c788650..c47f7c94b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByGlobal.kt @@ -23,7 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1 +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds2 import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -43,11 +44,20 @@ fun filterHomePostsByGlobal( relay = relayUrl, filter = Filter( - kinds = HomePostsNewThreadKinds, + kinds = HomePostsNewThreadKinds1, limit = 50, since = since ?: newThreadSince, ), ), + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = HomePostsNewThreadKinds2, + limit = 5, + since = since ?: newThreadSince, + ), + ), RelayBasedFilter( relay = relayUrl, filter = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt index b5f02d5f6..d3542f77c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip01Core/FilterHomePostsByHashtags.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent @@ -39,7 +40,6 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent val HomePostsBuHashtagsKinds = listOf( @@ -50,12 +50,12 @@ val HomePostsBuHashtagsKinds = ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioHeaderEvent.KIND, - InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, VoiceEvent.KIND, - VoiceReplyEvent.KIND, + AttestationEvent.KIND, + InteractiveStoryPrologueEvent.KIND, ) fun filterHomePostsByHashtags( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 0aa7d3a1a..cd6508703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follo import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent @@ -50,7 +51,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import kotlin.math.min -val HomePostsNewThreadKinds = +val HomePostsNewThreadKinds1 = listOf( TextNoteEvent.KIND, RepostEvent.KIND, @@ -58,10 +59,15 @@ val HomePostsNewThreadKinds = ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, HighlightEvent.KIND, - WikiNoteEvent.KIND, - NipTextEvent.KIND, ZapPollEvent.KIND, PollEvent.KIND, + WikiNoteEvent.KIND, + AttestationEvent.KIND, + NipTextEvent.KIND, + ) + +val HomePostsNewThreadKinds2 = + listOf( InteractiveStoryPrologueEvent.KIND, ChessGameEvent.KIND, LiveChessGameChallengeEvent.KIND, @@ -91,12 +97,22 @@ fun filterNewHomePostsByAuthors( relay = relay, filter = Filter( - kinds = HomePostsNewThreadKinds, + kinds = HomePostsNewThreadKinds1, authors = authorList, limit = min(authorList.size * 10, 500), since = since, ), ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = HomePostsNewThreadKinds2, + authors = authorList, + limit = min(authorList.size * 10, 5), + since = since, + ), + ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt index 5c0c0602a..95b23a2ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/lists/PeopleListScreen.kt @@ -38,11 +38,14 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults.cardElevation -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton @@ -75,6 +78,9 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -416,15 +422,16 @@ private fun ListActionsMenuButton( onClick = { isActionListOpen.value = true }, ) { VerticalDotsIcon() + } - DropdownMenu( - expanded = isActionListOpen.value, - onDismissRequest = { isActionListOpen.value = false }, + if (isActionListOpen.value) { + val context = LocalContext.current + M3ActionDialog( + title = stringRes(R.string.list_actions_dialog_title), + onDismiss = { isActionListOpen.value = false }, ) { - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -443,38 +450,22 @@ private fun ListActionsMenuButton( Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) ContextCompat.startActivity(context, shareIntent, null) isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_set_edit_list_metadata)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) { onEditList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_set_broadcast)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_set_broadcast)) { onBroadcastList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_set_delete)) - }, - onClick = { + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_set_delete), isDestructive = true) { onDeleteList() isActionListOpen.value = false - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt index eefa908ad..c10fbc237 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/packs/FollowPackScreen.kt @@ -35,11 +35,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CellTower +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults.cardElevation -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton @@ -68,6 +71,9 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -271,15 +277,16 @@ private fun ListActionsMenuButton( onClick = { isActionListOpen.value = true }, ) { VerticalDotsIcon() + } - DropdownMenu( - expanded = isActionListOpen.value, - onDismissRequest = { isActionListOpen.value = false }, + if (isActionListOpen.value) { + val context = LocalContext.current + M3ActionDialog( + title = stringRes(R.string.pack_actions_dialog_title), + onDismiss = { isActionListOpen.value = false }, ) { - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -298,38 +305,22 @@ private fun ListActionsMenuButton( Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) ContextCompat.startActivity(context, shareIntent, null) isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_pack_edit_list_metadata)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_pack_edit_list_metadata)) { onEditList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_pack_broadcast)) - }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_pack_broadcast)) { onBroadcastList() isActionListOpen.value = false - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text(stringRes(R.string.follow_pack_delete)) - }, - onClick = { + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_pack_delete), isDestructive = true) { onDeleteList() isActionListOpen.value = false - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index 369852526..2268ff2e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -29,13 +29,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Public import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -59,6 +60,9 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -286,15 +290,15 @@ private fun PeopleListOptionsButton( onClick = { isMenuOpen.value = true }, ) { VerticalDotsIcon() - - ListOptionsMenu( - isExpanded = isMenuOpen.value, - onListEditMetadata = onListEditMetadata, - onListClone = onListCloneCreate, - onDelete = onListDelete, - onDismiss = { isMenuOpen.value = false }, - ) } + + ListOptionsMenu( + isExpanded = isMenuOpen.value, + onListEditMetadata = onListEditMetadata, + onListClone = onListCloneCreate, + onDelete = onListDelete, + onDismiss = { isMenuOpen.value = false }, + ) } @Composable @@ -309,36 +313,28 @@ private fun ListOptionsMenu( val optionalCloneName = remember { mutableStateOf(null) } val optionalCloneDescription = remember { mutableStateOf(null) } - DropdownMenu( - expanded = isExpanded, - onDismissRequest = onDismiss, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_edit_list_metadata)) - }, - onClick = { - onListEditMetadata() - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_copy_action_btn_label)) - }, - onClick = { - isCopyDialogOpen.value = true - onDismiss() - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.quick_action_delete)) - }, - onClick = { - onDelete() - }, - ) + if (isExpanded) { + M3ActionDialog( + title = stringRes(R.string.list_management_dialog_title), + onDismiss = onDismiss, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) { + onListEditMetadata() + onDismiss() + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) { + isCopyDialogOpen.value = true + onDismiss() + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + onDismiss() + } + } + } } if (isCopyDialogOpen.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt index f7e4db06b..7f37e0dad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/PeopleListAndUserItem.kt @@ -33,10 +33,9 @@ import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.RemoveCircleOutline -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -50,6 +49,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding @@ -193,6 +195,30 @@ private fun UserAdditionOptions( ) { val isUserAddTapped = remember { mutableStateOf(false) } + if (isUserAddTapped.value) { + M3ActionDialog( + title = stringRes(R.string.add_member_dialog_title), + onDismiss = { isUserAddTapped.value = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.PersonAdd, + text = stringRes(R.string.follow_set_public_member_add_label), + ) { + onAddUserToList(false) + isUserAddTapped.value = false + } + M3ActionRow( + icon = Icons.Outlined.Lock, + text = stringRes(R.string.follow_set_private_member_add_label), + ) { + onAddUserToList(true) + isUserAddTapped.value = false + } + } + } + } + Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, @@ -231,29 +257,5 @@ private fun UserAdditionOptions( ) } } - - DropdownMenu( - expanded = isUserAddTapped.value, - onDismissRequest = { isUserAddTapped.value = false }, - ) { - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_public_member_add_label)) - }, - onClick = { - onAddUserToList(false) - isUserAddTapped.value = false - }, - ) - DropdownMenuItem( - text = { - Text(text = stringRes(R.string.follow_set_private_member_add_label)) - }, - onClick = { - onAddUserToList(true) - isUserAddTapped.value = false - }, - ) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index dfe09eb44..382884187 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -28,11 +28,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -64,7 +67,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.Sho import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier @Composable fun RefreshableCardView( @@ -165,6 +170,7 @@ private fun FeedLoaded( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val openPolls by openPollsState(accountViewModel) LazyColumn( modifier = Modifier.fillMaxSize(), @@ -175,6 +181,37 @@ private fun FeedLoaded( ShowDonationCard(accountViewModel, nav) } + if (openPolls.isNotEmpty()) { + itemsIndexed( + items = openPolls, + key = { _, item -> "open-poll-${item.idHex}" }, + contentType = { _, _ -> "OpenPoll" }, + ) { _, note -> + Row(modifier = Modifier.padding(start = Size10dp, end = Size10dp, bottom = Size10dp)) { + Card( + modifier = MaterialTheme.colorScheme.imageModifier, + ) { + OpenPollsSectionHeader() + Row(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + baseNote = note, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = routeForLastRead, + isBoostedNote = false, + isQuotedNote = false, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + itemsIndexed( items = items.list, key = { _, item -> item.id() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt new file mode 100644 index 000000000..e3fa47d43 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsSection.kt @@ -0,0 +1,95 @@ +/* + * 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.notifications + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +@Composable +fun openPollsState(accountViewModel: AccountViewModel) = + remember(accountViewModel) { + val userPubkeyHex = accountViewModel.account.userProfile().pubkeyHex + + val filter = + Filter( + kinds = listOf(PollEvent.KIND, ZapPollEvent.KIND), + authors = listOf(userPubkeyHex), + ) + + accountViewModel.account.cache + .observeNotes(filter) + .map { notes -> filterOpenPolls(notes) } + .flowOn(Dispatchers.IO) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + +private fun filterOpenPolls(notes: List): List { + val now = TimeUtils.now() + val oneDayInSeconds = TimeUtils.ONE_DAY + + return notes + .filter { note -> + val event = note.event ?: return@filter false + + when (event) { + is PollEvent -> { + val endsAt = event.endsAt() + if (endsAt != null) endsAt > now else event.createdAt + oneDayInSeconds > now + } + + is ZapPollEvent -> { + val closedAt = event.closedAt() + if (closedAt != null) closedAt > now else event.createdAt + oneDayInSeconds > now + } + + else -> { + false + } + } + }.sortedByDescending { it.createdAt() } +} + +@Composable +fun OpenPollsSectionHeader() { + Text( + text = stringRes(R.string.open_poll), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 8c757dad6..2e2f89eba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent @@ -87,6 +88,7 @@ class NotificationFeedFilter( VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND, WikiNoteEvent.KIND, + AttestationRequestEvent.KIND, ) val NOTIFICATION_KINDS = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index a77b676b6..ef9ac5fbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent @@ -66,6 +67,7 @@ val UserProfilePostKinds2 = VoiceReplyEvent.KIND, ZapPollEvent.KIND, PinListEvent.KIND, + AttestationEvent.KIND, ) fun filterUserProfilePosts( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index d299d5aef..0dd30e5ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -21,20 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.Intent -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Report +import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.note.externalLinkForUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.quartz.nip56Reports.ReportType @Composable @@ -44,108 +48,94 @@ fun UserProfileDropDownMenu( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = onDismiss, + if (!popupExpanded) return + + M3ActionDialog( + title = stringRes(R.string.profile_actions_dialog_title), + onDismiss = onDismiss, ) { val clipboardManager = LocalClipboardManager.current - - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_user_id)) }, - onClick = { - clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) - onDismiss() - }, - ) - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.quick_action_share)) }, - onClick = { + // Share section + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.ContentCopy, + text = stringRes(R.string.copy_user_id), + ) { + clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) + onDismiss() + } + M3ActionRow( + icon = Icons.Outlined.Share, + text = stringRes(R.string.quick_action_share), + ) { val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForUser(user), - ) + putExtra(Intent.EXTRA_TEXT, externalLinkForUser(user)) putExtra( Intent.EXTRA_TITLE, stringRes(context, R.string.quick_action_share_browser_link), ) } - - val shareIntent = - Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) + val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) context.startActivity(shareIntent) onDismiss() - }, - ) + } + } + // Moderation section (if not self) if (accountViewModel.userProfile() != user) { - HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.isHidden(user)) { - DropdownMenuItem( - text = { Text(stringRes(R.string.unblock_user)) }, - onClick = { + M3ActionSection { + if (accountViewModel.account.isHidden(user)) { + M3ActionRow( + icon = Icons.Outlined.CheckCircle, + text = stringRes(R.string.unblock_user), + ) { accountViewModel.show(user) onDismiss() - }, - ) - } else { - DropdownMenuItem( - text = { Text(stringRes(id = R.string.block_hide_user)) }, - onClick = { + } + } else { + M3ActionRow( + icon = Icons.Outlined.Block, + text = stringRes(R.string.block_hide_user), + isDestructive = true, + ) { accountViewModel.hide(user) onDismiss() - }, - ) + } + } } - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_spam_scam)) }, - onClick = { + + // Report section + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_spam_scam), isDestructive = true) { accountViewModel.report(user, ReportType.SPAM) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.report_hateful_speech)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_hateful_speech), isDestructive = true) { accountViewModel.report(user, ReportType.PROFANITY) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_impersonation)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_impersonation), isDestructive = true) { accountViewModel.report(user, ReportType.IMPERSONATION) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.report_nudity_porn)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_nudity_porn), isDestructive = true) { accountViewModel.report(user, ReportType.NUDITY) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_illegal_behaviour)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_illegal_behaviour), isDestructive = true) { accountViewModel.report(user, ReportType.ILLEGAL) onDismiss() - }, - ) - DropdownMenuItem( - text = { Text(stringRes(id = R.string.report_malware)) }, - onClick = { + } + M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_malware), isDestructive = true) { accountViewModel.report(user, ReportType.MALWARE) onDismiss() - }, - ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index ea7964f1a..bdaa1d8af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -27,6 +27,10 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -87,7 +91,11 @@ class UserProfileNewThreadFeedFilter( it.event is AudioTrackEvent || it.event is AudioHeaderEvent || it.event is VoiceEvent || - it.event is TorrentEvent + it.event is TorrentEvent || + it.event is AttestationEvent || + it.event is AttestationRequestEvent || + it.event is AttestorRecommendationEvent || + it.event is AttestorProficiencyEvent ) && it.isNewThread() && account.isAcceptable(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index e45562fe9..c4d65da7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -29,8 +29,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.FolderZip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,6 +53,9 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultDMRelayList import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -490,6 +493,7 @@ fun SettingsCategoryWithButton( @Composable fun ExportDropdownMenu(collection: () -> RelayListCollection) { var expanded by remember { mutableStateOf(false) } + val context = LocalContext.current IconButton(onClick = { expanded = true }) { Icon( @@ -498,24 +502,27 @@ fun ExportDropdownMenu(collection: () -> RelayListCollection) { ) } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - val context = LocalContext.current - DropdownMenuItem( - text = { Text(stringRes(R.string.export_as_text)) }, - onClick = { - expanded = false - RelayExporter(context).export(collection()) - }, - ) - DropdownMenuItem( - text = { Text(stringRes(R.string.export_as_zip)) }, - onClick = { - expanded = false - RelayZipExporter(context).export(collection()) - }, - ) + if (expanded) { + M3ActionDialog( + title = stringRes(R.string.export_actions_dialog_title), + onDismiss = { expanded = false }, + ) { + M3ActionSection { + M3ActionRow( + icon = Icons.Outlined.Description, + text = stringRes(R.string.export_as_text), + ) { + expanded = false + RelayExporter(context).export(collection()) + } + M3ActionRow( + icon = Icons.Outlined.FolderZip, + text = stringRes(R.string.export_as_zip), + ) { + expanded = false + RelayZipExporter(context).export(collection()) + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 80e4e2a5f..139790f9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -128,6 +128,10 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent @@ -529,6 +533,10 @@ private fun kindDisplayName(kind: Int): Int = AppSpecificDataEvent.KIND -> R.string.kind_user_settings AudioHeaderEvent.KIND -> R.string.kind_audio_header AudioTrackEvent.KIND -> R.string.kind_audio_track + AttestationEvent.KIND -> R.string.attestation + AttestationRequestEvent.KIND -> R.string.attestation_request + AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation + AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency BadgeAwardEvent.KIND -> R.string.kind_badge_awards BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions BadgeProfilesEvent.KIND -> R.string.kind_profile_badges @@ -661,7 +669,7 @@ val zaps = setOf(9734, 9735, 9041, 17375, 23194, 23195) val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, RequestToVanishEvent.KIND) @Composable -private fun KindChip(kind: Int) { +fun KindChip(kind: Int) { val nameResId = kindDisplayName(kind) val name = if (nameResId != -1) stringResource(nameResId) else "k$kind" val (bg, fg) = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 82e84b9cc..0b1212fbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -139,6 +139,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency +import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage @@ -190,6 +194,10 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.selectedNote +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent +import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent +import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -620,6 +628,14 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) + } else if (noteEvent is AttestationEvent) { + RenderAttestation(baseNote, 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AttestationRequestEvent) { + RenderAttestationRequest(baseNote, 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AttestorRecommendationEvent) { + RenderAttestorRecommendation(baseNote, 3, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AttestorProficiencyEvent) { + RenderAttestorProficiency(baseNote, 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is AdvertisedRelayListEvent) { DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is LnZapEvent) { diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index fb662a515..14d9da01d 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -661,4 +661,5 @@ حذف الكل متأكد من حذف جميع المسودات؟ + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index c15476f54..e371e164f 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -573,4 +573,5 @@ এখনই দান করুন QR কোড স্ক্যান করুন + diff --git a/amethyst/src/main/res/values-ca-rES/strings.xml b/amethyst/src/main/res/values-ca-rES/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-ca-rES/strings.xml +++ b/amethyst/src/main/res/values-ca-rES/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 8d8314833..7f04ea0f4 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1310,6 +1310,10 @@ Moje seznamy Uživatelé Vyberte seznam pro filtrování kanálu + Kanály + Hashtagy + Komunity + Seznamy Odhlásit se na zámek zařízení Soukromá zpráva Veřejná zpráva @@ -1621,4 +1625,38 @@ Stahování Chyba Dokončeno + + Označit jako přečtené + Akce poznámky + Akce profilu + Akce médií + Přehrávání + Akce balíčku + Akce seznamu + Akce záložky + Akce skupiny + Přidat záložku + Přidat člena + Správa seznamu + Akce odkazu + Exportovat + Atestace + Platná + Neplatná + Přijata + Zamítnuta + Ověřování + Ověřena + Odvolána + Platná od %1$s + Platná do %1$s + Žádost o atestaci + Žádost o atestaci události + Doporučení atestátora + Doporučen pro druhy: %1$s + Odbornost atestátora + Odborník na ověřování druhů: %1$s + Potvrzuje + Žádosti o atestaci pro + Otevřená anketa diff --git a/amethyst/src/main/res/values-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-da-rDK/strings.xml b/amethyst/src/main/res/values-da-rDK/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-da-rDK/strings.xml +++ b/amethyst/src/main/res/values-da-rDK/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 3beec73d3..79fdfc669 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1315,6 +1315,10 @@ anz der Bedingungen ist erforderlich Meine Listen Benutzer Liste zum Filtern des Feeds auswählen + Feeds + Hash-Tags + Gemeinschaften + Listen Beim Sperren des Geräts abmelden Private Nachricht Öffentliche Nachricht @@ -1626,4 +1630,38 @@ anz der Bedingungen ist erforderlich Herunterladen Fehler Abgeschlossen + + Als gelesen markieren + Notiz-Aktionen + Profilaktionen + Medienaktionen + Wiedergabe + Paket-Aktionen + Listenaktionen + Lesezeichen-Aktionen + Gruppenaktionen + Lesezeichen hinzufügen + Mitglied hinzufügen + Listenverwaltung + Link-Aktionen + Exportieren + Attestierung + Gültig + Ungültig + Akzeptiert + Abgelehnt + Wird verifiziert + Verifiziert + Widerrufen + Gültig ab %1$s + Gültig bis %1$s + Attestierungsanfrage + Attestierung für ein Ereignis anfordern + Empfehlung des Attestierers + Empfohlen für Arten: %1$s + Kompetenz des Attestierers + Kompetent für die Verifizierung von Arten: %1$s + Bestätigt + Beantragt Attestierung für + Offene Umfrage diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index c4e013be1..ebe34bf4e 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -515,4 +515,5 @@ Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 29171ea5f..fec8238fe 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -8,4 +8,5 @@ Show Anyway 👀 + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 69a3a6a64..1719ad9c9 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -443,4 +443,5 @@ Mesaĝi la Uzanto Okej + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index a8c0a5d05..ff871b996 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -1183,4 +1183,5 @@ Este mensaje desaparecerá en %1$d días Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 073df47b1..8a6186601 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -1159,4 +1159,5 @@ Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 2e3b065a5..817fc89e8 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -1160,4 +1160,5 @@ Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index ca89c3a9e..b7918beed 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -948,4 +948,5 @@ با قفل کردن دستگاه از حساب کاربری خارج شو پیام خصوصی + diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index a8aaca7bd..1e71d96df 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -570,4 +570,5 @@ Mediaa ei voitu ladata palvelimelta Paikallista tiedostoa ei voitu valmistella ladattavaksi: %1$s + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index eb90843ab..f1beb4cc4 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -1365,4 +1365,5 @@ Continuer Ignorer pour le moment Tout sélectionner + diff --git a/amethyst/src/main/res/values-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 0c808a742..164002dac 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1558,4 +1558,5 @@ %1$d%% समय निरन्तर उपलब्ध नामरूप्य स्थापना विकल्प द्व्यंकरूप्य समन्वेषक (ओटीएस॰) + diff --git a/amethyst/src/main/res/values-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f97a68e25..2d637d8fa 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1630,4 +1630,5 @@ Letöltés Hiba Kész + diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 735d180cb..4e5b60c38 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -512,4 +512,5 @@ Seharusnya %3$s Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index c84df0a1a..01cc929ad 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -433,4 +433,5 @@ Globale Cerca + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index a8b78fd9a..3fd134573 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -390,4 +390,5 @@ 自動的にURLプレビューを表示 画像読み込み + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index e35584eb3..eea0aa7ad 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -172,4 +172,5 @@ Lejupielādēt Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni. + diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index d79ecae4f..3f51aeb8f 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -1145,4 +1145,5 @@ Verwijder lijst Verwijder pakket + diff --git a/amethyst/src/main/res/values-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 2c1d906a6..adbdc7d29 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1589,4 +1589,5 @@ Pobieranie Błąd Zakończone + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3dd79b99f..aae1d6fc6 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1310,6 +1310,10 @@ Minhas listas Usuários Selecione uma lista para filtrar o feed + Feeds + Hashtags + Comunidades + Listas Terminar sessão no bloqueio do dispositivo Mensagem Privada Mensagem pública @@ -1621,4 +1625,38 @@ Baixando Erro Concluído + + Marcar como lida + Ações da nota + Ações do perfil + Ações de mídia + Reprodução + Ações do pacote + Ações da lista + Ações de favorito + Ações do grupo + Adicionar favorito + Adicionar membro + Gerenciamento de lista + Ações do link + Exportar + Atestação + Válida + Inválida + Aceita + Rejeitada + Verificando + Verificada + Revogada + Válida a partir de %1$s + Válida até %1$s + Solicitação de atestação + Solicitando atestação para um evento + Recomendação do atestante + Recomendado para tipos: %1$s + Competência do atestante + Competente na verificação de tipos: %1$s + Atesta + Solicita atestação para + Enquete aberta diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index a8fc8ab26..44cf8be4c 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -432,4 +432,5 @@ Разное Другое + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 3b6fed0b9..1952d8e51 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1642,4 +1642,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prenašam Napaka Zaključeno + diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 0f3a3ed06..c05bfb1e6 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -34,4 +34,5 @@ Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове Нема подешавања износа Зап. Дуго притисните да бисте променили + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index cbfe22168..6fefd05ff 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1309,6 +1309,10 @@ Mina listor Användare Välj en lista för att filtrera flödet + Flöden + Hashtaggar + Gemenskaper + Listor Logga ut när enheten låses Privat meddelande Offentligt meddelande @@ -1620,4 +1624,38 @@ Laddar ner Fel Slutfört + + Markera som läst + Anteckningsåtgärder + Profilåtgärder + Medieåtgärder + Uppspelning + Paketåtgärder + Liståtgärder + Bokmärkesåtgärder + Gruppåtgärder + Lägg till bokmärke + Lägg till medlem + Listhantering + Länkåtgärder + Exportera + Attestering + Giltig + Ogiltig + Accepterad + Avvisad + Verifierar + Verifierad + Återkallad + Giltig från %1$s + Giltig till %1$s + Attesteringsförfrågan + Begär attestering för en händelse + Attestantens rekommendation + Rekommenderad för typer: %1$s + Attestantens kompetens + Kompetent att verifiera typer: %1$s + Intygar + Begär attestering till + Öppen omröstning diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index eede00f35..0e3f92eee 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -444,4 +444,5 @@ Ulimwenguni Tafuta + diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index e79aecaf2..090879e2a 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -370,4 +370,5 @@ இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 337dec3eb..562618425 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -815,4 +815,5 @@ แลือกลิสต์เพื่อโชว์ ออกจากระบบ + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 7fde8f9e5..307dff61d 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -168,4 +168,5 @@ Tümünü okundu olarak işaretle Hata + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 9e18b6bfc..14d221f28 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -500,4 +500,5 @@ Не вдалося завантажити завантажені медіафайли з сервера Не вдалося підготувати локальний файл для завантаження: %1$s + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index ae64d7d1f..b2f187423 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -69,4 +69,5 @@ "bu kanalda: " Profil banneri + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 5b4876f37..fc3443733 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -12,4 +12,5 @@ Lưu ý cho người nhận Cảm ơn rất nhiều! + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 5a14e0eeb..6a3edcadb 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1626,4 +1626,5 @@ 下载中 错误 已完成 + diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 9c99f2f57..8dbf75e36 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -240,4 +240,5 @@ 聰數量 "“正在查找事件%1$s”" + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 57843dc08..88bf85950 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1,4 +1,5 @@ + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 913f33afd..2e6017315 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -763,4 +763,5 @@ 正在等待 DVM 確認付款或返回結果 添加 NIP-96 服務器 + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a506604f9..21af8fa53 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -258,6 +258,7 @@ "Error loading replies: " Try again No notifications yet. + Open Poll Feed is empty. Refresh created @@ -1877,4 +1878,38 @@ Downloading Error Completed + + + Mark as Read + Note Actions + Profile Actions + Media Actions + Playback + Pack Actions + List Actions + Bookmark Actions + Group Actions + Add Bookmark + Add Member + List Management + Link Actions + Export + Attestation + Valid + Invalid + Accepted + Rejected + Verifying + Verified + Revoked + Valid from %1$s + Valid to %1$s + Attestation Request + Requesting attestation for an event + Attestor Recommendation + Recommended for kinds: + Attestor Proficiency + Proficient in verifying kinds: %1$s + Attests to + Requests attestation to diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt index 991689a1c..9e707b75b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt @@ -23,13 +23,26 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.json.JsonNull.content @Immutable class AttestationEvent( @@ -39,7 +52,22 @@ class AttestationEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) + + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun validity() = tags.validity() fun status() = tags.status() @@ -50,25 +78,91 @@ class AttestationEvent( fun request() = tags.request() + fun requestId() = tags.requestId() + + fun requestAddress() = tags.requestAddress() + fun isRevoked() = status() == AttestationStatus.REVOKED + fun assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse) + + fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + + fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) + companion object { const val KIND = 31871 const val ALT_DESCRIPTION = "Attestation" - fun build( + fun buildEvent( dTagId: String, + about: EventHintBundle, content: String = "", validity: Validity? = null, status: AttestationStatus? = null, validFrom: Long? = null, validTo: Long? = null, - requestAddress: String? = null, + requestAddress: EventHintBundle? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, content, createdAt) { alt(ALT_DESCRIPTION) dTag(dTagId) + about(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + + fun buildReplaceable( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutReplaceable(about) + validity?.let { validity(it) } + status?.let { status(it) } + validFrom?.let { validFrom(it) } + validTo?.let { validTo(it) } + requestAddress?.let { request(it) } + initializer() + } + + fun buildAddress( + dTagId: String, + about: EventHintBundle, + content: String = "", + validity: Validity? = null, + status: AttestationStatus? = null, + validFrom: Long? = null, + validTo: Long? = null, + requestAddress: EventHintBundle? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutAddressable(about) validity?.let { validity(it) } status?.let { status(it) } validFrom?.let { validFrom(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt index bca18c24c..b64090b6f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayBuilderExt.kt @@ -27,7 +27,14 @@ import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Valid import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidToTag import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag fun TagArrayBuilder.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity)) @@ -37,4 +44,10 @@ fun TagArrayBuilder.validFrom(timestamp: Long) = addUnique(Val fun TagArrayBuilder.validTo(timestamp: Long) = addUnique(ValidToTag.assemble(timestamp)) -fun TagArrayBuilder.request(requestAddress: String) = addUnique(RequestTag.assemble(requestAddress)) +fun TagArrayBuilder.request(request: EventHintBundle) = addUnique(RequestTag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutAddressable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutReplaceable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.about(request: EventHintBundle) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt index b85a76a64..2b545818e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/TagArrayExt.kt @@ -36,3 +36,7 @@ fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse) fun TagArray.validTo() = firstNotNullOfOrNull(ValidToTag::parse) fun TagArray.request() = firstNotNullOfOrNull(RequestTag::parse) + +fun TagArray.requestId() = firstNotNullOfOrNull(RequestTag::parseAddressId) + +fun TagArray.requestAddress() = firstNotNullOfOrNull(RequestTag::parseAddress) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt index b32c1602c..d22000d12 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt @@ -20,22 +20,108 @@ */ package com.vitorpamplona.quartz.experimental.attestations.attestation.tags +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure -class RequestTag { +class RequestTag( + val address: Address, + val relayHint: NormalizedRelayUrl? = null, +) { + fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) + + fun toTagArray() = assemble(address, relayHint) + + fun toTagIdOnly() = assemble(address, null) + companion object { const val TAG_NAME = "request" - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR) - fun parse(tag: Array): String? { + fun isTagged( + tag: Array, + addressId: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId + + fun isTagged( + tag: Array, + address: ApprovedAddressTag, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() + + fun isIn( + tag: Array, + addressIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds + + fun parse(tag: Array): ApprovedAddressTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + + val address = Address.parse(tag[1]) ?: return null + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + return ApprovedAddressTag(address, relayHint) + } + + fun parseValidAddress(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + return Address.parse(tag[1])?.toValue() + } + + fun parseAddress(tag: Array): Address? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } + val address = Address.parse(tag[1]) ?: return null + ensure(address.kind != CommunityDefinitionEvent.KIND) { return null } + return address + } + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } return tag[1] } - fun assemble(requestAddress: String) = arrayOf(TAG_NAME, requestAddress) + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(tag[1].contains(':')) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + ensure(relayHint != null) { return null } + + return AddressHint(tag[1], relayHint) + } + + fun assemble( + aTagId: HexKey, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url) + + fun assemble( + address: Address, + relay: NormalizedRelayUrl?, + ) = arrayOfNotNull(TAG_NAME, address.toValue(), relay?.url) + + fun assemble( + kind: Int, + pubKey: String, + dTag: String, + relay: NormalizedRelayUrl?, + ) = assemble(Address.assemble(kind, pubKey, dTag), relay) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt index b933121a0..f5c646a73 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt @@ -21,13 +21,27 @@ package com.vitorpamplona.quartz.experimental.attestations.request import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.attestations.request.attestorPubKeys +import com.vitorpamplona.quartz.experimental.attestations.request.cashuToken import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.let @Immutable class AttestationRequestEvent( @@ -37,15 +51,45 @@ class AttestationRequestEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + AddressHintProvider, + PubKeyHintProvider { + override fun eventHints(): List = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) + + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun cashuToken() = tags.cashuToken() + fun assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId) + + fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + + fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse) + + fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId) + + fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + + fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) + companion object { const val KIND = 31872 const val ALT_DESCRIPTION = "Attestation Request" - fun build( + fun buildEvent( dTagId: String, + about: EventHintBundle, content: String = "", attestorPubKeys: List = emptyList(), cashuToken: String? = null, @@ -54,6 +98,41 @@ class AttestationRequestEvent( ) = eventTemplate(KIND, content, createdAt) { alt(ALT_DESCRIPTION) dTag(dTagId) + about(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + + fun buildReplaceable( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutReplaceable(about) + attestorPubKeys(attestorPubKeys) + cashuToken?.let { cashuToken(it) } + initializer() + } + + fun buildAddress( + dTagId: String, + about: EventHintBundle, + content: String = "", + attestorPubKeys: List = emptyList(), + cashuToken: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTagId) + aboutAddressable(about) attestorPubKeys(attestorPubKeys) cashuToken?.let { cashuToken(it) } initializer() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt index 8f5dcf51a..e356deadd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/TagArrayBuilderExt.kt @@ -21,10 +21,22 @@ package com.vitorpamplona.quartz.experimental.attestations.request import com.vitorpamplona.quartz.experimental.attestations.request.tags.CashuTokenTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag fun TagArrayBuilder.attestorPubKeys(pubKeys: List) = addAll(pubKeys.map { PTag.assemble(it, null) }) fun TagArrayBuilder.cashuToken(token: String) = addUnique(CashuTokenTag.assemble(token)) + +fun TagArrayBuilder.aboutAddressable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.aboutReplaceable(request: EventHintBundle) = addUnique(ATag.assemble(request.event.address(), request.relay)) + +fun TagArrayBuilder.about(request: EventHintBundle) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt index 77f1df4fb..4268dcef3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/LiveEventStore.kt @@ -26,6 +26,16 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +/** + * A reactive event store that combines historical data retrieval with live event streaming. + * + * This class wraps an [IEventStore] to provide real-time updates. When a [query] is executed, + * it first replays all matching historical events from the underlying store, signals the + * End of Stored Events (EOSE), and then continues to stream matching new events as they + * are inserted. + * + * @property store The underlying persistent storage for events. + */ class LiveEventStore( private val store: IEventStore, ) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt index d21d344c4..78651df5e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServer.kt @@ -29,14 +29,10 @@ import kotlinx.coroutines.cancel import kotlin.coroutines.CoroutineContext /** - * This class manages per-connection subscriptions as coroutines. Each - * subscription (REQ) launches a child coroutine that first replays stored - * events matching the filters, sends EOSE, and then streams live events. - * Closing a subscription (CLOSE) immediately cancels its coroutine. + * Represents a Nostr relay server that manages client connections, event storage, and verification. * - * The server is transport-agnostic: callers feed incoming JSON via - * [RelaySession.receive] and receive outgoing JSON via the [RelaySession.send] callback - * provided to [connect]. This allows use with any WebSocket library. + * This class acts as the central coordinator for a relay server, handling the lifecycle of [RelaySession]s + * and providing access to the underlying event store. * * @param store The [IEventStore] backing this relay. * @param policyBuilder Controls requirements for relay commands. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index 8bf99765c..2a384d8a6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -41,7 +41,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch /** - * Represents a single connected client with its active subscriptions. + * Represents an active session between a Nostr client and the relay. + * Each one of these is a connection that can hold many subscriptions */ class RelaySession( private val store: LiveEventStore, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt index a8e28b6f8..a96ca7fd3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/rsvp/CalendarRSVPEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag @@ -47,7 +48,12 @@ class CalendarRSVPEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PubKeyHintProvider { + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + fun status() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parse) fun statusValue() = tags.firstNotNullOfOrNull(RSVPStatusTag.Companion::parseValue)