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
- Redesign of the poll and zap poll cards
- Adds Special notification card that stays while the poll is running
Adds support for Relay Feeds
- Adds support for NIP-51 favorite relay feeds
@@ -15,7 +20,10 @@ Redesigns Media Player
- Turn video controller creation into a flow to fix playback lifecycle issues
- Adds support for uploading audio
Adds support for NIP-47 Wallets
Adds support for NIP-47 Wallets and compete NWC spec
- Adds views for Balance and Transactions
- Add transaction filtering and pagination to wallet screen
- Added several test cases from other repos to guarantee interoperability
Adds support for NIP-52 Calendar appointments
@@ -29,30 +37,32 @@ Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel00
Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents.
Adds support for NIP-40 Expirations in any new post.
Adds support for custom NIP-40 Expirations in any new post.
- Displays expirations on posts and DMs
Adds support for NIP-66 Relay Monitor and discovery support to Quartz
Adds support for Namecoin .bit urls to NIP-05
- Adds choice of ElectrumX server to resolve namecoins.
Adds support for Attestations (https://attestr.xyz/)
Adds basic support for Chess with Jester protocol
Adds NIP-46 Bunker support to Quartz and Amethyst Desktop
Adds a Broadcasting feedback pop-up in the Complete UI mode
Adds support for inline reply, mark as read from Push Notifications
Adds support for rendering Zap events when quoted inside of posts.
Removes NIP-04 DMs and blocks DM sending if the receiver doesn't have NIP-17 relay lists.
Removes support for NIP-96 and updates Blossom recommendations
Adds support to upload Documents to all new post screens.
Uploads:
- Adds support to upload Documents to all new post screens.
- Adds toggle to stip file metadata regardless of compression by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Adds encrypted file upload fallback option for NIP-17 chats
- Removes support for NIP-96 and updates Blossom recommendations
Content warning improvements:
- Adds optional description field for sensitive content warnings in new posts.
- Adds an optional description field for sensitive content warnings in new posts.
- Displays additional information on warning composables
Redesigns and reorganizes Setting pages
Settings redesign
- Consolidate drawer settings into a single Settings hub screen
- Redesigns Zap Amount and NWC setup screens
- Redesigns Custom zap amount screens
@@ -67,7 +77,14 @@ URL/URI parser rewrite in Kotlin multiplatform (KMP)
- Treat multibyte characters as URL terminators in RichTextParser by @npub1k0jrarx8um0lyw3nmysn50539ky4k8p7gfgzgrsvn8d7lccx3d0s38dczd
- Adds a parser for blossom: uris
Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
UI Improvements:
- Minimizes parent thread rendering in quoted notes by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
- New UI for DropDowns by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- New UI for feed filters by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Draft Screen requests confirmation before deleting drafts on swipe
- Swipe to switch tabs. Main screen and messages by @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- Adds support for rendering Zap events when quoted inside of posts.
- Adds a Broadcasting feedback pop-up in the Complete UI mode
Relay Management:
- Adds relay search tooltip when adding relays
@@ -75,7 +92,9 @@ Relay Management:
- Adds active subscriptions and outbox event in the queue to relay information
- Adds a complete list of event kind names to the subscription card to relay information
- Tracks and displays connection success rate on relay settings
- Add relay settings export functionality
- Adds relay settings export functionality
- Adds NIP-45 count queries to show how many events each relay has.
- Adds Relay sync utility to help users move posts between relays.
Search fixes
- Breaks the search filter into two subscriptions to prioritize Metadata without punishing content.
@@ -88,11 +107,14 @@ Search fixes
Profiles:
- Adds a profile picture upload button when the user has no picture
- Adds last seen to the user profile
- Adds nprofile and npub copy options to the profile
- Groups received zap amounts by sending the user in the profile tab
- Increases the limit of Zap downloads for profiles to 1000
- Simplifies profile edit screen layout by @npub1aeh2zw4elewy5682lxc6xnlqzjnxksq303gwu2npfaxd49vmde6qcq4nwx
- Migrates profile galleries to display a thumbnail for videos
- Fixes profile galleries' aspect ratios
- Adds support for Namecoin .bit urls to NIP-05 and choice of ElectrumX server to resolve namecoins.
Bulk Follow onboarding
- Adds screens to search for a user and to copy his/her follow list
@@ -126,6 +148,7 @@ Fixes:
- Fixes bug on Show More calculations for very long texts without spaces
- Fixing IO Dispatchers and coroutine scopes of choice
- Fixes anySync parallel operation that was returning the first result, not the first positive "any".
- Fixes Req onCannotConnect listeners to the relays that actually sent the req
AI:
- Add SKILL.md for AI agent customization
@@ -137,6 +160,14 @@ Defaults:
- Adds wss://directory.yabu.me and wss://profiles.nostr1.com as index relays
- Adds electrumx.testls.space, nmc2.bitcoins.sk, 46.229.238.187 and i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion as ElectrumX servers
Quartz:
- Adds Relay Server implementation with NIP-45 COUNT and NIP-42 AUTH support
- Adds support for dynamic policies to the relay implementation.
- Migrates Quartz EventStore from Android-only to KMP
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
- Adds comprehensive NIP-46 Bunker support
- Adds comprehensive support for NIP-47 non-payment methods.
Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53eesr4z6asd4h4jwrd62jq0wkq4k
- Provide implementation for Rfc3986 on iOS, using the Swift Rfc3986UriBridge.
- Provide implementation for LargeCache, using a CacheMap
@@ -149,7 +180,6 @@ Adds complete support for iOS to Quartz by @npub1a3tx8wcrt789skl6gg7rqwj4wey0j53
- Provide implementation for AESGCM
- Provide implementation for DigestInstance
- Provide implementation for LibSodium
- Adds a reqUntilEoseAsFlow extension to the Nostr Client
Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- Adds NIP-46 Bunker Login
@@ -159,6 +189,8 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
- Adds encrypted DMs (NIP-04/NIP-17)
- Adds proper empty states with EOSE tracking
- Adds multi-column deck layout
- Adds Full media parity — images, video, audio, encrypted DMs, upload, lightbox
- Adds advanced search with NIP-50, collapsible sections, and nav state preservation
- Clear stored credentials on logout
- Adds bunker heartbeat indicator
- Adds QR-based signer pairing
@@ -176,6 +208,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
Code Quality
- Migrates to AGP 9.0
- Adds Amethyst Desktop to CI/CD and Release builds
- Removes the in-app memory counter methods
- Refactors the old NIP-05 code on Quartz
- Migrates contact list management to addressable notes
@@ -39,6 +39,10 @@ import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -619,6 +623,30 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestationRequestEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorRecommendationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume(
event: AttestorProficiencyEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consumeRegularEvent(
event: Event,
relay: NormalizedRelayUrl?,
@@ -3051,6 +3079,10 @@ object LocalCache : ILocalCache, ICacheProvider {
is AppDefinitionEvent -> consume(event, relay, wasVerified)
is AppRecommendationEvent -> consume(event, relay, wasVerified)
is AppSpecificDataEvent -> consume(event, relay, wasVerified)
is AttestationEvent -> consume(event, relay, wasVerified)
is AttestationRequestEvent -> consume(event, relay, wasVerified)
is AttestorRecommendationEvent -> consume(event, relay, wasVerified)
is AttestorProficiencyEvent -> consume(event, relay, wasVerified)
is AudioHeaderEvent -> consume(event, relay, wasVerified)
is AudioTrackEvent -> consume(event, relay, wasVerified)
is BadgeAwardEvent -> consume(event, relay, wasVerified)
@@ -32,12 +32,9 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PictureInPicture
import androidx.compose.material.icons.filled.SaveAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
@@ -45,9 +42,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@@ -134,61 +133,41 @@ fun OverflowMenuButton(
modifier = Size20Modifier,
)
}
}
DropdownMenu(
expanded = menuExpanded.value,
onDismissRequest = { menuExpanded.value = false },
containerColor = Color.Black.copy(alpha = 0.85f),
if (menuExpanded.value) {
M3ActionDialog(
title = stringRes(R.string.playback_actions_dialog_title),
onDismiss = { menuExpanded.value = false },
) {
M3ActionSection {
if (showShare) {
DropdownMenuItem(
text = { Text(stringRes(R.string.share_or_save), color = Color.White) },
onClick = {
M3ActionRow(
icon = Icons.Default.Share,
text = stringRes(R.string.share_or_save),
) {
menuExpanded.value = false
onShareClick()
},
leadingIcon = {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = Color.White,
)
},
)
}
}
if (showSave) {
DropdownMenuItem(
text = { Text(stringRes(R.string.download_to_phone), color = Color.White) },
onClick = {
M3ActionRow(
icon = Icons.Default.SaveAlt,
text = stringRes(R.string.download_to_phone),
) {
menuExpanded.value = false
onSaveClick()
},
leadingIcon = {
Icon(
Icons.Default.SaveAlt,
contentDescription = null,
tint = Color.White,
)
},
)
}
}
if (showPip) {
DropdownMenuItem(
text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) },
onClick = {
M3ActionRow(
icon = Icons.Default.PictureInPicture,
text = stringRes(R.string.picture_in_picture),
) {
menuExpanded.value = false
onPipClick()
},
leadingIcon = {
Icon(
Icons.Default.PictureInPicture,
contentDescription = null,
tint = Color.White,
)
},
)
}
}
}
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
@@ -85,6 +87,12 @@ val NotificationsPerKeyKinds2 =
InteractiveStorySceneEvent.KIND,
)
val NotificationsPerKeyKinds3 =
listOf(
AttestationRequestEvent.KIND,
AttestorRecommendationEvent.KIND,
)
fun filterSummaryNotificationsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
@@ -130,7 +138,17 @@ fun filterNotificationsToPubkey(
Filter(
kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)),
limit = 500,
limit = 200,
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = NotificationsPerKeyKinds3,
tags = mapOf("p" to listOf(pubkey)),
limit = 10,
since = since,
),
),
@@ -171,7 +189,17 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
Filter(
kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)),
limit = 20,
limit = 10,
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = NotificationsPerKeyKinds3,
tags = mapOf("p" to listOf(pubkey)),
limit = 2,
since = since,
),
),
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -47,6 +48,7 @@ val RepliesAndReactionsToAddressesKinds1 =
LnZapEvent.KIND,
ZapPollEvent.KIND,
CommentEvent.KIND,
AttestationEvent.KIND,
)
val PostsAndChatMessagesToAddresses =
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
CommentEvent.KIND,
AttestationEvent.KIND,
)
val RepliesAndReactionsKinds2 =
@@ -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.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
@@ -47,27 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.previewCardImageModifier
@Composable
private fun CopyToClipboard(
popupExpanded: MutableState<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)
@Composable
fun UrlPreviewCard(
@@ -81,13 +59,22 @@ fun UrlPreviewCard(
}
if (popupExpanded.value) {
CopyToClipboard(
popupExpanded = popupExpanded,
content = url,
val clipboardManager = LocalClipboardManager.current
M3ActionDialog(
title = stringRes(R.string.link_actions_dialog_title),
onDismiss = { popupExpanded.value = false },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.ContentCopy,
text = stringRes(R.string.copy_url_to_clipboard),
) {
clipboardManager.setText(AnnotatedString(url))
popupExpanded.value = false
}
}
}
}
Column(
modifier =
@@ -32,15 +32,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -771,63 +772,54 @@ fun ShareMediaAction(
// Track if video is downloading - hoisted here to block menu dismiss during download
val isDownloadingVideo = remember { mutableStateOf(false) }
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = { if (!isDownloadingVideo.value) onDismiss() },
if (popupExpanded.value) {
M3ActionDialog(
title = stringRes(R.string.media_actions_dialog_title),
onDismiss = { if (!isDownloadingVideo.value) onDismiss() },
) {
val clipboardManager = LocalClipboardManager.current
// Copy & Gallery section
if ((videoUri != null && !videoUri.startsWith("file")) || postNostrUri != null) {
M3ActionSection {
if (videoUri != null && !videoUri.startsWith("file")) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_url_to_clipboard)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Link, text = stringRes(R.string.copy_url_to_clipboard)) {
clipboardManager.setText(AnnotatedString(videoUri))
onDismiss()
},
)
}
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_the_note_id_to_the_clipboard)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_the_note_id_to_the_clipboard)) {
clipboardManager.setText(AnnotatedString(it))
onDismiss()
},
)
}
postNostrUri?.let {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_media_to_gallery)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Collections, text = stringRes(R.string.add_media_to_gallery)) {
if (videoUri != null) {
val n19 = Nip19Parser.uriToRoute(postNostrUri)?.entity as? NEvent
if (n19 != null) {
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType) // TODO Whole list or first?
accountViewModel.addMediaToGallery(n19.hex, videoUri, n19.relay.getOrNull(0), blurhash, dim, hash, mimeType)
accountViewModel.toastManager.toast(R.string.media_added, R.string.media_added_to_profile_gallery)
}
}
onDismiss()
},
)
}
}
}
}
// Share section
content?.let {
val context = LocalContext.current
M3ActionSection {
when (content) {
is MediaUrlImage -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.share_image)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_image)) {
scope.launch { shareImageFile(context, videoUri, mimeType) }
onDismiss()
},
)
}
}
}
}
@@ -835,18 +827,11 @@ fun ShareMediaAction(
is MediaUrlVideo -> {
videoUri?.let {
if (videoUri.isNotEmpty()) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringRes(R.string.share_video))
if (isDownloadingVideo.value) {
Spacer(modifier = Modifier.width(8.dp))
LoadingAnimation(indicatorSize = 16.dp, circleWidth = 2.dp)
}
}
},
M3ActionRow(
icon = Icons.Outlined.Share,
text = stringRes(R.string.share_video),
enabled = !isDownloadingVideo.value,
onClick = {
) {
isDownloadingVideo.value = true
scope.launch {
shareVideoFile(
@@ -865,21 +850,17 @@ fun ShareMediaAction(
},
)
}
},
)
}
}
}
}
is MediaLocalVideo -> {
content.localFile?.let { localFile ->
DropdownMenuItem(
text = { Text(stringRes(R.string.share_video)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.share_video)) {
scope.launch { shareLocalVideoFile(context, localFile, mimeType) }
onDismiss()
},
)
}
}
}
@@ -887,6 +868,8 @@ fun ShareMediaAction(
}
}
}
}
}
}
private suspend fun shareImageFile(
@@ -102,6 +102,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
@@ -174,6 +178,10 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
@@ -767,6 +775,22 @@ private fun RenderNoteRow(
RenderAppDefinition(baseNote, accountViewModel, nav)
}
is AttestationEvent -> {
RenderAttestation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestationRequestEvent -> {
RenderAttestationRequest(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestorRecommendationEvent -> {
RenderAttestorRecommendation(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AttestorProficiencyEvent -> {
RenderAttestorProficiency(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AudioTrackEvent -> {
RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
}
@@ -21,10 +21,22 @@
package com.vitorpamplona.amethyst.ui.note.elements
import android.content.Intent
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bookmark
import androidx.compose.material.icons.outlined.BookmarkAdd
import androidx.compose.material.icons.outlined.BookmarkRemove
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.PlaylistAdd
import androidx.compose.material.icons.outlined.Report
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
@@ -44,6 +56,9 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
@@ -53,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -78,6 +92,7 @@ fun MoreOptionsButton(
onClick = { popupExpanded.value = true },
) {
VerticalDotsIcon()
}
if (popupExpanded.value) {
NoteDropDownMenu(
@@ -88,7 +103,6 @@ fun MoreOptionsButton(
nav = nav,
)
}
}
}
@Immutable
@@ -146,213 +160,150 @@ fun NoteDropDownMenu(
)
}
DropdownMenu(
expanded = true,
onDismissRequest = onDismiss,
M3ActionDialog(
title = stringRes(R.string.note_actions_dialog_title),
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
val actContext = LocalContext.current
val scope = rememberCoroutineScope()
// Follow section
M3ActionSection {
if (!state.isFollowingAuthor) {
DropdownMenuItem(
text = { Text(stringRes(R.string.follow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.unfollow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.unfollow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
DropdownMenuItem(
text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) },
onClick = {
val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem
}
M3ActionRow(icon = Icons.Outlined.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) {
val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow
nav.nav(Route.PeopleListManagement(authorHexKey))
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_text)) },
onClick = {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) {
clipboardManager.setText(AnnotatedString(it))
}
}
// Copy & Share section
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss()
}
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_note_id)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss()
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
stringRes(actContext, R.string.quick_action_share_browser_link),
)
putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note))
putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link))
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
if (state.isLoggedUser && note.isDraft()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_draft)) },
onClick = {
nav.nav {
routeEditDraftTo(note, accountViewModel.account)
}
},
)
}
// Edit & Broadcast section
M3ActionSection {
if (state.isLoggedUser && note.isDraft()) {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) {
nav.nav { routeEditDraftTo(note, accountViewModel.account) }
}
}
if (note.event is TextNoteEvent && !note.isDraft()) {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_post)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) {
wantsToEditPost.value = true
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.propose_an_edit)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) {
wantsToEditPost.value = true
},
)
}
}
DropdownMenuItem(
text = { Text(stringRes(R.string.broadcast)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
// Timestamp & Bookmarks section
M3ActionSection {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_pending)) },
onClick = {
onDismiss()
},
)
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() }
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_it)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
onDismiss()
},
)
}
HorizontalDivider(thickness = DividerThickness)
note.let {
}
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
DropdownMenuItem(
text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
val noteAddress = (note as AddressableNote).address
nav.nav(Route.ArticleBookmarkManagement(noteAddress))
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
},
)
}
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_private_bookmarks)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
onDismiss()
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_to_private_bookmarks)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Lock, text = stringRes(R.string.add_to_private_bookmarks)) {
accountViewModel.addPrivateBookmark(note)
onDismiss()
},
)
}
}
if (state.isPublicBookmarkNote) {
DropdownMenuItem(
text = { Text(stringRes(R.string.remove_from_public_bookmarks)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
onDismiss()
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.add_to_public_bookmarks)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
onDismiss()
},
)
}
HorizontalDivider(thickness = DividerThickness)
}
}
// Moderation section
M3ActionSection {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.request_deletion)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
accountViewModel.delete(note)
onDismiss()
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.block_report)) },
onClick = { reportDialogShowing = true },
)
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
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
import android.content.Intent
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BookmarkRemove
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonRemove
import androidx.compose.material.icons.outlined.Report
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -42,6 +51,9 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
@@ -52,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.Dispatchers
@@ -76,6 +87,7 @@ fun BookmarkGroupItemOptions(
onClick = { popupExpanded.value = true },
) {
VerticalDotsIcon()
}
if (popupExpanded.value) {
BookmarkGroupItemOptionsMenu(
@@ -90,7 +102,6 @@ fun BookmarkGroupItemOptions(
nav = nav,
)
}
}
}
@Composable
@@ -142,89 +153,71 @@ fun BookmarkGroupItemOptionsMenu(
)
}
DropdownMenu(
expanded = true,
onDismissRequest = onDismiss,
M3ActionDialog(
title = stringRes(R.string.bookmark_item_actions_dialog_title),
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
val actContext = LocalContext.current
val scope = rememberCoroutineScope()
DropdownMenuItem(
text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) },
onClick = if (isBookmarkItemPrivate) onMoveBookmarkToPublic else onMoveBookmarkToPrivate,
)
DropdownMenuItem(
text = { Text(stringRes(R.string.bookmark_remove_action_label)) },
onClick = {
// Bookmark Management section
M3ActionSection {
M3ActionRow(
icon = if (isBookmarkItemPrivate) Icons.Outlined.LockOpen else Icons.Outlined.Lock,
text = stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label),
) {
if (isBookmarkItemPrivate) onMoveBookmarkToPublic() else onMoveBookmarkToPrivate()
}
M3ActionRow(
icon = Icons.Outlined.BookmarkRemove,
text = stringRes(R.string.bookmark_remove_action_label),
isDestructive = true,
) {
onDeleteBookmarkItem()
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
// Follow section
M3ActionSection {
if (!state.isFollowingAuthor) {
DropdownMenuItem(
text = { Text(stringRes(R.string.follow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
M3ActionRow(icon = Icons.Outlined.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.unfollow)) },
onClick = {
val author = note.author ?: return@DropdownMenuItem
M3ActionRow(icon = Icons.Outlined.PersonRemove, text = stringRes(R.string.unfollow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.unfollow(author)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
// DropdownMenuItem(
// text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) },
// onClick = {
// val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem
// nav.nav(Route.PeopleListManagement(authorHexKey))
// onDismiss()
// },
// )
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_text)) },
onClick = {
}
}
// Copy & Share section
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) {
clipboardManager.setText(AnnotatedString(it))
}
accountViewModel.decrypt(lastNoteVersion) { clipboardManager.setText(AnnotatedString(it)) }
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_pubkey)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
onDismiss()
}
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_note_id)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
onDismiss()
}
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
@@ -243,74 +236,57 @@ fun BookmarkGroupItemOptionsMenu(
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
ContextCompat.startActivity(actContext, shareIntent, null)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
// Edit & Broadcast section
M3ActionSection {
if (state.isLoggedUser && note.isDraft()) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_draft)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_draft)) {
nav.nav {
routeEditDraftTo(note, accountViewModel.account)
}
},
)
}
}
if (note.event is TextNoteEvent && !note.isDraft()) {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.edit_post)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_post)) {
wantsToEditPost.value = true
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.propose_an_edit)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.propose_an_edit)) {
wantsToEditPost.value = true
},
)
}
}
DropdownMenuItem(
text = { Text(stringRes(R.string.broadcast)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
onDismiss()
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
// Timestamp & Moderation section
M3ActionSection {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_pending)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_pending)) {
onDismiss()
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.timestamp_it)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Schedule, text = stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
onDismiss()
},
)
}
HorizontalDivider(thickness = DividerThickness)
}
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringRes(R.string.request_deletion)) },
onClick = {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
accountViewModel.delete(note)
onDismiss()
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(R.string.block_report)) },
onClick = { reportDialogShowing = true },
)
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.block_report), isDestructive = true) {
reportDialogShowing = true
}
}
}
}
@@ -30,11 +30,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@@ -57,6 +57,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
@@ -65,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.coroutines.launch
@@ -304,6 +306,31 @@ fun BookmarkGroupActionsMenuButton(
) {
val isActionListOpen = remember { mutableStateOf(false) }
if (isActionListOpen.value) {
M3ActionDialog(
title = stringRes(R.string.list_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.CellTower,
text = stringRes(R.string.bookmark_list_broadcast_btn_label),
) {
onBroadcastList()
isActionListOpen.value = false
}
M3ActionRow(
icon = Icons.Outlined.Delete,
text = stringRes(R.string.bookmark_list_delete_btn_label),
isDestructive = true,
) {
onDeleteList()
isActionListOpen.value = false
}
}
}
}
ClickableBox(
modifier =
StdPadding
@@ -319,44 +346,5 @@ fun BookmarkGroupActionsMenuButton(
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
BookmarkGroupActionsMenu(
onCloseMenu = { isActionListOpen.value = false },
isOpen = isActionListOpen.value,
onBroadcastList = onBroadcastList,
onDeleteList = onDeleteList,
)
}
}
@Composable
fun BookmarkGroupActionsMenu(
onCloseMenu: () -> Unit,
isOpen: Boolean,
onBroadcastList: () -> Unit,
onDeleteList: () -> Unit,
) {
DropdownMenu(
expanded = isOpen,
onDismissRequest = onCloseMenu,
) {
DropdownMenuItem(
text = {
Text(stringRes(R.string.bookmark_list_broadcast_btn_label))
},
onClick = {
onBroadcastList()
onCloseMenu()
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.bookmark_list_delete_btn_label))
},
onClick = {
onDeleteList()
onCloseMenu()
},
)
}
}
@@ -32,10 +32,12 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
@@ -57,6 +59,9 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
import com.vitorpamplona.amethyst.ui.stringRes
@@ -258,6 +263,7 @@ private fun BookmarkGroupOptionsButton(
onClick = { isMenuOpen.value = true },
) {
VerticalDotsIcon()
}
GroupOptionsMenu(
groupName = bookmarkGroupName,
@@ -269,7 +275,6 @@ private fun BookmarkGroupOptionsButton(
onGroupClone = onGroupCloneCreate,
onDelete = onGroupDelete,
)
}
}
@Composable
@@ -288,45 +293,32 @@ private fun GroupOptionsMenu(
val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
if (isExpanded) {
M3ActionDialog(
title = stringRes(R.string.group_actions_dialog_title),
onDismiss = onDismiss,
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_rename_btn_label))
},
onClick = {
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_rename_btn_label)) {
onGroupRename()
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_desc_modify_label))
},
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Description, text = stringRes(R.string.follow_set_desc_modify_label)) {
onGroupDescriptionChange()
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_copy_action_btn_label))
},
onClick = {
}
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.quick_action_delete))
},
onClick = {
}
}
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
onDelete()
},
)
onDismiss()
}
}
}
}
if (isCopyDialogOpen.value) {
@@ -30,12 +30,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove
import androidx.compose.material.icons.outlined.BookmarkAdd
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.RemoveCircleOutline
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -48,6 +47,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkMembershipStatusAndNumberDisplay
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
@@ -158,6 +160,30 @@ fun BookmarkManagementOptions(
) {
val isBookmarkAddTapped = remember { mutableStateOf(false) }
if (isBookmarkAddTapped.value) {
M3ActionDialog(
title = stringRes(R.string.add_bookmark_dialog_title),
onDismiss = { isBookmarkAddTapped.value = false },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.BookmarkAdd,
text = stringRes(R.string.public_bookmark_add_action_label),
) {
onAddBookmark(false)
isBookmarkAddTapped.value = false
}
M3ActionRow(
icon = Icons.Outlined.Lock,
text = stringRes(R.string.private_bookmark_add_action_label),
) {
onAddBookmark(true)
isBookmarkAddTapped.value = false
}
}
}
}
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -196,29 +222,5 @@ fun BookmarkManagementOptions(
)
}
}
DropdownMenu(
expanded = isBookmarkAddTapped.value,
onDismissRequest = { isBookmarkAddTapped.value = false },
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.public_bookmark_add_action_label))
},
onClick = {
onAddBookmark(false)
isBookmarkAddTapped.value = false
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.private_bookmark_add_action_label))
},
onClick = {
onAddBookmark(true)
isBookmarkAddTapped.value = false
},
)
}
}
}
@@ -28,8 +28,9 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.MoveToInbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -48,6 +49,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -103,12 +107,13 @@ fun MessagesTabHeader(
contentDescription = stringRes(id = R.string.more_options),
tint = MaterialTheme.colorScheme.placeholderText,
)
}
MessagesTabMenu(
moreActionsExpanded,
{ moreActionsExpanded = false },
onMarkKnownAsRead,
onMarkNewAsRead,
if (moreActionsExpanded) {
MessagesMarkAsReadDialog(
onDismiss = { moreActionsExpanded = false },
onMarkKnownAsRead = onMarkKnownAsRead,
onMarkNewAsRead = onMarkNewAsRead,
)
}
}
@@ -142,34 +147,38 @@ fun MessagesPager(
}
@Composable
fun MessagesTabMenu(
expanded: Boolean,
fun MessagesMarkAsReadDialog(
onDismiss: () -> Unit,
onMarkKnownAsRead: () -> Unit,
onMarkNewAsRead: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuItem(
text = { Text(stringRes(R.string.mark_all_known_as_read)) },
onClick = {
M3ActionDialog(
title = stringRes(R.string.mark_as_read_dialog_title),
onDismiss = onDismiss,
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.Groups,
text = stringRes(R.string.mark_all_known_as_read),
) {
onMarkKnownAsRead()
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.mark_all_new_as_read)) },
onClick = {
}
M3ActionRow(
icon = Icons.Outlined.MoveToInbox,
text = stringRes(R.string.mark_all_new_as_read),
) {
onMarkNewAsRead()
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.mark_all_as_read)) },
onClick = {
}
M3ActionRow(
icon = Icons.Outlined.DoneAll,
text = stringRes(R.string.mark_all_as_read),
) {
onMarkKnownAsRead()
onMarkNewAsRead()
onDismiss()
},
)
}
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -42,6 +43,7 @@ val CommunityPostKinds =
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
AttestationEvent.KIND,
)
fun filterCommunityPosts(
@@ -29,6 +29,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -60,6 +64,7 @@ class HomeNewThreadFeedFilter(
LongTextNoteEvent.KIND,
LiveChessGameChallengeEvent.KIND,
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
)
}
@@ -126,7 +131,11 @@ class HomeNewThreadFeedFilter(
noteEvent is AudioHeaderEvent ||
noteEvent is ChessGameEvent ||
noteEvent is LiveChessGameChallengeEvent ||
noteEvent is LiveChessGameEndEvent
noteEvent is LiveChessGameEndEvent ||
noteEvent is AttestationEvent ||
noteEvent is AttestationRequestEvent ||
noteEvent is AttestorRecommendationEvent ||
noteEvent is AttestorProficiencyEvent
) &&
filterParams.match(noteEvent, it.relays) &&
it.isNewThread()
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -36,7 +37,6 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
val HomePostsByGeohashKinds =
listOf(
@@ -46,11 +46,11 @@ val HomePostsByGeohashKinds =
LongTextNoteEvent.KIND,
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND,
CommentEvent.KIND,
VoiceEvent.KIND,
VoiceReplyEvent.KIND,
AttestationEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
)
fun filterHomePostsByGeohashes(
@@ -23,7 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsConversationKinds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds1
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.HomePostsNewThreadKinds2
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -43,11 +44,20 @@ fun filterHomePostsByGlobal(
relay = relayUrl,
filter =
Filter(
kinds = HomePostsNewThreadKinds,
kinds = HomePostsNewThreadKinds1,
limit = 50,
since = since ?: newThreadSince,
),
),
RelayBasedFilter(
relay = relayUrl,
filter =
Filter(
kinds = HomePostsNewThreadKinds2,
limit = 5,
since = since ?: newThreadSince,
),
),
RelayBasedFilter(
relay = relayUrl,
filter =
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip22Comments.filterHomePostsByScopes
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
@@ -39,7 +40,6 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
val HomePostsBuHashtagsKinds =
listOf(
@@ -50,12 +50,12 @@ val HomePostsBuHashtagsKinds =
ClassifiedsEvent.KIND,
HighlightEvent.KIND,
AudioHeaderEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
CommentEvent.KIND,
WikiNoteEvent.KIND,
NipTextEvent.KIND,
VoiceEvent.KIND,
VoiceReplyEvent.KIND,
AttestationEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
)
fun filterHomePostsByHashtags(
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follo
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
@@ -50,7 +51,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import kotlin.math.min
val HomePostsNewThreadKinds =
val HomePostsNewThreadKinds1 =
listOf(
TextNoteEvent.KIND,
RepostEvent.KIND,
@@ -58,10 +59,15 @@ val HomePostsNewThreadKinds =
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
HighlightEvent.KIND,
WikiNoteEvent.KIND,
NipTextEvent.KIND,
ZapPollEvent.KIND,
PollEvent.KIND,
WikiNoteEvent.KIND,
AttestationEvent.KIND,
NipTextEvent.KIND,
)
val HomePostsNewThreadKinds2 =
listOf(
InteractiveStoryPrologueEvent.KIND,
ChessGameEvent.KIND,
LiveChessGameChallengeEvent.KIND,
@@ -91,12 +97,22 @@ fun filterNewHomePostsByAuthors(
relay = relay,
filter =
Filter(
kinds = HomePostsNewThreadKinds,
kinds = HomePostsNewThreadKinds1,
authors = authorList,
limit = min(authorList.size * 10, 500),
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = HomePostsNewThreadKinds2,
authors = authorList,
limit = min(authorList.size * 10, 5),
since = since,
),
),
)
}
@@ -38,11 +38,14 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
@@ -75,6 +78,9 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -416,15 +422,16 @@ private fun ListActionsMenuButton(
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
}
DropdownMenu(
expanded = isActionListOpen.value,
onDismissRequest = { isActionListOpen.value = false },
) {
if (isActionListOpen.value) {
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
M3ActionDialog(
title = stringRes(R.string.list_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) {
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
@@ -443,38 +450,22 @@ private fun ListActionsMenuButton(
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_edit_list_metadata))
},
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) {
onEditList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_broadcast))
},
onClick = {
}
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_set_broadcast)) {
onBroadcastList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_set_delete))
},
onClick = {
}
}
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_set_delete), isDestructive = true) {
onDeleteList()
isActionListOpen.value = false
},
)
}
}
}
}
}
@@ -35,11 +35,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults.cardElevation
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
@@ -68,6 +71,9 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -271,15 +277,16 @@ private fun ListActionsMenuButton(
onClick = { isActionListOpen.value = true },
) {
VerticalDotsIcon()
}
DropdownMenu(
expanded = isActionListOpen.value,
onDismissRequest = { isActionListOpen.value = false },
) {
if (isActionListOpen.value) {
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
M3ActionDialog(
title = stringRes(R.string.pack_actions_dialog_title),
onDismiss = { isActionListOpen.value = false },
) {
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Share, text = stringRes(R.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
@@ -298,38 +305,22 @@ private fun ListActionsMenuButton(
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
ContextCompat.startActivity(context, shareIntent, null)
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_edit_list_metadata))
},
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_pack_edit_list_metadata)) {
onEditList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_broadcast))
},
onClick = {
}
M3ActionRow(icon = Icons.Outlined.CellTower, text = stringRes(R.string.follow_pack_broadcast)) {
onBroadcastList()
isActionListOpen.value = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Text(stringRes(R.string.follow_pack_delete))
},
onClick = {
}
}
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.follow_pack_delete), isDestructive = true) {
onDeleteList()
isActionListOpen.value = false
},
)
}
}
}
}
}
@@ -29,13 +29,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
@@ -59,6 +60,9 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -286,6 +290,7 @@ private fun PeopleListOptionsButton(
onClick = { isMenuOpen.value = true },
) {
VerticalDotsIcon()
}
ListOptionsMenu(
isExpanded = isMenuOpen.value,
@@ -294,7 +299,6 @@ private fun PeopleListOptionsButton(
onDelete = onListDelete,
onDismiss = { isMenuOpen.value = false },
)
}
}
@Composable
@@ -309,36 +313,28 @@ private fun ListOptionsMenu(
val optionalCloneName = remember { mutableStateOf<String?>(null) }
val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
if (isExpanded) {
M3ActionDialog(
title = stringRes(R.string.list_management_dialog_title),
onDismiss = onDismiss,
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_edit_list_metadata))
},
onClick = {
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.follow_set_edit_list_metadata)) {
onListEditMetadata()
onDismiss()
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_copy_action_btn_label))
},
onClick = {
}
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.quick_action_delete))
},
onClick = {
}
}
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
onDelete()
},
)
onDismiss()
}
}
}
}
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.outlined.Groups
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.RemoveCircleOutline
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@@ -50,6 +49,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.DisplayParticipantNumberAndStatus
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
@@ -193,6 +195,30 @@ private fun UserAdditionOptions(
) {
val isUserAddTapped = remember { mutableStateOf(false) }
if (isUserAddTapped.value) {
M3ActionDialog(
title = stringRes(R.string.add_member_dialog_title),
onDismiss = { isUserAddTapped.value = false },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.PersonAdd,
text = stringRes(R.string.follow_set_public_member_add_label),
) {
onAddUserToList(false)
isUserAddTapped.value = false
}
M3ActionRow(
icon = Icons.Outlined.Lock,
text = stringRes(R.string.follow_set_private_member_add_label),
) {
onAddUserToList(true)
isUserAddTapped.value = false
}
}
}
}
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -231,29 +257,5 @@ private fun UserAdditionOptions(
)
}
}
DropdownMenu(
expanded = isUserAddTapped.value,
onDismissRequest = { isUserAddTapped.value = false },
) {
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_public_member_add_label))
},
onClick = {
onAddUserToList(false)
isUserAddTapped.value = false
},
)
DropdownMenuItem(
text = {
Text(text = stringRes(R.string.follow_set_private_member_add_label))
},
onClick = {
onAddUserToList(true)
isUserAddTapped.value = false
},
)
}
}
}
@@ -28,11 +28,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -64,7 +67,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.Sho
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
@Composable
fun RefreshableCardView(
@@ -165,6 +170,7 @@ private fun FeedLoaded(
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val openPolls by openPollsState(accountViewModel)
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -175,6 +181,37 @@ private fun FeedLoaded(
ShowDonationCard(accountViewModel, nav)
}
if (openPolls.isNotEmpty()) {
itemsIndexed(
items = openPolls,
key = { _, item -> "open-poll-${item.idHex}" },
contentType = { _, _ -> "OpenPoll" },
) { _, note ->
Row(modifier = Modifier.padding(start = Size10dp, end = Size10dp, bottom = Size10dp)) {
Card(
modifier = MaterialTheme.colorScheme.imageModifier,
) {
OpenPollsSectionHeader()
Row(Modifier.fillMaxWidth().animateItem()) {
NoteCompose(
baseNote = note,
modifier = Modifier.fillMaxWidth(),
routeForLastRead = routeForLastRead,
isBoostedNote = false,
isQuotedNote = false,
quotesLeft = 3,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
itemsIndexed(
items = items.list,
key = { _, item -> item.id() },
@@ -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.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
@@ -87,6 +88,7 @@ class NotificationFeedFilter(
VideoVerticalEvent.KIND,
VideoHorizontalEvent.KIND,
WikiNoteEvent.KIND,
AttestationRequestEvent.KIND,
)
val NOTIFICATION_KINDS =
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
@@ -66,6 +67,7 @@ val UserProfilePostKinds2 =
VoiceReplyEvent.KIND,
ZapPollEvent.KIND,
PinListEvent.KIND,
AttestationEvent.KIND,
)
fun filterUserProfilePosts(
@@ -21,20 +21,24 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Report
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.note.externalLinkForUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable
@@ -44,108 +48,94 @@ fun UserProfileDropDownMenu(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss,
if (!popupExpanded) return
M3ActionDialog(
title = stringRes(R.string.profile_actions_dialog_title),
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboardManager.current
DropdownMenuItem(
text = { Text(stringRes(R.string.copy_user_id)) },
onClick = {
clipboardManager.setText(AnnotatedString(user.pubkeyNpub()))
onDismiss()
},
)
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.quick_action_share)) },
onClick = {
// Share section
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.ContentCopy,
text = stringRes(R.string.copy_user_id),
) {
clipboardManager.setText(AnnotatedString(user.pubkeyNpub()))
onDismiss()
}
M3ActionRow(
icon = Icons.Outlined.Share,
text = stringRes(R.string.quick_action_share),
) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForUser(user),
)
putExtra(Intent.EXTRA_TEXT, externalLinkForUser(user))
putExtra(
Intent.EXTRA_TITLE,
stringRes(context, R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
val shareIntent = Intent.createChooser(sendIntent, stringRes(context, R.string.quick_action_share))
context.startActivity(shareIntent)
onDismiss()
},
)
}
}
// Moderation section (if not self)
if (accountViewModel.userProfile() != user) {
HorizontalDivider(thickness = DividerThickness)
M3ActionSection {
if (accountViewModel.account.isHidden(user)) {
DropdownMenuItem(
text = { Text(stringRes(R.string.unblock_user)) },
onClick = {
M3ActionRow(
icon = Icons.Outlined.CheckCircle,
text = stringRes(R.string.unblock_user),
) {
accountViewModel.show(user)
onDismiss()
},
)
}
} else {
DropdownMenuItem(
text = { Text(stringRes(id = R.string.block_hide_user)) },
onClick = {
M3ActionRow(
icon = Icons.Outlined.Block,
text = stringRes(R.string.block_hide_user),
isDestructive = true,
) {
accountViewModel.hide(user)
onDismiss()
},
)
}
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_spam_scam)) },
onClick = {
}
}
// Report section
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_spam_scam), isDestructive = true) {
accountViewModel.report(user, ReportType.SPAM)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.report_hateful_speech)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_hateful_speech), isDestructive = true) {
accountViewModel.report(user, ReportType.PROFANITY)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_impersonation)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_impersonation), isDestructive = true) {
accountViewModel.report(user, ReportType.IMPERSONATION)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.report_nudity_porn)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_nudity_porn), isDestructive = true) {
accountViewModel.report(user, ReportType.NUDITY)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_illegal_behaviour)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_illegal_behaviour), isDestructive = true) {
accountViewModel.report(user, ReportType.ILLEGAL)
onDismiss()
},
)
DropdownMenuItem(
text = { Text(stringRes(id = R.string.report_malware)) },
onClick = {
}
M3ActionRow(icon = Icons.Outlined.Report, text = stringRes(R.string.report_malware), isDestructive = true) {
accountViewModel.report(user, ReportType.MALWARE)
onDismiss()
},
)
}
}
}
}
}
@@ -27,6 +27,10 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -87,7 +91,11 @@ class UserProfileNewThreadFeedFilter(
it.event is AudioTrackEvent ||
it.event is AudioHeaderEvent ||
it.event is VoiceEvent ||
it.event is TorrentEvent
it.event is TorrentEvent ||
it.event is AttestationEvent ||
it.event is AttestationRequestEvent ||
it.event is AttestorRecommendationEvent ||
it.event is AttestorProficiencyEvent
) &&
it.isNewThread() &&
account.isAcceptable(it)
@@ -29,8 +29,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -53,6 +53,9 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -490,6 +493,7 @@ fun SettingsCategoryWithButton(
@Composable
fun ExportDropdownMenu(collection: () -> RelayListCollection) {
var expanded by remember { mutableStateOf(false) }
val context = LocalContext.current
IconButton(onClick = { expanded = true }) {
Icon(
@@ -498,24 +502,27 @@ fun ExportDropdownMenu(collection: () -> RelayListCollection) {
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
if (expanded) {
M3ActionDialog(
title = stringRes(R.string.export_actions_dialog_title),
onDismiss = { expanded = false },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.Description,
text = stringRes(R.string.export_as_text),
) {
val context = LocalContext.current
DropdownMenuItem(
text = { Text(stringRes(R.string.export_as_text)) },
onClick = {
expanded = false
RelayExporter(context).export(collection())
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.export_as_zip)) },
onClick = {
}
M3ActionRow(
icon = Icons.Outlined.FolderZip,
text = stringRes(R.string.export_as_zip),
) {
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.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
@@ -529,6 +533,10 @@ private fun kindDisplayName(kind: Int): Int =
AppSpecificDataEvent.KIND -> R.string.kind_user_settings
AudioHeaderEvent.KIND -> R.string.kind_audio_header
AudioTrackEvent.KIND -> R.string.kind_audio_track
AttestationEvent.KIND -> R.string.attestation
AttestationRequestEvent.KIND -> R.string.attestation_request
AttestorRecommendationEvent.KIND -> R.string.attestor_recommendation
AttestorProficiencyEvent.KIND -> R.string.attestor_proficiency
BadgeAwardEvent.KIND -> R.string.kind_badge_awards
BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions
BadgeProfilesEvent.KIND -> R.string.kind_profile_badges
@@ -661,7 +669,7 @@ val zaps = setOf(9734, 9735, 9041, 17375, 23194, 23195)
val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, RequestToVanishEvent.KIND)
@Composable
private fun KindChip(kind: Int) {
fun KindChip(kind: Int) {
val nameResId = kindDisplayName(kind)
val name = if (nameResId != -1) stringResource(nameResId) else "k$kind"
val (bg, fg) =
@@ -139,6 +139,10 @@ import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.PictureDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestation
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestationRequest
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorProficiency
import com.vitorpamplona.amethyst.ui.note.types.RenderAttestorRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
@@ -190,6 +194,10 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
@@ -620,6 +628,14 @@ private fun FullBleedNoteCompose(
accountViewModel,
nav,
)
} else if (noteEvent is AttestationEvent) {
RenderAttestation(baseNote, 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AttestationRequestEvent) {
RenderAttestationRequest(baseNote, 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AttestorRecommendationEvent) {
RenderAttestorRecommendation(baseNote, 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AttestorProficiencyEvent) {
RenderAttestorProficiency(baseNote, 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AdvertisedRelayListEvent) {
DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is LnZapEvent) {
@@ -661,4 +661,5 @@
<string name="delete_all">حذف الكل</string>
<string name="delete_all_drafts_confirmation">متأكد من حذف جميع المسودات؟</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -573,4 +573,5 @@
<string name="donate_now">এখনই দান করুন</string>
<string name="accessibility_scan_qr_code">QR কোড স্ক্যান করুন</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1310,6 +1310,10 @@
<string name="my_lists">Moje seznamy</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="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="private_message">Soukromá 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_error">Chyba</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>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1315,6 +1315,10 @@ anz der Bedingungen ist erforderlich</string>
<string name="my_lists">Meine Listen</string>
<string name="people_list_label">Benutzer</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="private_message">Private 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_error">Fehler</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>
@@ -515,4 +515,5 @@
Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις.
</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -8,4 +8,5 @@
<string name="show_anyway">Show Anyway</string>
<string name="post_not_found_short">👀</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -443,4 +443,5 @@
<string name="error_dialog_talk_to_user">Mesaĝi la Uzanto</string>
<string name="error_dialog_button_ok">Okej</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1183,4 +1183,5 @@
<string name="this_message_will_disappear_in_days">Este mensaje desaparecerá en %1$d días</string>
<string name="select_signer">Seleccionar firmante</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</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="select_signer">Seleccionar firmante</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</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="select_signer">Seleccionar firmante</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -948,4 +948,5 @@
<string name="temporary_account">با قفل کردن دستگاه از حساب کاربری خارج شو</string>
<string name="private_message">پیام خصوصی</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -570,4 +570,5 @@
<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>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1365,4 +1365,5 @@
<string name="continue_txt">Continuer</string>
<string name="skip_for_now">Ignorer pour le moment</string>
<string name="select_all">Tout sélectionner</string>
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1558,4 +1558,5 @@
<string name="uptime">%1$d%% समय निरन्तर उपलब्ध</string>
<string name="namecoin_settings">नामरूप्य स्थापना विकल्प</string>
<string name="ots_explorer_settings">द्व्यंकरूप्य समन्वेषक (ओटीएस॰)</string>
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1630,4 +1630,5 @@
<string name="event_sync_status_downloading">Letöltés</string>
<string name="event_sync_status_error">Hiba</string>
<string name="event_sync_status_completed">Kész</string>
<!-- M3 Action Dialog titles -->
</resources>
@@ -512,4 +512,5 @@ Seharusnya %3$s</string>
Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan.
</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -433,4 +433,5 @@
<string name="active_for_global">Globale</string>
<string name="active_for_search">Cerca</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -390,4 +390,5 @@
<string name="automatically_show_url_preview">自動的にURLプレビューを表示</string>
<string name="load_image">画像読み込み</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -172,4 +172,5 @@
<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>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1145,4 +1145,5 @@
<string name="follow_set_delete">Verwijder lijst</string>
<string name="follow_pack_delete">Verwijder pakket</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1589,4 +1589,5 @@
<string name="event_sync_status_downloading">Pobieranie</string>
<string name="event_sync_status_error">Błąd</string>
<string name="event_sync_status_completed">Zakończone</string>
<!-- M3 Action Dialog titles -->
</resources>
@@ -1310,6 +1310,10 @@
<string name="my_lists">Minhas listas</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="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="private_message">Mensagem Privada</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_error">Erro</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>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -432,4 +432,5 @@
<string name="classifieds_category_misc">Разное</string>
<string name="classifieds_category_other">Другое</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</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_error">Napaka</string>
<string name="event_sync_status_completed">Zaključeno</string>
<!-- M3 Action Dialog titles -->
</resources>
@@ -34,4 +34,5 @@
<string name="login_with_a_private_key_to_like_posts">Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове</string>
<string name="no_zap_amount_setup_long_press_to_change">Нема подешавања износа Зап. Дуго притисните да бисте променили</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1309,6 +1309,10 @@
<string name="my_lists">Mina listor</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="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="private_message">Privat 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_error">Fel</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>
@@ -444,4 +444,5 @@
<string name="active_for_global">Ulimwenguni</string>
<string name="active_for_search">Tafuta</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -370,4 +370,5 @@
<string name="groups_no_descriptor">இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் </string>
<string name="community_no_descriptor">இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும்</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -815,4 +815,5 @@
<string name="select_list_to_filter">แลือกลิสต์เพื่อโชว์</string>
<string name="temporary_account">ออกจากระบบ</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -168,4 +168,5 @@
<string name="mark_all_as_read">Tümünü okundu olarak işaretle</string>
<string name="biometric_error">Hata</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -500,4 +500,5 @@
<string name="could_not_download_from_the_server">Не вдалося завантажити завантажені медіафайли з сервера</string>
<string name="could_not_prepare_local_file_to_upload">Не вдалося підготувати локальний файл для завантаження: %1$s</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -69,4 +69,5 @@
<string name="in_channel">"bu kanalda: "</string>
<string name="profile_banner">Profil banneri</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -12,4 +12,5 @@
<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>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1626,4 +1626,5 @@
<string name="event_sync_status_downloading">下载中</string>
<string name="event_sync_status_error">错误</string>
<string name="event_sync_status_completed">已完成</string>
<!-- M3 Action Dialog titles -->
</resources>
@@ -240,4 +240,5 @@
<string name="pledge_amount_in_sats">聰數量</string>
<string name="looking_for_event">"“正在查找事件%1$s”"</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
@@ -763,4 +763,5 @@
<string name="dvm_waiting_to_confirm_payment">正在等待 DVM 確認付款或返回結果</string>
<string name="add_a_nip96_server">添加 NIP-96 服務器</string>
<!-- Kind display names for relay subscription filter chips -->
<!-- M3 Action Dialog titles -->
</resources>
+35
View File
@@ -258,6 +258,7 @@
<string name="error_loading_replies">"Error loading replies: "</string>
<string name="try_again">Try again</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="refresh">Refresh</string>
<string name="created">created</string>
@@ -1877,4 +1878,38 @@
<string name="event_sync_status_downloading">Downloading</string>
<string name="event_sync_status_error">Error</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>
@@ -23,13 +23,26 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.Validity
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content
@Immutable
class AttestationEvent(
@@ -39,7 +52,22 @@ class AttestationEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
override fun eventHints(): List<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 status() = tags.status()
@@ -50,25 +78,91 @@ class AttestationEvent(
fun request() = tags.request()
fun requestId() = tags.requestId()
fun requestAddress() = tags.requestAddress()
fun isRevoked() = status() == AttestationStatus.REVOKED
fun assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress)
fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse)
fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId)
fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse)
fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey)
fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse)
companion object {
const val KIND = 31871
const val ALT_DESCRIPTION = "Attestation"
fun build(
fun buildEvent(
dTagId: String,
about: EventHintBundle<Event>,
content: String = "",
validity: Validity? = null,
status: AttestationStatus? = null,
validFrom: Long? = null,
validTo: Long? = null,
requestAddress: String? = null,
requestAddress: EventHintBundle<AttestationRequestEvent>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<AttestationEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
dTag(dTagId)
about(about)
validity?.let { validity(it) }
status?.let { status(it) }
validFrom?.let { validFrom(it) }
validTo?.let { validTo(it) }
requestAddress?.let { request(it) }
initializer()
}
fun buildReplaceable(
dTagId: String,
about: EventHintBundle<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) }
status?.let { status(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.Validity
import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.ValidityTag
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
fun TagArrayBuilder<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>.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.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
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.utils.arrayOfNotNull
import com.vitorpamplona.quartz.utils.ensure
class RequestTag {
class RequestTag(
val address: Address,
val relayHint: NormalizedRelayUrl? = null,
) {
fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag)
fun toTagArray() = assemble(address, relayHint)
fun toTagIdOnly() = assemble(address, null)
companion object {
const val TAG_NAME = "request"
fun isTag(tag: Array<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[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
val address = Address.parse(tag[1]) ?: return null
ensure(address.kind != CommunityDefinitionEvent.KIND) { return null }
return address
}
fun parseAddressId(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null }
return tag[1]
}
fun assemble(requestAddress: String) = arrayOf(TAG_NAME, requestAddress)
fun parseAsHint(tag: Array<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
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.attestations.request.attestorPubKeys
import com.vitorpamplona.quartz.experimental.attestations.request.cashuToken
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.let
@Immutable
class AttestationRequestEvent(
@@ -37,15 +51,45 @@ class AttestationRequestEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
override fun eventHints(): List<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 assertionAddrId() = tags.firstNotNullOfOrNull(ATag::parseAddressId)
fun assertionAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress)
fun assertionATag() = tags.firstNotNullOfOrNull(ATag::parse)
fun assertionEventId() = tags.firstNotNullOfOrNull(ETag::parseId)
fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse)
fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey)
fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse)
companion object {
const val KIND = 31872
const val ALT_DESCRIPTION = "Attestation Request"
fun build(
fun buildEvent(
dTagId: String,
about: EventHintBundle<Event>,
content: String = "",
attestorPubKeys: List<HexKey> = emptyList(),
cashuToken: String? = null,
@@ -54,6 +98,41 @@ class AttestationRequestEvent(
) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION)
dTag(dTagId)
about(about)
attestorPubKeys(attestorPubKeys)
cashuToken?.let { cashuToken(it) }
initializer()
}
fun buildReplaceable(
dTagId: String,
about: EventHintBundle<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)
cashuToken?.let { cashuToken(it) }
initializer()
@@ -21,10 +21,22 @@
package com.vitorpamplona.quartz.experimental.attestations.request
import com.vitorpamplona.quartz.experimental.attestations.request.tags.CashuTokenTag
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
fun TagArrayBuilder<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>.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.flow.MutableSharedFlow
/**
* A reactive event store that combines historical data retrieval with live event streaming.
*
* This class wraps an [IEventStore] to provide real-time updates. When a [query] is executed,
* it first replays all matching historical events from the underlying store, signals the
* End of Stored Events (EOSE), and then continues to stream matching new events as they
* are inserted.
*
* @property store The underlying persistent storage for events.
*/
class LiveEventStore(
private val store: IEventStore,
) {
@@ -29,14 +29,10 @@ import kotlinx.coroutines.cancel
import kotlin.coroutines.CoroutineContext
/**
* This class manages per-connection subscriptions as coroutines. Each
* subscription (REQ) launches a child coroutine that first replays stored
* events matching the filters, sends EOSE, and then streams live events.
* Closing a subscription (CLOSE) immediately cancels its coroutine.
* Represents a Nostr relay server that manages client connections, event storage, and verification.
*
* The server is transport-agnostic: callers feed incoming JSON via
* [RelaySession.receive] and receive outgoing JSON via the [RelaySession.send] callback
* provided to [connect]. This allows use with any WebSocket library.
* This class acts as the central coordinator for a relay server, handling the lifecycle of [RelaySession]s
* and providing access to the underlying event store.
*
* @param store The [IEventStore] backing this relay.
* @param policyBuilder Controls requirements for relay commands.

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