diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md
index 9f8c14109c..f9bc30f72a 100644
--- a/.claude/skills/find-missing-translations/SKILL.md
+++ b/.claude/skills/find-missing-translations/SKILL.md
@@ -74,7 +74,14 @@ Before presenting results, **scan the missing English strings** for two red-flag
1. **Hardcoded `"1"` next to a noun.** A new English string like `"1 reply"`, `"1 follower"`, or `"1 minute ago"` almost always belongs in a `` resource — not a ``. Hardcoding `1` in English forces every translator to either also hardcode `1` (breaking languages where the `one` category covers other numbers, e.g. some Slavic languages) or to silently change the meaning.
2. **A `%d` / `%1$d` placeholder in a clearly singular/plural sentence** (e.g. `"%1$d reply"`, `"%d follower"`). Even though the placeholder is parameterised, English-only `one`/`other` agreement won't survive translation into languages that need `few`/`many`.
-Also **audit existing `` resources** for the same anti-pattern — any locale's `quantity="one"` item that hardcodes the literal `1` (instead of using a `%d` / `%1$d` placeholder) is broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.). Flag and offer to fix:
+Also **audit existing `` resources** for two anti-patterns:
+
+1. **`quantity="one"` items that hardcode the literal `1`** (instead of using a `%d` / `%1$d` placeholder) — broken for languages where the `one` CLDR category covers more than just `n=1` (Russian, Ukrainian, Croatian, etc.).
+2. **`quantity="zero"` items in any locale that doesn't natively use the `zero` CLDR category** — i.e. **everything except Arabic (`ar`) and Welsh (`cy`)**. ICU/CLDR maps `count=0` to `other` for English and all the locales we ship to (cs, de, pt-BR, sv, etc.), so `- ` is **dead code** there: `getQuantityString(id, 0)` will pick `other`, never the zero entry, and the visible runtime string ends up `"…0 items"` instead of the intended `"…no items"`.
+
+If a UX genuinely wants special "no items" wording at count=0, that has to be a call-site `if (count == 0)` branch to a separate ``, **not** a `quantity="zero"` plural item.
+
+Flag and offer to fix:
```bash
# Scan every locale's strings.xml for
- entries that
@@ -96,6 +103,27 @@ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*
done
```
+Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `
- ` entry is dead and likely a translator/author bug (or it silently never fires):
+
+```bash
+for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
+ # Skip Arabic and Welsh — they natively use the zero category.
+ case "$f" in
+ *values-ar*|*values-cy*) continue ;;
+ esac
+ awk -v file="$f" '
+ /]*>/, "", text); sub(/<.*$/, "", text)
+ print file ": zero=\"" text "\""
+ }
+ /<\/plurals>/ { in_plurals = 0 }
+ ' "$f"
+done
+```
+
+For each hit, warn the user that the entry is unreachable in that locale. The fix is to **remove the `
- `** and, if the UX wanted distinct wording for count=0, add a separate `` plus an `if (count == 0)` branch at the call site (see "Plurals: handle with care" below).
+
Quick scan over the missing keys:
```bash
@@ -147,6 +175,14 @@ When adding or proposing **``** entries, follow these rules:
- Arabic (`ar`): `zero`, `one`, `two`, `few`, `many`, `other`
- German / Swedish / Brazilian Portuguese: `one`, `other`
- When a missing string contains a count placeholder and is conceptually a singular/plural pair, **flag it before translating** — it may belong as a `` resource rather than a single ``. Surface this to the user before proposing translations.
+- **Do not use `quantity="zero"` outside Arabic (`ar`) and Welsh (`cy`).** CLDR's `zero` category is integer-bearing only in those two languages. Android calls `PluralRules.select(0)` for the device locale; in English/German/Czech/Polish/Russian/Swedish/Portuguese/etc. it returns `other`, so the explicit `
- ` is never picked at runtime and the user sees `"…0 items"` instead of the intended wording. If the design calls for "no items" at count=0, model it as a separate `` and an `if (count == 0)` branch at the call site:
+ ```kotlin
+ val label = if (count == 0) {
+ stringRes(R.string.foo_no_items, dateLabel)
+ } else {
+ pluralStringResource(R.plurals.foo_items, count, dateLabel, count)
+ }
+ ```
- Reference: [Android `` docs](https://developer.android.com/guide/topics/resources/string-resource#Plurals) and [CLDR plural rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html).
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
@@ -165,4 +201,5 @@ When adding translated strings to locale files:
- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
-- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
\ No newline at end of file
+- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
+- **Using `
- ` to special-case count=0** — outside Arabic and Welsh, this entry is unreachable: ICU/CLDR maps 0 → `other`, so the runtime never picks the zero item and the user sees `"…0 items"`. Special-case at the call site with a separate `` instead.
\ No newline at end of file
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt
index 2e50447b7a..29ed96a2c0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt
@@ -37,8 +37,8 @@ val DefaultReactions =
"\uD83D\uDE31",
)
-val DefaultZapAmounts = listOf(100L, 500L, 1000L)
-val DefaultOnchainZapAmounts = listOf(10_000L)
+val DefaultZapAmounts = listOf(21L, 50L, 100L)
+val DefaultOnchainZapAmounts = listOf(5_000L)
val DefaultReportWarningThreshold = 5
@Serializable
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 2d9671bf6d..12d34ba2d6 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -2099,7 +2099,15 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
val requestId = event.requestId()
- val pending = paymentTracker.onResponseReceived(requestId) ?: return false
+ val pending =
+ paymentTracker.onResponseReceived(requestId) ?: run {
+ Log.w(
+ "LocalCache",
+ "NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " +
+ "The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag.",
+ )
+ return false
+ }
val zappedNote = pending.zappedNote
val responseCallback = pending.onResponse
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt
index 5bf93e5ff2..1877c30d04 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt
@@ -152,7 +152,11 @@ class NwcSignerState(
val assembler = nwcFilterAssembler()
- assembler.subscribe(filter)
+ // Synchronous flush so the REQ frame is queued on the WebSocket before
+ // the EVENT is published. Without this, the bundler may delay REQ up
+ // to 500ms, and the wallet service's ephemeral kind 23195 reply can
+ // be missed.
+ assembler.subscribeAndFlush(filter)
scope.launch(Dispatchers.IO) {
delay(60000)
@@ -189,7 +193,9 @@ class NwcSignerState(
val assembler = nwcFilterAssembler()
- assembler.subscribe(filter)
+ // Synchronous flush so the REQ frame is queued before the EVENT.
+ // See sendNwcRequestToWallet above for the rationale.
+ assembler.subscribeAndFlush(filter)
scope.launch(Dispatchers.IO) {
delay(60000) // waits 1 minute to complete payment.
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt
index 9ea5235108..3f12d0bd05 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
+import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -31,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@Stable
class ChannelFinderQueryState(
val channel: Channel,
+ val account: Account,
)
@Stable
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt
index 8d5f6828ac..3bc756a125 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt
@@ -24,24 +24,26 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
+import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChannelFinderFilterAssemblerSubscription(
channel: Channel,
accountViewModel: AccountViewModel,
-) = ChannelFinderFilterAssemblerSubscription(channel, accountViewModel.dataSources().channelFinder)
+) = ChannelFinderFilterAssemblerSubscription(channel, accountViewModel.account, accountViewModel.dataSources().channelFinder)
@Composable
fun ChannelFinderFilterAssemblerSubscription(
channel: Channel,
+ account: Account,
dataSource: ChannelFinderFilterAssemblyGroup,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
- remember(channel) {
- ChannelFinderQueryState(channel)
+ remember(channel, account) {
+ ChannelFinderQueryState(channel, account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt
index f8c58f0910..c10fe0477e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
+import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
+import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -34,29 +36,28 @@ fun filterMissingChannelsById(keys: List): List
if (key.channel is PublicChatChannel && key.channel.event == null) {
- key.channel.relays().forEach {
+ val searchRelays =
+ key.account.searchRelayList.flow.value
+ .ifEmpty { DefaultSearchRelayList }
+ val indexerRelays =
+ key.account.indexerRelayList.flow.value
+ .ifEmpty { DefaultIndexerRelayList }
+
+ (key.channel.relays() + searchRelays + indexerRelays).forEach {
add(it, key.channel.idHex)
}
- } else {
- null
}
}
}
- if (relayPerChannel.isEmpty()) return emptyList()
-
- return relayPerChannel.mapNotNull {
- if (it.value.isEmpty()) {
- RelayBasedFilter(
- relay = it.key,
- filter =
- Filter(
- kinds = filterMissingPublicChannelsByIdKinds,
- ids = it.value.sorted(),
- ),
- )
- } else {
- null
- }
+ return relayPerChannel.map { (relay, channelIds) ->
+ RelayBasedFilter(
+ relay = relay,
+ filter =
+ Filter(
+ kinds = filterMissingPublicChannelsByIdKinds,
+ ids = channelIds.sorted(),
+ ),
+ )
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt
index 48c66be4ee..46df66e2d3 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt
@@ -48,5 +48,17 @@ class NWCPaymentFilterAssembler(
override fun invalidateKeys() = invalidateFilters()
+ /**
+ * Synchronously sends the REQ frame to the relay, bypassing the 500ms
+ * BundledUpdate debounce. Used for NIP-47 RPC where the response is an
+ * ephemeral event (kind 23195) and the subscription must be active on the
+ * relay before we publish the request event — otherwise the relay drops
+ * the response with no replay.
+ */
+ fun subscribeAndFlush(query: NWCPaymentQueryState) {
+ subscribe(query)
+ group.forEach { it.forceInvalidate() }
+ }
+
override fun destroy() = group.forEach { it.destroy() }
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt
index ddbb336cbc..fd8d7b7e29 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt
@@ -82,7 +82,13 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit,
) {
- note.event?.let { SensitivityWarning(it, accountViewModel, content) }
+ val noteEvent = note.event
+
+ if (noteEvent == null) {
+ content()
+ } else {
+ SensitivityWarning(noteEvent, accountViewModel, content)
+ }
}
@Composable
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt
index 337c19e403..67eb8ac256 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt
@@ -21,23 +21,44 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
+import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder
@Composable
fun DisplayAuthorBanner(
- note: Note,
+ baseNote: Note,
accountViewModel: AccountViewModel,
modifier: Modifier = SimpleImageBorder,
) {
- WatchAuthor(note, accountViewModel) {
+ val noteAuthor = baseNote.author
+ if (noteAuthor != null) {
BannerImage(
- it,
+ noteAuthor,
modifier,
accountViewModel,
)
+ } else {
+ val authorState by observeNote(baseNote, accountViewModel)
+ CrossfadeIfEnabled(authorState.note.author, accountViewModel = accountViewModel) { author ->
+ if (author != null) {
+ BannerImage(
+ author,
+ modifier,
+ accountViewModel,
+ )
+ } else {
+ BannerImage(
+ null as String?,
+ modifier,
+ accountViewModel,
+ )
+ }
+ }
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt
index 28e04659bb..a7e17d4536 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt
@@ -154,12 +154,11 @@ fun LoadPublicChatChannel(
accountViewModel: AccountViewModel,
content: @Composable (PublicChatChannel) -> Unit,
) {
- val channel =
- produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) {
- value = accountViewModel.checkGetOrCreatePublicChatChannel(id)
- }
+ val channel by produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) {
+ value = accountViewModel.checkGetOrCreatePublicChatChannel(id)
+ }
- channel.value?.let { content(it) }
+ channel?.let { content(it) }
}
@Composable
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt
index 8ca2213069..ca6750051e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt
@@ -284,7 +284,7 @@ fun UpdateZapAmountContent(
),
placeholder = {
Text(
- text = "100, 1000, 5000",
+ text = "21, 50, 100",
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -406,7 +406,7 @@ fun UpdateZapAmountContent(
),
placeholder = {
Text(
- text = "10000, 50000, 250000",
+ text = "5000, 25000, 100000",
color = MaterialTheme.colorScheme.placeholderText,
)
},
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt
index 8c4e6feff4..5bd7d3624c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt
@@ -236,7 +236,7 @@ fun ZapCustomDialog(
),
placeholder = {
Text(
- text = "100, 1000, 5000",
+ text = "21, 50, 100",
color = MaterialTheme.colorScheme.placeholderText,
)
},
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt
index dabcf9a69f..943c105290 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt
@@ -119,38 +119,32 @@ fun BannerImage(
if (!banner.isNullOrBlank()) {
MyAsyncImage(
imageUrl = banner,
- contentDescription =
- stringRes(
- R.string.preview_card_image_for,
- banner,
- ),
+ contentDescription = stringRes(R.string.preview_card_image_for, banner),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier,
loadedImageModifier = modifier,
accountViewModel = accountViewModel,
onLoadingBackground = {
- Image(
- painter = painterRes(R.drawable.profile_banner, 4),
- contentDescription = stringRes(R.string.profile_banner),
- contentScale = ContentScale.Crop,
- modifier = modifier,
- )
+ DefaultProfileBanner(modifier, 4)
},
onError = {
- Image(
- painter = painterRes(R.drawable.profile_banner, 4),
- contentDescription = stringRes(R.string.profile_banner),
- contentScale = ContentScale.Crop,
- modifier = modifier,
- )
+ DefaultProfileBanner(modifier, 4)
},
)
} else {
- Image(
- painter = painterRes(R.drawable.profile_banner, 5),
- contentDescription = stringRes(R.string.profile_banner),
- contentScale = ContentScale.Crop,
- modifier = modifier,
- )
+ DefaultProfileBanner(modifier, 5)
}
}
+
+@Composable
+fun DefaultProfileBanner(
+ modifier: Modifier,
+ sizeReference: Int,
+) {
+ Image(
+ painter = painterRes(R.drawable.profile_banner, sizeReference),
+ contentDescription = stringRes(R.string.profile_banner),
+ contentScale = ContentScale.Crop,
+ modifier = modifier,
+ )
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayA11y.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayA11y.kt
new file mode 100644
index 0000000000..dcaec39af4
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayA11y.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.calendars
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.pluralStringResource
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.stringRes
+
+// Picks calendar_day_a11y_no_events when count is 0 because ICU/CLDR maps 0 to
+// the `other` category for every locale we ship, which would otherwise render
+// "[date], 0 events" instead of "[date], no events".
+@Composable
+fun calendarDayA11yLabel(
+ dateLabel: String,
+ count: Int,
+): String =
+ if (count == 0) {
+ stringRes(R.string.calendar_day_a11y_no_events, dateLabel)
+ } else {
+ pluralStringResource(R.plurals.calendar_day_a11y_events, count, dateLabel, count)
+ }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt
index 648210bee6..69322f2b74 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt
@@ -46,7 +46,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -266,8 +265,7 @@ private fun DayCell(
MaterialTheme.colorScheme.surface
}
- val baseDescription =
- pluralStringResource(R.plurals.calendar_day_a11y_events, totalEventCount, dateLabel, totalEventCount)
+ val baseDescription = calendarDayA11yLabel(dateLabel, totalEventCount)
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
val a11y =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt
index c71f126c2a..46b9d8b4d9 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt
@@ -44,7 +44,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -202,7 +201,7 @@ private fun WeekStrip(
}
val dateLabel = formatLongDate(date.atStartOfDay(ZoneId.systemDefault()).toEpochSecond())
- val baseA11y = pluralStringResource(R.plurals.calendar_day_a11y_events, count, dateLabel, count)
+ val baseA11y = calendarDayA11yLabel(dateLabel, count)
val todaySuffix = stringRes(R.string.calendar_day_a11y_today_suffix)
val selectedSuffix = stringRes(R.string.calendar_day_a11y_selected_suffix)
val a11y =
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt
index 2d2cc7f720..e5bab74702 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt
@@ -68,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
-import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -81,8 +80,6 @@ fun RenderPublicChatChannelThumb(
accountViewModel: AccountViewModel,
nav: INav,
) {
- val noteEvent = baseNote.event as? ChannelCreateEvent ?: return
-
LoadPublicChatChannel(baseNote.idHex, accountViewModel) {
RenderPublicChatChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav)
}
@@ -96,7 +93,7 @@ fun RenderPublicChatChannelThumb(
nav: INav,
) {
val channelUpdates by observeChannel(channel, accountViewModel)
- val publicChat = channelUpdates?.channel as PublicChatChannel
+ val publicChat = (channelUpdates?.channel as? PublicChatChannel) ?: channel
val name = remember(channelUpdates) { publicChat.toBestDisplayName() }
val description = remember(channelUpdates) { publicChat.summary()?.ifBlank { null } }
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt
index 73872b432b..02e1a22882 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt
@@ -20,24 +20,46 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
+import com.vitorpamplona.amethyst.ui.note.ClickableNote
+import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
+import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
+import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
-import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderPublicChatChannelThumb
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
-import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
+import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
fun PublicChatsFeedLoaded(
@@ -47,30 +69,145 @@ fun PublicChatsFeedLoaded(
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
+ val followedSet by accountViewModel.account.publicChatList.flowSet
+ .collectAsStateWithLifecycle()
+
+ val pinned by accountViewModel.account.publicChatList.flowSetNote
+ .collectAsStateWithLifecycle()
+
+ val unpinned =
+ remember(items.list, followedSet) {
+ items.list.filter { it.idHex !in followedSet }
+ }
+
+ LaunchedEffect(pinned.firstOrNull()?.idHex, unpinned.firstOrNull()?.idHex) {
+ if (listState.firstVisibleItemIndex <= 1) {
+ listState.animateScrollToItem(0)
+ }
+ }
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
- itemsIndexed(
- items.list,
- key = { _, item -> item.idHex },
- contentType = { _, item -> item.event?.kind ?: -1 },
- ) { _, item ->
- Row(Modifier.fillMaxWidth().animateItem()) {
- ChannelCardCompose(
- baseNote = item,
- routeForLastRead = "PublicChatsFeed",
- modifier = Modifier.fillMaxWidth(),
- forceEventKind = ChannelCreateEvent.KIND,
- accountViewModel = accountViewModel,
- nav = nav,
- )
- }
+ items(
+ pinned,
+ key = { item -> "pinned-" + item.idHex },
+ ) { item ->
+ PublicChatRow(item, pinned = true, accountViewModel, nav)
+ }
- HorizontalDivider(
- thickness = DividerThickness,
- )
+ if (pinned.isNotEmpty() && unpinned.isNotEmpty()) {
+ item(key = "pinned-unpinned-gap", contentType = "section-gap") {
+ Box(Modifier.fillMaxWidth().height(8.dp))
+ }
+ }
+
+ itemsIndexed(
+ unpinned,
+ key = { _, item -> item.idHex },
+ ) { _, item ->
+ PublicChatRow(item, pinned = false, accountViewModel, nav)
}
}
}
+
+@Composable
+private fun LazyItemScope.PublicChatRow(
+ baseNote: Note,
+ pinned: Boolean,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ val modifier = Modifier.fillMaxWidth()
+
+ Box(Modifier.fillMaxWidth().animateItem()) {
+ WatchNoteEvent(
+ baseNote = baseNote,
+ accountViewModel = accountViewModel,
+ onBlank = {
+ RenderChannel(baseNote, modifier, accountViewModel, nav)
+ },
+ onNoteEventFound = {
+ CheckHiddenFeedWatchBlockAndReport(
+ note = baseNote,
+ modifier = modifier,
+ ignoreAllBlocksAndReports = false,
+ showHiddenWarning = false,
+ accountViewModel = accountViewModel,
+ nav = nav,
+ ) { _ ->
+ RenderChannel(baseNote, modifier, accountViewModel, nav)
+ }
+ },
+ )
+
+ if (pinned) {
+ PinBadge(
+ modifier =
+ Modifier
+ .align(Alignment.TopStart)
+ .padding(start = 14.dp, top = 14.dp),
+ )
+ }
+ }
+
+ HorizontalDivider(
+ thickness = DividerThickness,
+ )
+}
+
+@Composable
+private fun RenderChannel(
+ baseNote: Note,
+ modifier: Modifier,
+ accountViewModel: AccountViewModel,
+ nav: INav,
+) {
+ LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup ->
+ ClickableNote(
+ baseNote = baseNote,
+ backgroundColor =
+ calculateBackgroundColor(
+ createdAt = baseNote.createdAt(),
+ routeForLastRead = "PublicChatsFeed",
+ parentBackgroundColor = null,
+ accountViewModel = accountViewModel,
+ ),
+ modifier = modifier,
+ accountViewModel = accountViewModel,
+ showPopup = showPopup,
+ nav = nav,
+ ) {
+ Column(StdPadding) {
+ SensitivityWarning(
+ note = baseNote,
+ accountViewModel = accountViewModel,
+ ) {
+ RenderPublicChatChannelThumb(baseNote, accountViewModel, nav)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PinBadge(modifier: Modifier = Modifier) {
+ Box(
+ modifier =
+ modifier
+ .size(22.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
+ shape = CircleShape,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ symbol = MaterialSymbols.PushPin,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt
index 716a79570c..000ca33990 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt
@@ -99,6 +99,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.NumberFormat
+// UX floor for onchain zaps. Below this any on-chain transaction is dominated
+// by miner fees — the recipient nets close to nothing even at low fee rates,
+// so quietly funneling the user to a Lightning zap is the friendlier outcome.
+// This is stricter than the protocol-level [OnchainZapBuilder.DUST_THRESHOLD_SATS]
+// (330 sats), which only guards against creating outputs the network rejects.
+private const val MIN_ONCHAIN_ZAP_SATS = 1_000L
+
private enum class FeeTier(
val label: String,
val etaLabel: String,
@@ -242,12 +249,14 @@ fun OnchainZapSendDialog(
previewShares.orEmpty().filter { it.sats < OnchainZapBuilder.DUST_THRESHOLD_SATS }
}
+ val belowMinimum = amountSats != null && amountSats > 0 && amountSats < MIN_ONCHAIN_ZAP_SATS
+
val canSend =
!sending &&
result == null &&
(splitMode || (resolvedRecipient != null && resolvedRecipient != senderPubKey)) &&
amountSats != null &&
- amountSats > 0 &&
+ amountSats >= MIN_ONCHAIN_ZAP_SATS &&
fees != null &&
(!splitMode || (previewShares != null && belowDustShares.isEmpty()))
@@ -353,6 +362,7 @@ fun OnchainZapSendDialog(
amountInput = amountInput,
onAmountChange = { amountInput = it },
presetAmounts = presetAmounts,
+ belowMinimum = belowMinimum,
)
SectionSpacer()
@@ -610,6 +620,7 @@ private fun AmountSection(
amountInput: String,
onAmountChange: (String) -> Unit,
presetAmounts: List,
+ belowMinimum: Boolean,
) {
SectionLabel("Amount")
@@ -636,8 +647,20 @@ private fun AmountSection(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = { Text("0") },
suffix = { Text("sats", color = MaterialTheme.colorScheme.onSurfaceVariant) },
+ isError = belowMinimum,
modifier = Modifier.fillMaxWidth(),
)
+
+ if (belowMinimum) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text =
+ "Minimum on-chain zap is ${NumberFormat.getNumberInstance().format(MIN_ONCHAIN_ZAP_SATS)} sats — " +
+ "smaller amounts are eaten by miner fees. Use a Lightning zap instead.",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
}
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt
index f05e5c0a68..34f9bc673c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt
@@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
+import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -172,6 +173,19 @@ class WalletViewModel : ViewModel() {
private val _receiveState = MutableStateFlow(ReceiveState.Idle)
val receiveState = _receiveState.asStateFlow()
+ // A null `Response` means the wallet service replied but the payload could
+ // not be decrypted (wrong key, unexpected format). Any other unexpected
+ // subtype means the response shape didn't match any known NIP-47 result.
+ // Both used to be swallowed silently — now we surface them so the user
+ // can distinguish "wallet never answered" from "wallet answered with
+ // something we can't read".
+ private fun unreadableResponseError(response: Response?): String =
+ if (response == null) {
+ "Could not decrypt the wallet's reply — the wallet may be using a different key"
+ } else {
+ "Wallet returned an unrecognized reply for ${response.resultType}"
+ }
+
private fun launchTimeout(onTimeout: () -> Unit): Job =
viewModelScope.launch(Dispatchers.IO) {
delay(NWC_TIMEOUT_MS)
@@ -315,7 +329,9 @@ class WalletViewModel : ViewModel() {
}
else -> {
- updateWalletInfo(walletId) { it.copy(isLoading = false) }
+ updateWalletInfo(walletId) {
+ it.copy(error = unreadableResponseError(response), isLoading = false)
+ }
}
}
}
@@ -376,13 +392,16 @@ class WalletViewModel : ViewModel() {
is GetBalanceSuccessResponse -> {
_balanceSats.value = (response.result?.balance ?: 0L) / 1000L
updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) }
+ _error.value = null
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Balance request failed"
}
- else -> {}
+ else -> {
+ _error.value = unreadableResponseError(response)
+ }
}
_isLoading.value = false
}
@@ -444,13 +463,16 @@ class WalletViewModel : ViewModel() {
} else {
txs.size >= pageSize
}
+ _error.value = null
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Failed to load transactions"
}
- else -> {}
+ else -> {
+ _error.value = unreadableResponseError(response)
+ }
}
_isLoading.value = false
}
@@ -498,7 +520,9 @@ class WalletViewModel : ViewModel() {
_error.value = response.error?.message ?: "Failed to load more transactions"
}
- else -> {}
+ else -> {
+ _error.value = unreadableResponseError(response)
+ }
}
_isLoadingMore.value = false
}
diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml
index 90ca003b07..8f751e8640 100644
--- a/amethyst/src/main/res/values-cs-rCZ/strings.xml
+++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml
@@ -562,6 +562,7 @@
Vyberte, které z odznaků, které jste obdrželi, se zobrazí na vašem profilu.
Zatím jste neobdrželi žádné odznaky.
Obrázky
+ Kalendáře
Krátká videa
Veřejné chaty
Seznamy sledování
@@ -796,6 +797,10 @@
Pokročilé: zadejte podrobnosti připojení ručně
Rychlé částky Zap
Zobrazí se při stisknutí tlačítka zap. Klepnutím na částku ji odeberete. Pokud ponecháte prázdné, otevře se pokaždé dialog pro zadání částky.
+ Rychlé částky on-chain zapů
+ On-chain zapy platí poplatky těžařům, takže částky jsou obvykle vyšší než u Lightningu. Klepnutím na částku ji odstraníte.
+ Nová on-chain částka v sats
+ Odeslat on-chain
Soukromí Zap
Určuje, jak je zobrazena vaše identita při odesílání zapsu.
Připojit peněženku
@@ -1606,6 +1611,8 @@
Globální
Krátké
Obrázky
+ Kalendáře
+ Seznamy kalendářů
Šachy
Peněženka
Zůstatek
@@ -1626,6 +1633,7 @@
Vytváření faktury…
Kopírovat fakturu
Zatím žádné transakce
+ Žádné transakce neodpovídají tomuto filtru
Načítání…
Přijato
Odesláno
@@ -1654,6 +1662,15 @@
Neplatné URI NWC připojení
Posunout nahoru
Posunout dolů
+ On-chain transakce
+ Pro tento účet není k dispozici žádná on-chain adresa.
+ Není nakonfigurován žádný on-chain backend.
+ Čeká
+ Veřejné
+ Tato peněženka je veřejná
+ Vaše Taproot adresa je odvozena z vašeho veřejného klíče Nostr, takže kdokoli, kdo zná váš npub, může vidět zůstatek této peněženky a historii transakcí na blockchainu.\n\nAbyste zachovali soukromí, vkládejte do této peněženky a vybírejte z ní prostředky z nesoukromých účtů, jako jsou burzy. Nikdy nemíchejte tyto prostředky se svými studenými peněženkami a zacházejte s nimi jako s penězi, které můžete ztratit, protože kdokoli, kdo má pod kontrolou váš nsec, je může utratit.
+ Rozumím
+ Zkopírovat adresu
Bezpečnostní filtry
Importovat sledované
Nový příspěvek
@@ -1666,6 +1683,128 @@
Nová zap anketa
Nová běžná anketa
Nový obrázek
+ Nová událost kalendáře
+ Upravit událost kalendáře
+ Uzamčeno během úprav — změna by vytvořila novou událost.
+ Nový kalendář
+ Upravit kalendář
+ Události v tomto kalendáři (%1$d)
+ Zatím jste nevytvořili žádné události.
+ Kanál
+ Měsíc
+ Týden
+ Den
+ Nadcházející
+ Minulé
+ Z vybraného kanálu zatím nejsou žádné nadcházející ani minulé události.
+ Zatím žádné kalendářové seznamy.
+ Váš kalendář je prázdný
+ Události sdílené lidmi, které sledujete, se zobrazí zde. Klepnutím na tlačítko + vytvořte vlastní.
+ Zatím žádné seznamy
+ Spojte události dohromady — sérii setkání, sekci konference, plán vašeho týmu. Klepnutím na + vytvořte nový.
+ Nic naplánováno
+ V tento den nejsou žádné události. Klepnutím na + přidejte.
+ Tento týden nic
+ V tomto týdnu nejsou žádné události.
+ Název
+ Shrnutí
+ Místo
+ URL obrázku
+ Celodenní událost
+ Začátek
+ Konec
+ Hashtagy (oddělené čárkou)
+ Vybrat datum
+ Vybrat čas
+ Název a začátek jsou povinné.
+ Konec musí být po začátku.
+ Předchozí měsíc
+ Další měsíc
+ Předchozí týden
+ Další týden
+ Předchozí den
+ Další den
+ V tento den nejsou žádné události
+ Žádné události
+ Pokračuje
+ Den %1$d z %2$d
+ Přidat do kalendáře v telefonu
+ Skočit na dnešek
+
+
- %1$s, %2$d událost
+ - %1$s, %2$d události
+ - %1$s, %2$d událostí
+ - %1$s, %2$d událostí
+
+ %1$s, žádné události
+ dnes
+ vybráno
+ Vytvořit novou událost kalendáře
+ Vytvořit nový kalendář
+ Zobrazit možnosti vytvoření
+ (bez názvu)
+ Celodenní
+ ✓ Účastním se
+ \? Možná
+ ✗ Nemůžu
+ Název
+ Popis
+ Název je povinný.
+
+ - %1$d událost
+ - %1$d události
+ - %1$d událostí
+ - %1$d událostí
+
+ V tomto kalendáři zatím nejsou žádné události.
+ Účastním se
+ Možná
+ Nemůžu
+ RSVP (%1$d)
+ Zatím žádné RSVP.
+ Účastníci (%1$d)
+ V kalendářích (%1$d)
+ Zatím není součástí žádného kalendáře.
+ Načítání události…
+ Probíhá nyní
+ %1$s · končí %2$s
+ Sdílet událost kalendáře
+ Exportovat do kalendáře (.ics)
+ Připomenutí kalendáře
+ Upozornění, když je událost, které se účastníte, blízko začátku.
+ Událost kalendáře
+
+ - Začíná za %1$d minutu
+ - Začíná za %1$d minuty
+ - Začíná za %1$d minut
+ - Začíná za %1$d minut
+
+ Přidat do jednoho z vašich kalendářů
+ Přidat do kalendáře
+ Zatím jste nevytvořili žádný kalendář.
+ Smazat kalendář
+ Smazat tento kalendář?
+ Kalendář bude odstraněn. Události v něm nebudou smazány.
+ Vybrat obrázek
+ Nahrání obrázku selhalo
+ Vybraný obrázek se nepodařilo nahrát. Zkuste to znovu nebo vložte URL.
+ Účastníci (%1$d)
+ Hledat jméno, npub nebo nip-05
+ Zadejte platný npub… nebo 64znakový hex pubkey.
+ Odebrat účastníka
+ Připomenutí kalendáře
+ Posílat připomenutí
+ Oznámení se spustí, když je událost, které se účastníte, blízko začátku.
+ Doba předstihu připomenutí
+ Kolik minut před událostí chcete být upozorněni.
+ Sdílet jako Nostr odkaz
+ Sdílet odkaz na kalendář
+ Všechny kalendáře
+ Zobrazit události z…
+ Zatím jste nevytvořili žádný kalendář.
+ Otevřít v mapách
+ Otevřít odkaz
+ Detail události
Nové krátké video
Nové dlouhé video
Název
@@ -1689,6 +1828,7 @@
Zvýšit nebo citovat
Olajkovat
Zap
+ Čeká na potvrzení
Změnit rychlé reakce
Spodní navigační lišta
Přetažením změníte pořadí. Přepnutím položku přidáte nebo odeberete ze spodní lišty. S nulovým počtem položek bude lišta skryta.
@@ -2040,6 +2180,9 @@
Odebrat uživatele ze seznamu
Balíček Sledování
Členové
+ Seznam sledování (%1$d):
+ Seznam sledování
+ Seznam sledování (%1$d)
Metadata seznamu sledování
Metadata seznamů sledování mohou vidět všichni na Nostr. Pouze vaši soukromí členové jsou šifrovaní.
Metadata sady doporučení
diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml
index 62f11d5819..cd69d1a59f 100644
--- a/amethyst/src/main/res/values-de-rDE/strings.xml
+++ b/amethyst/src/main/res/values-de-rDE/strings.xml
@@ -556,6 +556,7 @@ anz der Bedingungen ist erforderlich
Wähle aus, welche deiner erhaltenen Abzeichen auf deinem Profil erscheinen sollen.
Du hast noch keine Abzeichen erhalten.
Bilder
+ Kalender
Kurzvideos
Öffentliche Chats
Follow-Pakete
@@ -788,6 +789,10 @@ anz der Bedingungen ist erforderlich
Erweitert: Verbindungsdetails manuell eingeben
Schnelle Zap-Beträge
Wird beim Drücken der Zap-Schaltfläche angezeigt. Tippe auf einen Betrag, um ihn zu entfernen. Wenn du es leer lässt, wird jedes Mal ein Dialog zur Eingabe eines Betrags geöffnet.
+ Schnelle On-Chain-Zap-Beträge
+ On-Chain-Zaps zahlen Miner-Gebühren, daher sind die Beträge meist größer als bei Lightning. Tippe auf einen Betrag, um ihn zu entfernen.
+ Neuer On-Chain-Betrag in Sats
+ Stattdessen On-Chain senden
Zap-Datenschutz
Legt fest, wie deine Identität beim Senden eines Zaps angezeigt wird.
Wallet verbinden
@@ -1597,6 +1602,8 @@ anz der Bedingungen ist erforderlich
Global
Kurzfilme
Bilder
+ Kalender
+ Kalenderlisten
Schach
Wallet
Guthaben
@@ -1617,6 +1624,7 @@ anz der Bedingungen ist erforderlich
Rechnung wird erstellt…
Rechnung kopieren
Noch keine Transaktionen
+ Keine Transaktionen entsprechen diesem Filter
Wird geladen…
Empfangen
Gesendet
@@ -1645,6 +1653,15 @@ anz der Bedingungen ist erforderlich
Ungültige NWC-Verbindungs-URI
Nach oben
Nach unten
+ On-Chain-Transaktionen
+ Keine On-Chain-Adresse für dieses Konto verfügbar.
+ Kein On-Chain-Backend konfiguriert.
+ Ausstehend
+ Öffentlich
+ Diese Wallet ist öffentlich
+ Deine Taproot-Adresse wird aus deinem öffentlichen Nostr-Schlüssel abgeleitet, daher kann jeder, der deinen npub kennt, den Saldo und die Transaktionshistorie dieser Wallet in der Blockchain einsehen.\n\nUm deine Privatsphäre zu wahren, lade diese Wallet von nicht-privaten Konten (z. B. Börsen) auf und sende Mittel auch nur an solche Konten zurück. Vermische diese Mittel niemals mit deinen Cold Wallets und behandle sie wie Geld, das du verlieren kannst, da jeder, der die Kontrolle über deinen nsec hat, die Mittel ausgeben kann.
+ Verstanden
+ Adresse kopieren
Sicherheitsfilter
Folgeliste importieren
Neuer Beitrag
@@ -1657,6 +1674,124 @@ anz der Bedingungen ist erforderlich
Neue Zap-Umfrage
Neue reguläre Umfrage
Neues Bild
+ Neuer Kalendertermin
+ Kalendertermin bearbeiten
+ Während der Bearbeitung gesperrt — eine Änderung würde stattdessen einen neuen Termin erstellen.
+ Neuer Kalender
+ Kalender bearbeiten
+ Termine in diesem Kalender (%1$d)
+ Du hast noch keine Kalendertermine erstellt.
+ Monat
+ Woche
+ Tag
+ Bevorstehend
+ Vergangen
+ Noch keine bevorstehenden oder vergangenen Kalendertermine aus deinem ausgewählten Feed.
+ Noch keine Kalenderlisten.
+ Dein Kalender ist leer
+ Termine, die von Personen geteilt werden, denen du folgst, erscheinen hier. Tippe auf +, um eigene zu erstellen.
+ Noch keine Listen
+ Gruppiere Termine — eine Meetup-Reihe, ein Konferenz-Track, die Roadmap deines Teams. Tippe auf +, um eine zu erstellen.
+ Nichts geplant
+ Keine Termine an diesem Tag. Tippe auf +, um einen hinzuzufügen.
+ Nichts diese Woche
+ Keine Termine in dieser Woche.
+ Titel
+ Zusammenfassung
+ Ort
+ Bild-URL
+ Ganztägiger Termin
+ Beginnt
+ Endet
+ Hashtags (durch Komma getrennt)
+ Datum wählen
+ Zeit wählen
+ Titel und Start sind erforderlich.
+ Ende muss nach Beginn sein.
+ Vorheriger Monat
+ Nächster Monat
+ Vorherige Woche
+ Nächste Woche
+ Vorheriger Tag
+ Nächster Tag
+ Keine Termine an diesem Tag
+ Keine Termine
+ Geht weiter
+ Tag %1$d von %2$d
+ Zum Telefonkalender hinzufügen
+ Zu heute springen
+
+ - %1$s, %2$d Termin
+ - %1$s, %2$d Termine
+
+ %1$s, keine Termine
+ heute
+ ausgewählt
+ Neuen Kalendertermin erstellen
+ Neuen Kalender erstellen
+ Erstellungsoptionen anzeigen
+ (ohne Titel)
+ Ganztägig
+ ✓ Komme
+ \? Vielleicht
+ ✗ Komme nicht
+ Titel
+ Beschreibung
+ Ein Titel ist erforderlich.
+
+ - %1$d Termin
+ - %1$d Termine
+
+ Noch keine Termine in diesem Kalender.
+ Komme
+ Vielleicht
+ Komme nicht
+ Noch keine Antworten.
+ Teilnehmer (%1$d)
+ In Kalendern (%1$d)
+ Noch nicht Teil eines Kalenders.
+ Termin wird geladen…
+ Findet jetzt statt
+ %1$s · endet %2$s
+ Kalendertermin teilen
+ In Kalender exportieren (.ics)
+ Kalendererinnerungen
+ Hinweis, wenn ein Termin, an dem du teilnimmst, bald beginnt.
+ Kalendertermin
+
+ - Beginnt in %1$d Minute
+ - Beginnt in %1$d Minuten
+
+ Zu einem deiner Kalender hinzufügen
+ Zu einem Kalender hinzufügen
+ Du hast noch keine Kalender erstellt.
+ Kalender löschen
+ Diesen Kalender löschen?
+ Die Kalenderliste wird entfernt. Termine darin werden nicht gelöscht.
+ Bild wählen
+ Bild-Upload fehlgeschlagen
+ Das gewählte Bild konnte nicht hochgeladen werden. Versuche es erneut oder füge eine URL ein.
+ Teilnehmer (%1$d)
+ Name, npub oder nip-05 suchen
+ Gib einen gültigen npub… oder 64-stelligen hex pubkey ein.
+ Teilnehmer entfernen
+ Kalendererinnerungen
+ Erinnerungen senden
+ Eine Benachrichtigung wird angezeigt, wenn ein Termin, an dem du teilnimmst, bald beginnt.
+ Vorlaufzeit der Erinnerung
+ Wie viele Minuten vor dem Termin du benachrichtigt werden möchtest.
+
+ - %1$d Min.
+ - %1$d Min.
+
+ Als Nostr-Link teilen
+ Kalender-Link teilen
+ Alle Kalender
+ Termine anzeigen aus…
+ Du hast noch keine Kalender erstellt.
+ In Karten öffnen
+ Link öffnen
+ Termindetails
Neues kurzes Video
Neues langes Video
Titel
@@ -1680,6 +1815,8 @@ anz der Bedingungen ist erforderlich
Boosten oder Zitieren
Gefällt mir
Zap
+ On-Chain-Bitcoin-Zap
+ Wartet auf Bestätigung
Schnelle Reaktionen ändern
Untere Navigationsleiste
Zum Umordnen ziehen. Zum Hinzufügen oder Entfernen eines Elements aus der unteren Leiste umschalten. Mit null Elementen wird die untere Leiste ausgeblendet.
@@ -2029,6 +2166,9 @@ anz der Bedingungen ist erforderlich
Benutzer aus der Liste entfernen
Folge Paket
Mitglieder
+ Folgenliste (%1$d Nutzer):
+ Folgenliste
+ Folgenliste (%1$d)
Metadaten der Follow-Liste
Metadaten von Follow-Listen können von allen auf Nostr gesehen werden. Nur deine privaten Mitglieder sind verschlüsselt.
Metadaten des Empfehlungspakets
diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml
index a4c3d8e31c..7923c6eaf5 100644
--- a/amethyst/src/main/res/values-es-rES/strings.xml
+++ b/amethyst/src/main/res/values-es-rES/strings.xml
@@ -8,6 +8,10 @@
Mostrar de todos modos
Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas
Post marcado como inapropiado por
+
+ - Esta publicación tiene más de %1$d hashtag
+ - Esta publicación tiene más de %1$d hashtags
+
El evento se está cargando o no se puede encontrar en la lista de relés
👀
Imagen del canal
@@ -30,6 +34,7 @@
Copiar texto
Copiar PubKey del usuario
Copiar ID de la nota
+ Copiar JSON sin procesar
Transmisión
Poner marca de tiempo
Marca de tiempo: confirmaciones pendientes
@@ -49,6 +54,10 @@
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones.
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para indicar que te gustan las publicaciones.
Falta la configuración de zaps por defecto. Mantén pulsado unos segundos para cambiarla
+ zapeó %1$s sats
+ Anónimo
+ está en una incursión
+ creó un clip
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder enviar zaps.
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder seguir a otros usuarios.
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder dejar de seguir a otros usuarios.
@@ -153,6 +162,8 @@
Pronombres
Dirección LN
Dirección LN (antigua)
+ Los relés de origen pueden ser obsoletos
+ Aviso de relé obsoleto
Guardar en el teléfono
Guardar en la galería
Imagen guardada en la galería
@@ -161,6 +172,8 @@
Error al guardar la imagen
Video guardado en la galería de videos del teléfono
Error al guardar el video
+ PDF guardado en Descargas/Amethyst
+ Error al guardar el PDF
Cargar imagen
Cargar archivo
Hacer una foto
@@ -270,6 +283,8 @@
Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros
y, por lo tanto, los mensajes del chat desaparecen con el tiempo
Chat público
+ Grupo MLS
+ Aún no hay mensajes
Metadatos públicos del chat
Los chats públicos son visibles para todos en Nostr y cualquiera
puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos.
@@ -334,6 +349,11 @@
"<Unable to decrypt private message>\n\nTe citaron en una conversación privada o encriptada entre %1$s y %2$s."
Agregar cuenta nueva
Cuentas
+ Navegar
+ Tú
+ Tablones
+ Crear
+ Sistema
Seleccionar cuenta
Agregar cuenta nueva
Cuenta activa
@@ -356,6 +376,8 @@
Bloquear
Eliminar
Bloquear
+ Silenciar hilo
+ Reactivar hilo
Reportar
Eliminar
No mostrar de nuevo
@@ -385,9 +407,49 @@
Mover todo a marcadores nuevos
Los marcadores se migraron correctamente
Borradores
+ Publicaciones programadas
+ Programar
+ Hora programada
+ Las publicaciones se realizan en ~15 minutos a partir de la hora programada.
+ Elegir hora programada
+ Programación para…
+ Se publica en %1$s
+ Se venció hace %1$s
+ Hora
+ Programar publicación
+ Cancelar programación
+ Las notificaciones siempre activas están deshabilitadas
+ Las publicaciones programadas pueden no realizarse hasta que vuelvas a abrir la aplicación. Habilita la activación en \"Configuración → Preferencias de interfaz de usuario\" para garantizar una programación en segundo plano fiable.
+ Las publicaciones programadas pueden no realizarse hasta que vuelvas a abrir la aplicación. Las publicaciones programadas de otras cuentas no se activarán mientras esta cuenta esté activa. Habilita la activación en \"Configuración → Preferencias de interfaz de usuario\" para garantizar una programación en segundo plano fiable.
+ En 1 hora
+ Mañana a las 9 a. m.
+ El próximo lunes a las 9 a. m.
+ ¿Habilitar notificaciones siempre activas?
+ Las publicaciones programadas solo se realizan de forma fiable cuando las notificaciones siempre activas están habilitadas. De lo contrario, es posible que no se activen hasta que vuelvas a abrir la app.
+ Abrir configuración
+ Continuar de todos modos
+ %1$s · en %2$s
+ %1$s · hace %2$s
+ Mañana
+ Sesión cerrada
+
+ - Sesión cerrada · %d publicación programada eliminada
+ - Sesión cerrada · %d publicaciones programadas eliminadas
+
+ Se realizó la publicación programada
+ Fallo en la publicación programada
+ Publicaciones programadas
+ Notificaciones cuando una publicación programada se realiza o no.
+ Enviar ahora
Encuestas
Abiertas
Cerradas
+ Guardar límites
+ Usuarios bloqueados
+ Estas claves públicas no pueden publicar en la comunidad.
+ Agregar un usuario bloqueado
+ Nombre, npub, o NIP-05
+ Portal de red de confianza (opcional)
Imágenes
Cortos
Videos
diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml
index e5c28cb02a..be12d8f895 100644
--- a/amethyst/src/main/res/values-es-rMX/strings.xml
+++ b/amethyst/src/main/res/values-es-rMX/strings.xml
@@ -8,6 +8,10 @@
Mostrar de todos modos
Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas
La publicación fue reportada por
+
+ - Esta publicación tiene más de %1$d hashtag
+ - Esta publicación tiene más de %1$d hashtags
+
El evento se está cargando o no se puede encontrar en la lista de relés
👀
Imagen del canal
@@ -30,6 +34,7 @@
Copiar texto
Copiar llave pública del usuario
Copiar ID de la nota
+ Copiar JSON sin procesar
Transmisión
Poner marca de tiempo
Marca de tiempo: confirmaciones pendientes
@@ -49,6 +54,10 @@
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para indicar que te gustan las publicaciones.
No has configurado la cantidad de zaps. Mantén presionado el botón para cambiarla.
+ zapeó %1$s sats
+ Anónimo
+ está en una incursión
+ creó un clip
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder enviar zaps.
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder seguir a otros usuarios.
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder dejar de seguir a otros usuarios.
@@ -152,6 +161,8 @@
Pronombres
Dirección de Lightning
URL de Lightning (obsoleta)
+ Los relés de origen pueden ser obsoletos
+ Aviso de relé obsoleto
Guardar en el teléfono
Guardar en la galería
Imagen guardada en la galería
@@ -160,6 +171,8 @@
Error al guardar la imagen
Video guardado en la galería de videos del teléfono
Error al guardar el video
+ PDF guardado en Descargas/Amethyst
+ Error al guardar el PDF
Subir imagen
Subir archivo
Tomar una foto
@@ -266,6 +279,8 @@
Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros
y, por lo tanto, los mensajes del chat desaparecen con el tiempo
Chat público
+ Grupo MLS
+ Aún no hay mensajes
Metadatos públicos del chat
Los chats públicos son visibles para todos en Nostr y cualquiera
puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos.
@@ -330,6 +345,11 @@
"<Unable to decrypt private message>\n\nTe citaron en una conversación privada o encriptada entre %1$s y %2$s."
Agregar cuenta nueva
Cuentas
+ Navegar
+ Tú
+ Tablones
+ Crear
+ Sistema
Seleccionar cuenta
Agregar cuenta nueva
Cuenta activa
@@ -352,6 +372,8 @@
Bloquear
Eliminar
Bloquear
+ Silenciar hilo
+ Reactivar hilo
Reportar
Eliminar
No mostrar de nuevo
@@ -381,9 +403,49 @@
Mover todo a marcadores nuevos
Los marcadores se migraron correctamente
Borradores
+ Publicaciones programadas
+ Programar
+ Hora programada
+ Las publicaciones se realizan en ~15 minutos a partir de la hora programada.
+ Elegir hora programada
+ Programación para…
+ Se publica en %1$s
+ Se venció hace %1$s
+ Hora
+ Programar publicación
+ Cancelar programación
+ Las notificaciones siempre activas están deshabilitadas
+ Las publicaciones programadas pueden no realizarse hasta que vuelvas a abrir la aplicación. Habilita la activación en \"Configuración → Preferencias de interfaz de usuario\" para garantizar una programación en segundo plano fiable.
+ Las publicaciones programadas pueden no realizarse hasta que vuelvas a abrir la aplicación. Las publicaciones programadas de otras cuentas no se activarán mientras esta cuenta esté activa. Habilita la activación en \"Configuración → Preferencias de interfaz de usuario\" para garantizar una programación en segundo plano fiable.
+ En 1 hora
+ Mañana a las 9 a. m.
+ El próximo lunes a las 9 a. m.
+ ¿Habilitar notificaciones siempre activas?
+ Las publicaciones programadas solo se realizan de forma fiable cuando las notificaciones siempre activas están habilitadas. De lo contrario, es posible que no se activen hasta que vuelvas a abrir la app.
+ Abrir configuración
+ Continuar de todos modos
+ %1$s · en %2$s
+ %1$s · hace %2$s
+ Mañana
+ Sesión cerrada
+
+ - Sesión cerrada · %d publicación programada eliminada
+ - Sesión cerrada · %d publicaciones programadas eliminadas
+
+ Se realizó la publicación programada
+ Fallo en la publicación programada
+ Publicaciones programadas
+ Notificaciones cuando una publicación programada se realiza o no.
+ Enviar ahora
Encuestas
Abiertas
Cerradas
+ Guardar límites
+ Usuarios bloqueados
+ Estas claves públicas no pueden publicar en la comunidad.
+ Agregar un usuario bloqueado
+ Nombre, npub, o NIP-05
+ Portal de red de confianza (opcional)
Imágenes
Cortos
Videos
diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml
index 52dad5014a..9bd1f0e5e2 100644
--- a/amethyst/src/main/res/values-es-rUS/strings.xml
+++ b/amethyst/src/main/res/values-es-rUS/strings.xml
@@ -8,6 +8,10 @@
Mostrar de todos modos
Esta publicación se ocultó porque menciona tus usuarios o palabras ocultas
La publicación fue reportada por
+
+ - Esta publicación tiene más de %1$d hashtag
+ - Esta publicación tiene más de %1$d hashtags
+
El evento se está cargando o no se puede encontrar en la lista de relés
👀
Imagen del canal
@@ -30,6 +34,7 @@
Copiar texto
Copiar ID del autor
Copiar ID de la nota
+ Copiar JSON sin procesar
Transmisión
Poner marca de tiempo
Marca de tiempo: confirmaciones pendientes
@@ -49,6 +54,10 @@
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder impulsar publicaciones
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para indicar que te gustan las publicaciones.
No has configurado la cantidad de zaps. Mantén presionado el botón para cambiarla.
+ zapeó %1$s sats
+ Anónimo
+ está en una incursión
+ creó un clip
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder enviar zaps.
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder seguir a otros usuarios.
Estás usando una clave pública, que es solo de lectura. Inicia sesión con una clave privada para poder dejar de seguir a otros usuarios.
@@ -152,6 +161,8 @@
Pronombres
Dirección de Lightning
URL de Lightning (obsoleta)
+ Los relés de origen pueden ser obsoletos
+ Aviso de relé obsoleto
Guardar en el teléfono
Guardar en la galería
Imagen guardada en la galería
@@ -160,6 +171,8 @@
Error al guardar la imagen
Video guardado en la galería de videos del teléfono
Error al guardar el video
+ PDF guardado en Descargas/Amethyst
+ Error al guardar el PDF
Subir imagen
Subir archivo
Tomar una foto
@@ -266,6 +279,8 @@
Son ideales para comunidades abiertas en torno a temas específicos. Algunos de estos grupos son efímeros
y, por lo tanto, los mensajes del chat desaparecen con el tiempo
Chat público
+ Grupo MLS
+ Aún no hay mensajes
Metadatos públicos del chat
Los chats públicos son visibles para todos en Nostr y cualquiera
puede participar en ellos. Son ideales para comunidades abiertas alrededor de temas específicos.
@@ -330,6 +345,11 @@
"<No se pudo desencriptar el mensaje privado>\n\nTe citaron en una conversación privada o encriptada entre %1$s y %2$s."
Agregar cuenta nueva
Cuentas
+ Navegar
+ Tú
+ Tablones
+ Crear
+ Sistema
Seleccionar cuenta
Agregar cuenta nueva
Cuenta activa
@@ -352,6 +372,8 @@
Bloquear
Eliminar
Bloquear
+ Silenciar hilo
+ Reactivar hilo
Reportar
Eliminar
No mostrar de nuevo
@@ -381,9 +403,49 @@
Mover todo a marcadores nuevos
Los marcadores se migraron correctamente
Borradores
+ Publicaciones programadas
+ Programar
+ Hora programada
+ Las publicaciones se realizan en ~15 minutos a partir de la hora programada.
+ Elegir hora programada
+ Programación para…
+ Se publica en %1$s
+ Se venció hace %1$s
+ Hora
+ Programar publicación
+ Cancelar programación
+ Las notificaciones siempre activas están deshabilitadas
+ Las publicaciones programadas pueden no realizarse hasta que vuelvas a abrir la aplicación. Habilita la activación en \"Configuración → Preferencias de interfaz de usuario\" para garantizar una programación en segundo plano fiable.
+ Las publicaciones programadas pueden no realizarse hasta que vuelvas a abrir la aplicación. Las publicaciones programadas de otras cuentas no se activarán mientras esta cuenta esté activa. Habilita la activación en \"Configuración → Preferencias de interfaz de usuario\" para garantizar una programación en segundo plano fiable.
+ En 1 hora
+ Mañana a las 9 a. m.
+ El próximo lunes a las 9 a. m.
+ ¿Habilitar notificaciones siempre activas?
+ Las publicaciones programadas solo se realizan de forma fiable cuando las notificaciones siempre activas están habilitadas. De lo contrario, es posible que no se activen hasta que vuelvas a abrir la app.
+ Abrir configuración
+ Continuar de todos modos
+ %1$s · en %2$s
+ %1$s · hace %2$s
+ Mañana
+ Sesión cerrada
+
+ - Sesión cerrada · %d publicación programada eliminada
+ - Sesión cerrada · %d publicaciones programadas eliminadas
+
+ Se realizó la publicación programada
+ Fallo en la publicación programada
+ Publicaciones programadas
+ Notificaciones cuando una publicación programada se realiza o no.
+ Enviar ahora
Encuestas
Abiertas
Cerradas
+ Guardar límites
+ Usuarios bloqueados
+ Estas claves públicas no pueden publicar en la comunidad.
+ Agregar un usuario bloqueado
+ Nombre, npub, o NIP-05
+ Portal de red de confianza (opcional)
Imágenes
Cortos
Videos
diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml
index 5f3a40e45e..96415b3792 100644
--- a/amethyst/src/main/res/values-hu-rHU/strings.xml
+++ b/amethyst/src/main/res/values-hu-rHU/strings.xml
@@ -16,7 +16,7 @@
👀
Csatorna profilképe
A hivatkozott esemény nem található
- Nem sikerült az üzenetet visszafejteni
+ Nem sikerült visszafejteni az üzenetet
Csoport profilképe
Szókimondó tartalom
Átjátszóval kapcsolatos megjegyzések
@@ -785,6 +785,10 @@
Speciális: kapcsolat részleteinek kézi megadása
Gyors Zap-összegek
A zap gomb megnyomásakor jelenik meg. Érintse meg az összeget, hogy eltávolítsa. Ha üresen hagyja, akkor minden alkalommal megnyílik a párbeszédablak az összeg beírásához.
+ Gyors láncon belüli Zap összegek
+ A láncon belüli Zapek bányászdíjat fizetnek, ezért az összegek általában nagyobbak, mint Lightning esetén. Koppintson egy összegre a eltávolításához.
+ Új láncon belüli összeg Satoshiban
+ Küldés inkább láncon belül
Zap adatvédelem
Állítsa be, hogy hogyan jelenjen meg a személyazonossága a zap elküldésekor.
Pénztárca összekapcsolása
@@ -1454,8 +1458,8 @@
Profilkép
Profilképek megjelenítése
Válasszon egy lehetőséget
- Sikertelen számla-kifizetés
- Sikertelen pénzkivétel a pénztárcából
+ Nem sikerült kifizetni a számlát
+ Nem sikerült a pénzkivétel a pénztárcából
Nem sikerült beállítani a Wallet Connectet
Hiba a NIP-47 kapcsolati karakterlánc elemzésekor. Ellenőrizze, hogy a pénztárca-szolgáltatónál a következő helyes-e: %1$s. Hiba: %2$s
Hiba a NIP-47 kapcsolati karakterlánc elemzésekor. Ellenőrizze, hogy a pénztárca-szolgáltatónál a következő helyes-e: %1$s.
@@ -1653,6 +1657,11 @@
Ehhez a fiókhoz nincs elérhető láncon belüli cím.
Nincs beállítva lánc-háttérprogram.
Függőben
+ Nyilvános
+ Ez a pénztárca nyilvános
+ A Taproot-cím az Ön Nostr nyilvános kulcsából van levezetve, ezért bárki, aki ismeri az npub-ot, láthatja a pénztárca egyenlegét és tranzakciótörténetét a blokkláncon.\n\nAz adatvédelem megőrzése érdekében ezt a pénztárcát kizárólag nem privát számlákról, például kriptovaluta-váltókról töltse fel és ürítse ki. Soha ne keverje ezeket az összegeket a hideg pénztárcákkal, és olyan pénzként kezelje őket, amelyet elveszíthet, mivel bárki, aki hozzáfér az nsec-hez, elköltheti.
+ Megértettem
+ Cím másolása
Biztonsági szűrők
Követettek importálása
Új bejegyzés
@@ -1716,6 +1725,7 @@
- %1$s, %2$d esemény
- %1$s, %2$d esemény
+ %1$s, nincsenek események
ma
kiválasztva
Új naptáresemény létrehozása
@@ -1729,6 +1739,10 @@
Cím
Leírás
A cím megadása kötelező.
+
+ - %1$d esemény
+ - %1$d esemény
+
Ebben a naptárban még nincsenek események.
Ott leszek
Talán
@@ -1747,6 +1761,10 @@
Naptár-emlékeztetők
Értesítés, ha egy esemény, amin Ön részt vesz, hamarosan elkezdődik.
Naptáresemény
+
+ - %1$d perc múlva kezdődik
+ - %1$d perc múlva kezdődik
+
Vegye fel az egyik naptárba
Hozzáadás egy naptárhoz
Ön még nem hozott létre egyetlen naptárat sem.
@@ -2153,6 +2171,9 @@
Felhasználó törlése a listáról
Követési csomag
Tagok
+ A követési lista %1$d felhasználót tartalmaz:
+ Követési lista
+ Követési lista (%1$d)
Követési lista metaadatai
A követési listák metaadatai a Nostr-on bárki számára láthatók. Csak a privát tagok adatai vannak titkosítva.
Követési csomag metaadatai
diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml
index b3e0eaac67..5f9ee02a4d 100644
--- a/amethyst/src/main/res/values-pl-rPL/strings.xml
+++ b/amethyst/src/main/res/values-pl-rPL/strings.xml
@@ -797,6 +797,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
Zaawansowane: wprowadź szczegóły połączenia ręcznie
Kwoty Quick Zap
Wyświetlane po naciśnięciu przycisku zap. Dotknij kwoty, aby ją usunąć. Jeśli pozostawisz to pole puste, za każdym razem otworzy się okno dialogowe umożliwiające wprowadzenie kwoty.
+ Szybkie kwoty zapoów On-chain
+ Transakcje on-chain są obciążone opłatami minerów, więc kwoty są zazwyczaj wyższe niż w sieci Lightning. Kliknij kwotę, aby ją usunąć.
+ Nowa kwota on-chain w satoszach
+ Zamiast tego wyślij on-chain
Prywatność Zap
Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa.
Podłącz portfel
@@ -1669,6 +1673,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
Ten portfel jest publiczny
Twój adres Taproot jest generowany na podstawie Twojego klucza publicznego Nostr, więc każdy, kto zna Twój klucz npub, może sprawdzić saldo tego portfela oraz historię transakcji w łańcuchu bloków.\n\nAby zachować prywatność, zasilaj ten portfel i wypłacaj z niego środki wyłącznie z kont publicznych, takich jak giełdy. Nigdy nie mieszaj tych środków ze środkami z portfeli offline i traktuj je jako pieniądze, które możesz stracić, ponieważ każdy, kto ma dostęp do Twojego klucza nsec, może je wydać.
Rozumiem
+ Skopiuj adres
Filtry bezpieczeństwa
Import Obserwowanych
Nowy post
@@ -1734,6 +1739,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
- %1$s, wydarzeń %2$d
- %1$s, %2$d wydarzeń
+ %1$s, brak wydarzeń
dziś
zaznaczone
Utwórz nowe wydarzenie kalendarza
@@ -2173,6 +2179,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
Usuń użytkownika z listy
Kategoria profili
Uczestnicy
+ Lista obserwowanych zawiera %1$d użytkownika(ów):
+ Lista obserwowanych
+ Lista obserwowanych (%1$d)
Metadane listy profili
Metadane listy obserwowanych są widoczne dla wszystkich użytkowników w sieci Nostr. Tylko Twoi uczestnicy prywatni są zaszyfrowani.
Metadane pakietu subskrybentów
diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml
index 7d60e1d9b4..955e8373ed 100644
--- a/amethyst/src/main/res/values-pt-rBR/strings.xml
+++ b/amethyst/src/main/res/values-pt-rBR/strings.xml
@@ -550,6 +550,7 @@
Escolha quais dos selos que você recebeu aparecerão no seu perfil.
Você ainda não recebeu nenhum selo.
Imagens
+ Calendários
Curtas
Chats públicos
Pacotes de seguidos
@@ -782,6 +783,10 @@
Avançado: inserir detalhes de conexão manualmente
Valores Rápidos de Zap
Exibido ao pressionar o botão de zap. Toque em um valor para removê-lo. Se deixar vazio, abrirá o diálogo para inserir um valor todas as vezes.
+ Valores rápidos de zap on-chain
+ Zaps on-chain pagam taxas de mineradores, por isso os valores costumam ser maiores que no Lightning. Toque num valor para removê-lo.
+ Novo valor on-chain em sats
+ Enviar on-chain em vez disso
Privacidade do Zap
Controla como sua identidade é exibida ao enviar um Zap.
Conectar Carteira
@@ -1592,6 +1597,8 @@
Global
Vídeos Curtos
Imagens
+ Calendários
+ Listas de calendário
Xadrez
Carteira
Saldo
@@ -1612,6 +1619,7 @@
Criando fatura…
Copiar Fatura
Nenhuma transação ainda
+ Nenhuma transação corresponde a este filtro
Carregando…
Recebido
Enviado
@@ -1640,6 +1648,15 @@
URI de conexão NWC inválida
Mover para cima
Mover para baixo
+ Transações on-chain
+ Nenhum endereço on-chain disponível para esta conta.
+ Nenhum backend on-chain configurado.
+ Pendente
+ Pública
+ Esta carteira é pública
+ Seu endereço Taproot é derivado da sua chave pública Nostr, então qualquer pessoa que conheça seu npub pode ver o saldo e o histórico de transações desta carteira no blockchain.\n\nPara preservar sua privacidade, abasteça esta carteira a partir de contas não privadas (como corretoras) e envie os fundos de volta para elas. Nunca misture esses fundos com suas carteiras frias e trate-os como dinheiro que você pode perder, pois qualquer um que controle seu nsec pode gastar os fundos.
+ Entendi
+ Copiar endereço
Filtros de Segurança
Importar Seguidos
Novo Post
@@ -1652,6 +1669,120 @@
Nova Enquete Zap
Nova Enquete Regular
Nova Foto
+ Novo evento de calendário
+ Editar evento de calendário
+ Bloqueado durante edição — alterar isto criaria um novo evento.
+ Novo calendário
+ Editar calendário
+ Eventos neste calendário (%1$d)
+ Você ainda não criou nenhum evento de calendário.
+ Mês
+ Semana
+ Dia
+ Próximos
+ Passados
+ Ainda não há eventos de calendário (futuros ou passados) do feed selecionado.
+ Ainda não há listas de calendário.
+ Seu calendário está vazio
+ Eventos compartilhados por pessoas que você segue aparecem aqui. Toque no botão + para criar os seus.
+ Ainda não há listas
+ Agrupe eventos — uma série de encontros, uma trilha de conferência, o roteiro da sua equipe. Toque em + para criar uma.
+ Nada agendado
+ Sem eventos neste dia. Toque em + para adicionar um.
+ Nada esta semana
+ Nenhum evento nesta semana.
+ Título
+ Resumo
+ Local
+ URL da imagem
+ Evento de dia inteiro
+ Começa
+ Termina
+ Hashtags (separadas por vírgula)
+ Escolher data
+ Escolher hora
+ Título e início são obrigatórios.
+ O término deve ser após o início.
+ Mês anterior
+ Próximo mês
+ Semana anterior
+ Próxima semana
+ Dia anterior
+ Próximo dia
+ Sem eventos neste dia
+ Sem eventos
+ Continua
+ Dia %1$d de %2$d
+ Adicionar ao calendário do telefone
+ Ir para hoje
+
+ - %1$s, %2$d evento
+ - %1$s, %2$d eventos
+
+ %1$s, sem eventos
+ hoje
+ selecionado
+ Criar um novo evento de calendário
+ Criar um novo calendário
+ Mostrar opções de criação
+ (sem título)
+ Dia inteiro
+ ✓ Vou
+ \? Talvez
+ ✗ Não vou
+ Título
+ Descrição
+ Um título é obrigatório.
+
+ - %1$d evento
+ - %1$d eventos
+
+ Nenhum evento neste calendário ainda.
+ Vou
+ Talvez
+ Não vou
+ Sem RSVPs ainda.
+ Participantes (%1$d)
+ Em calendários (%1$d)
+ Ainda não faz parte de nenhum calendário.
+ Carregando evento…
+ Acontecendo agora
+ %1$s · termina %2$s
+ Compartilhar evento de calendário
+ Exportar para calendário (.ics)
+ Lembretes de calendário
+ Aviso quando um evento do qual você participa está prestes a começar.
+ Evento de calendário
+
+ - Começa em %1$d minuto
+ - Começa em %1$d minutos
+
+ Adicionar a um dos seus calendários
+ Adicionar a um calendário
+ Você ainda não criou nenhum calendário.
+ Excluir calendário
+ Excluir este calendário?
+ O calendário será removido. Os eventos dentro dele não serão excluídos.
+ Escolher imagem
+ Falha no envio da imagem
+ A imagem escolhida não pôde ser enviada. Tente novamente ou cole uma URL.
+ Participantes (%1$d)
+ Buscar nome, npub ou nip-05
+ Insira um npub… válido ou pubkey hex de 64 caracteres.
+ Remover participante
+ Lembretes de calendário
+ Enviar lembretes
+ Uma notificação é disparada quando um evento do qual você participa está prestes a começar.
+ Antecedência do lembrete
+ Quantos minutos antes do evento você quer ser notificado.
+ Compartilhar como link Nostr
+ Compartilhar link do calendário
+ Todos os calendários
+ Mostrar eventos de…
+ Você ainda não criou nenhum calendário.
+ Abrir no mapa
+ Abrir link
+ Detalhes do evento
Novo Vídeo Curto
Novo Vídeo Longo
Título
@@ -1675,6 +1806,8 @@
Impulsionar ou Citar
Gostar
Zap
+ Zap Bitcoin on-chain
+ Aguardando confirmação
Mudar reações rápidas
Barra de navegação inferior
Arraste para reordenar. Alterne para adicionar ou remover um item da barra inferior. Com zero itens, a barra inferior fica oculta.
@@ -2024,6 +2157,9 @@
Remover usuário da lista
Pacote Seguir
Membros
+ Lista de seguidos contendo %1$d usuário(s):
+ Lista de seguidos
+ Lista de seguidos (%1$d)
Metadados da lista de seguidores
Os metadados das listas de seguidores podem ser vistos por qualquer pessoa no Nostr. Apenas os membros privados são criptografados.
Metadados do pacote de recomendações
diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml
index 9d46d84fb3..010fb91c5a 100644
--- a/amethyst/src/main/res/values-sv-rSE/strings.xml
+++ b/amethyst/src/main/res/values-sv-rSE/strings.xml
@@ -550,6 +550,7 @@
Välj vilka av märkena du fått som ska visas på din profil.
Du har inte fått några märken ännu.
Bilder
+ Kalendrar
Kortfilmer
Offentliga chattar
Följpaket
@@ -782,6 +783,10 @@
Avancerat: ange anslutningsinformation manuellt
Snabba Zap-belopp
Visas när du trycker på Zap-knappen. Tryck på ett belopp för att ta bort det. Om du lämnar det tomt öppnas en dialog för att ange ett belopp varje gång.
+ Snabba on-chain-zapbelopp
+ On-chain-zappar betalar miner-avgifter, så beloppen är vanligtvis större än för Lightning. Tryck på ett belopp för att ta bort det.
+ Nytt on-chain-belopp i sats
+ Skicka on-chain istället
Zap-sekretess
Styr hur din identitet visas när du skickar ett Zap.
Anslut plånbok
@@ -1591,6 +1596,8 @@
Globalt
Kortfilmer
Bilder
+ Kalendrar
+ Kalenderlistor
Schack
Plånbok
Saldo
@@ -1611,6 +1618,7 @@
Skapar faktura…
Kopiera faktura
Inga transaktioner ännu
+ Inga transaktioner matchar detta filter
Laddar…
Mottagen
Skickad
@@ -1639,6 +1647,15 @@
Ogiltig URI för NWC-anslutning
Flytta upp
Flytta ner
+ On-chain-transaktioner
+ Ingen on-chain-adress tillgänglig för detta konto.
+ Ingen on-chain-backend är konfigurerad.
+ Väntar
+ Offentlig
+ Denna plånbok är offentlig
+ Din Taproot-adress härleds från din publika Nostr-nyckel, så alla som känner till din npub kan se denna plånboks saldo och transaktionshistorik på blockchainen.\n\nFör att bevara din integritet, fyll på denna plånbok från icke-privata konton (som börser) och skicka tillbaka medel dit. Blanda aldrig dessa medel med dina kalla plånböcker, och behandla dem som pengar du kan förlora, eftersom alla som har kontroll över din nsec kan spendera medlen.
+ Uppfattat
+ Kopiera adress
Säkerhetsfilter
Importera följare
Nytt inlägg
@@ -1651,6 +1668,122 @@
Ny zap-omröstning
Ny vanlig omröstning
Ny bild
+ Ny kalenderhändelse
+ Redigera kalenderhändelse
+ Låst under redigering — att ändra detta skulle skapa en ny händelse istället.
+ Ny kalender
+ Redigera kalender
+ Händelser i denna kalender (%1$d)
+ Du har inte skapat några kalenderhändelser än.
+ Flöde
+ Månad
+ Vecka
+ Dag
+ Kommande
+ Tidigare
+ Inga kommande eller tidigare kalenderhändelser från ditt valda flöde än.
+ Inga kalenderlistor än.
+ Din kalender är tom
+ Händelser delade av personer du följer dyker upp här. Tryck på +-knappen för att skapa egna.
+ Inga listor än
+ Gruppera händelser tillsammans — en träffserie, ett konferensspår, ditt teams färdplan. Tryck på + för att skapa en.
+ Inget schemalagt
+ Inga händelser denna dag. Tryck på + för att lägga till en.
+ Inget den här veckan
+ Inga händelser denna vecka.
+ Titel
+ Sammanfattning
+ Plats
+ Bild-URL
+ Heldagshändelse
+ Börjar
+ Slutar
+ Hashtaggar (kommaseparerade)
+ Välj datum
+ Välj tid
+ Titel och starttid krävs.
+ Slut måste vara efter start.
+ Föregående månad
+ Nästa månad
+ Föregående vecka
+ Nästa vecka
+ Föregående dag
+ Nästa dag
+ Inga händelser denna dag
+ Inga händelser
+ Fortsätter
+ Dag %1$d av %2$d
+ Lägg till i telefonens kalender
+ Hoppa till idag
+
+ - %1$s, %2$d händelse
+ - %1$s, %2$d händelser
+
+ %1$s, inga händelser
+ idag
+ vald
+ Skapa en ny kalenderhändelse
+ Skapa en ny kalender
+ Visa skapandealternativ
+ (utan titel)
+ Heldag
+ ✓ Kommer
+ \? Kanske
+ ✗ Kommer inte
+ Titel
+ Beskrivning
+ En titel krävs.
+
+ - %1$d händelse
+ - %1$d händelser
+
+ Inga händelser i denna kalender än.
+ Kommer
+ Kanske
+ Kommer inte
+ OSA (%1$d)
+ Inga OSA än.
+ Deltagare (%1$d)
+ I kalendrar (%1$d)
+ Inte del av någon kalender än.
+ Laddar händelse…
+ Pågår nu
+ %1$s · slutar %2$s
+ Dela kalenderhändelse
+ Exportera till kalender (.ics)
+ Kalenderpåminnelser
+ Påminnelse när en händelse du deltar i snart ska börja.
+ Kalenderhändelse
+
+ - Börjar om %1$d minut
+ - Börjar om %1$d minuter
+
+ Lägg till i en av dina kalendrar
+ Lägg till i en kalender
+ Du har inte skapat några kalendrar än.
+ Radera kalender
+ Radera denna kalender?
+ Kalenderlistan kommer att tas bort. Händelser inuti den raderas inte.
+ Välj bild
+ Bilduppladdning misslyckades
+ Den valda bilden kunde inte laddas upp. Försök igen eller klistra in en URL.
+ Deltagare (%1$d)
+ Sök namn, npub eller nip-05
+ Ange en giltig npub… eller 64-teckens hex pubkey.
+ Ta bort deltagare
+ Kalenderpåminnelser
+ Skicka påminnelser
+ En notis visas när en händelse du deltar i snart ska börja.
+ Påminnelsetid
+ Hur många minuter före händelsen du vill bli notifierad.
+ Dela som Nostr-länk
+ Dela kalenderlänk
+ Alla kalendrar
+ Visa händelser från…
+ Du har inte skapat några kalendrar än.
+ Öppna i kartor
+ Öppna länk
+ Händelsedetaljer
Ny kort video
Ny lång video
Titel
@@ -1674,6 +1807,8 @@
Boosta eller citera
Gilla
Zap
+ On-chain Bitcoin-zap
+ Väntar på bekräftelse
Ändra Snabba Reaktioner
Nedre navigeringsfält
Dra för att ändra ordning. Växla för att lägga till eller ta bort ett objekt från nedre fältet. Med noll objekt döljs det nedre fältet.
@@ -2025,6 +2160,9 @@
Ta bort användare från listan
Följpaket
Medlemmar
+ Följlista som innehåller %1$d användare:
+ Följlista
+ Följlista (%1$d)
Metadata för följlista
Metadata för följlistor kan ses av alla på Nostr. Endast privata medlemmar är krypterade.
Metadata för rekommendationspaket
diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml
index f7274f778b..1656bf9fde 100644
--- a/amethyst/src/main/res/values-zh-rCN/strings.xml
+++ b/amethyst/src/main/res/values-zh-rCN/strings.xml
@@ -778,6 +778,10 @@
高级:手动输入连接详情
快速打闪金额
按下打闪按钮时显示。点击接即可删除它。 如果留空,每次都会打开对话框来插入金融。
+ 链上快速打闪金额
+ 链上打闪支付矿工费,所以金额通常大于 Lightning 网络。点击一个金额去掉它。
+ 新的链上金额(sats)
+ 在链上发送
打闪隐私
控制发送打闪时如何显示您的身份。
连接钱包
@@ -1646,6 +1650,11 @@
此账户没有链上地址可用。
没有配置链后端。
待确认
+ 公开
+ 这个钱包是公开的
+ 您的Taproot地址衍生自您的Nostr公钥, 因此任何知道您的 npub的人都可以在区块链上看到这个钱包的余额和交易历史。\n\n为了保护您的隐私,这个钱包的资金流入和资金流出请使用非私密账户,例如交易所。 永远不要将这些资金与你的冷钱包混合,把它们当作你可能丢失的钱, 因为任何控制你 nsec 的人都可以花这些钱。
+ 明白了
+ 复制地址
安全滤镜
导入关注
新帖子
@@ -1708,6 +1717,7 @@
- %1$s, %2$d 个活动
+ %1$s,无活动
今天
已选中
创建新的日历活动
@@ -2150,6 +2160,9 @@
从列表中删除用户
关注包
成员
+ 包含 %1$d 个用户的关注列表:
+ 关注列表
+ 关注列表(%1$d)
关注列表元数据
关注列表元数据可以被Nostr上的任何人看到。只有您的私人成员是加密的。
关注包元数据
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 557cfb208c..15b09c7920 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1932,10 +1932,10 @@
Add to phone calendar
Jump to today
- - %1$s, no events
- %1$s, %2$d event
- %1$s, %2$d events
+ %1$s, no events
today
selected
Create a new calendar event
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt
index 2a676de5f9..3103bc76a7 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
@@ -96,6 +97,19 @@ class PublicChatListState(
emptySet(),
)
+ val flowSetNote =
+ flowSet
+ .map {
+ it.mapNotNull {
+ cache.checkGetOrCreateNote(it)
+ }
+ }.flowOn(Dispatchers.IO)
+ .stateIn(
+ scope,
+ SharingStarted.Eagerly,
+ emptyList(),
+ )
+
suspend fun follow(channel: PublicChatChannel): ChannelListEvent {
val publicChatList = getChannelList()
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt
index 29d750d34e..8d64214427 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt
@@ -42,7 +42,6 @@ fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String =
bridgeUrl(
url = url,
useBridge = useLocalBlossomBridge,
- explicitHash = hash,
mimeType = mimeType,
authorPubKey = authorPubKey,
skipBridge = this is MediaUrlVideo && isLiveStream,
@@ -95,7 +94,6 @@ const val DEFAULT_LOCAL_CACHE_BASE = "http://127.0.0.1:24242"
private fun bridgeUrl(
url: String,
useBridge: Boolean,
- explicitHash: String?,
mimeType: String?,
authorPubKey: String?,
skipBridge: Boolean,
@@ -105,19 +103,15 @@ private fun bridgeUrl(
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
// The local Blossom cache fetches `/.` on miss per BUD-01,
- // which only works when the upstream URL is itself BUD-01 layout. For
- // non-BUD-01 URLs (e.g. https://i.nostr.build/M5AwJ.gif) the imeta `x`
- // hash identifies the blob but the upstream server doesn't host it at
- // /., so trusting only `explicitHash` would point the cache
- // at a 404. Require the sha to be in the URL path before bridging.
- val urlSha = extractSha256FromUrlPath(url) ?: return url
-
- // Prefer the imeta hash when it's a valid sha256 (authoritative casing),
- // otherwise fall back to what was parsed from the URL.
- val sha = explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } ?: urlSha
+ // which only works when the upstream URL is itself BUD-01 layout — the
+ // file at `/.` is the one named in the URL path, not the
+ // imeta `x` hash (which on resizing CDNs may identify a different blob
+ // than the URL filename, e.g. `x` = post-resize, `ox` = original).
+ // Always use the URL's sha; never trust the imeta override.
+ val sha = extractSha256FromUrlPath(url) ?: return url
val ext = guessExtension(url, mimeType)
- val serverBase = extractServerBase(url, urlSha) ?: return url
+ val serverBase = extractServerBase(url, sha) ?: return url
val authors =
authorPubKey
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt
index 84dcf80c83..16e4427cac 100644
--- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt
@@ -255,4 +255,19 @@ class MediaUrlContentExtTest {
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
+
+ @Test
+ fun bridgeOnUsesUrlShaNotImetaWhenTheyDiffer() {
+ // On resizing CDNs the imeta `x` (post-resize) can differ from the
+ // `ox` (original) embedded in the URL. The upstream file is named
+ // after the URL's sha, so the cache request must use that — using
+ // the imeta `x` would point xs= at a non-existent path on miss.
+ val urlSha = "f24026b7281e598973a775adefb1b9a13b9f037a94ac98dd48ccc91b83f4b7b3"
+ val imetaX = "6932a918de1bfae3bf6611794ff54dd677013d22b760a9212117a0bd9079badf"
+ val image = MediaUrlImage(url = "https://image.nostr.build/$urlSha.png", hash = imetaX)
+ assertEquals(
+ "blossom:$urlSha.png?xs=https://image.nostr.build",
+ image.toCoilModel(useLocalBlossomBridge = true),
+ )
+ }
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt
index 0a195b25bf..afa616fb00 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt
@@ -175,6 +175,11 @@ class DesktopRelaySubscriptionsCoordinator(
kinds = listOf(com.vitorpamplona.quartz.nip18Reposts.RepostEvent.KIND),
tags = mapOf("e" to noteIds),
),
+ // Replies (kind 1) targeting these notes
+ Filter(
+ kinds = listOf(com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND),
+ tags = mapOf("e" to noteIds),
+ ),
)
val listener =
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt
index 50b1715581..ab5a6c3ef7 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt
@@ -74,6 +74,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
+import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag
+import com.vitorpamplona.quartz.nip18Reposts.quotes.quote
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -96,8 +99,19 @@ fun ComposeNoteDialog(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
replyTo: Event? = null,
+ quoteOf: Event? = null,
) {
- var content by remember { mutableStateOf("") }
+ val initialContent =
+ remember(quoteOf) {
+ if (quoteOf != null) {
+ val relays = relayManager.connectedRelays.value.take(3)
+ val nevent = NEvent.create(quoteOf.id, quoteOf.pubKey, quoteOf.kind, relays)
+ "\nnostr:$nevent"
+ } else {
+ ""
+ }
+ }
+ var content by remember { mutableStateOf(initialContent) }
var isPosting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
@@ -165,7 +179,11 @@ fun ComposeNoteDialog(
) {
Column(modifier = Modifier.padding(24.dp)) {
Text(
- if (replyTo != null) "Reply" else "New Note",
+ when {
+ replyTo != null -> "Reply"
+ quoteOf != null -> "Quote"
+ else -> "New Note"
+ },
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
@@ -179,6 +197,15 @@ fun ComposeNoteDialog(
)
}
+ quoteOf?.let { quoted ->
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "Quoting: ${quoted.content.take(50)}${if (quoted.content.length > 50) "..." else ""}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
Spacer(Modifier.height(16.dp))
OutlinedTextField(
@@ -357,6 +384,7 @@ fun ComposeNoteDialog(
account = account,
relayManager = relayManager,
replyTo = replyTo,
+ quoteOf = quoteOf,
imetaTags = imetaTags,
relays = selectedRelays,
)
@@ -529,6 +557,7 @@ private suspend fun publishNote(
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
replyTo: Event?,
+ quoteOf: Event? = null,
imetaTags: List = emptyList(),
relays: Set,
) {
@@ -546,6 +575,10 @@ private suspend fun publishNote(
eTag(etag)
pTag(PTag(replyTo.pubKey, relayHint = null))
}
+ if (quoteOf != null) {
+ quote(QEventTag(quoteOf.id, relayHint = null, authorPubKeyHex = quoteOf.pubKey))
+ pTag(PTag(quoteOf.pubKey, relayHint = null))
+ }
hashtags(findHashtags(content))
references(findURLs(content))
for (imeta in imetaTags) {
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
index 744762c5af..8c666e145c 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt
@@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -153,10 +154,10 @@ fun FeedNoteCard(
return
}
- val reactionCount = originalNote.countReactions()
- val replyCount = originalNote.replies.size
- val repostCount = originalNote.boosts.size
- val zapAmount = originalNote.zapsAmount
+ val reactionCount = remember(reactionsState) { originalNote.countReactions() }
+ val replyCount = remember(repliesState) { originalNote.replies.size }
+ val repostCount = remember(metadataState) { originalNote.boosts.size }
+ val zapAmount = remember(zapsState) { originalNote.zapsAmount }
val reposterUser = localCache.getUserIfExists(event.pubKey)
val originalUser = localCache.getUserIfExists(originalEvent.pubKey)
@@ -170,15 +171,15 @@ fun FeedNoteCard(
GenericRepostLayout(
baseAuthorPicture = {
UserAvatar(
- userHex = originalEvent.pubKey,
- pictureUrl = originalUser?.profilePicture(),
+ userHex = event.pubKey,
+ pictureUrl = reposterUser?.profilePicture(),
size = 35.dp,
)
},
repostAuthorPicture = {
UserAvatar(
- userHex = event.pubKey,
- pictureUrl = reposterUser?.profilePicture(),
+ userHex = originalEvent.pubKey,
+ pictureUrl = originalUser?.profilePicture(),
size = 35.dp,
)
},
@@ -209,14 +210,15 @@ fun FeedNoteCard(
onReplyClick = onReply,
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
- relayHint = originalNote.relayHintUrl(),
- authorRelayHint = originalNote.author?.bestRelayHint(),
+ note = originalNote,
zapCount = originalNote.zaps.size,
zapAmountSats = zapAmount.toLong(),
zapReceipts = emptyList(),
reactionCount = reactionCount,
replyCount = replyCount,
repostCount = repostCount,
+ onNavigateToThread = onNavigateToThread,
+ onNavigateToProfile = onNavigateToProfile,
)
}
}
@@ -228,10 +230,10 @@ fun FeedNoteCard(
val repliesState by flowSet.replies.stateFlow.collectAsState()
val zapsState by flowSet.zaps.stateFlow.collectAsState()
- val reactionCount = note.countReactions()
- val replyCount = note.replies.size
- val repostCount = note.boosts.size
- val zapAmount = note.zapsAmount
+ val reactionCount = remember(reactionsState) { note.countReactions() }
+ val replyCount = remember(repliesState) { note.replies.size }
+ val repostCount = remember(metadataState) { note.boosts.size }
+ val zapAmount = remember(zapsState) { note.zapsAmount }
DisposableEffect(note) {
onDispose { note.clearFlow() }
@@ -259,14 +261,15 @@ fun FeedNoteCard(
onReplyClick = onReply,
onZapFeedback = onZapFeedback,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
- relayHint = note.relayHintUrl(),
- authorRelayHint = note.author?.bestRelayHint(),
+ note = note,
zapCount = note.zaps.size,
zapAmountSats = zapAmount.toLong(),
zapReceipts = emptyList(),
reactionCount = reactionCount,
replyCount = replyCount,
repostCount = repostCount,
+ onNavigateToThread = onNavigateToThread,
+ onNavigateToProfile = onNavigateToProfile,
)
}
}
@@ -409,16 +412,17 @@ fun FeedScreen(
onDispose { viewModel.destroy() }
}
- // Rescan cache when followedUsers populates (fixes cold-boot race where
- // the initial scan runs before the contact list arrives from relays)
- LaunchedEffect(viewModel, followedUsers) {
- if (feedMode == FeedMode.FOLLOWING && followedUsers.isNotEmpty()) {
- viewModel.feedState.refreshSuspended()
+ val feedState by viewModel.feedState.feedContent.collectAsState()
+
+ // Force refresh when followedUsers arrives and feed is still empty
+ LaunchedEffect(followedUsers, feedState) {
+ if (followedUsers.isNotEmpty() && feedState is FeedState.Empty) {
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ viewModel.feedState.refreshSuspended()
+ }
}
}
- val feedState by viewModel.feedState.feedContent.collectAsState()
-
// Viewport-aware metadata loading: only fetch for visible notes + buffer
// Uses snapshotFlow to avoid per-frame recomposition from scroll observation
LaunchedEffect(feedState, subscriptionsCoordinator) {
@@ -521,19 +525,32 @@ fun FeedScreen(
)
}
- // Request interaction subscriptions — keyed on feedMode (stable), not feedState (changes every 250ms)
- DisposableEffect(feedMode, subscriptionsCoordinator) {
- val coordinator = subscriptionsCoordinator ?: return@DisposableEffect onDispose {}
- val relays = relayManager.relayStatuses.value.keys
- // Initial subscription with whatever notes are visible now
- val noteIds = viewModel.feedState.visibleNotes().mapNotNull { it.event?.id }
- val subId =
- if (noteIds.isNotEmpty()) {
- coordinator.requestInteractions(noteIds, relays)
- } else {
- null
- }
- onDispose { subId?.let { coordinator.releaseInteractions(it) } }
+ // Interaction subscriptions (reactions, zaps, reposts, replies) — same pattern as metadata
+ val interactionNoteIds =
+ remember(feedState) {
+ if (feedState !is FeedState.Loaded) return@remember emptyList()
+ viewModel.feedState.visibleNotes().mapNotNull { it.event?.id }
+ }
+
+ rememberSubscription(allRelayUrls, interactionNoteIds, relayManager = relayManager) {
+ if (allRelayUrls.isEmpty() || interactionNoteIds.isEmpty()) return@rememberSubscription null
+ SubscriptionConfig(
+ subId = generateSubId("fetch-interactions"),
+ filters =
+ listOf(
+ FilterBuilders.reactionsForEvents(interactionNoteIds),
+ FilterBuilders.zapsForEvents(interactionNoteIds),
+ FilterBuilders.repostsForEvents(interactionNoteIds),
+ Filter(
+ kinds = listOf(com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND),
+ tags = mapOf("e" to interactionNoteIds),
+ ),
+ ),
+ relays = allRelayUrls,
+ onEvent = { event, _, relay, _ ->
+ subscriptionsCoordinator?.consumeEvent(event, relay)
+ },
+ )
}
Box(modifier = Modifier.fillMaxSize()) {
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
index 4b2b1dccc2..8f90bfdf00 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt
@@ -20,7 +20,10 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -28,19 +31,28 @@ 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.heightIn
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -54,7 +66,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.commons.icons.Bookmark
import com.vitorpamplona.amethyst.commons.icons.BookmarkFilled
import com.vitorpamplona.amethyst.commons.icons.Reply
@@ -62,11 +79,13 @@ import com.vitorpamplona.amethyst.commons.icons.Repost
import com.vitorpamplona.amethyst.commons.icons.Zap
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.model.nip51Bookmarks.BookmarkAction
import com.vitorpamplona.amethyst.commons.model.nip57Zaps.ZapAction
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
+import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
@@ -91,26 +110,24 @@ import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import kotlin.coroutines.resume
-internal val DEFAULT_ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)
+private val ZAP_AMOUNTS = listOf(21L, 100L, 500L, 1000L, 5000L, 10000L)
/**
- * Zap type for the zap dialog.
+ * Mutually exclusive popup state for note action bar.
+ * Only one popup can be open at a time.
*/
-enum class ZapType(
- val label: String,
- val description: String,
-) {
- PUBLIC("Public", "Everyone sees your zap"),
- PRIVATE("Private", "Only recipient sees your identity"),
- ANONYMOUS("Anonymous", "No identity attached"),
- ;
+sealed class ActivePopup {
+ data object None : ActivePopup()
- fun toLnZapType(): LnZapEvent.ZapType =
- when (this) {
- PUBLIC -> LnZapEvent.ZapType.PUBLIC
- PRIVATE -> LnZapEvent.ZapType.PRIVATE
- ANONYMOUS -> LnZapEvent.ZapType.ANONYMOUS
- }
+ data object ZapReceipts : ActivePopup()
+
+ data object Reactions : ActivePopup()
+
+ data object EmojiPicker : ActivePopup()
+
+ data object RepostOptions : ActivePopup()
+
+ data object Boosts : ActivePopup()
}
/**
@@ -139,6 +156,7 @@ sealed class ZapFeedback {
/**
* Data class representing a zap receipt for display.
*/
+@Immutable
data class ZapReceipt(
val senderPubKey: String,
val amountSats: Long,
@@ -174,120 +192,58 @@ fun getDisplayName(
}
/**
- * Dialog for selecting zap amount, type, and optional message.
+ * Dialog for selecting zap amount and optional message.
*/
@Composable
fun ZapAmountDialog(
onDismiss: () -> Unit,
- onZap: (Long, String, ZapType) -> Unit,
- zapAmounts: List = DEFAULT_ZAP_AMOUNTS,
- defaultZapType: ZapType = ZapType.PUBLIC,
+ onZap: (Long, String) -> Unit,
) {
- var selectedAmount by remember { mutableStateOf(zapAmounts.firstOrNull() ?: 21L) }
- var customAmount by remember { mutableStateOf("") }
- var useCustom by remember { mutableStateOf(false) }
+ var selectedAmount by remember { mutableStateOf(21L) }
var message by remember { mutableStateOf("") }
- var selectedType by remember { mutableStateOf(defaultZapType) }
-
- val effectiveAmount = if (useCustom) customAmount.toLongOrNull() ?: 0L else selectedAmount
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Zap") },
text = {
Column {
- // Amount selection
Text(
- "Amount (sats)",
- style = MaterialTheme.typography.labelMedium,
+ "Select amount in sats",
+ style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- Spacer(Modifier.height(8.dp))
+ Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(6.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- zapAmounts.take(3).forEach { amount ->
+ ZAP_AMOUNTS.take(3).forEach { amount ->
FilterChip(
- selected = !useCustom && selectedAmount == amount,
- onClick = {
- selectedAmount = amount
- useCustom = false
- },
+ selected = selectedAmount == amount,
+ onClick = { selectedAmount = amount },
label = { Text("$amount") },
)
}
}
- Spacer(Modifier.height(4.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- zapAmounts.drop(3).forEach { amount ->
- FilterChip(
- selected = !useCustom && selectedAmount == amount,
- onClick = {
- selectedAmount = amount
- useCustom = false
- },
- label = { Text(formatSats(amount)) },
- )
- }
- FilterChip(
- selected = useCustom,
- onClick = { useCustom = true },
- label = { Text("Custom") },
- )
- }
-
- if (useCustom) {
- Spacer(Modifier.height(8.dp))
- androidx.compose.material3.OutlinedTextField(
- value = customAmount,
- onValueChange = { new -> if (new.all { it.isDigit() }) customAmount = new },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Custom amount") },
- placeholder = { Text("Enter sats...") },
- singleLine = true,
- )
- }
-
- Spacer(Modifier.height(16.dp))
-
- // Zap type selection
- Text(
- "Zap Type",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(6.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- ZapType.entries.forEach { type ->
+ ZAP_AMOUNTS.drop(3).forEach { amount ->
FilterChip(
- selected = selectedType == type,
- onClick = { selectedType = type },
- label = { Text(type.label) },
+ selected = selectedAmount == amount,
+ onClick = { selectedAmount = amount },
+ label = { Text(formatSats(amount)) },
)
}
}
-
Spacer(Modifier.height(16.dp))
-
- // Message
- val messageLabel =
- when (selectedType) {
- ZapType.PRIVATE -> "Private message (only recipient sees)"
- ZapType.ANONYMOUS -> "Message (optional)"
- ZapType.PUBLIC -> "Message (optional)"
- }
androidx.compose.material3.OutlinedTextField(
value = message,
onValueChange = { message = it },
modifier = Modifier.fillMaxWidth(),
- label = { Text(messageLabel) },
+ label = { Text("Message (optional)") },
placeholder = { Text("Add a comment...") },
singleLine = false,
maxLines = 3,
@@ -295,11 +251,8 @@ fun ZapAmountDialog(
}
},
confirmButton = {
- Button(
- onClick = { onZap(effectiveAmount, message, selectedType) },
- enabled = effectiveAmount > 0,
- ) {
- Text("Zap ${formatSats(effectiveAmount)} sats")
+ Button(onClick = { onZap(selectedAmount, message) }) {
+ Text("Zap ${formatSats(selectedAmount)} sats")
}
},
dismissButton = {
@@ -310,7 +263,7 @@ fun ZapAmountDialog(
)
}
-internal fun formatSats(amount: Long): String = if (amount >= 1000) "${amount / 1000}k" else "$amount"
+private fun formatSats(amount: Long): String = if (amount >= 1000) "${amount / 1000}k" else "$amount"
/**
* Dialog for choosing bookmark visibility (public or private).
@@ -484,6 +437,387 @@ fun ZapReceiptsDialog(
)
}
+/**
+ * Floating popup showing zap receipts from a Note's zaps map.
+ * Uses Popup + ElevatedCard for rich scrollable content.
+ */
+@Composable
+fun ZapReceiptsPopup(
+ note: Note,
+ localCache: DesktopLocalCache,
+ relayManager: DesktopRelayConnectionManager,
+ onDismiss: () -> Unit,
+ onNavigateToProfile: (String) -> Unit = {},
+) {
+ var metadataVersion by remember { mutableIntStateOf(0) }
+
+ // Fetch missing metadata for zap senders
+ LaunchedEffect(note.idHex) {
+ val pubKeys =
+ note.zaps.keys
+ .mapNotNull { it.event?.pubKey }
+ .distinct()
+ .filter { localCache.getUserIfExists(it)?.profilePicture() == null }
+ if (pubKeys.isNotEmpty()) {
+ fetchMetadataForUsers(pubKeys, relayManager, localCache) { metadataVersion++ }
+ }
+ }
+
+ @Suppress("UNUSED_EXPRESSION")
+ metadataVersion
+
+ data class ZapEntry(
+ val pubKey: String,
+ val pictureUrl: String?,
+ val name: String,
+ val amount: Long,
+ val message: String?,
+ )
+
+ val zapEntries =
+ remember(note.zaps, metadataVersion) {
+ note.zaps
+ .mapNotNull { (request, receipt) ->
+ val pubKey = request.event?.pubKey ?: return@mapNotNull null
+ val user = request.author
+ val name = user?.toBestDisplayName() ?: pubKey.take(12)
+ val pictureUrl = user?.profilePicture()
+ val amount =
+ (receipt?.event as? LnZapEvent)?.amount?.toLong()
+ ?: return@mapNotNull null
+ val message = request.event?.content?.ifBlank { null }
+ ZapEntry(pubKey, pictureUrl, name, amount, message)
+ }.sortedByDescending { it.amount }
+ }
+
+ val totalSats = remember(zapEntries) { zapEntries.sumOf { it.amount } }
+
+ Popup(
+ alignment = Alignment.TopCenter,
+ offset = IntOffset(0, -40),
+ onDismissRequest = onDismiss,
+ properties = PopupProperties(focusable = true),
+ ) {
+ ElevatedCard(
+ modifier = Modifier.widthIn(max = 280.dp),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .verticalScroll(rememberScrollState())
+ .heightIn(max = 300.dp)
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (zapEntries.isEmpty()) {
+ Text(
+ "No zaps yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ // Header: total sats
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Icon(
+ Zap,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ "${formatSats(totalSats)} sats",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ HorizontalDivider()
+
+ // Sorted receipts
+ zapEntries.take(10).forEach { entry ->
+ Row(
+ modifier =
+ Modifier.fillMaxWidth().clickable {
+ onDismiss()
+ onNavigateToProfile(entry.pubKey)
+ },
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ UserAvatar(
+ userHex = entry.pubKey,
+ pictureUrl = entry.pictureUrl,
+ size = 24.dp,
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (!entry.message.isNullOrBlank()) {
+ Text(
+ text = entry.message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+ }
+ Text(
+ text = "${formatSats(entry.amount)} sats",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ if (zapEntries.size > 10) {
+ Text(
+ text = "and ${zapEntries.size - 10} more...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Floating popup showing reactions grouped by emoji from a Note's reactions map.
+ * Uses Popup + ElevatedCard for rich scrollable content.
+ */
+@Composable
+fun ReactionsPopup(
+ note: Note,
+ localCache: DesktopLocalCache,
+ relayManager: DesktopRelayConnectionManager,
+ onDismiss: () -> Unit,
+ onNavigateToProfile: (String) -> Unit = {},
+) {
+ var metadataVersion by remember { mutableIntStateOf(0) }
+
+ LaunchedEffect(note.idHex) {
+ val pubKeys =
+ note.reactions.values
+ .flatten()
+ .mapNotNull { it.event?.pubKey }
+ .distinct()
+ .filter { localCache.getUserIfExists(it)?.profilePicture() == null }
+ if (pubKeys.isNotEmpty()) {
+ fetchMetadataForUsers(pubKeys, relayManager, localCache) { metadataVersion++ }
+ }
+ }
+
+ @Suppress("UNUSED_EXPRESSION")
+ metadataVersion
+
+ val totalCount = remember(note.reactions, metadataVersion) { note.countReactions() }
+
+ Popup(
+ alignment = Alignment.TopCenter,
+ offset = IntOffset(0, -40),
+ onDismissRequest = onDismiss,
+ properties = PopupProperties(focusable = true),
+ ) {
+ ElevatedCard(
+ modifier = Modifier.widthIn(max = 280.dp),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .verticalScroll(rememberScrollState())
+ .heightIn(max = 300.dp)
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (note.reactions.isEmpty()) {
+ Text(
+ "No reactions yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ // Header: total count
+ Text(
+ "$totalCount reaction${if (totalCount != 1) "s" else ""}",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ HorizontalDivider()
+
+ // Group by emoji
+ note.reactions.forEach { (emoji, reactionNotes) ->
+ val displayEmoji = if (emoji == "+") "\u2764\ufe0f" else emoji
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(
+ displayEmoji,
+ fontSize = 16.sp,
+ )
+ Text(
+ "${reactionNotes.size}",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ // Sender avatars + names
+ reactionNotes.take(5).forEach { reactionNote ->
+ val pubKey = reactionNote.event?.pubKey ?: return@forEach
+ val user = reactionNote.author
+ val senderName = user?.toBestDisplayName() ?: pubKey.take(12)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier =
+ Modifier.padding(start = 24.dp).clickable {
+ onDismiss()
+ onNavigateToProfile(pubKey)
+ },
+ ) {
+ UserAvatar(
+ userHex = pubKey,
+ pictureUrl = user?.profilePicture(),
+ size = 20.dp,
+ )
+ Text(
+ text = senderName,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (reactionNotes.size > 5) {
+ Text(
+ text = "and ${reactionNotes.size - 5} more...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 24.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Floating popup showing who boosted (reposted) a note.
+ * Shows kind 6/1621 reposts only (matching Android — quotes are not aggregated on Note).
+ * Uses Popup + ElevatedCard for rich scrollable content.
+ */
+@Composable
+fun BoostsPopup(
+ note: Note,
+ localCache: DesktopLocalCache,
+ relayManager: DesktopRelayConnectionManager,
+ onDismiss: () -> Unit,
+ onNavigateToThread: (String) -> Unit = {},
+ onNavigateToProfile: (String) -> Unit = {},
+) {
+ var metadataVersion by remember { mutableIntStateOf(0) }
+
+ data class BoostEntry(
+ val pubKey: String,
+ val pictureUrl: String?,
+ val name: String,
+ )
+
+ LaunchedEffect(note.idHex) {
+ val pubKeys =
+ note.boosts
+ .mapNotNull { it.event?.pubKey }
+ .distinct()
+ .filter { localCache.getUserIfExists(it)?.profilePicture() == null }
+ if (pubKeys.isNotEmpty()) {
+ fetchMetadataForUsers(pubKeys, relayManager, localCache) { metadataVersion++ }
+ }
+ }
+
+ @Suppress("UNUSED_EXPRESSION")
+ metadataVersion
+
+ val boostEntries =
+ remember(note.boosts, metadataVersion) {
+ note.boosts.mapNotNull { boostNote ->
+ val pubKey = boostNote.event?.pubKey ?: return@mapNotNull null
+ val user = boostNote.author
+ BoostEntry(pubKey, user?.profilePicture(), user?.toBestDisplayName() ?: pubKey.take(12))
+ }
+ }
+
+ Popup(
+ alignment = Alignment.TopCenter,
+ offset = IntOffset(0, -40),
+ onDismissRequest = onDismiss,
+ properties = PopupProperties(focusable = true),
+ ) {
+ ElevatedCard(
+ modifier = Modifier.widthIn(max = 280.dp),
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .verticalScroll(rememberScrollState())
+ .heightIn(max = 300.dp)
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (boostEntries.isEmpty()) {
+ Text(
+ "No reposts yet",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Text(
+ "${boostEntries.size} repost${if (boostEntries.size != 1) "s" else ""}",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ HorizontalDivider()
+
+ boostEntries.take(10).forEach { entry ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier.clickable {
+ onDismiss()
+ onNavigateToProfile(entry.pubKey)
+ },
+ ) {
+ UserAvatar(userHex = entry.pubKey, pictureUrl = entry.pictureUrl, size = 24.dp)
+ Text(entry.name, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
+ }
+ }
+ if (boostEntries.size > 10) {
+ Text(
+ text = "and ${boostEntries.size - 10} more...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
/**
* Fetches metadata for multiple users in a single subscription.
*/
@@ -559,9 +893,13 @@ private suspend fun fetchMetadataForUsers(
}
}
+private val EMOJI_OPTIONS = listOf("+", "\u2764\ufe0f", "\ud83e\udd19", "\ud83d\udd25", "\ud83d\udc40", "\ud83d\ude02")
+
/**
* Action buttons row for a note (react, reply, repost, zap, bookmark).
+ * Supports click (action), long-press (view details popup), and right-click (customize).
*/
+@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
@Composable
fun NoteActionsRow(
event: Event,
@@ -571,8 +909,7 @@ fun NoteActionsRow(
onReplyClick: () -> Unit,
onZapFeedback: (ZapFeedback) -> Unit,
modifier: Modifier = Modifier,
- relayHint: NormalizedRelayUrl? = null,
- authorRelayHint: NormalizedRelayUrl? = null,
+ note: Note? = null,
zapCount: Int = 0,
zapAmountSats: Long = 0,
zapReceipts: List = emptyList(),
@@ -583,6 +920,8 @@ fun NoteActionsRow(
isBookmarked: Boolean = false,
bookmarkList: BookmarkListEvent? = null,
onBookmarkChanged: (BookmarkListEvent) -> Unit = {},
+ onNavigateToThread: (String) -> Unit = {},
+ onNavigateToProfile: (String) -> Unit = {},
) {
var isLiked by remember { mutableStateOf(false) }
var isReposted by remember { mutableStateOf(false) }
@@ -593,16 +932,30 @@ fun NoteActionsRow(
var showZapReceiptsDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
+ // Mutually exclusive popup state
+ var activePopup by remember { mutableStateOf(ActivePopup.None) }
+
+ // Quote compose state
+ var quoteEvent by remember { mutableStateOf(null) }
+
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- // Reply button with count
+ // Reply button with count — long-press = same as click (open thread)
Row(verticalAlignment = Alignment.CenterVertically) {
- IconButton(
- onClick = onReplyClick,
- modifier = Modifier.size(32.dp),
+ Box(
+ modifier =
+ Modifier
+ .size(32.dp)
+ .combinedClickable(
+ onClick = onReplyClick,
+ onLongClick = onReplyClick,
+ indication = ripple(bounded = false, radius = 16.dp),
+ interactionSource = remember { MutableInteractionSource() },
+ ),
+ contentAlignment = Alignment.Center,
) {
Icon(
Reply,
@@ -620,36 +973,93 @@ fun NoteActionsRow(
}
}
- // Like button with count
+ // Like button with count — long-press = reactions popup, right-click = emoji picker
Row(verticalAlignment = Alignment.CenterVertically) {
- IconButton(
- onClick = {
- if (!isLiked) {
- scope.launch {
- reactToNote(
- event = EventHintBundle(event, relayHint, authorRelayHint),
- reaction = "+",
- account = account,
- relayManager = relayManager,
- )
- isLiked = true
- localReactionCount++
- }
+ Box {
+ Box(
+ modifier =
+ Modifier
+ .size(32.dp)
+ .combinedClickable(
+ onClick = {
+ if (!isLiked) {
+ scope.launch {
+ reactToNote(
+ event = EventHintBundle(event, null),
+ reaction = "+",
+ account = account,
+ relayManager = relayManager,
+ )
+ isLiked = true
+ localReactionCount++
+ }
+ }
+ },
+ onLongClick = {
+ if (note != null) {
+ activePopup = ActivePopup.Reactions
+ }
+ },
+ indication = ripple(bounded = false, radius = 16.dp),
+ interactionSource = remember { MutableInteractionSource() },
+ ).onPointerEvent(PointerEventType.Press) { pointerEvent ->
+ if (pointerEvent.buttons.isSecondaryPressed) {
+ activePopup = ActivePopup.EmojiPicker
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ if (isLiked) MaterialSymbols.Favorite else MaterialSymbols.FavoriteBorder,
+ contentDescription = if (isLiked) "Unlike" else "Like",
+ tint =
+ if (isLiked) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.size(18.dp),
+ )
+ }
+
+ // Reactions popup (long-press)
+ if (activePopup is ActivePopup.Reactions && note != null) {
+ ReactionsPopup(
+ note = note,
+ localCache = localCache,
+ relayManager = relayManager,
+ onDismiss = { activePopup = ActivePopup.None },
+ onNavigateToProfile = onNavigateToProfile,
+ )
+ }
+
+ // Emoji picker (right-click)
+ DropdownMenu(
+ expanded = activePopup is ActivePopup.EmojiPicker,
+ onDismissRequest = { activePopup = ActivePopup.None },
+ ) {
+ EMOJI_OPTIONS.forEach { emoji ->
+ val displayEmoji = if (emoji == "+") "\u2764\ufe0f" else emoji
+ DropdownMenuItem(
+ text = { Text(displayEmoji, fontSize = 20.sp) },
+ onClick = {
+ activePopup = ActivePopup.None
+ if (!isLiked) {
+ scope.launch {
+ reactToNote(
+ event = EventHintBundle(event, null),
+ reaction = emoji,
+ account = account,
+ relayManager = relayManager,
+ )
+ isLiked = true
+ localReactionCount++
+ }
+ }
+ },
+ )
}
- },
- modifier = Modifier.size(32.dp),
- ) {
- Icon(
- if (isLiked) MaterialSymbols.Favorite else MaterialSymbols.FavoriteBorder,
- contentDescription = if (isLiked) "Unlike" else "Like",
- tint =
- if (isLiked) {
- MaterialTheme.colorScheme.error
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- },
- modifier = Modifier.size(18.dp),
- )
+ }
}
if (localReactionCount > 0) {
Text(
@@ -660,35 +1070,96 @@ fun NoteActionsRow(
}
}
- // Repost button with count
+ // Repost button with count — right-click = repost options
Row(verticalAlignment = Alignment.CenterVertically) {
- IconButton(
- onClick = {
- if (!isReposted) {
- scope.launch {
- repostNote(
- event = EventHintBundle(event, relayHint, authorRelayHint),
- account = account,
- relayManager = relayManager,
- )
- isReposted = true
- localRepostCount++
- }
- }
- },
- modifier = Modifier.size(32.dp),
- ) {
- Icon(
- Repost,
- contentDescription = "Repost",
- tint =
- if (isReposted) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
+ Box {
+ Box(
+ modifier =
+ Modifier
+ .size(32.dp)
+ .combinedClickable(
+ onClick = {
+ if (!isReposted) {
+ scope.launch {
+ repostNote(
+ event = EventHintBundle(event, null),
+ account = account,
+ relayManager = relayManager,
+ )
+ isReposted = true
+ localRepostCount++
+ }
+ }
+ },
+ onLongClick = {
+ if (note != null) {
+ activePopup = ActivePopup.Boosts
+ }
+ },
+ indication = ripple(bounded = false, radius = 16.dp),
+ interactionSource = remember { MutableInteractionSource() },
+ ).onPointerEvent(PointerEventType.Press) { pointerEvent ->
+ if (pointerEvent.buttons.isSecondaryPressed) {
+ activePopup = ActivePopup.RepostOptions
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Repost,
+ contentDescription = "Repost",
+ tint =
+ if (isReposted) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.size(18.dp),
+ )
+ }
+
+ // Repost options (right-click)
+ DropdownMenu(
+ expanded = activePopup is ActivePopup.RepostOptions,
+ onDismissRequest = { activePopup = ActivePopup.None },
+ ) {
+ DropdownMenuItem(
+ text = { Text("Repost") },
+ onClick = {
+ activePopup = ActivePopup.None
+ if (!isReposted) {
+ scope.launch {
+ repostNote(
+ event = EventHintBundle(event, null),
+ account = account,
+ relayManager = relayManager,
+ )
+ isReposted = true
+ localRepostCount++
+ }
+ }
},
- modifier = Modifier.size(18.dp),
- )
+ )
+ DropdownMenuItem(
+ text = { Text("Quote") },
+ onClick = {
+ activePopup = ActivePopup.None
+ quoteEvent = event
+ },
+ )
+ }
+
+ // Boosts popup (long-press)
+ if (activePopup is ActivePopup.Boosts && note != null) {
+ BoostsPopup(
+ note = note,
+ localCache = localCache,
+ relayManager = relayManager,
+ onDismiss = { activePopup = ActivePopup.None },
+ onNavigateToThread = onNavigateToThread,
+ onNavigateToProfile = onNavigateToProfile,
+ )
+ }
}
if (localRepostCount > 0) {
Text(
@@ -699,64 +1170,64 @@ fun NoteActionsRow(
}
}
- // Zap button: left-click = quick zap (default amount), right-click = custom dialog
+ // Zap button with amount — long-press = zap receipts popup, right-click = custom zap dialog
Row(verticalAlignment = Alignment.CenterVertically) {
- Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) {
- if (isZapping) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.primary,
- )
- } else {
- @OptIn(ExperimentalComposeUiApi::class)
- IconButton(
- onClick = {
- // Quick zap with default amount (first preset)
- if (nwcConnection != null) {
- val defaultAmount = DEFAULT_ZAP_AMOUNTS.first()
- scope.launch {
- isZapping = true
- val feedback =
- zapNote(
- event = event,
- account = account,
- relayManager = relayManager,
- localCache = localCache,
- amountSats = defaultAmount,
- message = "",
- nwcConnection = nwcConnection,
- )
- isZapping = false
- onZapFeedback(feedback)
- }
- } else {
- // No wallet connected — open dialog for external wallet fallback
- showZapDialog = true
- }
- },
- modifier =
- Modifier
- .size(32.dp)
- .onPointerEvent(PointerEventType.Press) { pointerEvent ->
- if (pointerEvent.buttons.isSecondaryPressed) {
- showZapDialog = true
- }
- },
- ) {
- Icon(
- Zap,
- contentDescription = "Zap",
- tint =
- if (zapAmountSats > 0) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- },
- modifier = Modifier.size(18.dp),
+ Box {
+ Box(modifier = Modifier.size(32.dp), contentAlignment = Alignment.Center) {
+ if (isZapping) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary,
)
+ } else {
+ Box(
+ modifier =
+ Modifier
+ .size(32.dp)
+ .combinedClickable(
+ onClick = { showZapDialog = true },
+ onLongClick = {
+ if (note != null) {
+ activePopup = ActivePopup.ZapReceipts
+ } else {
+ showZapReceiptsDialog = true
+ }
+ },
+ indication = ripple(bounded = false, radius = 16.dp),
+ interactionSource = remember { MutableInteractionSource() },
+ ).onPointerEvent(PointerEventType.Press) { pointerEvent ->
+ if (pointerEvent.buttons.isSecondaryPressed) {
+ showZapDialog = true
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Zap,
+ contentDescription = "Zap",
+ tint =
+ if (zapAmountSats > 0) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ modifier = Modifier.size(18.dp),
+ )
+ }
}
}
+
+ // Zap receipts popup (long-press)
+ if (activePopup is ActivePopup.ZapReceipts && note != null) {
+ ZapReceiptsPopup(
+ note = note,
+ localCache = localCache,
+ relayManager = relayManager,
+ onDismiss = { activePopup = ActivePopup.None },
+ onNavigateToProfile = onNavigateToProfile,
+ )
+ }
}
if (zapAmountSats > 0) {
Text(
@@ -891,7 +1362,7 @@ fun NoteActionsRow(
if (showZapDialog) {
ZapAmountDialog(
onDismiss = { showZapDialog = false },
- onZap = { amountSats, message, zapType ->
+ onZap = { amountSats, message ->
showZapDialog = false
scope.launch {
isZapping = true
@@ -904,7 +1375,6 @@ fun NoteActionsRow(
amountSats = amountSats,
message = message,
nwcConnection = nwcConnection,
- zapType = zapType.toLnZapType(),
)
isZapping = false
onZapFeedback(feedback)
@@ -913,7 +1383,7 @@ fun NoteActionsRow(
)
}
- // Zap receipts dialog
+ // Zap receipts dialog (from clicking the amount text)
if (showZapReceiptsDialog) {
ZapReceiptsDialog(
receipts = zapReceipts,
@@ -923,6 +1393,16 @@ fun NoteActionsRow(
onDismiss = { showZapReceiptsDialog = false },
)
}
+
+ // Quote compose dialog
+ if (quoteEvent != null) {
+ ComposeNoteDialog(
+ onDismiss = { quoteEvent = null },
+ relayManager = relayManager,
+ account = account,
+ quoteOf = quoteEvent,
+ )
+ }
}
/**
@@ -1036,15 +1516,15 @@ private suspend fun zapNote(
amountSats: Long,
message: String = "",
nwcConnection: Nip47WalletConnect.Nip47URINorm? = null,
- zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
): ZapFeedback =
withContext(Dispatchers.IO) {
// Get author's lightning address from cache
var user = localCache.getUserIfExists(event.pubKey)
var lnAddress = user?.lnAddress()
- // On-demand fetch: desktop doesn't have Android's always-on feed subscriptions
- // that load metadata as a side effect. The 5s timeout is acceptable UX for desktop.
+ // TODO: Use UserFinderFilterAssemblerSubscription pattern from Amethyst
+ // to proactively load metadata when zap button is displayed.
+ // For now, fetch on-demand if missing.
if (lnAddress == null) {
lnAddress = fetchUserLightningAddress(event.pubKey, relayManager, localCache)
}
@@ -1070,7 +1550,6 @@ private suspend fun zapNote(
relays = relays,
signer = account.signer,
resolver = resolver,
- zapType = zapType,
)
when (result) {
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt
index d67732731e..c1878269a0 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
@@ -62,12 +63,18 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
+import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
+import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createNoteSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createThreadRepliesSubscription
+import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
+import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
+import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
/**
* Desktop Thread Screen - displays a note and all its replies in a thread view.
@@ -177,6 +184,38 @@ fun ThreadScreen(
}
}
+ // Fetch quoted notes referenced in thread content
+ val quotedNoteIds =
+ remember(threadNotes) {
+ threadNotes
+ .mapNotNull { it.event }
+ .flatMap { event ->
+ UrlParser()
+ .parseValidUrls(event.content)
+ .bech32s
+ .mapNotNull { bech32 ->
+ when (val entity = Nip19Parser.uriToRoute(bech32)?.entity) {
+ is NNote -> entity.hex
+ is NEvent -> entity.hex
+ else -> null
+ }
+ }
+ }.filter { localCache.getNoteIfExists(it)?.event == null }
+ .distinct()
+ }
+
+ rememberSubscription(connectedRelays, quotedNoteIds, relayManager = relayManager) {
+ if (connectedRelays.isEmpty() || quotedNoteIds.isEmpty()) return@rememberSubscription null
+ SubscriptionConfig(
+ subId = generateSubId("thread-quoted"),
+ filters = listOf(FilterBuilders.byIds(quotedNoteIds)),
+ relays = connectedRelays,
+ onEvent = { event, _, relay, _ ->
+ subscriptionsCoordinator?.consumeEvent(event, relay)
+ },
+ )
+ }
+
// Calculate reply level for a note based on e-tags
fun calculateLevel(note: Note): Int {
val event = note.event ?: return 1