Merge branch 'main' into claude/nip42-relay-auth-P878m

This commit is contained in:
Vitor Pamplona
2026-03-21 13:37:21 -04:00
committed by GitHub
102 changed files with 2481 additions and 918 deletions
@@ -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-<locale>/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 '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
This gives the list of missing key names.
### 3. Get English values for missing keys
For each missing key, extract its English value:
```bash
# For each missing key, extract the full line from default strings.xml
while IFS= read -r key; do
grep "name=\"$key\"" amethyst/src/main/res/values/strings.xml
done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-<LOCALE>/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
### 4. Present results
Output the missing entries as raw XML resource lines (copy-paste ready for the locale file):
```xml
<string name="attestation_valid">Valid</string>
<string name="attestation_valid_from">Valid from %1$s</string>
<string name="feed_group_lists">Lists</string>
```
Also check `<string-array>` and `<plurals>` 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 `<string>` misses other resource types
- **Modifying files** — this is a read-only research task unless the user asks to add entries
+47 -14
View File
@@ -1,4 +1,9 @@
<a id="v1.06.0"></a>
# [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 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 Relay Feeds
- Adds support for NIP-51 favorite 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 - Turn video controller creation into a flow to fix playback lifecycle issues
- Adds support for uploading audio - 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 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 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 NIP-66 Relay Monitor and discovery support to Quartz
Adds support for Namecoin .bit urls to NIP-05 Adds support for Attestations (https://attestr.xyz/)
- Adds choice of ElectrumX server to resolve namecoins.
Adds basic support for Chess with Jester protocol Adds basic support for Chess with Jester protocol
Adds NIP-46 Bunker support to Quartz and Amethyst Desktop 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 Uploads:
- Adds support to upload Documents to all new post screens.
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: 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 - Displays additional information on warning composables
Redesigns and reorganizes Setting pages Settings redesign
- Consolidate drawer settings into a single Settings hub screen - Consolidate drawer settings into a single Settings hub screen
- Redesigns Zap Amount and NWC setup screens - Redesigns Zap Amount and NWC setup screens
- Redesigns Custom zap amount 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 - Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd
- Adds a parser for blossom: uris - 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: Relay Management:
- Adds relay search tooltip when adding relays - 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 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 - Adds a complete list of event kind names to the subscription card to relay information
- Tracks and displays connection success rate on relay settings - 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 Search fixes
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content. - Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
@@ -88,11 +107,14 @@ Search fixes
Profiles: Profiles:
- Adds a profile picture upload button when the user has no picture - 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 - Groups received zap amounts by sending the user in the profile tab
- Increases the limit of Zap downloads for profiles to 1000 - Increases the limit of Zap downloads for profiles to 1000
- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx - Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
- Migrates profile galleries to display a thumbnail for videos - Migrates profile galleries to display a thumbnail for videos
- Fixes profile galleries' aspect ratios - 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 Bulk Follow onboarding
- Adds screens to search for a user and to copy his/her follow list - 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 - Fixes bug on Show More calculations for very long texts without spaces
- Fixing IO Dispatchers and coroutine scopes of choice - Fixing IO Dispatchers and coroutine scopes of choice
- Fixes anySync parallel operation that was returning the first result, not the first positive "any". - 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: AI:
- Add SKILL.md for AI agent customization - 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 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 - 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 Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge. - Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge.
- Provide implementation for LargeCache, using a CacheMap - 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 AESGCM
- Provide implementation for DigestInstance - Provide implementation for DigestInstance
- Provide implementation for LibSodium - Provide implementation for LibSodium
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- Adds NIP-46 Bunker Login - Adds NIP-46 Bunker Login
@@ -159,6 +189,8 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
- Adds encrypted DMs (NIP-04/NIP-17) - Adds encrypted DMs (NIP-04/NIP-17)
- Adds proper empty states with EOSE tracking - Adds proper empty states with EOSE tracking
- Adds multi-column deck layout - 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 - Clear stored credentials on logout
- Adds bunker heartbeat indicator - Adds bunker heartbeat indicator
- Adds QR-based signer pairing - Adds QR-based signer pairing
@@ -176,6 +208,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
Code Quality Code Quality
- Migrates to AGP 9.0 - Migrates to AGP 9.0
- Adds Amethyst Desktop to CI/CD and Release builds
- Removes the in-app memory counter methods - Removes the in-app memory counter methods
- Refactors the old NIP-05 code on Quartz - Refactors the old NIP-05 code on Quartz
- Migrates contact list management to addressable notes - Migrates contact list management to addressable notes
@@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter 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.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -619,6 +623,30 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified) ) = 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( fun consumeRegularEvent(
event: Event, event: Event,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -3051,6 +3079,10 @@ object LocalCache : ILocalCache, ICacheProvider {
is AppDefinitionEvent -> consume(event, relay, wasVerified) is AppDefinitionEvent -> consume(event, relay, wasVerified)
is AppRecommendationEvent -> consume(event, relay, wasVerified) is AppRecommendationEvent -> consume(event, relay, wasVerified)
is AppSpecificDataEvent -> 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 AudioHeaderEvent -> consume(event, relay, wasVerified)
is AudioTrackEvent -> consume(event, relay, wasVerified) is AudioTrackEvent -> consume(event, relay, wasVerified)
is BadgeAwardEvent -> consume(event, relay, wasVerified) is BadgeAwardEvent -> consume(event, relay, wasVerified)
@@ -32,12 +32,9 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PictureInPicture import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material.icons.filled.Share 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -45,9 +42,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@@ -134,61 +133,41 @@ fun OverflowMenuButton(
modifier = Size20Modifier, modifier = Size20Modifier,
) )
} }
}
DropdownMenu( if (menuExpanded.value) {
expanded = menuExpanded.value, M3ActionDialog(
onDismissRequest = { menuExpanded.value = false }, title = stringRes(R.string.playback_actions_dialog_title),
containerColor = Color.Black.copy(alpha = 0.85f), onDismiss = { menuExpanded.value = false },
) { ) {
if (showShare) { M3ActionSection {
DropdownMenuItem( if (showShare) {
text = { Text(stringRes(R.string.share_or_save), color = Color.White) }, M3ActionRow(
onClick = { icon = Icons.Default.Share,
text = stringRes(R.string.share_or_save),
) {
menuExpanded.value = false menuExpanded.value = false
onShareClick() onShareClick()
}, }
leadingIcon = { }
Icon( if (showSave) {
Icons.Default.Share, M3ActionRow(
contentDescription = null, icon = Icons.Default.SaveAlt,
tint = Color.White, text = stringRes(R.string.download_to_phone),
) ) {
},
)
}
if (showSave) {
DropdownMenuItem(
text = { Text(stringRes(R.string.download_to_phone), color = Color.White) },
onClick = {
menuExpanded.value = false menuExpanded.value = false
onSaveClick() onSaveClick()
}, }
leadingIcon = { }
Icon( if (showPip) {
Icons.Default.SaveAlt, M3ActionRow(
contentDescription = null, icon = Icons.Default.PictureInPicture,
tint = Color.White, text = stringRes(R.string.picture_in_picture),
) ) {
},
)
}
if (showPip) {
DropdownMenuItem(
text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) },
onClick = {
menuExpanded.value = false menuExpanded.value = false
onPipClick() onPipClick()
}, }
leadingIcon = { }
Icon(
Icons.Default.PictureInPicture,
contentDescription = null,
tint = Color.White,
)
},
)
} }
} }
} }
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications 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.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
@@ -85,6 +87,12 @@ val NotificationsPerKeyKinds2 =
InteractiveStorySceneEvent.KIND, InteractiveStorySceneEvent.KIND,
) )
val NotificationsPerKeyKinds3 =
listOf(
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
)
fun filterSummaryNotificationsToPubkey( fun filterSummaryNotificationsToPubkey(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
pubkey: HexKey?, pubkey: HexKey?,
@@ -130,7 +138,17 @@ fun filterNotificationsToPubkey(
Filter( Filter(
kinds = NotificationsPerKeyKinds2, kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)), 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, since = since,
), ),
), ),
@@ -171,7 +189,17 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
Filter( Filter(
kinds = NotificationsPerKeyKinds2, kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)), 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, since = since,
), ),
), ),
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap 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.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -47,6 +48,7 @@ val RepliesAndReactionsToAddressesKinds1 =
LnZapEvent.KIND, LnZapEvent.KIND,
ZapPollEvent.KIND, ZapPollEvent.KIND,
CommentEvent.KIND, CommentEvent.KIND,
AttestationEvent.KIND,
) )
val PostsAndChatMessagesToAddresses = val PostsAndChatMessagesToAddresses =
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap 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.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
OtsEvent.KIND, OtsEvent.KIND,
TextNoteModificationEvent.KIND, TextNoteModificationEvent.KIND,
CommentEvent.KIND, CommentEvent.KIND,
AttestationEvent.KIND,
) )
val RepliesAndReactionsKinds2 = val RepliesAndReactionsKinds2 =
@@ -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,
)
}
}
@@ -24,12 +24,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.Icons
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color 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.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
@Composable
private fun CopyToClipboard(
popupExpanded: MutableState<Boolean>,
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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun UrlPreviewCard( fun UrlPreviewCard(
@@ -81,11 +59,20 @@ fun UrlPreviewCard(
} }
if (popupExpanded.value) { if (popupExpanded.value) {
CopyToClipboard( val clipboardManager = LocalClipboardManager.current
popupExpanded = popupExpanded, M3ActionDialog(
content = url, 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
}
}
} }
} }
@@ -32,15 +32,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Report
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material3.DropdownMenuItem 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -771,119 +772,101 @@ fun ShareMediaAction(
// Track if video is downloading - hoisted here to block menu dismiss during download // Track if video is downloading - hoisted here to block menu dismiss during download
val isDownloadingVideo = remember { mutableStateOf(false) } val isDownloadingVideo = remember { mutableStateOf(false) }
DropdownMenu( if (popupExpanded.value) {
expanded = popupExpanded.value, M3ActionDialog(
onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() }, title = stringRes(R.string.media_actions_dialog_title),
) { onDismiss = { if (!isDownloadingVideo.value) onDismiss() },
val clipboardManager = LocalClipboardManager.current ) {
val clipboardManager = LocalClipboardManager.current
if (videoUri != null && !videoUri.startsWith("file")) { // Copy & Gallery section
DropdownMenuItem( if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) {
text = { Text(stringRes(R.string.copy_url_to_clipboard)) }, M3ActionSection {
onClick = { if (videoUri != null && !videoUri.startsWith("file")) {
clipboardManager.setText(AnnotatedString(videoUri)) M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) {
onDismiss() 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)
} }
} }
postNostrUri?.let {
onDismiss() 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)) {
content?.let { if (videoUri != null) {
val context = LocalContext.current val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
if (n19 != null) {
when (content) { accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType)
is MediaUrlImage -> { accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
videoUri?.let { }
if (videoUri.isNotEmpty()) { }
DropdownMenuItem( onDismiss()
text = { Text(stringRes(R.string.share_image)) },
onClick = {
scope.launch { shareImageFile(context, videoUri, mimeType) }
onDismiss()
},
)
} }
} }
} }
}
is MediaUrlVideo -> { // Share section
videoUri?.let { content?.let {
if (videoUri.isNotEmpty()) { val context = LocalContext.current
DropdownMenuItem(
text = { M3ActionSection {
Row(verticalAlignment = Alignment.CenterVertically) { when (content) {
Text(stringRes(R.string.share_video)) is MediaUrlImage -> {
if (isDownloadingVideo.value) { videoUri?.let {
Spacer(modifier = Modifier.width(8.dp)) if (videoUri.isNotEmpty()) {
LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp) 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 */ }
} }
} }
} }
@@ -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.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition 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.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward 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.normalWithTopMarginNoteModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier 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.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
@@ -767,6 +775,22 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav) 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 -> { is AudioTrackEvent -> {
RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav) RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
} }
@@ -21,10 +21,22 @@
package com.vitorpamplona.amethyst.ui.note.elements package com.vitorpamplona.amethyst.ui.note.elements
import android.content.Intent import android.content.Intent
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.Icons
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material3.HorizontalDivider import androidx.compose.material.icons.outlined.BookmarkAdd
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State 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.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -78,16 +92,16 @@ fun MoreOptionsButton(
onClick = { popupExpanded.value = true }, onClick = { popupExpanded.value = true },
) { ) {
VerticalDotsIcon() VerticalDotsIcon()
}
if (popupExpanded.value) { if (popupExpanded.value) {
NoteDropDownMenu( NoteDropDownMenu(
note = baseNote, note = baseNote,
onDismiss = { popupExpanded.value = false }, onDismiss = { popupExpanded.value = false },
editState = editState, editState = editState,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
}
} }
} }
@@ -146,213 +160,150 @@ fun NoteDropDownMenu(
) )
} }
DropdownMenu( M3ActionDialog(
expanded = true, title = stringRes(R.string.note_actions_dialog_title),
onDismissRequest = onDismiss, onDismiss = onDismiss,
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val actContext = LocalContext.current val actContext = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
if (!state.isFollowingAuthor) { // Follow section
DropdownMenuItem( M3ActionSection {
text = { Text(stringRes(R.string.follow)) }, if (!state.isFollowingAuthor) {
onClick = { M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@DropdownMenuItem val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author) accountViewModel.follow(author)
onDismiss() onDismiss()
}, }
) } else {
HorizontalDivider(thickness = DividerThickness) M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
} else { val author = note.author ?: return@M3ActionRow
DropdownMenuItem(
text = { Text(stringRes(R.string.unfollow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
accountViewModel.unfollow(author) accountViewModel.unfollow(author)
onDismiss() onDismiss()
}, }
) }
HorizontalDivider(thickness = DividerThickness) M3ActionRow(icon = Icons.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) {
} val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow
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)) nav.nav(Route.PeopleListManagement(authorHexKey))
onDismiss() onDismiss()
}, }
) }
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_text)) }, // Copy & Share section
onClick = { M3ActionSection {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) { accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
clipboardManager.setText(AnnotatedString(it))
}
onDismiss() onDismiss()
}, }
) M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = {
note.author?.let { note.author?.let {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss() onDismiss()
} }
} }
}, }
) M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_note_id)) },
onClick = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(note.toNostrUri())) clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss() onDismiss()
} }
}, }
) M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent = val sendIntent =
Intent().apply { Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
type = "text/plain" type = "text/plain"
putExtra( putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note))
Intent.EXTRA_TEXT, putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link))
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) ContextCompat.startActivity(actContext, shareIntent, null)
onDismiss() 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)) }, // Edit & Broadcast section
onClick = { 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) accountViewModel.broadcast(note)
onDismiss() onDismiss()
}, }
) }
HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.otsState.hasPendingAttestations(note)) { // Timestamp & Bookmarks section
DropdownMenuItem( M3ActionSection {
text = { Text(stringRes(R.string.timestamp_pending)) }, if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
onClick = { M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() }
onDismiss() } else {
}, M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
)
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_it)) },
onClick = {
accountViewModel.timestamp(note) accountViewModel.timestamp(note)
onDismiss() onDismiss()
}, }
) }
}
HorizontalDivider(thickness = DividerThickness)
note.let {
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post) val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
DropdownMenuItem( M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) }, if (note.event is LongTextNoteEvent) {
onClick = { nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
if (note.event is LongTextNoteEvent) { } else {
val noteAddress = (note as AddressableNote).address nav.nav(Route.PostBookmarkManagement(note.idHex))
nav.nav(Route.ArticleBookmarkManagement(noteAddress)) }
} else { onDismiss()
nav.nav(Route.PostBookmarkManagement(note.idHex)) }
} if (state.isPrivateBookmarkNote) {
onDismiss() M3ActionRow(icon = Icons.Outlined.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
},
)
}
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_private_bookmarks)) },
onClick = {
accountViewModel.removePrivateBookmark(note) accountViewModel.removePrivateBookmark(note)
onDismiss() onDismiss()
}, }
) } else {
} else { M3ActionRow(icon = Icons.Outlined.Lock, text = stringRes(R.string.add_to_private_bookmarks)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_to_private_bookmarks)) },
onClick = {
accountViewModel.addPrivateBookmark(note) accountViewModel.addPrivateBookmark(note)
onDismiss() onDismiss()
}, }
) }
} if (state.isPublicBookmarkNote) {
if (state.isPublicBookmarkNote) { M3ActionRow(icon = Icons.Outlined.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_public_bookmarks)) },
onClick = {
accountViewModel.removePublicBookmark(note) accountViewModel.removePublicBookmark(note)
onDismiss() onDismiss()
}, }
) } else {
} else { M3ActionRow(icon = Icons.Outlined.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_to_public_bookmarks)) },
onClick = {
accountViewModel.addPublicBookmark(note) accountViewModel.addPublicBookmark(note)
onDismiss() onDismiss()
}, }
) }
} }
HorizontalDivider(thickness = DividerThickness)
if (state.isLoggedUser) { // Moderation section
DropdownMenuItem( M3ActionSection {
text = { Text(stringRes(R.string.request_deletion)) }, if (state.isLoggedUser) {
onClick = { M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
accountViewModel.delete(note) accountViewModel.delete(note)
onDismiss() onDismiss()
}, }
) } else {
} else { M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
DropdownMenuItem( reportDialogShowing = true
text = { Text(stringRes(R.string.block_report)) }, }
onClick = { reportDialogShowing = true }, }
)
} }
} }
@@ -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<Color>,
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<Color>,
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<Color>,
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<Color>,
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<Color>,
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))
}
@@ -21,10 +21,19 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display
import android.content.Intent import android.content.Intent
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.Icons
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.outlined.BookmarkRemove
import androidx.compose.material3.HorizontalDivider import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue 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.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -76,20 +87,20 @@ fun BookmarkGroupItemOptions(
onClick = { popupExpanded.value = true }, onClick = { popupExpanded.value = true },
) { ) {
VerticalDotsIcon() VerticalDotsIcon()
}
if (popupExpanded.value) { if (popupExpanded.value) {
BookmarkGroupItemOptionsMenu( BookmarkGroupItemOptionsMenu(
note = baseNote, note = baseNote,
isBookmarkItemPrivate = isBookmarkItemPrivate, isBookmarkItemPrivate = isBookmarkItemPrivate,
onDismiss = { popupExpanded.value = false }, onDismiss = { popupExpanded.value = false },
onMoveBookmarkToPublic = onMoveBookmarkToPublic, onMoveBookmarkToPublic = onMoveBookmarkToPublic,
onMoveBookmarkToPrivate = onMoveBookmarkToPrivate, onMoveBookmarkToPrivate = onMoveBookmarkToPrivate,
onDeleteBookmarkItem = onDeleteBookmarkItem, onDeleteBookmarkItem = onDeleteBookmarkItem,
editState = editState, editState = editState,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
}
} }
} }
@@ -142,89 +153,71 @@ fun BookmarkGroupItemOptionsMenu(
) )
} }
DropdownMenu( M3ActionDialog(
expanded = true, title = stringRes(R.string.bookmark_item_actions_dialog_title),
onDismissRequest = onDismiss, onDismiss = onDismiss,
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val actContext = LocalContext.current val actContext = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
DropdownMenuItem(
text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) }, // Bookmark Management section
onClick = if (isBookmarkItemPrivate) onMoveBookmarkToPublic else onMoveBookmarkToPrivate, M3ActionSection {
) M3ActionRow(
DropdownMenuItem( icon = if (isBookmarkItemPrivate) Icons.Outlined.LockOpen else Icons.Outlined.Lock,
text = { Text(stringRes(R.string.bookmark_remove_action_label)) }, 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()
}
M3ActionRow(
icon = Icons.Outlined.BookmarkRemove,
text = stringRes(R.string.bookmark_remove_action_label),
isDestructive = true,
) {
onDeleteBookmarkItem() onDeleteBookmarkItem()
onDismiss() onDismiss()
}, }
) }
HorizontalDivider(thickness = DividerThickness)
if (!state.isFollowingAuthor) { // Follow section
DropdownMenuItem( M3ActionSection {
text = { Text(stringRes(R.string.follow)) }, if (!state.isFollowingAuthor) {
onClick = { M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@DropdownMenuItem val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author) accountViewModel.follow(author)
onDismiss() onDismiss()
}, }
) } else {
HorizontalDivider(thickness = DividerThickness) M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
} else { val author = note.author ?: return@M3ActionRow
DropdownMenuItem(
text = { Text(stringRes(R.string.unfollow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
accountViewModel.unfollow(author) accountViewModel.unfollow(author)
onDismiss() 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() onDismiss()
}, }
) M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = {
note.author?.let { note.author?.let {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}")) clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss() onDismiss()
} }
} }
}, }
) M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_note_id)) },
onClick = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(note.toNostrUri())) clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss() onDismiss()
} }
}, }
) M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent = val sendIntent =
Intent().apply { Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
@@ -243,74 +236,57 @@ fun BookmarkGroupItemOptionsMenu(
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null) ContextCompat.startActivity(actContext, shareIntent, null)
onDismiss() onDismiss()
}, }
) }
HorizontalDivider(thickness = DividerThickness)
if (state.isLoggedUser && note.isDraft()) { // Edit & Broadcast section
DropdownMenuItem( M3ActionSection {
text = { Text(stringRes(R.string.edit_draft)) }, if (state.isLoggedUser && note.isDraft()) {
onClick = { M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) {
nav.nav { nav.nav {
routeEditDraftTo(note, accountViewModel.account) 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
},
)
} }
} if (note.event is TextNoteEvent && !note.isDraft()) {
DropdownMenuItem( if (state.isLoggedUser) {
text = { Text(stringRes(R.string.broadcast)) }, M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) {
onClick = { 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) accountViewModel.broadcast(note)
onDismiss() onDismiss()
}, }
) }
HorizontalDivider(thickness = DividerThickness)
if (accountViewModel.account.otsState.hasPendingAttestations(note)) { // Timestamp & Moderation section
DropdownMenuItem( M3ActionSection {
text = { Text(stringRes(R.string.timestamp_pending)) }, if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
onClick = { M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) {
onDismiss() onDismiss()
}, }
) } else {
} else { M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_it)) },
onClick = {
accountViewModel.timestamp(note) accountViewModel.timestamp(note)
onDismiss() onDismiss()
}, }
) }
} if (state.isLoggedUser) {
HorizontalDivider(thickness = DividerThickness) M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.request_deletion)) },
onClick = {
accountViewModel.delete(note) accountViewModel.delete(note)
onDismiss() onDismiss()
}, }
) } else {
} else { M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
DropdownMenuItem( reportDialogShowing = true
text = { Text(stringRes(R.string.block_report)) }, }
onClick = { reportDialogShowing = true }, }
)
} }
} }
@@ -30,11 +30,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState 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.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -57,6 +57,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ClickableBox 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon 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.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder 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.StdPadding
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -304,6 +306,31 @@ fun BookmarkGroupActionsMenuButton(
) { ) {
val isActionListOpen = remember { mutableStateOf(false) } 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( ClickableBox(
modifier = modifier =
StdPadding StdPadding
@@ -319,44 +346,5 @@ fun BookmarkGroupActionsMenuButton(
onClick = { isActionListOpen.value = true }, onClick = { isActionListOpen.value = true },
) { ) {
VerticalDotsIcon() 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()
},
)
} }
} }
@@ -32,10 +32,12 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.outlined.CollectionsBookmark 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@@ -57,6 +59,9 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.components.ClickableBox 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.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -258,18 +263,18 @@ private fun BookmarkGroupOptionsButton(
onClick = { isMenuOpen.value = true }, onClick = { isMenuOpen.value = true },
) { ) {
VerticalDotsIcon() 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 @Composable
@@ -288,45 +293,32 @@ private fun GroupOptionsMenu(
val optionalCloneName = remember { mutableStateOf<String?>(null) } val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) } val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
DropdownMenu( if (isExpanded) {
expanded = isExpanded, M3ActionDialog(
onDismissRequest = onDismiss, title = stringRes(R.string.group_actions_dialog_title),
) { onDismiss = onDismiss,
DropdownMenuItem( ) {
text = { M3ActionSection {
Text(text = stringRes(R.string.follow_set_rename_btn_label)) M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_rename_btn_label)) {
}, onGroupRename()
onClick = { onDismiss()
onGroupRename() }
onDismiss() M3ActionRow(icon = Icons.Outlined.Description, text = stringRes(R.string.follow_set_desc_modify_label)) {
}, onGroupDescriptionChange()
) onDismiss()
DropdownMenuItem( }
text = { M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) {
Text(text = stringRes(R.string.follow_set_desc_modify_label)) isCopyDialogOpen.value = true
}, onDismiss()
onClick = { }
onGroupDescriptionChange() }
onDismiss() M3ActionSection {
}, M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
) onDelete()
DropdownMenuItem( onDismiss()
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 (isCopyDialogOpen.value) { if (isCopyDialogOpen.value) {
@@ -30,12 +30,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkAdd import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove 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.CollectionsBookmark
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.RemoveCircleOutline 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@@ -48,6 +47,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R 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.screen.loggedIn.bookmarkgroups.list.BookmarkMembershipStatusAndNumberDisplay
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
@@ -158,6 +160,30 @@ fun BookmarkManagementOptions(
) { ) {
val isBookmarkAddTapped = remember { mutableStateOf(false) } 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( Column(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, 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
},
)
}
} }
} }
@@ -28,8 +28,9 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.MoveToInbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -48,6 +49,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState 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.components.zonedDrawerSwipe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -103,12 +107,13 @@ fun MessagesTabHeader(
contentDescription = stringRes(id = R.string.more_options), contentDescription = stringRes(id = R.string.more_options),
tint = MaterialTheme.colorScheme.placeholderText, tint = MaterialTheme.colorScheme.placeholderText,
) )
}
MessagesTabMenu( if (moreActionsExpanded) {
moreActionsExpanded, MessagesMarkAsReadDialog(
{ moreActionsExpanded = false }, onDismiss = { moreActionsExpanded = false },
onMarkKnownAsRead, onMarkKnownAsRead = onMarkKnownAsRead,
onMarkNewAsRead, onMarkNewAsRead = onMarkNewAsRead,
) )
} }
} }
@@ -142,34 +147,38 @@ fun MessagesPager(
} }
@Composable @Composable
fun MessagesTabMenu( fun MessagesMarkAsReadDialog(
expanded: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onMarkKnownAsRead: () -> Unit, onMarkKnownAsRead: () -> Unit,
onMarkNewAsRead: () -> Unit, onMarkNewAsRead: () -> Unit,
) { ) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { M3ActionDialog(
DropdownMenuItem( title = stringRes(R.string.mark_as_read_dialog_title),
text = { Text(stringRes(R.string.mark_all_known_as_read)) }, onDismiss = onDismiss,
onClick = { ) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.Groups,
text = stringRes(R.string.mark_all_known_as_read),
) {
onMarkKnownAsRead() onMarkKnownAsRead()
onDismiss() onDismiss()
}, }
) M3ActionRow(
DropdownMenuItem( icon = Icons.Outlined.MoveToInbox,
text = { Text(stringRes(R.string.mark_all_new_as_read)) }, text = stringRes(R.string.mark_all_new_as_read),
onClick = { ) {
onMarkNewAsRead() onMarkNewAsRead()
onDismiss() onDismiss()
}, }
) M3ActionRow(
DropdownMenuItem( icon = Icons.Outlined.DoneAll,
text = { Text(stringRes(R.string.mark_all_as_read)) }, text = stringRes(R.string.mark_all_as_read),
onClick = { ) {
onMarkKnownAsRead() onMarkKnownAsRead()
onMarkNewAsRead() onMarkNewAsRead()
onDismiss() onDismiss()
}, }
) }
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource 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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -42,6 +43,7 @@ val CommunityPostKinds =
ClassifiedsEvent.KIND, ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND, LongTextNoteEvent.KIND,
CommunityPostApprovalEvent.KIND, CommunityPostApprovalEvent.KIND,
AttestationEvent.KIND,
) )
fun filterCommunityPosts( fun filterCommunityPosts(
@@ -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.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams 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.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -60,6 +64,7 @@ class HomeNewThreadFeedFilter(
LongTextNoteEvent.KIND, LongTextNoteEvent.KIND,
LiveChessGameChallengeEvent.KIND, LiveChessGameChallengeEvent.KIND,
LiveChessGameEndEvent.KIND, LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
) )
} }
@@ -126,7 +131,11 @@ class HomeNewThreadFeedFilter(
noteEvent is AudioHeaderEvent || noteEvent is AudioHeaderEvent ||
noteEvent is ChessGameEvent || noteEvent is ChessGameEvent ||
noteEvent is LiveChessGameChallengeEvent || 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) && filterParams.match(noteEvent, it.relays) &&
it.isNewThread() it.isNewThread()
@@ -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.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes 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.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
val HomePostsByGeohashKinds = val HomePostsByGeohashKinds =
listOf( listOf(
@@ -46,11 +46,11 @@ val HomePostsByGeohashKinds =
LongTextNoteEvent.KIND, LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND, ClassifiedsEvent.KIND,
HighlightEvent.KIND, HighlightEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND, WikiNoteEvent.KIND,
CommentEvent.KIND, CommentEvent.KIND,
VoiceEvent.KIND, VoiceEvent.KIND,
VoiceReplyEvent.KIND, AttestationEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
) )
fun filterHomePostsByGeohashes( fun filterHomePostsByGeohashes(
@@ -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.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap 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.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.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -43,11 +44,20 @@ fun filterHomePostsByGlobal(
relay = relayUrl, relay = relayUrl,
filter = filter =
Filter( Filter(
kinds = HomePostsNewThreadKinds, kinds = HomePostsNewThreadKinds1,
limit = 50, limit = 50,
since = since ?: newThreadSince, since = since ?: newThreadSince,
), ),
), ),
RelayBasedFilter(
relay = relayUrl,
filter =
Filter(
kinds = HomePostsNewThreadKinds2,
limit = 5,
since = since ?: newThreadSince,
),
),
RelayBasedFilter( RelayBasedFilter(
relay = relayUrl, relay = relayUrl,
filter = filter =
@@ -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.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes 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.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent 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.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
val HomePostsBuHashtagsKinds = val HomePostsBuHashtagsKinds =
listOf( listOf(
@@ -50,12 +50,12 @@ val HomePostsBuHashtagsKinds =
ClassifiedsEvent.KIND, ClassifiedsEvent.KIND,
HighlightEvent.KIND, HighlightEvent.KIND,
AudioHeaderEvent.KIND, AudioHeaderEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND, CommentEvent.KIND,
WikiNoteEvent.KIND, WikiNoteEvent.KIND,
NipTextEvent.KIND, NipTextEvent.KIND,
VoiceEvent.KIND, VoiceEvent.KIND,
VoiceReplyEvent.KIND, AttestationEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
) )
fun filterHomePostsByHashtags( fun filterHomePostsByHashtags(
@@ -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.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap 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.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
@@ -50,7 +51,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import kotlin.math.min import kotlin.math.min
val HomePostsNewThreadKinds = val HomePostsNewThreadKinds1 =
listOf( listOf(
TextNoteEvent.KIND, TextNoteEvent.KIND,
RepostEvent.KIND, RepostEvent.KIND,
@@ -58,10 +59,15 @@ val HomePostsNewThreadKinds =
ClassifiedsEvent.KIND, ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND, LongTextNoteEvent.KIND,
HighlightEvent.KIND, HighlightEvent.KIND,
WikiNoteEvent.KIND,
NipTextEvent.KIND,
ZapPollEvent.KIND, ZapPollEvent.KIND,
PollEvent.KIND, PollEvent.KIND,
WikiNoteEvent.KIND,
AttestationEvent.KIND,
NipTextEvent.KIND,
)
val HomePostsNewThreadKinds2 =
listOf(
InteractiveStoryPrologueEvent.KIND, InteractiveStoryPrologueEvent.KIND,
ChessGameEvent.KIND, ChessGameEvent.KIND,
LiveChessGameChallengeEvent.KIND, LiveChessGameChallengeEvent.KIND,
@@ -91,12 +97,22 @@ fun filterNewHomePostsByAuthors(
relay = relay, relay = relay,
filter = filter =
Filter( Filter(
kinds = HomePostsNewThreadKinds, kinds = HomePostsNewThreadKinds1,
authors = authorList, authors = authorList,
limit = min(authorList.size * 10, 500), limit = min(authorList.size * 10, 500),
since = since, since = since,
), ),
), ),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = HomePostsNewThreadKinds2,
authors = authorList,
limit = min(authorList.size * 10, 5),
since = since,
),
),
) )
} }
@@ -38,11 +38,14 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState 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.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton 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.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ClickableBox 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.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -416,15 +422,16 @@ private fun ListActionsMenuButton(
onClick = { isActionListOpen.value = true }, onClick = { isActionListOpen.value = true },
) { ) {
VerticalDotsIcon() VerticalDotsIcon()
}
DropdownMenu( if (isActionListOpen.value) {
expanded = isActionListOpen.value, val context = LocalContext.current
onDismissRequest = { isActionListOpen.value = false }, M3ActionDialog(
title = stringRes(R.string.list_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) { ) {
val context = LocalContext.current M3ActionSection {
DropdownMenuItem( M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent = val sendIntent =
Intent().apply { Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
@@ -443,38 +450,22 @@ private fun ListActionsMenuButton(
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null) ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false isActionListOpen.value = false
}, }
) M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) {
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_edit_list_metadata))
},
onClick = {
onEditList() onEditList()
isActionListOpen.value = false isActionListOpen.value = false
}, }
) M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_set_broadcast)) {
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_broadcast))
},
onClick = {
onBroadcastList() onBroadcastList()
isActionListOpen.value = false isActionListOpen.value = false
}, }
) }
HorizontalDivider(thickness = DividerThickness) M3ActionSection {
DropdownMenuItem( M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_set_delete), isDestructive = true) {
text = {
Text(stringRes(R.string.follow_set_delete))
},
onClick = {
onDeleteList() onDeleteList()
isActionListOpen.value = false isActionListOpen.value = false
}, }
) }
} }
} }
} }
@@ -35,11 +35,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed 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.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation 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.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton 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.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ClickableBox 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.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -271,15 +277,16 @@ private fun ListActionsMenuButton(
onClick = { isActionListOpen.value = true }, onClick = { isActionListOpen.value = true },
) { ) {
VerticalDotsIcon() VerticalDotsIcon()
}
DropdownMenu( if (isActionListOpen.value) {
expanded = isActionListOpen.value, val context = LocalContext.current
onDismissRequest = { isActionListOpen.value = false }, M3ActionDialog(
title = stringRes(R.string.pack_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) { ) {
val context = LocalContext.current M3ActionSection {
DropdownMenuItem( M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
val sendIntent = val sendIntent =
Intent().apply { Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
@@ -298,38 +305,22 @@ private fun ListActionsMenuButton(
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share)) Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null) ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false isActionListOpen.value = false
}, }
) M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_pack_edit_list_metadata)) {
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_edit_list_metadata))
},
onClick = {
onEditList() onEditList()
isActionListOpen.value = false isActionListOpen.value = false
}, }
) M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_pack_broadcast)) {
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_broadcast))
},
onClick = {
onBroadcastList() onBroadcastList()
isActionListOpen.value = false isActionListOpen.value = false
}, }
) }
HorizontalDivider(thickness = DividerThickness) M3ActionSection {
DropdownMenuItem( M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_pack_delete), isDestructive = true) {
text = {
Text(stringRes(R.string.follow_pack_delete))
},
onClick = {
onDeleteList() onDeleteList()
isActionListOpen.value = false isActionListOpen.value = false
}, }
) }
} }
} }
} }
@@ -29,13 +29,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons 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.Groups
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.Text 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.User
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.components.ClickableBox 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.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -286,15 +290,15 @@ private fun PeopleListOptionsButton(
onClick = { isMenuOpen.value = true }, onClick = { isMenuOpen.value = true },
) { ) {
VerticalDotsIcon() 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 @Composable
@@ -309,36 +313,28 @@ private fun ListOptionsMenu(
val optionalCloneName = remember { mutableStateOf<String?>(null) } val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) } val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
DropdownMenu( if (isExpanded) {
expanded = isExpanded, M3ActionDialog(
onDismissRequest = onDismiss, title = stringRes(R.string.list_management_dialog_title),
) { onDismiss = onDismiss,
DropdownMenuItem( ) {
text = { M3ActionSection {
Text(text = stringRes(R.string.follow_set_edit_list_metadata)) M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) {
}, onListEditMetadata()
onClick = { onDismiss()
onListEditMetadata() }
onDismiss() M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.follow_set_copy_action_btn_label)) {
}, isCopyDialogOpen.value = true
) onDismiss()
DropdownMenuItem( }
text = { }
Text(text = stringRes(R.string.follow_set_copy_action_btn_label)) M3ActionSection {
}, M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
onClick = { onDelete()
isCopyDialogOpen.value = true onDismiss()
onDismiss() }
}, }
) }
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.quick_action_delete))
},
onClick = {
onDelete()
},
)
} }
if (isCopyDialogOpen.value) { if (isCopyDialogOpen.value) {
@@ -33,10 +33,9 @@ import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Lock 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.Public
import androidx.compose.material.icons.outlined.RemoveCircleOutline 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R 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.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
@@ -193,6 +195,30 @@ private fun UserAdditionOptions(
) { ) {
val isUserAddTapped = remember { mutableStateOf(false) } 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( Column(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, 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
},
)
}
} }
} }
@@ -28,11 +28,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding 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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@Composable @Composable
fun RefreshableCardView( fun RefreshableCardView(
@@ -165,6 +170,7 @@ private fun FeedLoaded(
nav: INav, nav: INav,
) { ) {
val items by loaded.feed.collectAsStateWithLifecycle() val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by openPollsState(accountViewModel)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -175,6 +181,37 @@ private fun FeedLoaded(
ShowDonationCard(accountViewModel, nav) 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( itemsIndexed(
items = items.list, items = items.list,
key = { _, item -> item.id() }, key = { _, item -> item.id() },
@@ -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<Note>): List<Note> {
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),
)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams 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.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
@@ -87,6 +88,7 @@ class NotificationFeedFilter(
VideoVerticalEvent.KIND, VideoVerticalEvent.KIND,
VideoHorizontalEvent.KIND, VideoHorizontalEvent.KIND,
WikiNoteEvent.KIND, WikiNoteEvent.KIND,
AttestationRequestEvent.KIND,
) )
val NOTIFICATION_KINDS = val NOTIFICATION_KINDS =
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap 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.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
@@ -66,6 +67,7 @@ val UserProfilePostKinds2 =
VoiceReplyEvent.KIND, VoiceReplyEvent.KIND,
ZapPollEvent.KIND, ZapPollEvent.KIND,
PinListEvent.KIND, PinListEvent.KIND,
AttestationEvent.KIND,
) )
fun filterUserProfilePosts( fun filterUserProfilePosts(
@@ -21,20 +21,24 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent import android.content.Intent
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.Icons
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.outlined.Block
import androidx.compose.material3.HorizontalDivider import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Text 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.runtime.Composable
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User 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.note.externalLinkForUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable @Composable
@@ -44,108 +48,94 @@ fun UserProfileDropDownMenu(
onDismiss: () -> Unit, onDismiss: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
DropdownMenu( if (!popupExpanded) return
expanded = popupExpanded,
onDismissRequest = onDismiss, M3ActionDialog(
title = stringRes(R.string.profile_actions_dialog_title),
onDismiss = onDismiss,
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_id)) },
onClick = {
clipboardManager.setText(AnnotatedString(user.pubkeyNpub()))
onDismiss()
},
)
val context = LocalContext.current val context = LocalContext.current
DropdownMenuItem( // Share section
text = { Text(stringRes(R.string.quick_action_share)) }, M3ActionSection {
onClick = { 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 = val sendIntent =
Intent().apply { Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
type = "text/plain" type = "text/plain"
putExtra( putExtra(Intent.EXTRA_TEXT, externalLinkForUser(user))
Intent.EXTRA_TEXT,
externalLinkForUser(user),
)
putExtra( putExtra(
Intent.EXTRA_TITLE, Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link), 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) context.startActivity(shareIntent)
onDismiss() onDismiss()
}, }
) }
// Moderation section (if not self)
if (accountViewModel.userProfile() != user) { if (accountViewModel.userProfile() != user) {
HorizontalDivider(thickness = DividerThickness) M3ActionSection {
if (accountViewModel.account.isHidden(user)) { if (accountViewModel.account.isHidden(user)) {
DropdownMenuItem( M3ActionRow(
text = { Text(stringRes(R.string.unblock_user)) }, icon = Icons.Outlined.CheckCircle,
onClick = { text = stringRes(R.string.unblock_user),
) {
accountViewModel.show(user) accountViewModel.show(user)
onDismiss() onDismiss()
}, }
) } else {
} else { M3ActionRow(
DropdownMenuItem( icon = Icons.Outlined.Block,
text = { Text(stringRes(id = R.string.block_hide_user)) }, text = stringRes(R.string.block_hide_user),
onClick = { isDestructive = true,
) {
accountViewModel.hide(user) accountViewModel.hide(user)
onDismiss() onDismiss()
}, }
) }
} }
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem( // Report section
text = { Text(stringRes(id = R.string.report_spam_scam)) }, M3ActionSection {
onClick = { M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_spam_scam), isDestructive = true) {
accountViewModel.report(user, ReportType.SPAM) accountViewModel.report(user, ReportType.SPAM)
onDismiss() onDismiss()
}, }
) M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_hateful_speech), isDestructive = true) {
DropdownMenuItem(
text = { Text(stringRes(R.string.report_hateful_speech)) },
onClick = {
accountViewModel.report(user, ReportType.PROFANITY) accountViewModel.report(user, ReportType.PROFANITY)
onDismiss() onDismiss()
}, }
) M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_impersonation), isDestructive = true) {
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_impersonation)) },
onClick = {
accountViewModel.report(user, ReportType.IMPERSONATION) accountViewModel.report(user, ReportType.IMPERSONATION)
onDismiss() onDismiss()
}, }
) M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_nudity_porn), isDestructive = true) {
DropdownMenuItem(
text = { Text(stringRes(R.string.report_nudity_porn)) },
onClick = {
accountViewModel.report(user, ReportType.NUDITY) accountViewModel.report(user, ReportType.NUDITY)
onDismiss() onDismiss()
}, }
) M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_illegal_behaviour), isDestructive = true) {
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_illegal_behaviour)) },
onClick = {
accountViewModel.report(user, ReportType.ILLEGAL) accountViewModel.report(user, ReportType.ILLEGAL)
onDismiss() onDismiss()
}, }
) M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_malware), isDestructive = true) {
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_malware)) },
onClick = {
accountViewModel.report(user, ReportType.MALWARE) accountViewModel.report(user, ReportType.MALWARE)
onDismiss() onDismiss()
}, }
) }
} }
} }
} }
@@ -27,6 +27,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder 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.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -87,7 +91,11 @@ class UserProfileNewThreadFeedFilter(
it.event is AudioTrackEvent || it.event is AudioTrackEvent ||
it.event is AudioHeaderEvent || it.event is AudioHeaderEvent ||
it.event is VoiceEvent || 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() && it.isNewThread() &&
account.isAcceptable(it) account.isAcceptable(it)
@@ -29,8 +29,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.DefaultDMRelayList
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -490,6 +493,7 @@ fun SettingsCategoryWithButton(
@Composable @Composable
fun ExportDropdownMenu(collection: () -> RelayListCollection) { fun ExportDropdownMenu(collection: () -> RelayListCollection) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val context = LocalContext.current
IconButton(onClick = { expanded = true }) { IconButton(onClick = { expanded = true }) {
Icon( Icon(
@@ -498,24 +502,27 @@ fun ExportDropdownMenu(collection: () -> RelayListCollection) {
) )
} }
DropdownMenu( if (expanded) {
expanded = expanded, M3ActionDialog(
onDismissRequest = { expanded = false }, title = stringRes(R.string.export_actions_dialog_title),
) { onDismiss = { expanded = false },
val context = LocalContext.current ) {
DropdownMenuItem( M3ActionSection {
text = { Text(stringRes(R.string.export_as_text)) }, M3ActionRow(
onClick = { icon = Icons.Outlined.Description,
expanded = false text = stringRes(R.string.export_as_text),
RelayExporter(context).export(collection()) ) {
}, expanded = false
) RelayExporter(context).export(collection())
DropdownMenuItem( }
text = { Text(stringRes(R.string.export_as_zip)) }, M3ActionRow(
onClick = { icon = Icons.Outlined.FolderZip,
expanded = false text = stringRes(R.string.export_as_zip),
RelayZipExporter(context).export(collection()) ) {
}, expanded = false
) RelayZipExporter(context).export(collection())
}
}
}
} }
} }
@@ -128,6 +128,10 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface 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.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -529,6 +533,10 @@ private fun kindDisplayName(kind: Int): Int =
AppSpecificDataEvent.KIND -> R.string.kind_user_settings AppSpecificDataEvent.KIND -> R.string.kind_user_settings
AudioHeaderEvent.KIND -> R.string.kind_audio_header AudioHeaderEvent.KIND -> R.string.kind_audio_header
AudioTrackEvent.KIND -> R.string.kind_audio_track 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 BadgeAwardEvent.KIND -> R.string.kind_badge_awards
BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions
BadgeProfilesEvent.KIND -> R.string.kind_profile_badges 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) val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, RequestToVanishEvent.KIND)
@Composable @Composable
private fun KindChip(kind: Int) { fun KindChip(kind: Int) {
val nameResId = kindDisplayName(kind) val nameResId = kindDisplayName(kind)
val name = if (nameResId != -1) stringResource(nameResId) else "k$kind" val name = if (nameResId != -1) stringResource(nameResId) else "k$kind"
val (bg, fg) = val (bg, fg) =
@@ -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.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition 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.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage 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.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote 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.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
@@ -620,6 +628,14 @@ private fun FullBleedNoteCompose(
accountViewModel, accountViewModel,
nav, 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) { } else if (noteEvent is AdvertisedRelayListEvent) {
DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is LnZapEvent) { } else if (noteEvent is LnZapEvent) {
@@ -661,4 +661,5 @@
<string name="delete_all">حذف الكل</string> <string name="delete_all">حذف الكل</string>
<string name="delete_all_drafts_confirmation">متأكد من حذف جميع المسودات؟</string> <string name="delete_all_drafts_confirmation">متأكد من حذف جميع المسودات؟</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -573,4 +573,5 @@
<string name="donate_now">এখনই দান করুন</string> <string name="donate_now">এখনই দান করুন</string>
<string name="accessibility_scan_qr_code">QR কোড স্ক্যান করুন</string> <string name="accessibility_scan_qr_code">QR কোড স্ক্যান করুন</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1310,6 +1310,10 @@
<string name="my_lists">Moje seznamy</string> <string name="my_lists">Moje seznamy</string>
<string name="people_list_label">Uživatelé</string> <string name="people_list_label">Uživatelé</string>
<string name="select_list_to_filter">Vyberte seznam pro filtrování kanálu</string> <string name="select_list_to_filter">Vyberte seznam pro filtrování kanálu</string>
<string name="feed_group_feeds">Kanály</string>
<string name="feed_group_hashtags">Hashtagy</string>
<string name="feed_group_communities">Komunity</string>
<string name="feed_group_lists">Seznamy</string>
<string name="temporary_account">Odhlásit se na zámek zařízení</string> <string name="temporary_account">Odhlásit se na zámek zařízení</string>
<string name="private_message">Soukromá zpráva</string> <string name="private_message">Soukromá zpráva</string>
<string name="public_message">Veřejná zpráva</string> <string name="public_message">Veřejná zpráva</string>
@@ -1621,4 +1625,38 @@
<string name="event_sync_status_downloading">Stahování</string> <string name="event_sync_status_downloading">Stahování</string>
<string name="event_sync_status_error">Chyba</string> <string name="event_sync_status_error">Chyba</string>
<string name="event_sync_status_completed">Dokončeno</string> <string name="event_sync_status_completed">Dokončeno</string>
<!-- M3 Action Dialog titles -->
<string name="mark_as_read_dialog_title">Označit jako přečtené</string>
<string name="note_actions_dialog_title">Akce poznámky</string>
<string name="profile_actions_dialog_title">Akce profilu</string>
<string name="media_actions_dialog_title">Akce médií</string>
<string name="playback_actions_dialog_title">Přehrávání</string>
<string name="pack_actions_dialog_title">Akce balíčku</string>
<string name="list_actions_dialog_title">Akce seznamu</string>
<string name="bookmark_item_actions_dialog_title">Akce záložky</string>
<string name="group_actions_dialog_title">Akce skupiny</string>
<string name="add_bookmark_dialog_title">Přidat záložku</string>
<string name="add_member_dialog_title">Přidat člena</string>
<string name="list_management_dialog_title">Správa seznamu</string>
<string name="link_actions_dialog_title">Akce odkazu</string>
<string name="export_actions_dialog_title">Exportovat</string>
<string name="attestation">Atestace</string>
<string name="attestation_valid">Platná</string>
<string name="attestation_invalid">Neplatná</string>
<string name="attestation_status_accepted">Přijata</string>
<string name="attestation_status_rejected">Zamítnuta</string>
<string name="attestation_status_verifying">Ověřování</string>
<string name="attestation_status_verified">Ověřena</string>
<string name="attestation_status_revoked">Odvolána</string>
<string name="attestation_valid_from">Platná od %1$s</string>
<string name="attestation_valid_to">Platná do %1$s</string>
<string name="attestation_request">Žádost o atestaci</string>
<string name="attestation_request_description">Žádost o atestaci události</string>
<string name="attestor_recommendation">Doporučení atestátora</string>
<string name="attestor_recommendation_for_kinds">Doporučen pro druhy: %1$s</string>
<string name="attestor_proficiency">Odbornost atestátora</string>
<string name="attestor_proficiency_for_kinds">Odborník na ověřování druhů: %1$s</string>
<string name="attestation_attests_to">Potvrzuje</string>
<string name="attestation_requests_attestation_to">Žádosti o atestaci pro</string>
<string name="open_poll">Otevřená anketa</string>
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1315,6 +1315,10 @@ anz der Bedingungen ist erforderlich</string>
<string name="my_lists">Meine Listen</string> <string name="my_lists">Meine Listen</string>
<string name="people_list_label">Benutzer</string> <string name="people_list_label">Benutzer</string>
<string name="select_list_to_filter">Liste zum Filtern des Feeds auswählen</string> <string name="select_list_to_filter">Liste zum Filtern des Feeds auswählen</string>
<string name="feed_group_feeds">Feeds</string>
<string name="feed_group_hashtags">Hash-Tags</string>
<string name="feed_group_communities">Gemeinschaften</string>
<string name="feed_group_lists">Listen</string>
<string name="temporary_account">Beim Sperren des Geräts abmelden</string> <string name="temporary_account">Beim Sperren des Geräts abmelden</string>
<string name="private_message">Private Nachricht</string> <string name="private_message">Private Nachricht</string>
<string name="public_message">Öffentliche Nachricht</string> <string name="public_message">Öffentliche Nachricht</string>
@@ -1626,4 +1630,38 @@ anz der Bedingungen ist erforderlich</string>
<string name="event_sync_status_downloading">Herunterladen</string> <string name="event_sync_status_downloading">Herunterladen</string>
<string name="event_sync_status_error">Fehler</string> <string name="event_sync_status_error">Fehler</string>
<string name="event_sync_status_completed">Abgeschlossen</string> <string name="event_sync_status_completed">Abgeschlossen</string>
<!-- M3 Action Dialog titles -->
<string name="mark_as_read_dialog_title">Als gelesen markieren</string>
<string name="note_actions_dialog_title">Notiz-Aktionen</string>
<string name="profile_actions_dialog_title">Profilaktionen</string>
<string name="media_actions_dialog_title">Medienaktionen</string>
<string name="playback_actions_dialog_title">Wiedergabe</string>
<string name="pack_actions_dialog_title">Paket-Aktionen</string>
<string name="list_actions_dialog_title">Listenaktionen</string>
<string name="bookmark_item_actions_dialog_title">Lesezeichen-Aktionen</string>
<string name="group_actions_dialog_title">Gruppenaktionen</string>
<string name="add_bookmark_dialog_title">Lesezeichen hinzufügen</string>
<string name="add_member_dialog_title">Mitglied hinzufügen</string>
<string name="list_management_dialog_title">Listenverwaltung</string>
<string name="link_actions_dialog_title">Link-Aktionen</string>
<string name="export_actions_dialog_title">Exportieren</string>
<string name="attestation">Attestierung</string>
<string name="attestation_valid">Gültig</string>
<string name="attestation_invalid">Ungültig</string>
<string name="attestation_status_accepted">Akzeptiert</string>
<string name="attestation_status_rejected">Abgelehnt</string>
<string name="attestation_status_verifying">Wird verifiziert</string>
<string name="attestation_status_verified">Verifiziert</string>
<string name="attestation_status_revoked">Widerrufen</string>
<string name="attestation_valid_from">Gültig ab %1$s</string>
<string name="attestation_valid_to">Gültig bis %1$s</string>
<string name="attestation_request">Attestierungsanfrage</string>
<string name="attestation_request_description">Attestierung für ein Ereignis anfordern</string>
<string name="attestor_recommendation">Empfehlung des Attestierers</string>
<string name="attestor_recommendation_for_kinds">Empfohlen für Arten: %1$s</string>
<string name="attestor_proficiency">Kompetenz des Attestierers</string>
<string name="attestor_proficiency_for_kinds">Kompetent für die Verifizierung von Arten: %1$s</string>
<string name="attestation_attests_to">Bestätigt</string>
<string name="attestation_requests_attestation_to">Beantragt Attestierung für</string>
<string name="open_poll">Offene Umfrage</string>
</resources> </resources>
@@ -515,4 +515,5 @@
Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις.
</string> </string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -8,4 +8,5 @@
<string name="show_anyway">Show Anyway</string> <string name="show_anyway">Show Anyway</string>
<string name="post_not_found_short">👀</string> <string name="post_not_found_short">👀</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -443,4 +443,5 @@
<string name="error_dialog_talk_to_user">Mesaĝi la Uzanto</string> <string name="error_dialog_talk_to_user">Mesaĝi la Uzanto</string>
<string name="error_dialog_button_ok">Okej</string> <string name="error_dialog_button_ok">Okej</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1183,4 +1183,5 @@
<string name="this_message_will_disappear_in_days">Este mensaje desaparecerá en %1$d días</string> <string name="this_message_will_disappear_in_days">Este mensaje desaparecerá en %1$d días</string>
<string name="select_signer">Seleccionar firmante</string> <string name="select_signer">Seleccionar firmante</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1159,4 +1159,5 @@
<string name="dont_translate_from_description">Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo.</string> <string name="dont_translate_from_description">Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo.</string>
<string name="select_signer">Seleccionar firmante</string> <string name="select_signer">Seleccionar firmante</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1160,4 +1160,5 @@
<string name="dont_translate_from_description">Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo.</string> <string name="dont_translate_from_description">Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo.</string>
<string name="select_signer">Seleccionar firmante</string> <string name="select_signer">Seleccionar firmante</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -948,4 +948,5 @@
<string name="temporary_account">با قفل کردن دستگاه از حساب کاربری خارج شو</string> <string name="temporary_account">با قفل کردن دستگاه از حساب کاربری خارج شو</string>
<string name="private_message">پیام خصوصی</string> <string name="private_message">پیام خصوصی</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -570,4 +570,5 @@
<string name="could_not_download_from_the_server">Mediaa ei voitu ladata palvelimelta</string> <string name="could_not_download_from_the_server">Mediaa ei voitu ladata palvelimelta</string>
<string name="could_not_prepare_local_file_to_upload">Paikallista tiedostoa ei voitu valmistella ladattavaksi: %1$s</string> <string name="could_not_prepare_local_file_to_upload">Paikallista tiedostoa ei voitu valmistella ladattavaksi: %1$s</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1365,4 +1365,5 @@
<string name="continue_txt">Continuer</string> <string name="continue_txt">Continuer</string>
<string name="skip_for_now">Ignorer pour le moment</string> <string name="skip_for_now">Ignorer pour le moment</string>
<string name="select_all">Tout sélectionner</string> <string name="select_all">Tout sélectionner</string>
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1558,4 +1558,5 @@
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string> <string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string>
<string name="namecoin_settings">नामरूप्य स्थापना विकल्प</string> <string name="namecoin_settings">नामरूप्य स्थापना विकल्प</string>
<string name="ots_explorer_settings">द्व्यंकरूप्य समन्वेषक (ओटीएस॰)</string> <string name="ots_explorer_settings">द्व्यंकरूप्य समन्वेषक (ओटीएस॰)</string>
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1630,4 +1630,5 @@
<string name="event_sync_status_downloading">Letöltés</string> <string name="event_sync_status_downloading">Letöltés</string>
<string name="event_sync_status_error">Hiba</string> <string name="event_sync_status_error">Hiba</string>
<string name="event_sync_status_completed">Kész</string> <string name="event_sync_status_completed">Kész</string>
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -512,4 +512,5 @@ Seharusnya %3$s</string>
Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan.
</string> </string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -433,4 +433,5 @@
<string name="active_for_global">Globale</string> <string name="active_for_global">Globale</string>
<string name="active_for_search">Cerca</string> <string name="active_for_search">Cerca</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -390,4 +390,5 @@
<string name="automatically_show_url_preview">自動的にURLプレビューを表示</string> <string name="automatically_show_url_preview">自動的にURLプレビューを表示</string>
<string name="load_image">画像読み込み</string> <string name="load_image">画像読み込み</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -172,4 +172,5 @@
<string name="torrent_download">Lejupielādēt</string> <string name="torrent_download">Lejupielādēt</string>
<string name="torrent_no_apps">Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni.</string> <string name="torrent_no_apps">Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni.</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1145,4 +1145,5 @@
<string name="follow_set_delete">Verwijder lijst</string> <string name="follow_set_delete">Verwijder lijst</string>
<string name="follow_pack_delete">Verwijder pakket</string> <string name="follow_pack_delete">Verwijder pakket</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1589,4 +1589,5 @@
<string name="event_sync_status_downloading">Pobieranie</string> <string name="event_sync_status_downloading">Pobieranie</string>
<string name="event_sync_status_error">Błąd</string> <string name="event_sync_status_error">Błąd</string>
<string name="event_sync_status_completed">Zakończone</string> <string name="event_sync_status_completed">Zakończone</string>
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1310,6 +1310,10 @@
<string name="my_lists">Minhas listas</string> <string name="my_lists">Minhas listas</string>
<string name="people_list_label">Usuários</string> <string name="people_list_label">Usuários</string>
<string name="select_list_to_filter">Selecione uma lista para filtrar o feed</string> <string name="select_list_to_filter">Selecione uma lista para filtrar o feed</string>
<string name="feed_group_feeds">Feeds</string>
<string name="feed_group_hashtags">Hashtags</string>
<string name="feed_group_communities">Comunidades</string>
<string name="feed_group_lists">Listas</string>
<string name="temporary_account">Terminar sessão no bloqueio do dispositivo</string> <string name="temporary_account">Terminar sessão no bloqueio do dispositivo</string>
<string name="private_message">Mensagem Privada</string> <string name="private_message">Mensagem Privada</string>
<string name="public_message">Mensagem pública</string> <string name="public_message">Mensagem pública</string>
@@ -1621,4 +1625,38 @@
<string name="event_sync_status_downloading">Baixando</string> <string name="event_sync_status_downloading">Baixando</string>
<string name="event_sync_status_error">Erro</string> <string name="event_sync_status_error">Erro</string>
<string name="event_sync_status_completed">Concluído</string> <string name="event_sync_status_completed">Concluído</string>
<!-- M3 Action Dialog titles -->
<string name="mark_as_read_dialog_title">Marcar como lida</string>
<string name="note_actions_dialog_title">Ações da nota</string>
<string name="profile_actions_dialog_title">Ações do perfil</string>
<string name="media_actions_dialog_title">Ações de mídia</string>
<string name="playback_actions_dialog_title">Reprodução</string>
<string name="pack_actions_dialog_title">Ações do pacote</string>
<string name="list_actions_dialog_title">Ações da lista</string>
<string name="bookmark_item_actions_dialog_title">Ações de favorito</string>
<string name="group_actions_dialog_title">Ações do grupo</string>
<string name="add_bookmark_dialog_title">Adicionar favorito</string>
<string name="add_member_dialog_title">Adicionar membro</string>
<string name="list_management_dialog_title">Gerenciamento de lista</string>
<string name="link_actions_dialog_title">Ações do link</string>
<string name="export_actions_dialog_title">Exportar</string>
<string name="attestation">Atestação</string>
<string name="attestation_valid">Válida</string>
<string name="attestation_invalid">Inválida</string>
<string name="attestation_status_accepted">Aceita</string>
<string name="attestation_status_rejected">Rejeitada</string>
<string name="attestation_status_verifying">Verificando</string>
<string name="attestation_status_verified">Verificada</string>
<string name="attestation_status_revoked">Revogada</string>
<string name="attestation_valid_from">Válida a partir de %1$s</string>
<string name="attestation_valid_to">Válida até %1$s</string>
<string name="attestation_request">Solicitação de atestação</string>
<string name="attestation_request_description">Solicitando atestação para um evento</string>
<string name="attestor_recommendation">Recomendação do atestante</string>
<string name="attestor_recommendation_for_kinds">Recomendado para tipos: %1$s</string>
<string name="attestor_proficiency">Competência do atestante</string>
<string name="attestor_proficiency_for_kinds">Competente na verificação de tipos: %1$s</string>
<string name="attestation_attests_to">Atesta</string>
<string name="attestation_requests_attestation_to">Solicita atestação para</string>
<string name="open_poll">Enquete aberta</string>
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -432,4 +432,5 @@
<string name="classifieds_category_misc">Разное</string> <string name="classifieds_category_misc">Разное</string>
<string name="classifieds_category_other">Другое</string> <string name="classifieds_category_other">Другое</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1642,4 +1642,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="event_sync_status_downloading">Prenašam</string> <string name="event_sync_status_downloading">Prenašam</string>
<string name="event_sync_status_error">Napaka</string> <string name="event_sync_status_error">Napaka</string>
<string name="event_sync_status_completed">Zaključeno</string> <string name="event_sync_status_completed">Zaključeno</string>
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -34,4 +34,5 @@
<string name="login_with_a_private_key_to_like_posts">Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове</string> <string name="login_with_a_private_key_to_like_posts">Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове</string>
<string name="no_zap_amount_setup_long_press_to_change">Нема подешавања износа Зап. Дуго притисните да бисте променили</string> <string name="no_zap_amount_setup_long_press_to_change">Нема подешавања износа Зап. Дуго притисните да бисте променили</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1309,6 +1309,10 @@
<string name="my_lists">Mina listor</string> <string name="my_lists">Mina listor</string>
<string name="people_list_label">Användare</string> <string name="people_list_label">Användare</string>
<string name="select_list_to_filter">Välj en lista för att filtrera flödet</string> <string name="select_list_to_filter">Välj en lista för att filtrera flödet</string>
<string name="feed_group_feeds">Flöden</string>
<string name="feed_group_hashtags">Hashtaggar</string>
<string name="feed_group_communities">Gemenskaper</string>
<string name="feed_group_lists">Listor</string>
<string name="temporary_account">Logga ut när enheten låses</string> <string name="temporary_account">Logga ut när enheten låses</string>
<string name="private_message">Privat meddelande</string> <string name="private_message">Privat meddelande</string>
<string name="public_message">Offentligt meddelande</string> <string name="public_message">Offentligt meddelande</string>
@@ -1620,4 +1624,38 @@
<string name="event_sync_status_downloading">Laddar ner</string> <string name="event_sync_status_downloading">Laddar ner</string>
<string name="event_sync_status_error">Fel</string> <string name="event_sync_status_error">Fel</string>
<string name="event_sync_status_completed">Slutfört</string> <string name="event_sync_status_completed">Slutfört</string>
<!-- M3 Action Dialog titles -->
<string name="mark_as_read_dialog_title">Markera som läst</string>
<string name="note_actions_dialog_title">Anteckningsåtgärder</string>
<string name="profile_actions_dialog_title">Profilåtgärder</string>
<string name="media_actions_dialog_title">Medieåtgärder</string>
<string name="playback_actions_dialog_title">Uppspelning</string>
<string name="pack_actions_dialog_title">Paketåtgärder</string>
<string name="list_actions_dialog_title">Liståtgärder</string>
<string name="bookmark_item_actions_dialog_title">Bokmärkesåtgärder</string>
<string name="group_actions_dialog_title">Gruppåtgärder</string>
<string name="add_bookmark_dialog_title">Lägg till bokmärke</string>
<string name="add_member_dialog_title">Lägg till medlem</string>
<string name="list_management_dialog_title">Listhantering</string>
<string name="link_actions_dialog_title">Länkåtgärder</string>
<string name="export_actions_dialog_title">Exportera</string>
<string name="attestation">Attestering</string>
<string name="attestation_valid">Giltig</string>
<string name="attestation_invalid">Ogiltig</string>
<string name="attestation_status_accepted">Accepterad</string>
<string name="attestation_status_rejected">Avvisad</string>
<string name="attestation_status_verifying">Verifierar</string>
<string name="attestation_status_verified">Verifierad</string>
<string name="attestation_status_revoked">Återkallad</string>
<string name="attestation_valid_from">Giltig från %1$s</string>
<string name="attestation_valid_to">Giltig till %1$s</string>
<string name="attestation_request">Attesteringsförfrågan</string>
<string name="attestation_request_description">Begär attestering för en händelse</string>
<string name="attestor_recommendation">Attestantens rekommendation</string>
<string name="attestor_recommendation_for_kinds">Rekommenderad för typer: %1$s</string>
<string name="attestor_proficiency">Attestantens kompetens</string>
<string name="attestor_proficiency_for_kinds">Kompetent att verifiera typer: %1$s</string>
<string name="attestation_attests_to">Intygar</string>
<string name="attestation_requests_attestation_to">Begär attestering till</string>
<string name="open_poll">Öppen omröstning</string>
</resources> </resources>
@@ -444,4 +444,5 @@
<string name="active_for_global">Ulimwenguni</string> <string name="active_for_global">Ulimwenguni</string>
<string name="active_for_search">Tafuta</string> <string name="active_for_search">Tafuta</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -370,4 +370,5 @@
<string name="groups_no_descriptor">இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் </string> <string name="groups_no_descriptor">இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் </string>
<string name="community_no_descriptor">இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும்</string> <string name="community_no_descriptor">இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும்</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -815,4 +815,5 @@
<string name="select_list_to_filter">แลือกลิสต์เพื่อโชว์</string> <string name="select_list_to_filter">แลือกลิสต์เพื่อโชว์</string>
<string name="temporary_account">ออกจากระบบ</string> <string name="temporary_account">ออกจากระบบ</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -168,4 +168,5 @@
<string name="mark_all_as_read">Tümünü okundu olarak işaretle</string> <string name="mark_all_as_read">Tümünü okundu olarak işaretle</string>
<string name="biometric_error">Hata</string> <string name="biometric_error">Hata</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -500,4 +500,5 @@
<string name="could_not_download_from_the_server">Не вдалося завантажити завантажені медіафайли з сервера</string> <string name="could_not_download_from_the_server">Не вдалося завантажити завантажені медіафайли з сервера</string>
<string name="could_not_prepare_local_file_to_upload">Не вдалося підготувати локальний файл для завантаження: %1$s</string> <string name="could_not_prepare_local_file_to_upload">Не вдалося підготувати локальний файл для завантаження: %1$s</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -69,4 +69,5 @@
<string name="in_channel">"bu kanalda: "</string> <string name="in_channel">"bu kanalda: "</string>
<string name="profile_banner">Profil banneri</string> <string name="profile_banner">Profil banneri</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -12,4 +12,5 @@
<string name="note_to_receiver">Lưu ý cho người nhận</string> <string name="note_to_receiver">Lưu ý cho người nhận</string>
<string name="thank_you_so_much">Cảm ơn rất nhiều!</string> <string name="thank_you_so_much">Cảm ơn rất nhiều!</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1626,4 +1626,5 @@
<string name="event_sync_status_downloading">下载中</string> <string name="event_sync_status_downloading">下载中</string>
<string name="event_sync_status_error">错误</string> <string name="event_sync_status_error">错误</string>
<string name="event_sync_status_completed">已完成</string> <string name="event_sync_status_completed">已完成</string>
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -240,4 +240,5 @@
<string name="pledge_amount_in_sats">聰數量</string> <string name="pledge_amount_in_sats">聰數量</string>
<string name="looking_for_event">"“正在查找事件%1$s”"</string> <string name="looking_for_event">"“正在查找事件%1$s”"</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
@@ -763,4 +763,5 @@
<string name="dvm_waiting_to_confirm_payment">正在等待 DVM 確認付款或返回結果</string> <string name="dvm_waiting_to_confirm_payment">正在等待 DVM 確認付款或返回結果</string>
<string name="add_a_nip96_server">添加 NIP-96 服務器</string> <string name="add_a_nip96_server">添加 NIP-96 服務器</string>
<!-- Kind display names for relay subscription filter chips --> <!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources> </resources>
+35
View File
@@ -258,6 +258,7 @@
<string name="error_loading_replies">"Error loading replies: "</string> <string name="error_loading_replies">"Error loading replies: "</string>
<string name="try_again">Try again</string> <string name="try_again">Try again</string>
<string name="notification_feed_is_empty">No notifications yet.</string> <string name="notification_feed_is_empty">No notifications yet.</string>
<string name="open_poll">Open Poll</string>
<string name="feed_is_empty">Feed is empty.</string> <string name="feed_is_empty">Feed is empty.</string>
<string name="refresh">Refresh</string> <string name="refresh">Refresh</string>
<string name="created">created</string> <string name="created">created</string>
@@ -1877,4 +1878,38 @@
<string name="event_sync_status_downloading">Downloading</string> <string name="event_sync_status_downloading">Downloading</string>
<string name="event_sync_status_error">Error</string> <string name="event_sync_status_error">Error</string>
<string name="event_sync_status_completed">Completed</string> <string name="event_sync_status_completed">Completed</string>
<!-- M3 Action Dialog titles -->
<string name="mark_as_read_dialog_title">Mark as Read</string>
<string name="note_actions_dialog_title">Note Actions</string>
<string name="profile_actions_dialog_title">Profile Actions</string>
<string name="media_actions_dialog_title">Media Actions</string>
<string name="playback_actions_dialog_title">Playback</string>
<string name="pack_actions_dialog_title">Pack Actions</string>
<string name="list_actions_dialog_title">List Actions</string>
<string name="bookmark_item_actions_dialog_title">Bookmark Actions</string>
<string name="group_actions_dialog_title">Group Actions</string>
<string name="add_bookmark_dialog_title">Add Bookmark</string>
<string name="add_member_dialog_title">Add Member</string>
<string name="list_management_dialog_title">List Management</string>
<string name="link_actions_dialog_title">Link Actions</string>
<string name="export_actions_dialog_title">Export</string>
<string name="attestation">Attestation</string>
<string name="attestation_valid">Valid</string>
<string name="attestation_invalid">Invalid</string>
<string name="attestation_status_accepted">Accepted</string>
<string name="attestation_status_rejected">Rejected</string>
<string name="attestation_status_verifying">Verifying</string>
<string name="attestation_status_verified">Verified</string>
<string name="attestation_status_revoked">Revoked</string>
<string name="attestation_valid_from">Valid from %1$s</string>
<string name="attestation_valid_to">Valid to %1$s</string>
<string name="attestation_request">Attestation Request</string>
<string name="attestation_request_description">Requesting attestation for an event</string>
<string name="attestor_recommendation">Attestor Recommendation</string>
<string name="attestor_recommendation_for_kinds">Recommended for kinds: </string>
<string name="attestor_proficiency">Attestor Proficiency</string>
<string name="attestor_proficiency_for_kinds">Proficient in verifying kinds: %1$s</string>
<string name="attestation_attests_to">Attests to</string>
<string name="attestation_requests_attestation_to">Requests attestation to</string>
</resources> </resources>
@@ -23,13 +23,26 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity 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.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.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag 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.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Immutable @Immutable
class AttestationEvent( class AttestationEvent(
@@ -39,7 +52,22 @@ class AttestationEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, 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<EventIdHint> = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds(): List<HexKey> = tags.mapNotNull(ETag::parseId)
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
fun validity() = tags.validity() fun validity() = tags.validity()
fun status() = tags.status() fun status() = tags.status()
@@ -50,25 +78,91 @@ class AttestationEvent(
fun request() = tags.request() fun request() = tags.request()
fun requestId() = tags.requestId()
fun requestAddress() = tags.requestAddress()
fun isRevoked() = status() == AttestationStatus.REVOKED 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 { companion object {
const val KIND = 31871 const val KIND = 31871
const val ALT_DESCRIPTION = "Attestation" const val ALT_DESCRIPTION = "Attestation"
fun build( fun buildEvent(
dTagId: String, dTagId: String,
about: EventHintBundle<Event>,
content: String = "", content: String = "",
validity: Validity? = null, validity: Validity? = null,
status: AttestationStatus? = null, status: AttestationStatus? = null,
validFrom: Long? = null, validFrom: Long? = null,
validTo: Long? = null, validTo: Long? = null,
requestAddress: String? = null, requestAddress: EventHintBundle<AttestationRequestEvent>? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestationEvent>.() -> Unit = {}, initializer: TagArrayBuilder<AttestationEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) { ) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
dTag(dTagId) 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<BaseReplaceableEvent>,
content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null,
validFrom: Long? = null,
validTo: Long? = null,
requestAddress: EventHintBundle<AttestationRequestEvent>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestationEvent>.() -> 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<BaseAddressableEvent>,
content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null,
validFrom: Long? = null,
validTo: Long? = null,
requestAddress: EventHintBundle<AttestationRequestEvent>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestationEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
dTag(dTagId)
aboutAddressable(about)
validity?.let { validity(it) } validity?.let { validity(it) }
status?.let { status(it) } status?.let { status(it) }
validFrom?.let { validFrom(it) } validFrom?.let { validFrom(it) }
@@ -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.ValidToTag
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag 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.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<AttestationEvent>.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity)) fun TagArrayBuilder<AttestationEvent>.validity(validity: Validity) = addUnique(ValidityTag.assemble(validity))
@@ -37,4 +44,10 @@ fun TagArrayBuilder<AttestationEvent>.validFrom(timestamp: Long) = addUnique(Val
fun TagArrayBuilder<AttestationEvent>.validTo(timestamp: Long) = addUnique(ValidToTag.assemble(timestamp)) fun TagArrayBuilder<AttestationEvent>.validTo(timestamp: Long) = addUnique(ValidToTag.assemble(timestamp))
fun TagArrayBuilder<AttestationEvent>.request(requestAddress: String) = addUnique(RequestTag.assemble(requestAddress)) fun TagArrayBuilder<AttestationEvent>.request(request: EventHintBundle<AttestationRequestEvent>) = addUnique(RequestTag.assemble(request.event.address(), request.relay))
fun TagArrayBuilder<AttestationEvent>.aboutAddressable(request: EventHintBundle<BaseAddressableEvent>) = addUnique(ATag.assemble(request.event.address(), request.relay))
fun TagArrayBuilder<AttestationEvent>.aboutReplaceable(request: EventHintBundle<BaseReplaceableEvent>) = addUnique(ATag.assemble(request.event.address(), request.relay))
fun TagArrayBuilder<AttestationEvent>.about(request: EventHintBundle<Event>) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey))
@@ -36,3 +36,7 @@ fun TagArray.validFrom() = firstNotNullOfOrNull(ValidFromTag::parse)
fun TagArray.validTo() = firstNotNullOfOrNull(ValidToTag::parse) fun TagArray.validTo() = firstNotNullOfOrNull(ValidToTag::parse)
fun TagArray.request() = firstNotNullOfOrNull(RequestTag::parse) fun TagArray.request() = firstNotNullOfOrNull(RequestTag::parse)
fun TagArray.requestId() = firstNotNullOfOrNull(RequestTag::parseAddressId)
fun TagArray.requestAddress() = firstNotNullOfOrNull(RequestTag::parseAddress)
@@ -20,22 +20,108 @@
*/ */
package com.vitorpamplona.quartz.experimental.attestations.attestation.tags 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.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 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 { companion object {
const val TAG_NAME = "request" const val TAG_NAME = "request"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)
fun parse(tag: Array<String>): String? { fun isTagged(
tag: Array<String>,
addressId: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId
fun isTagged(
tag: Array<String>,
address: ApprovedAddressTag,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag()
fun isIn(
tag: Array<String>,
addressIds: Set<String>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds
fun parse(tag: Array<String>): 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>): 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<String>): Address? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { 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>): 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] return tag[1]
} }
fun assemble(requestAddress: String) = arrayOf(TAG_NAME, requestAddress) fun parseAsHint(tag: Array<String>): 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)
} }
} }
@@ -21,13 +21,27 @@
package com.vitorpamplona.quartz.experimental.attestations.request package com.vitorpamplona.quartz.experimental.attestations.request
import androidx.compose.runtime.Immutable 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.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.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag 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.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.let
@Immutable @Immutable
class AttestationRequestEvent( class AttestationRequestEvent(
@@ -37,15 +51,45 @@ class AttestationRequestEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, 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<EventIdHint> = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds(): List<HexKey> = tags.mapNotNull(ETag::parseId)
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
fun cashuToken() = tags.cashuToken() 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 { companion object {
const val KIND = 31872 const val KIND = 31872
const val ALT_DESCRIPTION = "Attestation Request" const val ALT_DESCRIPTION = "Attestation Request"
fun build( fun buildEvent(
dTagId: String, dTagId: String,
about: EventHintBundle<Event>,
content: String = "", content: String = "",
attestorPubKeys: List<HexKey> = emptyList(), attestorPubKeys: List<HexKey> = emptyList(),
cashuToken: String? = null, cashuToken: String? = null,
@@ -54,6 +98,41 @@ class AttestationRequestEvent(
) = eventTemplate(KIND, content, createdAt) { ) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
dTag(dTagId) dTag(dTagId)
about(about)
attestorPubKeys(attestorPubKeys)
cashuToken?.let { cashuToken(it) }
initializer()
}
fun buildReplaceable(
dTagId: String,
about: EventHintBundle<BaseReplaceableEvent>,
content: String = "",
attestorPubKeys: List<HexKey> = emptyList(),
cashuToken: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestationRequestEvent>.() -> 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<BaseAddressableEvent>,
content: String = "",
attestorPubKeys: List<HexKey> = emptyList(),
cashuToken: String? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestationRequestEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
dTag(dTagId)
aboutAddressable(about)
attestorPubKeys(attestorPubKeys) attestorPubKeys(attestorPubKeys)
cashuToken?.let { cashuToken(it) } cashuToken?.let { cashuToken(it) }
initializer() initializer()
@@ -21,10 +21,22 @@
package com.vitorpamplona.quartz.experimental.attestations.request package com.vitorpamplona.quartz.experimental.attestations.request
import com.vitorpamplona.quartz.experimental.attestations.request.tags.CashuTokenTag 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.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder 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 import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<AttestationRequestEvent>.attestorPubKeys(pubKeys: List<HexKey>) = addAll(pubKeys.map { PTag.assemble(it, null) }) fun TagArrayBuilder<AttestationRequestEvent>.attestorPubKeys(pubKeys: List<HexKey>) = addAll(pubKeys.map { PTag.assemble(it, null) })
fun TagArrayBuilder<AttestationRequestEvent>.cashuToken(token: String) = addUnique(CashuTokenTag.assemble(token)) fun TagArrayBuilder<AttestationRequestEvent>.cashuToken(token: String) = addUnique(CashuTokenTag.assemble(token))
fun TagArrayBuilder<AttestationRequestEvent>.aboutAddressable(request: EventHintBundle<BaseAddressableEvent>) = addUnique(ATag.assemble(request.event.address(), request.relay))
fun TagArrayBuilder<AttestationRequestEvent>.aboutReplaceable(request: EventHintBundle<BaseReplaceableEvent>) = addUnique(ATag.assemble(request.event.address(), request.relay))
fun TagArrayBuilder<AttestationRequestEvent>.about(request: EventHintBundle<Event>) = addUnique(ETag.assemble(request.event.id, request.relay, request.event.pubKey))
@@ -26,6 +26,16 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow 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( class LiveEventStore(
private val store: IEventStore, private val store: IEventStore,
) { ) {
@@ -29,14 +29,10 @@ import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
/** /**
* This class manages per-connection subscriptions as coroutines. Each * Represents a Nostr relay server that manages client connections, event storage, and verification.
* 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.
* *
* The server is transport-agnostic: callers feed incoming JSON via * This class acts as the central coordinator for a relay server, handling the lifecycle of [RelaySession]s
* [RelaySession.receive] and receive outgoing JSON via the [RelaySession.send] callback * and providing access to the underlying event store.
* provided to [connect]. This allows use with any WebSocket library.
* *
* @param store The [IEventStore] backing this relay. * @param store The [IEventStore] backing this relay.
* @param policyBuilder Controls requirements for relay commands. * @param policyBuilder Controls requirements for relay commands.

Some files were not shown because too many files have changed in this diff Show More