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 + + 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 + + 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 + + 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